@reddoorla/maintenance 0.85.0 → 0.85.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.
Files changed (37) hide show
  1. package/dist/chunk-5ML7FMBD.js +10 -0
  2. package/dist/chunk-5ML7FMBD.js.map +1 -0
  3. package/dist/{chunk-N4MOEF32.js → chunk-C6B4RHXR.js} +36 -8
  4. package/dist/chunk-C6B4RHXR.js.map +1 -0
  5. package/dist/{chunk-QRDB4NLB.js → chunk-DTTIFYAM.js} +2 -2
  6. package/dist/{chunk-R4AB6GCT.js → chunk-S56JQJ4F.js} +5 -2
  7. package/dist/{chunk-R4AB6GCT.js.map → chunk-S56JQJ4F.js.map} +1 -1
  8. package/dist/{chunk-6V7565O2.js → chunk-WYYQK4D3.js} +3 -3
  9. package/dist/cli/bin.js +6 -6
  10. package/dist/cli/commands/audit.js +2 -2
  11. package/dist/configs/playwright-a11y.js +1 -1
  12. package/dist/configs/svelte.d.ts +30 -1
  13. package/dist/configs/svelte.js +9 -1
  14. package/dist/configs/svelte.js.map +1 -1
  15. package/dist/{digest-SQ5U6SN2.js → digest-UVHYZUYE.js} +5 -3
  16. package/dist/digest-UVHYZUYE.js.map +1 -0
  17. package/dist/{forms-notify-target-SMKDKNT2.js → forms-notify-target-TWN4EH6K.js} +4 -5
  18. package/dist/forms-notify-target-TWN4EH6K.js.map +1 -0
  19. package/dist/index.js +4 -4
  20. package/dist/{init-TFVAN5OY.js → init-JN26NTWF.js} +5 -5
  21. package/dist/{launch-DWY2MF54.js → launch-JA7Z4CIM.js} +3 -3
  22. package/dist/{report-WG6RHA2X.js → report-6CKSWJNG.js} +6 -3
  23. package/dist/report-6CKSWJNG.js.map +1 -0
  24. package/dist/{selftest-BVCLJRNI.js → selftest-CQ6NOX52.js} +5 -2
  25. package/dist/selftest-CQ6NOX52.js.map +1 -0
  26. package/dist/{smoke-suite-OBWNPALQ.js → smoke-suite-TMCSOYRK.js} +2 -2
  27. package/package.json +1 -1
  28. package/dist/chunk-N4MOEF32.js.map +0 -1
  29. package/dist/digest-SQ5U6SN2.js.map +0 -1
  30. package/dist/forms-notify-target-SMKDKNT2.js.map +0 -1
  31. package/dist/report-WG6RHA2X.js.map +0 -1
  32. package/dist/selftest-BVCLJRNI.js.map +0 -1
  33. /package/dist/{chunk-QRDB4NLB.js.map → chunk-DTTIFYAM.js.map} +0 -0
  34. /package/dist/{chunk-6V7565O2.js.map → chunk-WYYQK4D3.js.map} +0 -0
  35. /package/dist/{init-TFVAN5OY.js.map → init-JN26NTWF.js.map} +0 -0
  36. /package/dist/{launch-DWY2MF54.js.map → launch-JA7Z4CIM.js.map} +0 -0
  37. /package/dist/{smoke-suite-OBWNPALQ.js.map → smoke-suite-TMCSOYRK.js.map} +0 -0
@@ -1,4 +1,7 @@
1
1
  import "./chunk-SNSTJLQW.js";
2
+ import {
3
+ operatorEmail
4
+ } from "./chunk-5ML7FMBD.js";
2
5
  import "./chunk-SEEA6RXF.js";
3
6
  import "./chunk-2OP4JKIQ.js";
4
7
  import {
@@ -16,10 +19,6 @@ import "./chunk-6VAL7XJT.js";
16
19
  var DUPLICATE_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
17
20
 
18
21
  // src/forms/notify.ts
19
- var OPERATOR_FALLBACK = "tucker@reddoorla.com";
20
- function operatorEmail() {
21
- return process.env.OPERATOR_EMAIL?.trim() || OPERATOR_FALLBACK;
22
- }
23
22
  function pocAddress(site) {
24
23
  return site.pointOfContact ?? site.reportRecipientsTo ?? null;
25
24
  }
@@ -255,4 +254,4 @@ export {
255
254
  formatNotifyTarget,
256
255
  runFormsNotifyTargetCommand
257
256
  };
258
- //# sourceMappingURL=forms-notify-target-SMKDKNT2.js.map
257
+ //# sourceMappingURL=forms-notify-target-TWN4EH6K.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms/ingest.ts","../src/forms/notify.ts","../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type {\n SubmissionRow,\n SubmissionInput,\n NotifyStatus,\n SubmissionStatus,\n} from \"../reports/submission-row.js\";\nimport { normalizeSubmission, type NormalizedSubmission } from \"./payload.js\";\nimport type { TurnstileOutcome, TurnstileVerification } from \"./turnstile.js\";\nimport { SPAM_THRESHOLD, type SpamVerdict } from \"./spam-classifier.js\";\n\n/** How far back the duplicate-spray AND repeat-sender lookups scan. Sprays arrive in\n * bursts but bots also re-run for weeks; 30 days catches repeats without an unbounded\n * table scan. */\nconst DUPLICATE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport type IngestDeps = {\n getWebsiteBySlug: (slug: string) => Promise<WebsiteRow | null>;\n createSubmission: (input: SubmissionInput) => Promise<SubmissionRow>;\n notify: (\n site: WebsiteRow,\n submission: SubmissionRow,\n ) => Promise<{ status: NotifyStatus; messageId: string | null }>;\n stampNotified: (id: string, status: NotifyStatus, messageId: string | null) => Promise<void>;\n now: () => Date;\n /** Optional spam classifier. When present, its verdict drives the stored\n * status/score/reason; absent → every submission scores 0 (fail-open, clean).\n * Injected so the classifier stays a pure, transport-agnostic leaf. */\n classifySpam?: (n: NormalizedSubmission, turnstile: TurnstileOutcome) => SpamVerdict;\n /** Optional: fleet-wide duplicate/near-duplicate body lookup since `since`. Drives\n * the duplicate-spray signal — the same pitch blasted to many sites shows up as\n * identical (`exact`) or template-substituted (`similar`, token-set Jaccard) bodies.\n * Statuses let the retro re-bucket pick still-'new' prior copies; siteId+email let\n * the caller exempt a genuine same-sender resubmission on the same site. Absent →\n * the check is skipped (fail-open clean). */\n findRecentDuplicates?: (\n message: string,\n since: Date,\n ) => Promise<{\n exact: Array<{ id: string; status: string; siteId: string; email: string }>;\n similar: Array<{ id: string; status: string; siteId: string; email: string }>;\n }>;\n /** Optional: recent non-newsletter submissions from the same email since `since`.\n * Drives the cross-site repeat-sender signal — the fleet's sites are unrelated\n * businesses, so one address contacting 2+ of them within the window is a\n * solicitation tell. Absent → the check is skipped (fail-open clean). */\n listRecentSubmissionsForEmail?: (\n email: string,\n since: Date,\n ) => Promise<Array<{ id: string; siteId: string; status: string }>>;\n /** Optional: retroactively re-bucket prior still-'new' rows once a later copy\n * identifies a spray (the first copy is delivered by design). Implementations\n * must only touch status='new' rows. Best-effort — failures are swallowed. */\n retroBucket?: (ids: string[], reason: string) => Promise<void>;\n /** Optional: POST a newsletter submission to the site's configured webhook\n * (best-effort). Omitted in tests/handlers that don't need it. */\n forwardNewsletter?: (\n webhookUrl: string,\n submission: SubmissionRow,\n site: WebsiteRow,\n ) => Promise<{ ok: boolean; status: number }>;\n /** Optional: add a newsletter submitter to the site's Mailchimp audience\n * (best-effort). Omitted where unused. `tagged:false` reports a member who was\n * upserted but could NOT be tagged — recorded separately, see FANOUT below. */\n addToMailchimp?: (\n site: WebsiteRow,\n submission: SubmissionRow,\n ) => Promise<{ ok: boolean; status: number; tagged?: boolean }>;\n /** Optional: persist the newsletter fan-out outcome on the row (see FANOUT below).\n * Best-effort — failures are swallowed. Absent → the fan-out still runs, just\n * unrecorded (older callers and most tests). */\n stampFanout?: (id: string, fanoutStatus: string) => Promise<void>;\n /** Optional: run the best-effort tail (notify → stamp → fan-out) AFTER the\n * response instead of before it. Production passes Netlify's\n * `context.waitUntil`, which keeps the invocation alive until the promise\n * settles. Absent → the tail runs inline, exactly as it always did; this is a\n * latency change, never a behavioural one.\n *\n * Why it matters: the submitting site waits on this response under an abort\n * budget (forms/client.ts `INGEST_TIMEOUT_MS`). Once the row is written the\n * lead is captured, so every further second spent on email/webhook providers\n * is time in which a captured lead can still be reported to the visitor as a\n * failed submission — which is exactly what happened to 1836dig on\n * 2026-08-03. */\n defer?: (work: Promise<unknown>) => void;\n};\n\nexport type IngestResult =\n | { status: \"accepted\"; submissionId: string; notifyStatus: NotifyStatus | \"deferred\" }\n | { status: \"rejected\"; reason: \"invalid-payload\"; errors: string[] }\n | { status: \"unknown-site\"; slug: string };\n\nexport type ScreenOutDeps = {\n getWebsiteBySlug: (slug: string) => Promise<WebsiteRow | null>;\n recordScreenOut: (siteId: string, reason: \"honeypot\" | \"too-fast\") => Promise<void>;\n};\n\nexport type ScreenOutResult =\n { status: \"recorded\"; slug: string } | { status: \"unknown-site\"; slug: string };\n\n/** Extract the screen-out reason from a beacon body, or null if it isn't one.\n * The beacon key is the reserved `_screenOut` (underscore-namespaced like `_meta`,\n * see payload.ts). The bare `screenOut` key is the DEPRECATED pre-namespacing wire\n * shape — sites run older package versions for a while, so the central receiver\n * keeps accepting it. `_screenOut` wins when both are present. */\nexport function parseScreenOut(payload: unknown): \"honeypot\" | \"too-fast\" | null {\n if (!payload || typeof payload !== \"object\") return null;\n const body = payload as Record<string, unknown>;\n const v = \"_screenOut\" in body ? body[\"_screenOut\"] : body[\"screenOut\"];\n return v === \"honeypot\" || v === \"too-fast\" ? v : null;\n}\n\n/** Resolve the site and record a caught screen-out. Best-effort: a record failure is\n * the caller's to swallow — a missed count must never error a screened bot. */\nexport async function ingestScreenOut(\n deps: ScreenOutDeps,\n slug: string,\n reason: \"honeypot\" | \"too-fast\",\n): Promise<ScreenOutResult> {\n const site = await deps.getWebsiteBySlug(slug);\n if (!site) return { status: \"unknown-site\", slug };\n await deps.recordScreenOut(site.id, reason);\n return { status: \"recorded\", slug };\n}\n\n/**\n * Normalize → resolve site → persist → notify → stamp. The order is load-bearing:\n * the row is written BEFORE notify, and notify/stamp failures are swallowed (logged)\n * so a Resend or Airtable-write-back hiccup can never turn an accepted lead into a 502.\n */\nexport async function ingestSubmission(\n deps: IngestDeps,\n slug: string,\n rawPayload: unknown,\n turnstileInput: TurnstileOutcome | TurnstileVerification = \"unverifiable\",\n): Promise<IngestResult> {\n // Accept either the full verification (the production handler) or a bare outcome\n // string (older callers and virtually every test) — a string simply has no solved-\n // hostname to check. Normalized once so the body has a single shape to reason about.\n const verification: TurnstileVerification =\n typeof turnstileInput === \"string\"\n ? { outcome: turnstileInput, hostname: null }\n : turnstileInput;\n const turnstile = verification.outcome;\n const normalized = normalizeSubmission(rawPayload);\n if (!normalized.ok) {\n return { status: \"rejected\", reason: \"invalid-payload\", errors: normalized.errors };\n }\n const site = await deps.getWebsiteBySlug(slug);\n if (!site) return { status: \"unknown-site\", slug };\n\n // Synthetic end-to-end probe (the `form-e2e` fleet audit). A central-only marker\n // on the payload routes the submission away from EVERY real sink: no row is\n // persisted, no spam classification, no operator/autoresponder email, no\n // newsletter fan-out — and Turnstile enforcement is bypassed (the short-circuit\n // sits before that check). The marker therefore grants a bot NO benefit: it\n // reaches no inbox/DB/webhook, so skipping Turnstile costs nothing. This\n // suppression MUST be central — the submitting site alone cannot stop the real\n // inbox firing. Validity + site resolution are still enforced above (a junk body\n // is rejected, an unknown slug is unknown-site), so the marker can't smuggle\n // anything through. Return accepted+skipped so the probe asserts success.\n if (isTestMode(rawPayload)) {\n return { status: \"accepted\", submissionId: \"test-mode\", notifyStatus: \"skipped\" };\n }\n\n const n = normalized.value;\n\n // Fold the content signals + the Turnstile verdict into ONE spam decision.\n // Absent classifier → treat as clean (fail-open). A throwing classifier is\n // swallowed the same way — a bug in the heuristic must never turn an\n // otherwise-good lead into a 500; it just scores clean. On a `requireTurnstile`\n // site, both an ACTUAL \"fail\" (forged token) AND an \"absent\" token escalate to\n // auto-spam regardless of score — a real browser that renders the widget ALWAYS\n // sends a token, so a completely missing one is the direct-POST-bot signature. A\n // present-but-\"unverifiable\" token (expired/duplicate — a real browser DID render\n // the widget) stays neutral, as does anything on a site that hasn't opted in.\n let verdict: SpamVerdict = { score: 0, reasons: [] };\n if (deps.classifySpam) {\n try {\n verdict = deps.classifySpam(n, turnstile);\n } catch (err) {\n console.error(`[ingest] classifySpam threw: ${String(err)}`);\n }\n }\n const reasons = [...verdict.reasons];\n let status: SubmissionStatus = verdict.score >= SPAM_THRESHOLD ? \"spam_auto\" : \"new\";\n if (site.requireTurnstile && (turnstile === \"fail\" || turnstile === \"absent\")) {\n status = \"spam_auto\";\n const reason = turnstile === \"fail\" ? \"turnstile-required-failed\" : \"turnstile-required-absent\";\n if (!reasons.includes(reason)) reasons.push(reason);\n } else if (\n site.requireTurnstile &&\n turnstile === \"pass\" &&\n verification.hostname !== null &&\n !turnstileHostnameAcceptable(verification.hostname, site.url)\n ) {\n // Defense-in-depth vs token farming: the token PASSED, but Cloudflare says it was\n // solved on a host unrelated to this site. Cloudflare domain-binds sitekeys, so\n // this only trips on a loose widget allowlist — still, a passing token from a\n // foreign host accompanying a gated site's submission is not a real visitor. A\n // null hostname (older responses) or an unparseable site.url skips the check\n // entirely (fail-open); subdomains of the site's host (www., previews) match.\n status = \"spam_auto\";\n if (!reasons.includes(\"turnstile-required-hostname\"))\n reasons.push(\"turnstile-required-hostname\");\n }\n\n // Cross-site repeat-sender signal: the fleet's sites are UNRELATED businesses\n // (art gallery, realtor, home builder…), so the same email writing to 2+ different\n // sites within the window is a solicitation tell no single-message scan can see.\n // Same-site repeats alone must NOT trigger — those are genuine follow-ups. The\n // operator explicitly accepts overblocking here (spam_auto is recoverable). Prior\n // still-'new' rows on OTHER sites are retro-bucketed too: the first copy of a\n // spray is delivered by design, so the copy that identifies it also cleans the\n // queue. The scan runs EVEN WHEN the incoming copy is already spam_auto — after the\n // classifier began catching whole spray families, an already-bucketed copy was\n // skipping this scan and the retro cleanup never fired for exactly the sprays it\n // was built for; escalation/reason still only applies when not already spam.\n // Best-effort — a lookup failure never blocks a lead.\n if (n.formType !== \"newsletter\" && deps.listRecentSubmissionsForEmail) {\n try {\n const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);\n const prior = await deps.listRecentSubmissionsForEmail(n.email, since);\n const otherSites = prior.filter((p) => p.siteId !== site.id);\n if (otherSites.length > 0) {\n if (status !== \"spam_auto\") {\n status = \"spam_auto\";\n if (!reasons.includes(\"repeat-sender\")) reasons.push(\"repeat-sender\");\n }\n const retroIds = otherSites.filter((p) => p.status === \"new\").map((p) => p.id);\n if (deps.retroBucket && retroIds.length > 0) {\n try {\n await deps.retroBucket(retroIds, \"retro:repeat-sender\");\n } catch (err) {\n console.error(`[ingest] retroBucket (repeat-sender) threw: ${String(err)}`);\n }\n }\n }\n } catch (err) {\n console.error(`[ingest] listRecentSubmissionsForEmail threw: ${String(err)}`);\n }\n }\n\n // Duplicate/spray signal: the same pitch blasted across the fleet (or repeated) is\n // a bot tell a lone content scan can't see. An identical body ('exact') OR a\n // template-substituted near-copy ('similar' — the live dog-harness spray differed\n // only in greeting; SEO sprays swap the target domain per site) → auto-spam\n // (recoverable). Prior still-'new' copies are retro-bucketed for the same reason\n // as above, and the scan runs even when the incoming copy is already spam_auto\n // (see the repeat-sender note). GENUINE-RESUBMIT EXEMPTION: a match from the SAME\n // sender on the SAME site is a real visitor double-submitting or resending after\n // silence, not spray evidence — without this exemption the resend was silently\n // bucketed AND retro-flipped the delivered original, vanishing an active lead\n // with no signal. Only cross-site or different-sender copies count. Guarded:\n // non-newsletter forms with a real body; the db helper ignores short bodies /\n // small token sets. Best-effort — a lookup failure never blocks a lead.\n if (n.formType !== \"newsletter\" && n.message !== undefined && deps.findRecentDuplicates) {\n try {\n const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);\n const dupes = await deps.findRecentDuplicates(n.message, since);\n const senderEmail = n.email.trim().toLowerCase();\n const isOwnResend = (m: { siteId: string; email: string }) =>\n m.siteId === site.id && m.email.trim().toLowerCase() === senderEmail;\n const exact = dupes.exact.filter((m) => !isOwnResend(m));\n const similar = dupes.similar.filter((m) => !isOwnResend(m));\n const reason =\n exact.length > 0 ? \"duplicate-body\" : similar.length > 0 ? \"similar-body\" : null;\n if (reason !== null) {\n if (status !== \"spam_auto\") {\n status = \"spam_auto\";\n if (!reasons.includes(reason)) reasons.push(reason);\n }\n const retroIds = [...exact, ...similar].filter((m) => m.status === \"new\").map((m) => m.id);\n if (deps.retroBucket && retroIds.length > 0) {\n try {\n await deps.retroBucket(retroIds, \"retro:duplicate-body\");\n } catch (err) {\n console.error(`[ingest] retroBucket (duplicate-body) threw: ${String(err)}`);\n }\n }\n }\n } catch (err) {\n console.error(`[ingest] findRecentDuplicates threw: ${String(err)}`);\n }\n }\n const spamReason = reasons.length > 0 ? reasons.join(\",\") : null;\n\n const row = await deps.createSubmission({\n siteId: site.id,\n formType: n.formType,\n name: n.name,\n email: n.email,\n extraFields: n.extraFields,\n status,\n spamScore: verdict.score,\n spamReason,\n // Optional fields spread only when present — exactOptionalPropertyTypes\n // forbids assigning `undefined` to an optional `phone?: string` etc.\n ...(n.phone !== undefined ? { phone: n.phone } : {}),\n ...(n.message !== undefined ? { message: n.message } : {}),\n ...(n.sourceUrl !== undefined ? { sourceUrl: n.sourceUrl } : {}),\n ...(n.utm !== undefined ? { utm: n.utm } : {}),\n submittedAt: deps.now(),\n });\n\n // Auto-spam (and operator-marked spam) is captured but silent: no operator\n // email, no autoresponder, no newsletter fan-out. Skip notify entirely and\n // record the honest \"skipped\" stamp. notify.ts also nulls both builders for\n // these statuses (defense in depth), but short-circuiting here means the\n // injected notify dep is never even invoked for a spam row.\n const isSpam = row.status === \"spam_auto\" || row.status === \"spam\";\n\n // ── best-effort tail ───────────────────────────────────────────────────────\n // notify → stamp → newsletter fan-out. NOTHING below can cost the lead: the\n // row is already durable, and every step here is swallowed+logged rather than\n // allowed to turn an accepted lead into a 502. That is precisely why it does\n // not belong on the visitor's critical path — the submitting site waits on\n // this response under an abort budget (forms/client.ts INGEST_TIMEOUT_MS), so\n // Resend latency used to be able to make a captured lead read as a failed\n // submission in the visitor's browser (1836dig, 2026-08-03).\n //\n // `deps.defer` (Netlify's `context.waitUntil` in production) runs the tail\n // AFTER the response goes out. Absent — every test, and any runtime without\n // post-response execution — it runs inline exactly as before, so this is a\n // latency change and never a behavioural one.\n const runTail = async (): Promise<NotifyStatus> => {\n let notify: { status: NotifyStatus; messageId: string | null };\n if (isSpam) {\n notify = { status: \"skipped\", messageId: null };\n } else {\n try {\n notify = await deps.notify(site, row);\n } catch (err) {\n console.error(`[ingest] notify threw: ${String(err)}`);\n notify = { status: \"failed\", messageId: null };\n }\n }\n try {\n await deps.stampNotified(row.id, notify.status, notify.messageId);\n } catch (err) {\n console.error(`[ingest] stampNotified failed: ${String(err)}`);\n }\n\n await runFanout();\n return notify.status;\n };\n\n // Newsletter fan-out: each configured destination fires best-effort and is\n // swallowed+logged — the lead is already persisted; never turn it into a 502.\n // Guarded on the row status so a spam signup is never forwarded to a site\n // webhook or added to a Mailchimp audience.\n //\n // FANOUT provenance (2026-07-31): the outcome is ALSO recorded on the row, one\n // `<destination>:<outcome>` token per attempt, comma-joined in attempt order —\n // `webhook:ok,mailchimp:401`. Outcome is `ok`, the HTTP status of a non-2xx\n // (`0` for a network error / misconfiguration, see mailchimp.ts), or `threw`.\n // `mailchimp-tags:failed` is appended when the member was upserted but the tag\n // write did not land. Until this existed the results were console.error-only, so\n // an expired Mailchimp key stopped signups reaching the audience while the row\n // still read `notify=sent` — healthy-looking, with the failure visible nowhere the\n // operator ever looks. Nothing attempted → nothing stamped (null stays \"no\n // destination configured\", NOT \"we tried and lost the answer\"). The stamp is\n // best-effort like everything else here: a provenance write must never cost a lead.\n // A const arrow, not a hoisted declaration: `site` is narrowed non-null above,\n // and that narrowing only survives into a closure created after it. Declared\n // below runTail (which calls it) but before either call site, so there is no TDZ.\n const runFanout = async (): Promise<void> => {\n const fanout: string[] = [];\n if (n.formType === \"newsletter\" && !isSpam) {\n if (site.newsletterWebhook && deps.forwardNewsletter) {\n try {\n const fwd = await deps.forwardNewsletter(site.newsletterWebhook, row, site);\n if (!fwd.ok)\n console.error(`[ingest] newsletter webhook → ${fwd.status} for ${site.name}`);\n fanout.push(fwd.ok ? \"webhook:ok\" : `webhook:${fwd.status}`);\n } catch (err) {\n console.error(`[ingest] newsletter webhook threw: ${String(err)}`);\n fanout.push(\"webhook:threw\");\n }\n }\n if (site.mailchimpApiKey && site.mailchimpAudienceId && deps.addToMailchimp) {\n try {\n const mc = await deps.addToMailchimp(site, row);\n if (!mc.ok) console.error(`[ingest] mailchimp add → ${mc.status} for ${site.name}`);\n fanout.push(mc.ok ? \"mailchimp:ok\" : `mailchimp:${mc.status}`);\n // Only meaningful alongside a successful add — a failed add was never tagged\n // either, and one token per real problem keeps the row readable.\n if (mc.ok && mc.tagged === false) {\n console.error(`[ingest] mailchimp tags not applied for ${site.name}`);\n fanout.push(\"mailchimp-tags:failed\");\n }\n } catch (err) {\n console.error(`[ingest] mailchimp add threw: ${String(err)}`);\n fanout.push(\"mailchimp:threw\");\n }\n }\n }\n if (fanout.length > 0 && deps.stampFanout) {\n try {\n await deps.stampFanout(row.id, fanout.join(\",\"));\n } catch (err) {\n console.error(`[ingest] stampFanout failed: ${String(err)}`);\n }\n }\n };\n\n if (deps.defer) {\n // A rejection escaping here would land as an unhandled rejection in the\n // platform's post-response context, where nothing is left to catch it — so\n // the boundary is made unrejectable. Everything inside is already swallowed;\n // this guards the shape, not a known throw.\n deps.defer(\n runTail().catch((err) => {\n console.error(`[ingest] deferred tail threw: ${String(err)}`);\n }),\n );\n // The lead is durable and the response can go out now. The real notify\n // outcome lands on the ROW via stampNotified moments later — this field is\n // only ever read by callers wanting the in-request outcome, which by\n // definition does not exist once the tail is deferred.\n return { status: \"accepted\", submissionId: row.id, notifyStatus: \"deferred\" };\n }\n return { status: \"accepted\", submissionId: row.id, notifyStatus: await runTail() };\n}\n\n/** True when `a` and `b` are the same host or one is a subdomain of the other\n * (case-insensitive): `www.reddoorla.com` vs `reddoorla.com` matches both ways.\n * Exported for tests. PURE. */\nexport function hostsMatch(a: string, b: string): boolean {\n const ha = a.trim().toLowerCase();\n const hb = b.trim().toLowerCase();\n if (ha.length === 0 || hb.length === 0) return false;\n return ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`);\n}\n\n/** Whether a passing token's solved-hostname is acceptable for the site at `siteUrl`.\n * An unparseable/hostless `siteUrl` returns TRUE — the check self-disables rather\n * than punishing a possibly-real visitor for an operator data problem (fail-open,\n * same philosophy as verifyTurnstile). PURE. */\nexport function turnstileHostnameAcceptable(tokenHostname: string, siteUrl: string): boolean {\n let siteHost: string;\n try {\n siteHost = new URL(siteUrl).hostname;\n } catch {\n return true;\n }\n if (!siteHost) return true;\n return hostsMatch(tokenHostname, siteHost);\n}\n\n/** True when an untrusted ingest payload carries the synthetic-probe marker\n * (top-level `testMode: true`). Read from the RAW payload so the branch never\n * depends on normalization internals; any non-`true` value is ignored (a real\n * visitor's form never sets it — the starter only forwards it when the submitted\n * form field `testMode` equals \"true\"). */\nexport function isTestMode(rawPayload: unknown): boolean {\n if (!rawPayload || typeof rawPayload !== \"object\") return false;\n return (rawPayload as Record<string, unknown>).testMode === true;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { SubmissionRow, NotifyStatus } from \"../reports/submission-row.js\";\nimport type { ResendSendInput } from \"../reports/send/resend.js\";\nimport { hostsMatch } from \"./ingest.js\";\nimport { escapeHtml } from \"../util/html.js\";\nimport { operatorEmail } from \"../util/operator.js\";\n\nconst FORMS_FROM = \"forms@reddoorla.com\";\n// Reply-To only: a client replying to a lead notification should reach the\n// shared inbox. The RECIPIENT fallback is the operator's own — see util/operator.\nconst FALLBACK_REPLY_TO = \"info@reddoorla.com\";\n\n/** Strip characters that would break an RFC 5322 display name. */\nfunction displayName(raw: string): string {\n return raw.replace(/[\"\\r\\n]/g, \"\").trim() || \"Reddoor\";\n}\n\nfunction pocAddress(site: WebsiteRow): string | null {\n return site.pointOfContact ?? site.reportRecipientsTo ?? null;\n}\n\n/** Coerce a string|string[] recipient config into a clean, de-duped address list. */\nfunction normalizeRecipients(v: string | string[] | undefined): string[] {\n const arr = Array.isArray(v) ? v : v ? [v] : [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const raw of arr) {\n if (typeof raw !== \"string\") continue;\n const t = raw.trim();\n if (t && !seen.has(t)) {\n seen.add(t);\n out.push(t);\n }\n }\n return out;\n}\n\nexport type Recipients = { to: string[]; cc: string[] };\n\n/**\n * Where a submission notification goes.\n * - Pre-launch (status !== \"maintenance\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launch period\" to route tests to\n * yourself).\n * - Maintenance + a `Notify Routing` config: address by the routing field's value\n * (`extraFields[field]`) → its matched route, else the config `default`; CC from\n * the config. If nothing resolves, fall through to the single POC.\n * - Maintenance, no routing: the single site POC (pointOfContact ?? reportRecipientsTo).\n * Returns null only when nothing resolves — the lead is still persisted; notify skips.\n */\nexport function resolveRecipients(site: WebsiteRow, submission: SubmissionRow): Recipients | null {\n if (site.status !== \"maintenance\") {\n return { to: [operatorEmail()], cc: [] };\n }\n const routing = site.notifyRouting;\n if (routing) {\n const value = parseExtraFields(submission.extraFields)[routing.field];\n const match = typeof value === \"string\" ? routing.routes[value] : undefined;\n const to = normalizeRecipients(match ?? routing.default);\n if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };\n // routing matched nothing → fall through to the POC below\n }\n const poc = pocAddress(site);\n return poc ? { to: [poc], cc: [] } : null;\n}\n\n/** Who a submission to this site would notify, answered WITHOUT sending one. */\nexport type NotifyTarget = {\n /** `client` means a real point of contact receives it. `operator` means the\n * pre-launch guard is holding and only you do. `nobody` means nothing\n * resolves — the lead is still stored, but no notification goes out. */\n audience: \"client\" | \"operator\" | \"nobody\";\n /** Every address a submission could reach, across ALL routing branches. */\n to: string[];\n cc: string[];\n /** Plain-language reason, naming the field that decided it. */\n reason: string;\n};\n\n/** A submission that exists only to ask resolveRecipients a question. */\nfunction probeSubmission(extraFields: string | null): SubmissionRow {\n return {\n id: \"probe\",\n submissionId: null,\n siteId: \"probe\",\n formType: \"contact\" as SubmissionRow[\"formType\"],\n name: \"probe\",\n email: \"probe@example.com\",\n phone: null,\n message: null,\n extraFields,\n sourceUrl: null,\n utm: null,\n submittedAt: null,\n status: \"new\" as SubmissionRow[\"status\"],\n notifyStatus: \"pending\" as NotifyStatus,\n resendMessageId: null,\n };\n}\n\n/**\n * Resolve who a form submission would notify, for a site, right now.\n *\n * This exists because the pre-launch guard had no feedback. Its whole state is\n * one Airtable `Status` cell nobody is looking at while testing, the site\n * reports nothing about it, and the only way to learn the answer was to submit\n * a form and read the `resend_message_id` afterwards. On 2026-08-03 an intended\n * flip never landed and a real client received a test lead — an email cannot be\n * recalled. A guard whose state you cannot see is a guard you will eventually\n * assume is on.\n *\n * Every address here comes back out of `resolveRecipients` itself rather than\n * from a second reading of the same rules — for a routed site each branch is\n * probed with a synthetic submission and the results unioned. A copy of the\n * routing logic that drifted would be worse than no answer at all, because it\n * would be a confident wrong one.\n */\nexport function describeNotifyTarget(site: WebsiteRow): NotifyTarget {\n if (site.status !== \"maintenance\") {\n const guarded = resolveRecipients(site, probeSubmission(null));\n return {\n audience: \"operator\",\n to: guarded?.to ?? [],\n cc: [],\n reason: `Status is ${site.status === null ? \"blank\" : `\"${site.status}\"`}, not \"maintenance\" — the pre-launch guard routes every notification to the operator.`,\n };\n }\n\n const routing = site.notifyRouting;\n if (!routing) {\n const direct = resolveRecipients(site, probeSubmission(null));\n if (!direct || direct.to.length === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" but the site has no Point of Contact and no Notify Routing — the lead is stored and no notification is sent.`,\n };\n }\n return {\n audience: \"client\",\n to: direct.to,\n cc: direct.cc,\n reason: `Status is \"maintenance\" — notifications go to the site's Point of Contact.`,\n };\n }\n\n // Probe every declared route value, plus one value that matches none so the\n // `default` (and the POC fall-through behind it) is covered too.\n const to = new Set<string>();\n const cc = new Set<string>();\n const values = [...Object.keys(routing.routes), \"\u0000no-such-route\"];\n for (const value of values) {\n const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));\n r?.to.forEach((a) => to.add(a));\n r?.cc.forEach((a) => cc.add(a));\n }\n if (to.size === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\", but no route, default or Point of Contact resolves to an address.`,\n };\n }\n return {\n audience: \"client\",\n to: [...to],\n cc: [...cc],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\" — the recipient depends on that field's value; every address it could reach is listed.`,\n };\n}\n\n/** Humanize an extraFields key for display: \"appointment_date\" → \"Appointment date\". */\nfunction humanizeKey(k: string): string {\n const spaced = k.replace(/[_-]+/g, \" \").trim();\n return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;\n}\n\nfunction formatValue(v: unknown): string {\n if (v === null || v === undefined) return \"—\";\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n return JSON.stringify(v);\n}\n\n/** Parse the stored `extraFields` JSON into a plain object — bad JSON or a\n * non-object yields {} (never throws). Shared by the email renderer and routing. */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n /* malformed JSON → no fields */\n }\n return {};\n}\n\nfunction extraFieldRows(raw: string | null): Array<[string, string]> {\n return Object.entries(parseExtraFields(raw))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string]);\n}\n\nfunction fieldsTable(submission: SubmissionRow): string {\n const rows: Array<[string, string]> = [\n [\"Form\", submission.formType],\n [\"Name\", submission.name || \"—\"],\n [\"Email\", submission.email || \"—\"],\n ];\n if (submission.phone) rows.push([\"Phone\", submission.phone]);\n // Site-specific context (the artwork an inquiry is about, the event an rsvp is\n // for, etc.) lives in extraFields — surface it so the recipient sees what the\n // submitter was looking at, not just their name and email.\n rows.push(...extraFieldRows(submission.extraFields));\n if (submission.sourceUrl) rows.push([\"Page\", submission.sourceUrl]);\n if (submission.utm) rows.push([\"UTM\", submission.utm]);\n const body = rows\n .map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`)\n .join(\"\");\n const message = submission.message\n ? `<p style=\"white-space:pre-wrap\">${escapeHtml(submission.message)}</p>`\n : \"\";\n return `<table>${body}</table>${message}`;\n}\n\n/** POC notification — the primary email; null when the site has no contact address. */\nexport function buildPocNotification(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n const recipients = resolveRecipients(site, submission);\n if (!recipients || recipients.to.length === 0) return null;\n const input: ResendSendInput = {\n from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,\n to: recipients.to,\n subject: `New ${submission.formType} from ${site.name}`,\n html: `<h2>New ${escapeHtml(submission.formType)} submission — ${escapeHtml(\n site.name,\n )}</h2>${fieldsTable(submission)}`,\n };\n if (recipients.cc.length > 0) input.cc = recipients.cc;\n // Reply straight to the lead.\n if (submission.email) input.replyTo = submission.email;\n return input;\n}\n\n/** Autoresponder to the submitter — null when there's no submitter email. */\nexport function buildAutoresponder(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n if (!submission.email) return null;\n // A submitter email on the site's OWN domain is never a real lead wanting a\n // confirmation — it is the spoofed-sender backscatter case (a bot writes the\n // site's info@ as its email and the \"We got your message\" lands in the\n // client's inbox). The POC notification still sends; only this email is\n // suppressed. Unparseable/blank site url → fail open and send, same\n // philosophy as turnstileHostnameAcceptable.\n const emailDomain = submission.email.split(\"@\").pop() ?? \"\";\n let siteHost = \"\";\n try {\n siteHost = new URL(site.url).hostname;\n } catch {\n /* fail open */\n }\n if (siteHost && hostsMatch(emailDomain, siteHost)) return null;\n const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;\n const contact = site.copyContact ?? \"We've received your message and will be in touch soon.\";\n const footer = site.copyFooter ?? site.name;\n return {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject: \"We got your message\",\n html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`,\n };\n}\n\nexport type NotifyDeps = {\n send: (input: ResendSendInput) => Promise<{ messageId: string }>;\n};\n\nexport type NotifyOutcome = { status: NotifyStatus; messageId: string | null };\n\n/**\n * Send the POC notification (primary — drives notifyStatus) then the submitter\n * autoresponder (best-effort — logged, never changes the outcome). The submission\n * is already persisted before this runs, so a Resend outage degrades to\n * notifyStatus=\"failed\", never a lost lead.\n */\nexport async function notifySubmission(\n deps: NotifyDeps,\n site: WebsiteRow,\n submission: SubmissionRow,\n): Promise<NotifyOutcome> {\n const poc = buildPocNotification(site, submission);\n let outcome: NotifyOutcome;\n if (!poc) {\n outcome = { status: \"skipped\", messageId: null };\n } else {\n try {\n const { messageId } = await deps.send(poc);\n outcome = { status: \"sent\", messageId };\n } catch (err) {\n console.error(`[submissions] POC notification failed: ${String(err)}`);\n outcome = { status: \"failed\", messageId: null };\n }\n }\n const auto = buildAutoresponder(site, submission);\n if (auto) {\n try {\n await deps.send(auto);\n } catch (err) {\n console.error(`[submissions] autoresponder failed: ${String(err)}`);\n }\n }\n return outcome;\n}\n\n/**\n * Build the ingest `notify` dependency from a Resend send fn — or `null` when the\n * Resend client couldn't even be constructed (e.g. `RESEND_API_KEY` unset). A null\n * send marks the notification `failed` WITHOUT attempting it, so a Resend\n * misconfiguration degrades to a captured-but-unemailed lead rather than aborting\n * ingest and losing it. Mirrors the in-flight failure isolation in notifySubmission.\n */\nexport function makeNotify(\n send: NotifyDeps[\"send\"] | null,\n): (site: WebsiteRow, submission: SubmissionRow) => Promise<NotifyOutcome> {\n return (site, submission) =>\n send\n ? notifySubmission({ send }, site, submission)\n : Promise.resolve({ status: \"failed\", messageId: null });\n}\n","import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosting\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintenance\";\nexport const VERIFY_STATUS: Status = \"launch period\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n const to = deps.set === \"on\" ? VERIFY_STATUS : (deps.restore?.trim() as Status | undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosting\" or \"legacy\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintenance\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n await updateSiteField(base, row.id, STATUS_COLUMN, to!);\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n flip: { from: row.status, to: to!, confirmed: after.status === to },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n const result = await formsNotifyTarget({\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  DEFAULT_INIT_STEPS,
8
8
  a11yFixturesPage,
9
9
  init
10
- } from "./chunk-6V7565O2.js";
10
+ } from "./chunk-WYYQK4D3.js";
11
11
  import {
12
12
  onboard,
13
13
  selfCaretRange,
@@ -36,7 +36,7 @@ import {
36
36
  } from "./chunk-LS6QPANO.js";
37
37
  import "./chunk-J7SZQUCW.js";
38
38
  import "./chunk-R6FPFMOS.js";
39
- import "./chunk-R4AB6GCT.js";
39
+ import "./chunk-S56JQJ4F.js";
40
40
  import "./chunk-R53VL3RI.js";
41
41
  import {
42
42
  parseAddresses,
@@ -78,7 +78,7 @@ import {
78
78
  runAudits,
79
79
  runAuditsAcross,
80
80
  securityAudit
81
- } from "./chunk-QRDB4NLB.js";
81
+ } from "./chunk-DTTIFYAM.js";
82
82
  import "./chunk-LKGVSM2O.js";
83
83
  import "./chunk-GASBX52O.js";
84
84
  import "./chunk-VRWUL4TR.js";
@@ -107,7 +107,7 @@ import {
107
107
  import "./chunk-HN6I2XWI.js";
108
108
  import "./chunk-6VAL7XJT.js";
109
109
  import "./chunk-NMAWXBJA.js";
110
- import "./chunk-N4MOEF32.js";
110
+ import "./chunk-C6B4RHXR.js";
111
111
 
112
112
  // src/recipes/index.ts
113
113
  var ALL_RECIPE_NAMES = [
@@ -8,19 +8,19 @@ import {
8
8
  } from "./chunk-7J53SUT6.js";
9
9
  import {
10
10
  init
11
- } from "./chunk-6V7565O2.js";
11
+ } from "./chunk-WYYQK4D3.js";
12
12
  import "./chunk-UYCAF77E.js";
13
13
  import "./chunk-RJB3QUSP.js";
14
14
  import "./chunk-LS6QPANO.js";
15
15
  import "./chunk-J7SZQUCW.js";
16
16
  import "./chunk-R6FPFMOS.js";
17
- import "./chunk-R4AB6GCT.js";
17
+ import "./chunk-S56JQJ4F.js";
18
18
  import "./chunk-R53VL3RI.js";
19
19
  import "./chunk-Q276MZ7E.js";
20
20
  import "./chunk-73UFRZKY.js";
21
21
  import "./chunk-ZTPPCHDB.js";
22
22
  import "./chunk-OTGB6YQO.js";
23
- import "./chunk-QRDB4NLB.js";
23
+ import "./chunk-DTTIFYAM.js";
24
24
  import "./chunk-LKGVSM2O.js";
25
25
  import "./chunk-GASBX52O.js";
26
26
  import "./chunk-VRWUL4TR.js";
@@ -34,7 +34,7 @@ import {
34
34
  import "./chunk-XTK5VIQB.js";
35
35
  import "./chunk-U5SZ4FOP.js";
36
36
  import "./chunk-NMAWXBJA.js";
37
- import "./chunk-N4MOEF32.js";
37
+ import "./chunk-C6B4RHXR.js";
38
38
 
39
39
  // src/cli/commands/init.ts
40
40
  import { resolve } from "path";
@@ -107,4 +107,4 @@ async function runInitCommand(site, opts) {
107
107
  export {
108
108
  runInitCommand
109
109
  };
110
- //# sourceMappingURL=init-TFVAN5OY.js.map
110
+ //# sourceMappingURL=init-JN26NTWF.js.map
@@ -30,7 +30,7 @@ import "./chunk-SEEA6RXF.js";
30
30
  import "./chunk-73UFRZKY.js";
31
31
  import {
32
32
  runAudits
33
- } from "./chunk-QRDB4NLB.js";
33
+ } from "./chunk-DTTIFYAM.js";
34
34
  import "./chunk-LKGVSM2O.js";
35
35
  import "./chunk-GASBX52O.js";
36
36
  import "./chunk-VRWUL4TR.js";
@@ -53,7 +53,7 @@ import {
53
53
  } from "./chunk-HN6I2XWI.js";
54
54
  import "./chunk-6VAL7XJT.js";
55
55
  import "./chunk-NMAWXBJA.js";
56
- import "./chunk-N4MOEF32.js";
56
+ import "./chunk-C6B4RHXR.js";
57
57
 
58
58
  // src/cli/commands/launch.ts
59
59
  import { resolve } from "path";
@@ -216,4 +216,4 @@ async function runLaunchCommand(site, opts) {
216
216
  export {
217
217
  runLaunchCommand
218
218
  };
219
- //# sourceMappingURL=launch-DWY2MF54.js.map
219
+ //# sourceMappingURL=launch-JA7Z4CIM.js.map
@@ -1,3 +1,6 @@
1
+ import {
2
+ operatorEmail
3
+ } from "./chunk-5ML7FMBD.js";
1
4
  import {
2
5
  findDueReports,
3
6
  nextDueDate,
@@ -73,7 +76,7 @@ function dashboardBaseUrl() {
73
76
  }
74
77
  async function runReportCommand(slug, opts) {
75
78
  if (opts.digest) {
76
- const { runDigest } = await import("./digest-SQ5U6SN2.js");
79
+ const { runDigest } = await import("./digest-UVHYZUYE.js");
77
80
  return runDigest({ baseUrl: dashboardBaseUrl() });
78
81
  }
79
82
  if (opts.sendReady) {
@@ -109,7 +112,7 @@ async function runDueDraft() {
109
112
  async function alertOnFleetAnalyticsFailure(health) {
110
113
  if (!assessAnalyticsAlert(health).fire) return;
111
114
  try {
112
- const to = process.env.OPERATOR_EMAIL?.trim() || "info@reddoorla.com";
115
+ const to = operatorEmail();
113
116
  const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());
114
117
  const { defaultResendClient } = await import("./resend-G2BJMAQV.js");
115
118
  await defaultResendClient().send({
@@ -279,4 +282,4 @@ export {
279
282
  parseSingleSiteReportType,
280
283
  runReportCommand
281
284
  };
282
- //# sourceMappingURL=report-WG6RHA2X.js.map
285
+ //# sourceMappingURL=report-6CKSWJNG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/alerts/analytics-health.ts","../src/cli/commands/report.ts"],"sourcesContent":["import { escapeHtml } from \"../util/html.js\";\n\n/**\n * The GA/Search enrichment outcome of one `report --due` draft run, aggregated\n * across the sites it drafted. `softFailedSites` is how many drafted sites had GA\n * OR Search enrichment ERROR (not \"not configured\" — that's a legitimate skip);\n * `configuredSites` is how many drafted sites had analytics configured at all\n * (the subject is set AND the site has a GA4 property or a search query). The ratio\n * is what distinguishes a fleet-wide subject outage from a single transient blip.\n */\nexport type AnalyticsRunHealth = {\n softFailedSites: number;\n configuredSites: number;\n};\n\nexport type AnalyticsAlert = { fire: boolean; reason: string };\n\n/** A lone failure never alerts — that's a per-site config issue, not the shared-\n * subject SPOF. Two-plus AND a majority of configured sites is the fleet signature. */\nconst MIN_FAILED_SITES = 2;\n\n/**\n * Decide whether a draft run's GA/Search soft-failures look FLEET-WIDE — i.e. the\n * one impersonated subject (`GA_SUBJECT`) lost access — rather than one site's\n * transient blip. PURE. Fires when at least {@link MIN_FAILED_SITES} configured\n * sites failed AND they are a majority (≥ half) of the analytics-configured sites\n * this run. With <2 configured sites it can't distinguish a SPOF from a one-off, so\n * it never fires (at fleet scale there are always many configured sites).\n */\nexport function assessAnalyticsAlert(h: AnalyticsRunHealth): AnalyticsAlert {\n const { softFailedSites, configuredSites } = h;\n const fire =\n configuredSites >= 2 &&\n softFailedSites >= MIN_FAILED_SITES &&\n softFailedSites * 2 >= configuredSites;\n const reason = fire\n ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run — the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.`\n : \"\";\n return { fire, reason };\n}\n\n/**\n * Compose the operator alert email for a fleet-wide analytics failure. PURE — the\n * caller (the `report --due` cron) sends it best-effort via Resend only when\n * {@link assessAnalyticsAlert} fires. `dashboardUrl` is the fleet homepage link.\n */\nexport function composeAnalyticsAlertEmail(\n h: AnalyticsRunHealth,\n dashboardUrl: string,\n): { subject: string; html: string } {\n const subject = `⚠ Fleet analytics enrichment failing — ${h.softFailedSites}/${h.configuredSites} sites`;\n const { reason } = assessAnalyticsAlert(h);\n const html = `<p><strong>${escapeHtml(reason)}</strong></p>\n<p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send — but with blank analytics — until the subject is restored.</p>\n<p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>\n<p><a href=\"${escapeHtml(dashboardUrl)}\">Open the fleet dashboard →</a></p>`;\n return { subject, html };\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateNextDueDates,\n type WebsiteRow,\n} from \"../../reports/airtable/websites.js\";\nimport { listAllReports, type ReportRow } from \"../../reports/airtable/reports.js\";\nimport { findDueReports, nextDueDate, reportPeriodKey } from \"../../reports/due.js\";\nimport { draftReportForSite } from \"../../reports/draft.js\";\nimport { reportTier } from \"../../reports/queue.js\";\nimport { readGaConfig } from \"../../reports/ga/config.js\";\nimport {\n assessAnalyticsAlert,\n composeAnalyticsAlertEmail,\n type AnalyticsRunHealth,\n} from \"../../alerts/analytics-health.js\";\nimport type { ReportType } from \"../../reports/types.js\";\nimport { operatorEmail } from \"../../util/operator.js\";\n\nexport type ReportCommandOptions = {\n due?: boolean;\n preview?: boolean;\n enrich?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n type?: string;\n cwd?: string;\n};\n\n/**\n * Summary line for a drafted report, reflecting the single-queue outcome. `queued === false`\n * means a higher-or-equal-tier report was already pending for the site, so this draft was\n * created but deliberately left OUT of the approve queue. A non-empty `supersededIds` means it\n * un-queued that many lower-tier drafts. `null` is the previewOnly path (no Airtable queue).\n */\nfunction draftLine(\n reportId: string | undefined,\n queued: boolean | null,\n supersededIds: string[],\n verb = \"drafted\",\n): string {\n const id = reportId ?? \"(unknown)\";\n if (queued === false) {\n return `• ${verb} but NOT queued: ${id} — a higher-or-equal-tier report is already pending approval`;\n }\n const sup =\n supersededIds.length > 0\n ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? \"s\" : \"\"})`\n : \"\";\n return `✓ ${verb}: ${id}${sup}`;\n}\n\n/**\n * Parse the single-site `--type` flag. Only Maintenance and Testing are draftable\n * this way — Launch has the `launch <site>` command and Announcement has\n * `announce <site>`, each with its own purpose-built flow. Case-insensitive;\n * defaults to Maintenance (the historical single-site behaviour). Throws an\n * exitCode-2 usage error on anything else. PURE.\n */\nexport function parseSingleSiteReportType(raw: string | undefined): ReportType {\n if (raw === undefined || raw.trim() === \"\") return \"Maintenance\";\n const norm = raw.trim().toLowerCase();\n if (norm === \"maintenance\") return \"Maintenance\";\n if (norm === \"testing\") return \"Testing\";\n const hint =\n norm === \"launch\"\n ? \" — use the `launch <site>` command\"\n : norm === \"announcement\"\n ? \" — use the `announce <site>` command\"\n : \"\";\n throw Object.assign(\n new Error(`--type must be Maintenance or Testing (got ${JSON.stringify(raw)})${hint}`),\n { exitCode: 2 },\n );\n}\n\n/** Dashboard origin for digest /s/<slug> links. DASHBOARD_BASE_URL overrides the\n * production default; the trailing slash (if any) is trimmed by runDigest. */\nfunction dashboardBaseUrl(): string {\n return process.env.DASHBOARD_BASE_URL?.trim() || \"https://reddoor-maintenance.netlify.app\";\n}\n\nexport async function runReportCommand(\n slug: string | undefined,\n opts: ReportCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (opts.digest) {\n const { runDigest } = await import(\"../../reports/digest.js\");\n return runDigest({ baseUrl: dashboardBaseUrl() });\n }\n\n if (opts.sendReady) {\n const { sendApprovedReports } = await import(\"../../reports/send/orchestrate.js\");\n return sendApprovedReports();\n }\n\n if (opts.due) {\n return runDueDraft();\n }\n\n if (slug) {\n // Validate the type BEFORE any Airtable access so a bad --type fails fast (and\n // without needing credentials).\n const reportType = parseSingleSiteReportType(opts.type);\n return runSingleSiteDraft(slug, {\n previewOnly: Boolean(opts.preview),\n enrich: Boolean(opts.enrich),\n reportType,\n });\n }\n\n throw Object.assign(\n new Error(\n \"Usage: reddoor-maint report [<slug>] [--type <Maintenance|Testing>] [--due] [--preview] [--enrich] [--send-ready] [--digest]\",\n ),\n {\n exitCode: 2,\n },\n );\n}\n\nasync function runDueDraft(): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const result = await draftDueReports(base, new Date());\n await alertOnFleetAnalyticsFailure(result.health);\n return { output: result.output, code: result.code };\n}\n\n/** Best-effort: when a draft run's GA/Search soft-failures look FLEET-WIDE (the shared\n * GA_SUBJECT lost access — see assessAnalyticsAlert), email the operator one alert.\n * NEVER throws — a Resend or config hiccup must not fail the nightly draft cron; the\n * per-site `⚠ GA skipped` warnings + the run-output line still carry the signal. The\n * daily idempotency key dedupes multiple runs in one day (Resend dedupes 24h). */\nasync function alertOnFleetAnalyticsFailure(health: AnalyticsRunHealth): Promise<void> {\n if (!assessAnalyticsAlert(health).fire) return;\n try {\n const to = operatorEmail();\n const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());\n const { defaultResendClient } = await import(\"../../reports/send/resend.js\");\n await defaultResendClient().send({\n from: \"Reddoor Reports <reports@reddoorla.com>\",\n to: [to],\n subject,\n html,\n idempotencyKey: `analytics-alert-${new Date().toISOString().slice(0, 10)}`,\n });\n console.warn(`⚠ ${subject} — operator alerted (${to})`);\n } catch (e) {\n console.warn(`⚠ analytics-failure alert send failed: ${(e as Error).message}`);\n }\n}\n\n/**\n * Write each site's code-computed next-maintenance / next-testing date back to Airtable\n * (date-only, or null when there's no schedule), so the \"next\" dates shown there derive\n * from the SAME `nextDueDate` the scheduler uses — replacing the old Airtable formula +\n * automation. Best-effort and per-site isolated: a missing `Next … at` column or one bad\n * row warns and is skipped, never aborting the nightly draft run.\n */\nasync function writeNextDueDates(\n base: AirtableBase,\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n): Promise<void> {\n const ymd = (d: Date | null): string | null => (d ? d.toISOString().slice(0, 10) : null);\n for (const site of websites) {\n try {\n await updateNextDueDates(base, site.id, {\n maintenanceAt: ymd(nextDueDate(site, reports, \"Maintenance\", today)),\n testingAt: ymd(nextDueDate(site, reports, \"Testing\", today)),\n });\n } catch (e) {\n console.warn(`⚠ next-due write skipped for ${site.name}: ${(e as Error).message}`);\n }\n }\n}\n\nexport async function draftDueReports(\n base: AirtableBase,\n today: Date,\n): Promise<{ output: string; code: number; health: AnalyticsRunHealth }> {\n const websites = await listWebsites(base);\n // ONE unfiltered fetch for the whole fleet. Per-site queries can't be pushed to\n // Airtable anyway (linked-record fields aren't formula-filterable by record id),\n // and findDueReports + the period guard below match on siteId in memory.\n const reports = await listAllReports(base);\n\n // Refresh every site's code-owned next-due dates first, so they stay current even on\n // a run where nothing is due (the early return below).\n await writeNextDueDates(base, websites, reports, today);\n\n const due = findDueReports(websites, reports, today);\n\n // GA/Search enrichment is configured globally (the impersonation subject) AND\n // per-site (a GA4 property or a search query). `gaConfigured` is the global half;\n // the per-site half is checked as each draft runs, to build the fleet-wide\n // analytics-failure alert's denominator (see alertOnFleetAnalyticsFailure).\n const gaConfigured = readGaConfig() !== null;\n // Truthy (not `!== null`) to mirror fetchGaUsers/fetchSearch's own gate exactly\n // (`!siteRow.ga4PropertyId`), so an empty-string cell counts as not-configured in\n // BOTH places and can't inflate the alert denominator.\n const isAnalyticsConfigured = (s: WebsiteRow): boolean =>\n gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);\n\n if (due.length === 0) {\n return {\n output: \"No reports due.\",\n code: 0,\n health: { softFailedSites: 0, configuredSites: 0 },\n };\n }\n\n const lines: string[] = [];\n let softFailedSites = 0;\n let searchDefaultMisses = 0;\n let searchPropertiesMissing = 0;\n let gaConfiguredSites = 0;\n let skipped = 0;\n for (const item of due) {\n // Idempotency: a re-run must not re-draft a (site, type) already drafted this\n // recurrence. The dueDate's YYYY-MM is the stable per-cycle key. Match against the\n // reports we already fetched — no extra query on the hot path.\n const period = reportPeriodKey(item.dueDate);\n const existing = reports.find(\n (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period,\n );\n\n // A row already exists for THIS period. Two cases:\n // - Draft ready → truly done, skip (the idempotent re-run path).\n // - NOT ready → a crash between createDraft and setDraftReady wedged it: the\n // row exists (so we never re-draft) yet it's never sendable (listSendable\n // needs Draft ready). COMPLETE it in place instead of skipping forever —\n // re-render → re-upload the HTML → flip Draft ready on the EXISTING row.\n if (existing) {\n if (existing.draftReady) {\n skipped++;\n lines.push(`• skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);\n continue;\n }\n // A not-ready row is normally a crash between createDraft and setDraftReady — re-complete\n // it in place. BUT queueDraft also clears Draft ready on rows it supersedes/blocks, and\n // those must NOT be re-completed: doing so would re-render and APPEND a duplicate HTML\n // attachment every nightly run, only to be re-blocked. Distinguish the two: if a\n // higher-or-equal-tier report is still pending for this site, this row was intentionally\n // un-queued (not crashed) — skip it until the blocker is sent/approved or the month rolls.\n const blockedByPending = reports.some(\n (r) =>\n r.siteId === item.site.id &&\n r.id !== existing.id &&\n r.sentAt === null &&\n r.draftReady &&\n reportTier(r.reportType) >= reportTier(item.reportType),\n );\n if (blockedByPending) {\n skipped++;\n lines.push(\n `• skipped (superseded — a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`,\n );\n continue;\n }\n try {\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n completeRowId: existing.id,\n existingRow: existing,\n });\n existing.draftReady = result.queued === true;\n lines.push(\n draftLine(\n result.reportRow?.reportId ?? existing.reportId,\n result.queued,\n result.supersededIds,\n \"completed half-made draft\",\n ),\n );\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n continue;\n }\n\n // Pile-up guard: don't accrue a fresh new-period draft every recurrence for a\n // site nobody ever approves. The period key follows the DUE month, so each\n // recurrence wants a new (later-period) draft — but if a PRIOR draft is still\n // pending approval, a new one just stacks. Skip creating the new one while an\n // earlier-period draft for this (site, type) sits ready-but-unsent.\n //\n // `r.draftReady` is load-bearing: a draft a higher tier SUPERSEDED has\n // draftReady=false and never gets a Sent at, so without this clause it would\n // match (sentAt null + earlier period) and block EVERY future draft for the\n // site forever. Pending-approval means draftReady=true AND sentAt=null.\n const pendingEarlier = reports.find(\n (r) =>\n r.siteId === item.site.id &&\n r.reportType === item.reportType &&\n r.draftReady &&\n r.sentAt === null &&\n r.period !== null &&\n r.period < period,\n );\n if (pendingEarlier) {\n skipped++;\n lines.push(\n `• skipped: ${item.site.name} ${item.reportType} already has an unsent ${pendingEarlier.period} draft pending approval`,\n );\n continue;\n }\n\n try {\n // Pass the SAME key the guard searches by, so the stamped Period always\n // matches a future run's reportPeriodKey(dueDate) — even if this run lags\n // into a later month than the dueDate.\n const result = await draftReportForSite(base, item.site, item.reportType, { period });\n lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));\n // Keep the in-memory snapshot current so the guard's `.some()` check on the\n // NEXT iteration of this same run catches a row we JUST created — rather than\n // relying on findDueReports never emitting two items for the same (site, type).\n if (result.reportRow) reports.push(result.reportRow);\n // Count sites (not individual GA/Search failures) so a fleet-wide enrichment\n // outage is one obvious line at the bottom, not 200 buried console.warns.\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n }\n if (skipped > 0) {\n lines.push(`• ${skipped} already drafted or pending this period`);\n }\n if (softFailedSites > 0) {\n lines.push(\n `⚠ ${softFailedSites} site${softFailedSites === 1 ? \"\" : \"s\"} had GA/Search enrichment fail — drafted with blank analytics; check the logs above`,\n );\n }\n if (searchDefaultMisses > 0) {\n lines.push(\n `⚑ ${searchDefaultMisses} site${searchDefaultMisses === 1 ? \"\" : \"s\"} returned no Search Console data for their name — set an explicit \"Search query\" in Airtable to track brand presence.`,\n );\n }\n if (searchPropertiesMissing > 0) {\n lines.push(\n `⚑ ${searchPropertiesMissing} site${searchPropertiesMissing === 1 ? \"\" : \"s\"} matched NO Search Console property — verify the domain property exists and the service account has access (a \"Search query\" change cannot fix this).`,\n );\n }\n return {\n output: lines.join(\"\\n\"),\n code: lines.some((l) => l.startsWith(\"✗\")) ? 1 : 0,\n health: { softFailedSites, configuredSites: gaConfiguredSites },\n };\n}\n\nasync function runSingleSiteDraft(\n slug: string,\n opts: { previewOnly: boolean; enrich: boolean; reportType: ReportType },\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const site = websites.find((w) => siteSlug(w.name) === slug);\n if (!site) {\n throw Object.assign(new Error(`No Websites row matched slug \"${slug}\"`), { exitCode: 2 });\n }\n const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {\n previewOnly: opts.previewOnly,\n // Only forced on. Left undefined, draftReportForSite keeps its default\n // (enrich iff there is a base), so the real drafting path is untouched.\n ...(opts.enrich ? { enrich: true } : {}),\n });\n if (opts.previewOnly) {\n return { output: `Preview written to ${result.htmlPath}`, code: 0 };\n }\n return {\n output: draftLine(\n result.reportRow?.reportId,\n result.queued,\n result.supersededIds,\n \"Draft created\",\n ),\n code: 0,\n };\n}\n"],"mappings":"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","names":[]}
@@ -2,6 +2,9 @@ import {
2
2
  buildReportDataForSite,
3
3
  scoresFromRow
4
4
  } from "./chunk-GZNBCHAZ.js";
5
+ import {
6
+ operatorEmail
7
+ } from "./chunk-5ML7FMBD.js";
5
8
  import {
6
9
  ELIGIBLE_STATUSES
7
10
  } from "./chunk-D4FVAIEJ.js";
@@ -43,7 +46,7 @@ import { dirname } from "path";
43
46
  var FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
44
47
  var REPLY_TO = "info@reddoorla.com";
45
48
  function resolveRecipients(to) {
46
- const operator = process.env.OPERATOR_EMAIL?.trim() || "info@reddoorla.com";
49
+ const operator = operatorEmail();
47
50
  const parsed = to ? parseAddresses(to) : null;
48
51
  const list = parsed ?? [operator];
49
52
  for (const addr of list) {
@@ -162,4 +165,4 @@ async function runSelftestCommand(kind, site, opts) {
162
165
  export {
163
166
  runSelftestCommand
164
167
  };
165
- //# sourceMappingURL=selftest-BVCLJRNI.js.map
168
+ //# sourceMappingURL=selftest-CQ6NOX52.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/selftest-email.ts","../src/cli/commands/selftest.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug } from \"../reports/airtable/websites.js\";\nimport { ELIGIBLE_STATUSES } from \"../reports/due.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { fetchAttachmentBytes } from \"../reports/airtable/attachments.js\";\nimport { prepareHeaderImage } from \"../reports/maintenance-email/header-image.js\";\nimport { buildReportDataForSite, scoresFromRow } from \"../reports/report-data.js\";\nimport { renderReportEmail } from \"../reports/send/render-email.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport { parseAddresses, isProbablyEmail } from \"../reports/send/orchestrate.js\";\nimport { operatorEmail } from \"../util/operator.js\";\nimport type { ReportType } from \"../reports/types.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\nexport type SelftestEmailDeps = {\n /** Airtable handle (read-only here). Defaults to the live base from credentials. */\n base?: AirtableBase;\n /** Resend client. Defaults to the real client. */\n resend?: ResendClient;\n /** Single-site slug. Mutually exclusive with `all`. */\n site?: string;\n /** All `maintenance` sites (one email each). Mutually exclusive with `site`. */\n all?: boolean;\n /** Report type to preview. Default \"Announcement\". */\n type?: ReportType;\n /** Raw `--to` (comma- or newline-separated). Default: the operator inbox. */\n to?: string;\n /** Render only; write reports/<slug>/selftest-<type>.html, never send. */\n dryRun?: boolean;\n /** Single timestamp driving the window + completedOn. */\n now?: Date;\n};\n\nexport type SelftestEmailSiteResult =\n | { site: string; status: \"sent\" | \"dry-run\"; subject: string; recipients: string[] }\n | { site: string; status: \"skipped\"; reason: string }\n | { site: string; status: \"error\"; message: string };\n\nexport type SelftestEmailResult = { results: SelftestEmailSiteResult[] };\n\n/** Resolve the recipient list: explicit `--to` (validated) else the operator default. */\nfunction resolveRecipients(to: string | undefined): string[] {\n const operator = operatorEmail();\n const parsed = to ? parseAddresses(to) : null;\n const list = parsed ?? [operator];\n for (const addr of list) {\n if (!isProbablyEmail(addr)) {\n throw Object.assign(new Error(`--to has a malformed address: ${addr}`), { exitCode: 2 });\n }\n }\n return list;\n}\n\n/**\n * Send (or dry-render) a single report email per target site to the operator/`--to`, with NO\n * Airtable side effects (no draft, queue, or stamp). Mirrors the production render+send via the\n * shared `renderReportEmail` seam, so the preview matches a real send. One bad site never aborts\n * `--all` (per-site try/catch). Sites missing stored scores or a header image are skipped.\n */\nexport async function selftestEmail(deps: SelftestEmailDeps): Promise<SelftestEmailResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const resend = deps.resend ?? defaultResendClient();\n const type: ReportType = deps.type ?? \"Announcement\";\n const now = deps.now ?? new Date();\n const recipients = resolveRecipients(deps.to);\n\n const websites = await listWebsites(base);\n let targets: WebsiteRow[];\n if (deps.all) {\n // The report-eligible set (maintenance + hosting), not a hard-coded \"maintenance\" —\n // the latter silently excluded hosting sites and implied a type↔status coupling that\n // doesn't exist (the requested --type drives the rendered template; single-site mode\n // applies no status filter at all).\n targets = websites.filter((w) => w.status !== null && ELIGIBLE_STATUSES.has(w.status));\n } else if (deps.site) {\n const wanted = siteSlug(deps.site);\n targets = websites.filter((w) => siteSlug(w.name) === wanted);\n } else {\n throw Object.assign(new Error(\"Provide a site slug or --all\"), { exitCode: 2 });\n }\n\n const results: SelftestEmailSiteResult[] = [];\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (!scores) {\n results.push({ site: w.name, status: \"skipped\", reason: \"missing Lighthouse scores\" });\n continue;\n }\n if (!w.headerImage) {\n results.push({ site: w.name, status: \"skipped\", reason: \"no Header image\" });\n continue;\n }\n const original = await fetchAttachmentBytes(w.headerImage.url);\n const header = await prepareHeaderImage(original.bytes);\n const slug = siteSlug(w.name);\n const reportData = await buildReportDataForSite(w, type, now, { scores, header });\n const { html, attachments, subject } = await renderReportEmail(reportData, {\n header,\n cidName: `${slug}-header`,\n });\n\n if (deps.dryRun) {\n const path = `reports/${slug}/selftest-${type.toLowerCase()}.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n results.push({ site: w.name, status: \"dry-run\", subject, recipients });\n continue;\n }\n\n await resend.send({\n from: FROM_ADDRESS,\n to: recipients,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n });\n results.push({ site: w.name, status: \"sent\", subject, recipients });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n","import { selftestEmail, type SelftestEmailSiteResult } from \"../../recipes/selftest-email.js\";\nimport type { ReportType } from \"../../reports/types.js\";\n\nexport type SelftestCommandOptions = {\n type?: string;\n to?: string;\n all?: boolean;\n dryRun?: boolean;\n cwd?: string;\n};\n\nconst TYPES: Record<string, ReportType> = {\n announcement: \"Announcement\",\n maintenance: \"Maintenance\",\n testing: \"Testing\",\n launch: \"Launch\",\n};\n\nfunction formatResult(r: SelftestEmailSiteResult): string {\n if (r.status === \"skipped\") return `[${r.site}] skipped — ${r.reason}`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n return `[${r.site}] ${r.status} — \"${r.subject}\" → ${r.recipients.join(\", \")}`;\n}\n\n/**\n * `selftest <kind> [site]` — operator self-tests. The only kind today is `email`: preview a\n * report email for one site (or `--all` maintenance sites) to the operator/`--to`, with no\n * Airtable side effects. Validates kind/type and the site-xor-all rule before doing any work.\n */\nexport async function runSelftestCommand(\n kind: string,\n site: string | undefined,\n opts: SelftestCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (kind !== \"email\") {\n return { output: `Unknown selftest kind '${kind}'. Supported: email`, code: 2 };\n }\n if (Boolean(site) === Boolean(opts.all)) {\n return { output: \"Provide exactly one of <site> or --all.\", code: 2 };\n }\n const typeKey = (opts.type ?? \"announcement\").toLowerCase();\n const type = TYPES[typeKey];\n if (!type) {\n return {\n output: `Unknown --type '${opts.type}'. Supported: ${Object.keys(TYPES).join(\", \")}`,\n code: 2,\n };\n }\n\n try {\n const { results } = await selftestEmail({\n ...(site ? { site } : {}),\n ...(opts.all ? { all: true } : {}),\n type,\n ...(opts.to ? { to: opts.to } : {}),\n ...(opts.dryRun ? { dryRun: true } : {}),\n });\n const output =\n results.length === 0 ? \"No matching sites.\" : results.map(formatResult).join(\"\\n\");\n const code = results.some((r) => r.status === \"error\") ? 1 : 0;\n return { output, code };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,OAAO,iBAAiB;AACjC,SAAS,eAAe;AAexB,IAAM,eAAe;AACrB,IAAM,WAAW;AA6BjB,SAAS,kBAAkB,IAAkC;AAC3D,QAAM,WAAW,cAAc;AAC/B,QAAM,SAAS,KAAK,eAAe,EAAE,IAAI;AACzC,QAAM,OAAO,UAAU,CAAC,QAAQ;AAChC,aAAW,QAAQ,MAAM;AACvB,QAAI,CAAC,gBAAgB,IAAI,GAAG;AAC1B,YAAM,OAAO,OAAO,IAAI,MAAM,iCAAiC,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC;AAAA,IACzF;AAAA,EACF;AACA,SAAO;AACT;AAQA,eAAsB,cAAc,MAAuD;AACzF,QAAM,OAAO,KAAK,QAAQ,SAAS,mBAAmB,CAAC;AACvD,QAAM,SAAS,KAAK,UAAU,oBAAoB;AAClD,QAAM,OAAmB,KAAK,QAAQ;AACtC,QAAM,MAAM,KAAK,OAAO,oBAAI,KAAK;AACjC,QAAM,aAAa,kBAAkB,KAAK,EAAE;AAE5C,QAAM,WAAW,MAAM,aAAa,IAAI;AACxC,MAAI;AACJ,MAAI,KAAK,KAAK;AAKZ,cAAU,SAAS,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ,kBAAkB,IAAI,EAAE,MAAM,CAAC;AAAA,EACvF,WAAW,KAAK,MAAM;AACpB,UAAM,SAAS,SAAS,KAAK,IAAI;AACjC,cAAU,SAAS,OAAO,CAAC,MAAM,SAAS,EAAE,IAAI,MAAM,MAAM;AAAA,EAC9D,OAAO;AACL,UAAM,OAAO,OAAO,IAAI,MAAM,8BAA8B,GAAG,EAAE,UAAU,EAAE,CAAC;AAAA,EAChF;AAEA,QAAM,UAAqC,CAAC;AAC5C,aAAW,KAAK,SAAS;AACvB,QAAI;AACF,YAAM,SAAS,cAAc,CAAC;AAC9B,UAAI,CAAC,QAAQ;AACX,gBAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,WAAW,QAAQ,4BAA4B,CAAC;AACrF;AAAA,MACF;AACA,UAAI,CAAC,EAAE,aAAa;AAClB,gBAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,WAAW,QAAQ,kBAAkB,CAAC;AAC3E;AAAA,MACF;AACA,YAAM,WAAW,MAAM,qBAAqB,EAAE,YAAY,GAAG;AAC7D,YAAM,SAAS,MAAM,mBAAmB,SAAS,KAAK;AACtD,YAAM,OAAO,SAAS,EAAE,IAAI;AAC5B,YAAM,aAAa,MAAM,uBAAuB,GAAG,MAAM,KAAK,EAAE,QAAQ,OAAO,CAAC;AAChF,YAAM,EAAE,MAAM,aAAa,QAAQ,IAAI,MAAM,kBAAkB,YAAY;AAAA,QACzE;AAAA,QACA,SAAS,GAAG,IAAI;AAAA,MAClB,CAAC;AAED,UAAI,KAAK,QAAQ;AACf,cAAM,OAAO,WAAW,IAAI,aAAa,KAAK,YAAY,CAAC;AAC3D,cAAM,MAAM,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAC9C,cAAM,UAAU,MAAM,MAAM,OAAO;AACnC,gBAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,WAAW,SAAS,WAAW,CAAC;AACrE;AAAA,MACF;AAEA,YAAM,OAAO,KAAK;AAAA,QAChB,MAAM;AAAA,QACN,IAAI;AAAA,QACJ,SAAS;AAAA,QACT;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AACD,cAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,QAAQ,SAAS,WAAW,CAAC;AAAA,IACpE,SAAS,KAAK;AACZ,cAAQ,KAAK;AAAA,QACX,MAAM,EAAE;AAAA,QACR,QAAQ;AAAA,QACR,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MAC1D,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ;AACnB;;;AC3HA,IAAM,QAAoC;AAAA,EACxC,cAAc;AAAA,EACd,aAAa;AAAA,EACb,SAAS;AAAA,EACT,QAAQ;AACV;AAEA,SAAS,aAAa,GAAoC;AACxD,MAAI,EAAE,WAAW,UAAW,QAAO,IAAI,EAAE,IAAI,oBAAe,EAAE,MAAM;AACpE,MAAI,EAAE,WAAW,QAAS,QAAO,IAAI,EAAE,IAAI,YAAY,EAAE,OAAO;AAChE,SAAO,IAAI,EAAE,IAAI,KAAK,EAAE,MAAM,YAAO,EAAE,OAAO,YAAO,EAAE,WAAW,KAAK,IAAI,CAAC;AAC9E;AAOA,eAAsB,mBACpB,MACA,MACA,MAC2C;AAC3C,MAAI,SAAS,SAAS;AACpB,WAAO,EAAE,QAAQ,0BAA0B,IAAI,uBAAuB,MAAM,EAAE;AAAA,EAChF;AACA,MAAI,QAAQ,IAAI,MAAM,QAAQ,KAAK,GAAG,GAAG;AACvC,WAAO,EAAE,QAAQ,2CAA2C,MAAM,EAAE;AAAA,EACtE;AACA,QAAM,WAAW,KAAK,QAAQ,gBAAgB,YAAY;AAC1D,QAAM,OAAO,MAAM,OAAO;AAC1B,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,MACL,QAAQ,mBAAmB,KAAK,IAAI,iBAAiB,OAAO,KAAK,KAAK,EAAE,KAAK,IAAI,CAAC;AAAA,MAClF,MAAM;AAAA,IACR;AAAA,EACF;AAEA,MAAI;AACF,UAAM,EAAE,QAAQ,IAAI,MAAM,cAAc;AAAA,MACtC,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,MACvB,GAAI,KAAK,MAAM,EAAE,KAAK,KAAK,IAAI,CAAC;AAAA,MAChC;AAAA,MACA,GAAI,KAAK,KAAK,EAAE,IAAI,KAAK,GAAG,IAAI,CAAC;AAAA,MACjC,GAAI,KAAK,SAAS,EAAE,QAAQ,KAAK,IAAI,CAAC;AAAA,IACxC,CAAC;AACD,UAAM,SACJ,QAAQ,WAAW,IAAI,uBAAuB,QAAQ,IAAI,YAAY,EAAE,KAAK,IAAI;AACnF,UAAM,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,WAAW,OAAO,IAAI,IAAI;AAC7D,WAAO,EAAE,QAAQ,KAAK;AAAA,EACxB,SAAS,KAAK;AACZ,UAAM,IAAI;AACV,WAAO,EAAE,QAAQ,EAAE,WAAW,OAAO,GAAG,GAAG,MAAM,EAAE,YAAY,EAAE;AAAA,EACnE;AACF;","names":[]}
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-7J53SUT6.js";
12
12
  import {
13
13
  smokeSuite
14
- } from "./chunk-R4AB6GCT.js";
14
+ } from "./chunk-S56JQJ4F.js";
15
15
  import "./chunk-R53VL3RI.js";
16
16
  import "./chunk-ZTPPCHDB.js";
17
17
  import "./chunk-FDIFTIXC.js";
@@ -51,4 +51,4 @@ async function runSmokeSuiteCommand(site, opts) {
51
51
  export {
52
52
  runSmokeSuiteCommand
53
53
  };
54
- //# sourceMappingURL=smoke-suite-OBWNPALQ.js.map
54
+ //# sourceMappingURL=smoke-suite-TMCSOYRK.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddoorla/maintenance",
3
- "version": "0.85.0",
3
+ "version": "0.85.2",
4
4
  "description": "Canonical maintenance configs, audits, and recipes for the reddoor stack.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/configs/playwright-a11y.ts"],"sourcesContent":["import { defineConfig, devices, type PlaywrightTestConfig } from \"@playwright/test\";\n\nexport type A11yRoute = { path: string; name: string };\n\nexport const a11yRoutes: A11yRoute[] = [\n { path: \"/dev/a11y-fixtures\", name: \"a11y fixtures\" },\n { path: \"/dev/animate-in\", name: \"animate-in demo\" },\n];\n\n// Routes smoke-loaded for client-side (hydration) errors only — NOT axe-scanned.\n// Catches the class of bug where build + SSR succeed but client hydration throws\n// and blanks the page (data-dynamiq 2026-06-09: a Svelte 4->5 `run()` referenced\n// a `$state` declared after it → TDZ ReferenceError on hydrate). `/` is the one\n// route every site has; real routes carry a11y debt we don't gate on here, so we\n// assert only that they don't crash on hydrate.\nexport const smokeRoutes: A11yRoute[] = [{ path: \"/\", name: \"home\" }];\n\n// R1.1 (health-gate): the central `smoke` audit (src/audits/smoke.ts) allocates\n// a free port and passes it as REDDOOR_SMOKE_PORT so a zombie vite already\n// squatting the default 5173 can't silently hijack the run and green a stale\n// build. The per-site R1.1 config template honors it, but sites whose\n// playwright.config.ts merely re-exports this shared base (pre-R1.1 adopters\n// the smoke-suite recipe flags-but-never-rewrites) would otherwise ignore it —\n// so honor it here too and every re-exporter inherits the port binding on its\n// next package bump. Unset (local `pnpm test:smoke`) → the fixed 5173.\nconst smokePort = process.env.REDDOOR_SMOKE_PORT;\nconst port = smokePort || \"5173\";\n\n// NOTE: default export only — sites consume this as `import base from\n// \"@reddoorla/maintenance/configs/playwright-a11y\"` (or re-export the default).\n// The old `playwrightA11yConfig` named alias had zero importers and was removed.\nconst playwrightA11yConfig: PlaywrightTestConfig = defineConfig({\n testDir: \"tests\",\n testMatch: /.*\\.spec\\.ts$/,\n fullyParallel: true,\n forbidOnly: !!process.env.CI,\n retries: process.env.CI ? 2 : 0,\n reporter: process.env.CI ? \"github\" : \"list\",\n use: {\n baseURL: `http://localhost:${port}`,\n trace: \"on-first-retry\",\n },\n projects: [\n {\n name: \"chromium\",\n use: { ...devices[\"Desktop Chrome\"] },\n },\n ],\n webServer: {\n // Portable across pnpm and npm sites — pnpm respects `npm run` too.\n //\n // `--port ... --strictPort` in BOTH cases. It used to be applied only when\n // REDDOOR_SMOKE_PORT allocated one, on the reasoning that we should \"fail\n // loudly rather than let vite drift to a free port the baseURL doesn't\n // point at\" — but that argument covers the unset case just as well. 5173 is\n // equally a fixed port that `baseURL` and the readiness probe below are\n // pinned to, and vite left to itself drifts off it whenever something else\n // holds it.\n //\n // The symptom that exposed this: a non-vite process on 5173 sends vite to\n // 5174 while the probe keeps polling 5173, so the run dies on\n // \"Timed out waiting 120000ms from config.webServer\" — 120 seconds of\n // nothing, naming neither the port nor the squatter. With --strictPort it\n // is an immediate \"Port 5173 is already in use\".\n //\n // This does NOT overlap with `reuseExistingServer`: that check runs first,\n // so a dev server already serving the probe URL is still reused and the\n // command never executes. --strictPort only bites when 5173 is held by\n // something that is not the server under test, which is exactly the case\n // worth failing on.\n command: `npm run vite:dev -- --port ${port} --strictPort`,\n url: `http://localhost:${port}/dev/a11y-fixtures`,\n reuseExistingServer: !process.env.CI,\n timeout: 120_000,\n },\n});\n\nexport default playwrightA11yConfig;\n"],"mappings":";AAAA,SAAS,cAAc,eAA0C;AAI1D,IAAM,aAA0B;AAAA,EACrC,EAAE,MAAM,sBAAsB,MAAM,gBAAgB;AAAA,EACpD,EAAE,MAAM,mBAAmB,MAAM,kBAAkB;AACrD;AAQO,IAAM,cAA2B,CAAC,EAAE,MAAM,KAAK,MAAM,OAAO,CAAC;AAUpE,IAAM,YAAY,QAAQ,IAAI;AAC9B,IAAM,OAAO,aAAa;AAK1B,IAAM,uBAA6C,aAAa;AAAA,EAC9D,SAAS;AAAA,EACT,WAAW;AAAA,EACX,eAAe;AAAA,EACf,YAAY,CAAC,CAAC,QAAQ,IAAI;AAAA,EAC1B,SAAS,QAAQ,IAAI,KAAK,IAAI;AAAA,EAC9B,UAAU,QAAQ,IAAI,KAAK,WAAW;AAAA,EACtC,KAAK;AAAA,IACH,SAAS,oBAAoB,IAAI;AAAA,IACjC,OAAO;AAAA,EACT;AAAA,EACA,UAAU;AAAA,IACR;AAAA,MACE,MAAM;AAAA,MACN,KAAK,EAAE,GAAG,QAAQ,gBAAgB,EAAE;AAAA,IACtC;AAAA,EACF;AAAA,EACA,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAsBT,SAAS,8BAA8B,IAAI;AAAA,IAC3C,KAAK,oBAAoB,IAAI;AAAA,IAC7B,qBAAqB,CAAC,QAAQ,IAAI;AAAA,IAClC,SAAS;AAAA,EACX;AACF,CAAC;AAED,IAAO,0BAAQ;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/reports/digest.ts"],"sourcesContent":["// src/reports/digest.ts\nimport { openBase, readAirtableConfig, type AirtableBase } from \"./airtable/client.js\";\nimport { listAllReports, isPendingApproval } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { listWebsites, siteSlug, type WebsiteRow } from \"./airtable/websites.js\";\nimport { defaultResendClient, type ResendClient } from \"./send/resend.js\";\nimport { isIdempotencyConflict } from \"./send/idempotency.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n collectPrismicDriftAlerts,\n NOTIFY_BOUNCE_WINDOW_DAYS,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention, readDigestState, writeDigestState } from \"../alerts/digest-state.js\";\nimport { escapeHtml as esc } from \"../util/html.js\";\nimport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n// Type-only: erased at compile time, so the kysely/libsql devDependency never loads\n// in a consuming fleet site (the runtime read is the dynamic import in\n// fetchSubmissionsDigestCounts, same rule as fetchNotifyBounceCounts).\nimport type { SiteSubmissionCounts } from \"../db/submissions.js\";\n\n// The attention/digest contract lives in `../alerts/attention.ts` (a dependency-free\n// types module) so the `alerts/*` collectors can depend on it without importing back\n// from this renderer/IO module — see attention.ts for the cycle it breaks. Re-exported\n// here so existing `from \"./digest.js\"` type importers keep resolving.\nexport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n\nconst GREY = \"#757575\";\nconst RED = \"#C00\";\n\n/** Shared anchor style — Gmail does not inherit font-family into <a> tags. */\nconst ANCHOR_STYLE = `color:${RED};font-family:helvetica,sans-serif`;\n\nfunction readySection(items: ReadyItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Ready for your yes</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">Nothing waiting on you.</p>`;\n }\n const rows = items\n .map((it) => {\n const safeUrl = it.dashboardUrl.startsWith(\"https://\") ? it.dashboardUrl : undefined;\n const link = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">review &amp; approve</a>`\n : `review &amp; approve`;\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(it.siteName)}</strong> — ${esc(it.reportType)} (${esc(it.period)})\n — ${link}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${rows}</table>`;\n}\n\nconst SEVERITY_ORDER: Record<AttentionSeverity, number> = { critical: 0, warning: 1 };\n\n/** Render the per-item status badge (\"NEW\"/\"WORSE\"); standing items get nothing. */\nfunction attentionBadge(status?: AttentionStatus): string {\n if (status === \"new\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">NEW</strong> `;\n if (status === \"worse\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">WORSE</strong> `;\n return \"\";\n}\n\nfunction attentionSection(items: AttentionItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Needs attention</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">All clear — nothing needs attention.</p>`;\n }\n\n // Group by siteName, preserving first-seen site order; sort within a site by\n // severity (critical first).\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of items) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const groups = [...bySite.entries()]\n .map(([siteName, siteItems]) => {\n const sorted = [...siteItems].sort(\n (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],\n );\n const rows = sorted\n .map((it) => {\n const safeUrl = it.url?.startsWith(\"https://\") ? it.url : undefined;\n const titleHtml = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">${esc(it.title)}</a>`\n : esc(it.title);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">${attentionBadge(it.status)}${titleHtml}</td>\n </tr>`;\n })\n .join(\"\");\n return `\n <tr>\n <td style=\"color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px\">${esc(siteName)}</td>\n </tr>\n ${rows}`;\n })\n .join(\"\");\n\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${groups}</table>`;\n}\n\n/** The \"Submissions (24h)\" telemetry block: fleet totals + a per-site glance line.\n * Context, not an alarm — absent/null section or all-zero totals render nothing\n * (no-noise), and the section never triggers a send on its own (see runDigest). */\nfunction submissionsSection(s: SubmissionsDigestSection | null | undefined): string {\n if (!s) return \"\";\n if (s.leads === 0 && s.signups === 0 && s.spamAuto === 0) return \"\";\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Submissions (24h)</h2>`;\n // All three terms always render together — omitting a zero term next to a nonzero\n // one reads as \"not measured\" rather than \"none\"; simplest honest form.\n const totals = [\n `${s.leads} new lead${s.leads === 1 ? \"\" : \"s\"}`,\n `${s.signups} newsletter/RSVP signup${s.signups === 1 ? \"\" : \"s\"}`,\n `${s.spamAuto} auto-filtered spam`,\n ].join(\" · \");\n const totalLine = `<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">${totals}</p>`;\n if (s.bySite.length === 0) return `${heading}${totalLine}`;\n const rows = s.bySite\n .map((site) => {\n const parts: string[] = [];\n if (site.leads > 0) parts.push(`${site.leads} lead${site.leads === 1 ? \"\" : \"s\"}`);\n if (site.signups > 0) parts.push(`${site.signups} signup${site.signups === 1 ? \"\" : \"s\"}`);\n if (site.spamAuto > 0) parts.push(`${site.spamAuto} auto-filtered`);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(site.siteName)}</strong> — ${parts.join(\" · \")}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}${totalLine}<table role=\"presentation\" style=\"border-collapse:collapse;margin:8px 0 0\">${rows}</table>`;\n}\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n/** Single-operator fleet — fallback when OPERATOR_EMAIL is unset. */\nconst DIGEST_OPERATOR_FALLBACK = \"info@reddoorla.com\";\n\n/** UTC \"YYYY-MM-DD\" — the Resend idempotency key suffix, so a same-day cron re-fire dedupes. */\nfunction digestDateKey(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The gate for \"Ready for your yes\": Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n *\n * Implemented as `listAllReports(base).filter(...)` (the authorized deviation from the plan's\n * draft, which pre-dated Slice 1's merged fix): `listAllReports` already calls `mapRow`, which\n * handles `Period` correctly, so no local `rawToReportRow` duplicate is needed. The JS filter\n * does real work — the test fake does NOT evaluate `filterByFormula` — so correctness is\n * test-provable here.\n *\n * Exported: the fleet homepage (Task 3.5b) reuses it for the pending-approval count.\n */\nexport async function listPendingApproval(base: AirtableBase): Promise<ReportRow[]> {\n return (await listAllReports(base)).filter(isPendingApproval);\n}\n\n// ── collectAttention (IO wrapper, sibling to runDigest) ──────────────────────\n\nexport type CollectAttentionDeps = {\n base: AirtableBase;\n /** Same baseUrl value runDigest threads; used for the /s/<slug> links. */\n baseUrl: string;\n /** Pre-fetched Websites rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listWebsites`. */\n websites?: WebsiteRow[];\n /** Pre-fetched Reports rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listAllReports`. */\n reports?: ReportRow[];\n /** Clock for the GitHub-signals staleness gate (collectCiAlerts /\n * collectRenovateAlerts skip a >3-day-stale sweep). Defaults to wall-clock;\n * runDigest threads its run-start `today` so a quiet repo's frozen CI/Renovate\n * signal stops badging once its sweep goes stale. */\n now?: Date;\n /** Pre-fetched per-site bounced-lead-notification counts (tests, or a caller with\n * an open db). When omitted, collectAttention reads them from libSQL itself —\n * defensively, so a missing TURSO env or a Turso blip drops just this signal. */\n notifyBounces?: ReadonlyMap<string, number>;\n};\n\n/** Per-site bounced-notification counts for the notify-bounce collector, read from\n * libSQL over the collector's own window. The db modules are imported DYNAMICALLY:\n * src/db/submissions.ts pulls kysely at top level, and kysely/libsql live in\n * devDependencies consuming fleet sites don't install — a static import here would\n * crash their CLI at require time (same rule as openDb's own lazy loads). Any\n * failure (no TURSO env, Turso down) logs and yields an empty map: the digest\n * must never blank over a missing optional signal. */\nasync function fetchNotifyBounceCounts(now: Date): Promise<ReadonlyMap<string, number>> {\n try {\n const [{ openDb, readDbConfig }, { countNotifyBouncedBySite }, { screenOutsSince }] =\n await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n import(\"../db/screenouts.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countNotifyBouncedBySite(db, screenOutsSince(now, NOTIFY_BOUNCE_WINDOW_DAYS));\n } catch (e) {\n console.warn(`⚠ notify-bounce counts unavailable (libSQL): ${(e as Error).message}`);\n return new Map();\n }\n}\n\n/** The \"Submissions (24h)\" telemetry window — a precise 24h ISO-timestamp compare\n * (unlike screenOutsSince's date-only strings). */\nconst SUBMISSIONS_DIGEST_WINDOW_MS = 24 * 60 * 60 * 1000;\n\n/** Per-site submission counts for the digest telemetry section, read from libSQL.\n * Dynamic imports + fail-soft for the same reason as fetchNotifyBounceCounts:\n * kysely/libsql are devDependencies consuming fleet sites don't install, and a\n * Turso blip must drop just this section, never the digest. Returns null (not an\n * empty map) on failure so the renderer can omit the section rather than claim a\n * quiet fleet. */\nasync function fetchSubmissionsDigestCounts(\n now: Date,\n): Promise<ReadonlyMap<string, SiteSubmissionCounts> | null> {\n try {\n const [{ openDb, readDbConfig }, { countSubmissionsSinceBySite }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countSubmissionsSinceBySite(\n db,\n new Date(now.getTime() - SUBMISSIONS_DIGEST_WINDOW_MS).toISOString(),\n );\n } catch (e) {\n console.warn(`⚠ submissions telemetry unavailable (libSQL): ${(e as Error).message}`);\n return null;\n }\n}\n\n/** Assemble the \"Submissions (24h)\" section from the per-site counts. PURE (exported\n * for direct tests). Totals sum ALL map entries — an orphan site id still counts in\n * the fleet totals. bySite lists only ids that resolve in `sitesById` AND carry any\n * nonzero count, sorted by total volume desc then siteName A-Z. null counts (libSQL\n * unavailable) → null (section omitted). */\nexport function buildSubmissionsDigestSection(\n counts: ReadonlyMap<string, SiteSubmissionCounts> | null,\n sitesById: ReadonlyMap<string, WebsiteRow>,\n): SubmissionsDigestSection | null {\n if (counts === null) return null;\n let leads = 0;\n let signups = 0;\n let spamAuto = 0;\n const bySite: SubmissionsDigestSection[\"bySite\"] = [];\n for (const [siteId, c] of counts) {\n leads += c.leads;\n signups += c.signups;\n spamAuto += c.spamAuto;\n const site = sitesById.get(siteId);\n if (site && c.leads + c.signups + c.spamAuto > 0) {\n bySite.push({ siteName: site.name, ...c });\n }\n }\n bySite.sort(\n (a, b) =>\n b.leads + b.signups + b.spamAuto - (a.leads + a.signups + a.spamAuto) ||\n a.siteName.localeCompare(b.siteName),\n );\n return { leads, signups, spamAuto, bySite };\n}\n\n/** Run a single collector under a try/catch: a thrown collector logs and yields []\n * so one broken signal never blanks the whole \"Needs attention\" section. */\nfunction runCollector(label: string, fn: () => AttentionItem[]): AttentionItem[] {\n try {\n return fn();\n } catch (e) {\n console.warn(`⚠ attention collector \"${label}\" failed: ${(e as Error).message}`);\n return [];\n }\n}\n\n/**\n * Fetch the free signals once (listAllReports + listWebsites) — or reuse the\n * `reports`/`websites` arrays runDigest already read, so a single run reads each\n * table once — build the sitesById map the delivery collector needs, and run each\n * pure collector isolated. Returns the union of items; diffing/badging happens in\n * runDigest.\n *\n * The Renovate + CI signals come from the SAME persisted collectors the operator\n * cockpit (`buildCockpitModel`) runs — `collectRenovateAlerts` (key\n * `renovate:<siteId>`) and `collectCiAlerts` (key `ci:<siteId>`), reading the\n * nightly-persisted `renovateFailingCis`/`defaultBranchCi` fields. This is the\n * key-space unification: because the digest writes the shared Digest State\n * snapshot with these same keys, the cockpit's NEW/WORSE diff finds them and the\n * two surfaces agree (the prior live per-PR `renovate:<repo>#<n>` sweep never\n * matched the cockpit's keys, so its cards badged NEW forever).\n */\nexport async function collectAttention(deps: CollectAttentionDeps): Promise<AttentionItem[]> {\n const reports = deps.reports ?? (await listAllReports(deps.base));\n const websites = deps.websites ?? (await listWebsites(deps.base));\n const now = deps.now ?? new Date();\n const notifyBounces = deps.notifyBounces ?? (await fetchNotifyBounceCounts(now));\n const sitesById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n return [\n ...runCollector(\"vuln\", () => collectVulnAlerts(websites, deps.baseUrl)),\n ...runCollector(\"delivery\", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"preflight\", () => collectPreflightBlocked(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"lighthouse\", () => collectLighthouseAlerts(websites, deps.baseUrl)),\n ...runCollector(\"renovate\", () => collectRenovateAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"ci\", () => collectCiAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"analytics\", () => collectAnalyticsFailures(websites, deps.baseUrl, now)),\n ...runCollector(\"turnstile\", () =>\n collectTurnstileGuardrailAlerts(websites, deps.baseUrl, now),\n ),\n ...runCollector(\"notify-bounce\", () =>\n collectNotifyBounceAlerts(websites, notifyBounces, deps.baseUrl),\n ),\n // Same collector the cockpit runs, over the same persisted `Prismic Models`\n // columns and the same keys — so the snapshot this digest writes is the one\n // the cockpit diffs NEW/WORSE against, and the two surfaces agree.\n ...runCollector(\"prismic-drift\", () => collectPrismicDriftAlerts(websites, deps.baseUrl, now)),\n ];\n}\n\nexport type DigestRunOptions = {\n resend?: ResendClient;\n /** Dashboard origin for the /s/<slug> links, e.g. \"https://reddoor-maintenance.netlify.app\". */\n baseUrl: string;\n /**\n * Inject a pre-opened Airtable base (tests, server handlers).\n * When omitted, `openBase(readAirtableConfig())` is called from the environment.\n */\n base?: AirtableBase;\n /** Per-site submission counts for the \"Submissions (24h)\" section. undefined =\n * fetch from libSQL; null = simulate unavailable (section omitted); a Map =\n * injected (tests). */\n submissionCounts?: ReadonlyMap<string, SiteSubmissionCounts> | null;\n /**\n * The run clock. Production omits it and gets `new Date()`.\n *\n * Injectable because several collectors this run feeds are AGE-SENSITIVE —\n * `collectPrismicDriftAlerts` alone changes an item's key and its wording once\n * a verdict crosses `PRISMIC_DRIFT_STALE_DAYS` — so a digest test that cannot\n * fix the clock is asserting on the day it happens to run. Two did, and both\n * went red four days after they were written, reporting the staleness wording\n * as if the drift wiring had broken. `collectAttention` already takes a `now`\n * for exactly this reason; this threads the same lever one layer out.\n */\n now?: Date;\n};\n\nexport async function runDigest(\n options: DigestRunOptions,\n): Promise<{ output: string; code: number }> {\n // Capture clock BEFORE any await so the idempotency key can't roll past midnight mid-run.\n const today = options.now ?? new Date();\n try {\n const base = options.base ?? openBase(readAirtableConfig());\n // Read each table ONCE for the whole run, then thread the arrays into\n // collectAttention so it doesn't re-fetch (was: listWebsites ×2, listAllReports\n // ×2). Pending is derived in-line with listPendingApproval's exact predicate.\n const reports = await listAllReports(base);\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const pending = reports.filter(isPendingApproval);\n\n const readyForYourYes: ReadyItem[] = [];\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n for (const r of pending) {\n const site = sites.get(r.siteId);\n if (!site) continue; // orphan report → skip rather than render a broken link\n // An empty Name slugs to \"\" → `/s/` is a dead link (getWebsiteBySlug can't\n // match it). Fall back to the fleet homepage so the operator still lands\n // somewhere usable instead of a 404.\n const slug = siteSlug(site.name);\n readyForYourYes.push({\n siteName: site.name,\n reportType: r.reportType,\n period: r.period ?? \"—\",\n dashboardUrl: slug ? `${baseUrl}/s/${slug}` : baseUrl,\n });\n }\n\n // M5: collect the free signals (isolated), diff against yesterday's snapshot.\n // Renovate + CI come from the persisted collectors (the same ones the cockpit\n // runs), so the snapshot this digest writes carries the `renovate:<siteId>` /\n // `ci:<siteId>` keys the cockpit diffs against — no live GitHub sweep here.\n const collected = await collectAttention({\n base,\n baseUrl: options.baseUrl,\n websites,\n reports,\n now: today,\n });\n const prior = await readDigestState(base);\n const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));\n // The operator only HEARS about a vuln once Renovate's auto-fix is exhausted\n // (tried and failed a couple of nightly cycles) — before that the fleet is\n // still self-patching and the cockpit's amber Watch band is the only surface.\n // Filter the EMAIL list only: `next` (written below) must keep every vuln key\n // so the cockpit's diff against this same snapshot stays consistent, and so\n // the exhausted-flip diffs as WORSE instead of arriving pre-badged-away.\n const needsAttention = tagged.filter(\n (it) => it.kind !== \"vuln\" || it.autoFixExhausted === true,\n );\n\n // No-noise default: skip entirely when there's nothing to report.\n if (readyForYourYes.length === 0 && needsAttention.length === 0) {\n // Persist `next` even on a skip: a key that resolved on a quiet day clears so a\n // later recurrence diffs as NEW (spec §10), and — since the skip can now fire\n // with muted pre-exhaustion vulns collected — their keys keep snapshotting so\n // the eventual exhausted-flip diffs as WORSE. Wrapped: a write failure can't\n // fail the skip.\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: \"Digest skipped (nothing ready, nothing needs attention).\", code: 0 };\n }\n\n // Submissions telemetry rides only on a digest that is ALREADY sending — the\n // skip predicate above is deliberately unchanged (leads already fire their own\n // ingest-time notification; this is context, not an alarm), and fetching AFTER\n // the skip check keeps quiet days from touching libSQL at all.\n const counts =\n options.submissionCounts !== undefined\n ? options.submissionCounts\n : await fetchSubmissionsDigestCounts(today);\n const submissions = buildSubmissionsDigestSection(counts, sites);\n\n const html = renderDigestHtml({ readyForYourYes, needsAttention, submissions });\n const client = options.resend ?? defaultResendClient();\n const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];\n const n = readyForYourYes.length;\n const reportWord = n === 1 ? \"report\" : \"reports\";\n let result: Awaited<ReturnType<typeof client.send>>;\n try {\n result = await client.send({\n from: FROM_ADDRESS,\n to,\n subject: `Your fleet — ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,\n html,\n idempotencyKey: `digest-${digestDateKey(today)}`,\n });\n } catch (err) {\n // A same-UTC-day re-run whose content changed re-sends with the same\n // `digest-<date>` idempotency key but a DIFFERENT body. Resend rejects that\n // with a 409 (`invalid_idempotent_request`) — \"This idempotency key has been\n // used ... but the request body was modified ...\". The operator already got\n // today's digest on the first send, so re-sending a changed version same-day\n // would just be a duplicate: treat it as an \"already sent today\" no-op.\n //\n // ResendClient (send/resend.ts) wraps the API error as a plain Error and only\n // preserves the *message* string (no name/statusCode), so the message\n // substring is the only reliable discriminator — match defensively on it.\n // Any OTHER send error re-throws to the outer catch → {code:1}, so a genuine\n // Resend/network failure still fails loudly.\n if (isIdempotencyConflict(err)) {\n // Do NOT write the snapshot: the first send already persisted it; writing\n // this run's `next` would diff against the first run's snapshot and mis-badge.\n return {\n output:\n \"Digest already sent today (content changed since the first send) — skipped to avoid a duplicate.\",\n code: 0,\n };\n }\n throw err;\n }\n // Persist the next snapshot AFTER a successful send. A write failure is caught +\n // logged: the digest already went out, tomorrow re-news at worst. (The send-FAILURE\n // path never reaches here — the outer catch returns code 1 with no write, preserving\n // the NEW badge for the retry.)\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: `Digest sent to ${to.join(\", \")} (${result.messageId})`, code: 0 };\n } catch (err) {\n // Re-throw config errors (exitCode=2: missing env vars, bad config) so runOrExit\n // surfaces them with the correct process exit code rather than collapsing to 1.\n if (typeof (err as { exitCode?: unknown }).exitCode === \"number\") {\n throw err;\n }\n const message = err instanceof Error ? err.message : String(err);\n return { output: `digest failed: ${message}`, code: 1 };\n }\n}\n\n/** Pure render of the unified daily operator digest. No IO — the caller (runDigest)\n * collects the rows and decides whether to send. */\nexport function renderDigestHtml(sections: DigestSections): string {\n return `<!doctype html>\n<html>\n <head><meta charset=\"utf-8\"></head>\n <body style=\"margin:0;padding:0;background:#ffffff\">\n <table width=\"100%\" style=\"border-collapse:collapse\">\n <tr>\n <td align=\"center\" style=\"padding:24px\">\n <table width=\"600\" style=\"border-collapse:collapse\">\n <tr>\n <td>\n <h1 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px\">Your fleet today</h1>\n ${readySection(sections.readyForYourYes)}\n ${attentionSection(sections.needsAttention)}\n ${submissionsSection(sections.submissions)}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n</html>`;\n}\n"],"mappings":"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","names":[]}