@reddoorla/maintenance 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +3 -1
  2. package/dist/airtable-2BJX4ATJ.js +9 -0
  3. package/dist/airtable-2BJX4ATJ.js.map +1 -0
  4. package/dist/announce-JEVCDDKL.js +171 -0
  5. package/dist/announce-JEVCDDKL.js.map +1 -0
  6. package/dist/bump-deps-O3SOLQYY.js +57 -0
  7. package/dist/bump-deps-O3SOLQYY.js.map +1 -0
  8. package/dist/chunk-2X322ZES.js +99 -0
  9. package/dist/chunk-2X322ZES.js.map +1 -0
  10. package/dist/chunk-4NAOR44V.js +153 -0
  11. package/dist/chunk-4NAOR44V.js.map +1 -0
  12. package/dist/chunk-4PY4I5OR.js +1167 -0
  13. package/dist/chunk-4PY4I5OR.js.map +1 -0
  14. package/dist/chunk-5WL3ET24.js +203 -0
  15. package/dist/chunk-5WL3ET24.js.map +1 -0
  16. package/dist/chunk-6LIWDIXU.js +38 -0
  17. package/dist/chunk-6LIWDIXU.js.map +1 -0
  18. package/dist/chunk-6VAL7XJT.js +52 -0
  19. package/dist/chunk-6VAL7XJT.js.map +1 -0
  20. package/dist/chunk-BWLUNY7L.js +585 -0
  21. package/dist/chunk-BWLUNY7L.js.map +1 -0
  22. package/dist/chunk-CASCWQQX.js +161 -0
  23. package/dist/chunk-CASCWQQX.js.map +1 -0
  24. package/dist/chunk-CLVTVDOU.js +274 -0
  25. package/dist/chunk-CLVTVDOU.js.map +1 -0
  26. package/dist/chunk-D3DQFC32.js +79 -0
  27. package/dist/chunk-D3DQFC32.js.map +1 -0
  28. package/dist/chunk-DIVB6ZF7.js +217 -0
  29. package/dist/chunk-DIVB6ZF7.js.map +1 -0
  30. package/dist/chunk-EYODPUHV.js +37 -0
  31. package/dist/chunk-EYODPUHV.js.map +1 -0
  32. package/dist/chunk-FDIFTIXC.js +70 -0
  33. package/dist/chunk-FDIFTIXC.js.map +1 -0
  34. package/dist/chunk-FUD4I2ZW.js +35 -0
  35. package/dist/chunk-FUD4I2ZW.js.map +1 -0
  36. package/dist/chunk-HG5DTFNF.js +62 -0
  37. package/dist/chunk-HG5DTFNF.js.map +1 -0
  38. package/dist/chunk-HN6I2XWI.js +65 -0
  39. package/dist/chunk-HN6I2XWI.js.map +1 -0
  40. package/dist/chunk-HVOOCK6L.js +84 -0
  41. package/dist/chunk-HVOOCK6L.js.map +1 -0
  42. package/dist/chunk-IMIR7SC2.js +51 -0
  43. package/dist/chunk-IMIR7SC2.js.map +1 -0
  44. package/dist/chunk-J3OPWFZE.js +64 -0
  45. package/dist/chunk-J3OPWFZE.js.map +1 -0
  46. package/dist/chunk-JNXCDUYB.js +211 -0
  47. package/dist/chunk-JNXCDUYB.js.map +1 -0
  48. package/dist/chunk-JQG222CI.js +49 -0
  49. package/dist/chunk-JQG222CI.js.map +1 -0
  50. package/dist/chunk-LKGVSM2O.js +43 -0
  51. package/dist/chunk-LKGVSM2O.js.map +1 -0
  52. package/dist/chunk-LYCKN7ZR.js +41 -0
  53. package/dist/chunk-LYCKN7ZR.js.map +1 -0
  54. package/dist/chunk-O65I7G7G.js +246 -0
  55. package/dist/chunk-O65I7G7G.js.map +1 -0
  56. package/dist/chunk-OCS7SZUZ.js +243 -0
  57. package/dist/chunk-OCS7SZUZ.js.map +1 -0
  58. package/dist/chunk-OGGRFBL4.js +14 -0
  59. package/dist/chunk-OGGRFBL4.js.map +1 -0
  60. package/dist/chunk-OQPMZ6BM.js +368 -0
  61. package/dist/chunk-OQPMZ6BM.js.map +1 -0
  62. package/dist/chunk-OTGB6YQO.js +52 -0
  63. package/dist/chunk-OTGB6YQO.js.map +1 -0
  64. package/dist/chunk-PF6BNYGE.js +162 -0
  65. package/dist/chunk-PF6BNYGE.js.map +1 -0
  66. package/dist/chunk-QTHKOIFJ.js +13 -0
  67. package/dist/chunk-QTHKOIFJ.js.map +1 -0
  68. package/dist/chunk-U5SZ4FOP.js +26 -0
  69. package/dist/chunk-U5SZ4FOP.js.map +1 -0
  70. package/dist/chunk-VXSX2FHO.js +269 -0
  71. package/dist/chunk-VXSX2FHO.js.map +1 -0
  72. package/dist/chunk-W3G7XFBZ.js +444 -0
  73. package/dist/chunk-W3G7XFBZ.js.map +1 -0
  74. package/dist/chunk-WEKL2HYJ.js +31 -0
  75. package/dist/chunk-WEKL2HYJ.js.map +1 -0
  76. package/dist/chunk-WVRPOXKH.js +74 -0
  77. package/dist/chunk-WVRPOXKH.js.map +1 -0
  78. package/dist/chunk-XKOZUVK2.js +121 -0
  79. package/dist/chunk-XKOZUVK2.js.map +1 -0
  80. package/dist/chunk-XO2RUYVM.js +138 -0
  81. package/dist/chunk-XO2RUYVM.js.map +1 -0
  82. package/dist/chunk-XTK5VIQB.js +97 -0
  83. package/dist/chunk-XTK5VIQB.js.map +1 -0
  84. package/dist/chunk-XXTZBPUY.js +9 -0
  85. package/dist/chunk-XXTZBPUY.js.map +1 -0
  86. package/dist/chunk-ZYBOT5PM.js +153 -0
  87. package/dist/chunk-ZYBOT5PM.js.map +1 -0
  88. package/dist/cli/bin.js +75 -7785
  89. package/dist/cli/bin.js.map +1 -1
  90. package/dist/cli/commands/audit.js +37 -2336
  91. package/dist/cli/commands/audit.js.map +1 -1
  92. package/dist/client-EPVO3FEH.js +10 -0
  93. package/dist/client-EPVO3FEH.js.map +1 -0
  94. package/dist/client-JA6QLEDW.js +48 -0
  95. package/dist/client-JA6QLEDW.js.map +1 -0
  96. package/dist/configs/lighthouse.js +4 -31
  97. package/dist/configs/lighthouse.js.map +1 -1
  98. package/dist/configs/playwright-a11y.js +6 -33
  99. package/dist/configs/playwright-a11y.js.map +1 -1
  100. package/dist/convert-to-pnpm-UR64X3OX.js +51 -0
  101. package/dist/convert-to-pnpm-UR64X3OX.js.map +1 -0
  102. package/dist/db-AXKMRTVA.js +23 -0
  103. package/dist/db-AXKMRTVA.js.map +1 -0
  104. package/dist/digest-YTO73BEO.js +408 -0
  105. package/dist/digest-YTO73BEO.js.map +1 -0
  106. package/dist/forms/index.js +3 -8
  107. package/dist/forms/index.js.map +1 -1
  108. package/dist/github-signals-YD64R2AF.js +122 -0
  109. package/dist/github-signals-YD64R2AF.js.map +1 -0
  110. package/dist/index.js +102 -4757
  111. package/dist/index.js.map +1 -1
  112. package/dist/init-JITQXFVL.js +85 -0
  113. package/dist/init-JITQXFVL.js.map +1 -0
  114. package/dist/launch-HXHEL6ID.js +207 -0
  115. package/dist/launch-HXHEL6ID.js.map +1 -0
  116. package/dist/lighthouse-airtable-4FKYKFH6.js +12 -0
  117. package/dist/lighthouse-airtable-4FKYKFH6.js.map +1 -0
  118. package/dist/migrate-P2LS4WXI.js +7 -0
  119. package/dist/migrate-P2LS4WXI.js.map +1 -0
  120. package/dist/onboard-TCQOP2W4.js +69 -0
  121. package/dist/onboard-TCQOP2W4.js.map +1 -0
  122. package/dist/orchestrate-RMSBHLQK.js +19 -0
  123. package/dist/orchestrate-RMSBHLQK.js.map +1 -0
  124. package/dist/recipes/sync-configs.js +9 -505
  125. package/dist/recipes/sync-configs.js.map +1 -1
  126. package/dist/renovate-dispatch-ZGLXNPEK.js +97 -0
  127. package/dist/renovate-dispatch-ZGLXNPEK.js.map +1 -0
  128. package/dist/report-SD3CNBOQ.js +240 -0
  129. package/dist/report-SD3CNBOQ.js.map +1 -0
  130. package/dist/resend-G2BJMAQV.js +7 -0
  131. package/dist/resend-G2BJMAQV.js.map +1 -0
  132. package/dist/self-updating-CIUMOMYI.js +61 -0
  133. package/dist/self-updating-CIUMOMYI.js.map +1 -0
  134. package/dist/svelte-codemods-BYRJAU7I.js +51 -0
  135. package/dist/svelte-codemods-BYRJAU7I.js.map +1 -0
  136. package/dist/sync-configs-JPRLAW5J.js +109 -0
  137. package/dist/sync-configs-JPRLAW5J.js.map +1 -0
  138. package/dist/upgrade-ERA45EVR.js +64 -0
  139. package/dist/upgrade-ERA45EVR.js.map +1 -0
  140. package/dist/util/git.js +19 -76
  141. package/dist/util/git.js.map +1 -1
  142. package/dist/util/pkg.js +5 -45
  143. package/dist/util/pkg.js.map +1 -1
  144. package/dist/websites-X5DLC2PC.js +43 -0
  145. package/dist/websites-X5DLC2PC.js.map +1 -0
  146. package/dist/write-audits-to-airtable-CE4POBAF.js +13 -0
  147. package/dist/write-audits-to-airtable-CE4POBAF.js.map +1 -0
  148. package/package.json +13 -12
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/self-updating/index.ts","../src/github/config.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../../types.js\";\nimport { templatesByName } from \"../sync-configs/templates.js\";\nimport {\n getRemoteUrl,\n parseOwnerRepo,\n isOwnerRepo,\n push as gitPush,\n branchName,\n checkoutBranch,\n createBranch,\n currentBranch,\n commit as gitCommit,\n isWorkingTreeClean,\n} from \"../../util/git.js\";\nimport { siteLabel } from \"../../util/site.js\";\nimport { readGitHubConfig } from \"../../github/config.js\";\nimport { makeGitHub, type GitHub } from \"../../github/gh.js\";\n\nconst SELF_UPDATING_CONFIGS = [\"ci\", \"renovate-action\", \"renovate-config\"] as const;\n\n// Reusable-workflow jobs report their check as \"<caller-job> / <reusable-job>\".\n// The thin `ci` caller (job `ci`) calls reddoorla/.github's reusable workflow (job `ci`),\n// so the required context is \"ci / ci\", NOT \"ci\". (Provisional — verified empirically on the\n// starter during M7.1 rollout; correct here if the live checks API reports a different string.)\nconst REQUIRED_CHECK = \"ci / ci\";\n\nexport type SelfUpdatingDeps = {\n github?: GitHub;\n pushBranch?: (cwd: string, branch: string) => Promise<void>;\n renovateToken?: string;\n};\n\nfunction resultOf(\n site: Site,\n status: RecipeResult[\"status\"],\n notes: string,\n commits: string[] = [],\n): RecipeResult {\n return { recipe: \"self-updating\", site: siteLabel(site), status, commits, notes };\n}\n\n/**\n * Resolve the `owner/repo` this recipe will mutate. An explicit `site.gitRepo`\n * (from Airtable) wins; otherwise derive it from the checkout's `origin`.\n *\n * Returns `null` when there is no repo to act on (no `gitRepo`, no origin) — a\n * benign \"nothing wired\" state. THROWS when a repo value IS present but doesn't\n * match the strict `owner/repo` shape: this recipe writes the broad GitHub\n * token as a repo secret (plus branch protection / auto-merge) at this\n * identity, so an attacker/typo-controlled value must be rejected here, before\n * the first `gh` call, rather than passed through to `gh`.\n */\nasync function resolveRepo(site: Site): Promise<string | null> {\n if (site.gitRepo) {\n if (!isOwnerRepo(site.gitRepo)) {\n throw new Error(\n `refusing to act on malformed repo identity: expected \"owner/repo\", got ${JSON.stringify(site.gitRepo)}`,\n );\n }\n return site.gitRepo;\n }\n let fromOrigin: string | null;\n try {\n fromOrigin = parseOwnerRepo(await getRemoteUrl(site.path));\n } catch {\n return null;\n }\n if (fromOrigin === null) return null;\n if (!isOwnerRepo(fromOrigin)) {\n throw new Error(\n `refusing to act on malformed repo identity from origin: ${JSON.stringify(fromOrigin)}`,\n );\n }\n return fromOrigin;\n}\n\nexport async function selfUpdating(site: Site, deps: SelfUpdatingDeps = {}): Promise<RecipeResult> {\n const templates = templatesByName([...SELF_UPDATING_CONFIGS]);\n const paths = templates.map((t) => t.path);\n\n let repo: string | null;\n try {\n repo = await resolveRepo(site);\n } catch (err) {\n // A malformed repo identity must abort before any `gh` write — surface it\n // as a recipe failure rather than letting the token reach an unintended repo.\n return resultOf(site, \"failed\", err instanceof Error ? err.message : String(err));\n }\n if (!repo) {\n return resultOf(\n site,\n \"failed\",\n \"no Git repo (set Airtable 'Git repo' or add an origin remote)\",\n );\n }\n\n const cfg = readGitHubConfig();\n const renovateToken = deps.renovateToken ?? cfg?.renovateToken;\n if (!deps.github && !cfg) return resultOf(site, \"failed\", \"GITHUB_TOKEN not set\");\n if (!renovateToken) return resultOf(site, \"failed\", \"no RENOVATE_TOKEN available\");\n const github = deps.github ?? makeGitHub({ token: cfg!.token });\n\n const base = await github.defaultBranch(repo).catch(() => \"main\");\n const actions: string[] = [];\n const commits: string[] = [];\n // Hoisted so the `finally` can restore the operator's branch even when a\n // failure (push/PR error) aborts AFTER we created + checked out the maint\n // branch. `maintBranch` stays null until createBranch succeeds, so the\n // restore is a no-op on every path that never left the operator's branch.\n let original: string | null = null;\n let maintBranch: string | null = null;\n\n try {\n // A. CI files on the default branch.\n const present = await github.filesOnBranch(repo, base, paths);\n if (present.length < paths.length) {\n const existingPR = await github.findOpenSelfUpdatingPR(repo);\n if (existingPR) {\n actions.push(`bootstrap PR already open: ${existingPR}`);\n } else {\n if (!(await isWorkingTreeClean(site.path))) {\n return resultOf(site, \"failed\", \"working tree not clean — commit or stash first\");\n }\n // Capture the operator's branch BEFORE creating the maint branch so the\n // `finally` can return them to it (#2). Best-effort capture: if it fails\n // we just skip the restore rather than guess.\n try {\n original = await currentBranch(site.path);\n } catch {\n original = null;\n }\n maintBranch = branchName(\"self-updating\");\n await createBranch(site.path, maintBranch);\n for (const t of templates) {\n const dest = join(site.path, t.path);\n await mkdir(dirname(dest), { recursive: true });\n await writeFile(dest, t.contents, \"utf-8\");\n }\n const sha = await gitCommit(\n site.path,\n \"ci: enable self-updating (CI + Renovate auto-merge)\",\n );\n if (sha) commits.push(sha);\n await (deps.pushBranch ?? gitPush)(site.path, maintBranch);\n const pr = await github.openPullRequest(repo, {\n head: maintBranch,\n base,\n title: \"Enable self-updating (CI + Renovate)\",\n body: \"Adds the unified CI gate, nightly Renovate, and auto-merge for patch/minor updates.\",\n });\n actions.push(`opened PR ${pr.url}`);\n // (Branch restore happens in the `finally` below — on success AND on a\n // failure that aborts after createBranch.)\n }\n }\n\n // B. Repo settings — check-then-ensure, each independent (self-healing).\n if (!(await github.autoMergeEnabled(repo))) {\n await github.enableRepoAutoMerge(repo);\n actions.push(\"enabled auto-merge\");\n }\n const existingContexts = await github.branchProtectionContexts(repo, base);\n if (!existingContexts.includes(REQUIRED_CHECK)) {\n // protectBranch issues a full PUT that REPLACES required-status-check contexts.\n // Send the UNION of the branch's existing required contexts + our REQUIRED_CHECK\n // so we ADD the CI gate without silently dropping any other checks the repo\n // already requires. (When the branch has no protection yet, existingContexts is\n // [] and this is just [REQUIRED_CHECK] — the original behavior.) Dedupe defends\n // against REQUIRED_CHECK already appearing among the existing contexts.\n const contexts = [...new Set([...existingContexts, REQUIRED_CHECK])];\n await github.protectBranch(repo, base, contexts);\n actions.push(`required \"${REQUIRED_CHECK}\" check on ${base}`);\n }\n if (!(await github.secretExists(repo, \"RENOVATE_TOKEN\"))) {\n await github.setRepoSecret(repo, \"RENOVATE_TOKEN\", renovateToken);\n actions.push(\"set RENOVATE_TOKEN secret\");\n }\n } catch (err) {\n const done = actions.length ? ` (completed: ${actions.join(\"; \")})` : \"\";\n const message = err instanceof Error ? err.message : String(err);\n return resultOf(site, \"failed\", `${message}${done}`, commits);\n } finally {\n // Restore the operator's branch whenever we created + checked out a maint\n // branch — on SUCCESS and, critically, on FAILURE. Without this, a push/PR\n // error after createBranch strands the checkout on the maint branch with an\n // unpushed commit, and the retry then fails at createBranch (\"branch already\n // exists\"). Best-effort: a restore failure must not mask the real outcome.\n if (maintBranch !== null && original !== null && original !== maintBranch) {\n try {\n await checkoutBranch(site.path, original);\n } catch (err) {\n console.warn(\n `warning: could not restore branch ${original} after self-updating: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n }\n\n return actions.length\n ? resultOf(site, \"applied\", actions.join(\"; \"), commits)\n : resultOf(site, \"noop\", \"already self-updating\", commits);\n}\n","export type GitHubConfig = {\n /** Broad PAT used by the tool's own `gh` calls (PRs, branch protection, secrets). */\n token: string;\n /** Narrow PAT stored per-repo as the RENOVATE_TOKEN secret. Falls back to `token`. */\n renovateToken: string;\n};\n\n/**\n * Read GitHub config from the environment (credentials.env is loaded into process.env by the CLI).\n * Returns null when GITHUB_TOKEN is unset — the signal that git/GitHub features aren't configured.\n * RENOVATE_TOKEN falls back to GITHUB_TOKEN when unset (a narrower token is recommended but optional).\n */\nexport function readGitHubConfig(): GitHubConfig | null {\n const token = process.env.GITHUB_TOKEN?.trim();\n if (!token) return null;\n const renovateToken = process.env.RENOVATE_TOKEN?.trim() || token;\n return { token, renovateToken };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,13 @@
1
+ // src/forms/types.ts
2
+ var SUBMISSION_FORM_TYPES = [
3
+ "contact",
4
+ "inquiry",
5
+ "newsletter",
6
+ "rsvp",
7
+ "reserve"
8
+ ];
9
+
10
+ export {
11
+ SUBMISSION_FORM_TYPES
12
+ };
13
+ //# sourceMappingURL=chunk-QTHKOIFJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms/types.ts"],"sourcesContent":["/**\n * Form-type enum, kept in a leaf module (no Airtable/Resend imports) so it can\n * be shared with fleet sites via the `@reddoorla/maintenance/forms` subpath\n * without dragging server SDKs into a site bundle.\n */\nexport const SUBMISSION_FORM_TYPES = [\n \"contact\",\n \"inquiry\",\n \"newsletter\",\n \"rsvp\",\n \"reserve\",\n] as const;\nexport type FormType = (typeof SUBMISSION_FORM_TYPES)[number];\n"],"mappings":";AAKO,IAAM,wBAAwB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;","names":[]}
@@ -0,0 +1,26 @@
1
+ // src/util/url.ts
2
+ function isHttpUrl(s) {
3
+ let parsed;
4
+ try {
5
+ parsed = new URL(s);
6
+ } catch {
7
+ return false;
8
+ }
9
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
10
+ }
11
+ function isNetlifyAppUrl(s) {
12
+ let parsed;
13
+ try {
14
+ parsed = new URL(s);
15
+ } catch {
16
+ return false;
17
+ }
18
+ const host = parsed.hostname.toLowerCase();
19
+ return host === "netlify.app" || host.endsWith(".netlify.app");
20
+ }
21
+
22
+ export {
23
+ isHttpUrl,
24
+ isNetlifyAppUrl
25
+ };
26
+ //# sourceMappingURL=chunk-U5SZ4FOP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/util/url.ts"],"sourcesContent":["/**\n * True when `s` parses as an absolute URL whose scheme is `http:` or `https:`.\n *\n * The single allowlist gate for any value we hand to Chrome/Lighthouse. A\n * deployed-audit URL flows in from Airtable's `url` column (or a JSON\n * inventory's `deployedUrl`), so a `file://`/`gopher://`/`data:` value — or a\n * value pointing at an internal host — would otherwise become a local-file read\n * or SSRF when lhci drives a headless browser at it. Restricting to http(s)\n * keeps the audit to the real, network-reachable site.\n */\nexport function isHttpUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n return parsed.protocol === \"http:\" || parsed.protocol === \"https:\";\n}\n\n/**\n * True when `s` is a URL served from Netlify's default `*.netlify.app` host —\n * i.e. the site has no custom domain. Matches the apex `netlify.app` and any\n * subdomain of it (including deploy-preview hosts like `branch--site.netlify.app`),\n * but is not fooled by a look-alike such as `foo.netlify.app.evil.com` (the host\n * must END at `.netlify.app`). An unparseable/empty value is not a match.\n */\nexport function isNetlifyAppUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n const host = parsed.hostname.toLowerCase();\n return host === \"netlify.app\" || host.endsWith(\".netlify.app\");\n}\n\n/**\n * True when `s` is an `https:` URL whose host is NOT an obviously-internal target\n * (loopback / private / link-local / unique-local / CGNAT). The newsletter\n * webhook URL is operator-set in Airtable but fires server-side, so this blocks\n * the SSRF vector of pointing it at `127.0.0.1` / `10.x` / `169.254.x` / `::1`.\n *\n * Best-effort by host literal — it does NOT resolve DNS, so a hostname that\n * resolves to a private IP is not caught. Defense-in-depth, not a complete SSRF\n * guard (the response is never returned to the caller either, so it's a log\n * oracle at most).\n */\nexport function isPublicHttpsUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\") return false;\n let host = parsed.hostname.toLowerCase();\n if (host === \"localhost\" || host.endsWith(\".localhost\")) return false;\n // Strip IPv6 brackets (`[::1]` → `::1`).\n if (host.startsWith(\"[\") && host.endsWith(\"]\")) host = host.slice(1, -1);\n if (host.includes(\":\")) {\n // IPv6 literal: block loopback (::1), unspecified (::), link-local (fe80::/10),\n // and unique-local (fc00::/7).\n if (host === \"::1\" || host === \"::\") return false;\n if (/^fe[89ab]/.test(host)) return false;\n if (/^f[cd]/.test(host)) return false;\n // IPv4-mapped (`::ffff:a.b.c.d`, normalized by URL to `::ffff:aabb:ccdd`) and\n // NAT64 (`64:ff9b::/96`) embed a v4 address the dotted-quad block below never\n // sees — refuse both wholesale (no legitimate webhook target uses these forms).\n if (host.startsWith(\"::ffff:\") || host.startsWith(\"64:ff9b:\")) return false;\n return true;\n }\n // IPv4 dotted-quad: block the private/loopback/link-local/CGNAT ranges.\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host)) {\n const [a, b] = host.split(\".\").map(Number);\n if (a === 0 || a === 10 || a === 127) return false;\n if (a === 169 && b === 254) return false; // link-local\n if (a === 172 && b !== undefined && b >= 16 && b <= 31) return false; // 172.16/12\n if (a === 192 && b === 168) return false; // 192.168/16\n if (a === 100 && b !== undefined && b >= 64 && b <= 127) return false; // CGNAT 100.64/10\n return true;\n }\n // A registered hostname (not a literal IP) — accept; DNS isn't resolved here.\n return true;\n}\n"],"mappings":";AAUO,SAAS,UAAU,GAAoB;AAC5C,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,CAAC;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO,OAAO,aAAa,WAAW,OAAO,aAAa;AAC5D;AASO,SAAS,gBAAgB,GAAoB;AAClD,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,CAAC;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,OAAO,OAAO,SAAS,YAAY;AACzC,SAAO,SAAS,iBAAiB,KAAK,SAAS,cAAc;AAC/D;","names":[]}
@@ -0,0 +1,269 @@
1
+ import {
2
+ isIdempotencyConflict
3
+ } from "./chunk-OGGRFBL4.js";
4
+ import {
5
+ announcementSiteExtras,
6
+ fetchAttachmentBytes,
7
+ loadBundledImages,
8
+ renderReportHtml,
9
+ resolveCopy
10
+ } from "./chunk-BWLUNY7L.js";
11
+ import {
12
+ checklistFor,
13
+ isChecklistComplete,
14
+ listSendableReports,
15
+ stampSent
16
+ } from "./chunk-OCS7SZUZ.js";
17
+ import {
18
+ listWebsites,
19
+ siteSlug,
20
+ updateLaunched
21
+ } from "./chunk-CLVTVDOU.js";
22
+ import {
23
+ openBase,
24
+ readAirtableConfig
25
+ } from "./chunk-HN6I2XWI.js";
26
+ import {
27
+ defaultResendClient
28
+ } from "./chunk-WEKL2HYJ.js";
29
+
30
+ // src/reports/maintenance-email/header-image.ts
31
+ import sharp from "sharp";
32
+ var DEFAULT_DISPLAY_WIDTH = 600;
33
+ var RETINA_SCALE = 2;
34
+ var JPEG_QUALITY = 82;
35
+ function channelToHex(value) {
36
+ return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
37
+ }
38
+ async function prepareHeaderImage(bytes, options = {}) {
39
+ const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;
40
+ const input = Buffer.from(bytes);
41
+ const meta = await sharp(input).metadata();
42
+ const origWidth = meta.width;
43
+ const origHeight = meta.height;
44
+ if (!origWidth || !origHeight) {
45
+ throw new Error("prepareHeaderImage: could not read source image dimensions");
46
+ }
47
+ const displayWidth = Math.min(requestedDisplayWidth, origWidth);
48
+ const displayHeight = Math.round(displayWidth * origHeight / origWidth);
49
+ const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);
50
+ const out = await sharp(input).resize({ width: targetSourceWidth, withoutEnlargement: true }).flatten({ background: "#ffffff" }).jpeg({ quality: JPEG_QUALITY }).toBuffer();
51
+ const { dominant } = await sharp(out).stats();
52
+ const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;
53
+ return {
54
+ bytes: new Uint8Array(out),
55
+ contentType: "image/jpeg",
56
+ displayWidth,
57
+ displayHeight,
58
+ placeholderColor
59
+ };
60
+ }
61
+
62
+ // src/reports/send/orchestrate.ts
63
+ var FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
64
+ var REPLY_TO = "info@reddoorla.com";
65
+ var MONTHS = [
66
+ "January",
67
+ "February",
68
+ "March",
69
+ "April",
70
+ "May",
71
+ "June",
72
+ "July",
73
+ "August",
74
+ "September",
75
+ "October",
76
+ "November",
77
+ "December"
78
+ ];
79
+ function monthYear(d) {
80
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
81
+ }
82
+ function toInlineAttachment(a) {
83
+ return {
84
+ filename: a.filename,
85
+ content: Buffer.from(a.bytes).toString("base64"),
86
+ contentType: a.contentType,
87
+ inlineContentId: a.cid
88
+ };
89
+ }
90
+ async function sendApprovedReports(options = {}) {
91
+ const base = openBase(readAirtableConfig());
92
+ const client = options.resend ?? defaultResendClient();
93
+ const sendable = await listSendableReports(base);
94
+ if (sendable.length === 0) return { output: "No reports ready to send.", code: 0 };
95
+ const websites = await listWebsites(base);
96
+ const sites = new Map(websites.map((w) => [w.id, w]));
97
+ const lines = [];
98
+ let anyFailed = false;
99
+ for (const report of sendable) {
100
+ const site = sites.get(report.siteId);
101
+ if (!site) {
102
+ lines.push(`\u2717 ${report.reportId} \u2014 Site row not found for id=${report.siteId}`);
103
+ anyFailed = true;
104
+ continue;
105
+ }
106
+ try {
107
+ const messageId = await sendOne(client, base, site, report);
108
+ lines.push(`\u2713 sent: ${report.reportId} (${messageId})`);
109
+ if (report.reportType === "Launch") {
110
+ try {
111
+ await updateLaunched(base, site.id, (/* @__PURE__ */ new Date()).toISOString());
112
+ lines.push(` \u21B3 launched: ${site.name} flipped to maintenance`);
113
+ } catch (e) {
114
+ lines.push(` \u26A0 launch flip failed for ${site.name}: ${e.message}`);
115
+ }
116
+ }
117
+ } catch (e) {
118
+ lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
119
+ anyFailed = true;
120
+ }
121
+ }
122
+ return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
123
+ }
124
+ async function sendOne(client, base, site, report) {
125
+ if (!isChecklistComplete(report)) {
126
+ const items = checklistFor(report.reportType);
127
+ const done = items.filter((i) => report.checklist[i.field] === true).length;
128
+ throw new Error(
129
+ `Report ${report.reportId} checklist incomplete \u2014 ${done}/${items.length} items checked`
130
+ );
131
+ }
132
+ if (!site.headerImage) {
133
+ throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
134
+ }
135
+ if (!report.lighthouse) {
136
+ throw new Error(
137
+ `Report ${report.reportId} has no Lighthouse scores \u2014 all four cells (Lighthouse \u2014 Performance / Accessibility / Best Practices / SEO) must be numeric on the Reports row; one non-numeric or blank cell nulls all four`
138
+ );
139
+ }
140
+ const explicitTo = parseAddresses(site.reportRecipientsTo);
141
+ const fallbackTo = parseAddresses(site.pointOfContact);
142
+ const to = explicitTo ?? fallbackTo ?? [];
143
+ if (to.length === 0) {
144
+ throw new Error(
145
+ `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`
146
+ );
147
+ }
148
+ for (const addr of to) {
149
+ if (!isProbablyEmail(addr)) {
150
+ throw new Error(
151
+ `Site '${site.name}' recipient is malformed: ${addr} \u2014 use a bare address only (no \`Name <addr>\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`
152
+ );
153
+ }
154
+ }
155
+ const cc = parseAddresses(site.reportRecipientsCc);
156
+ if (cc) {
157
+ for (const addr of cc) {
158
+ if (!isProbablyEmail(addr)) {
159
+ throw new Error(
160
+ `Site '${site.name}' CC is malformed: ${addr} \u2014 fix Report recipients (CC) in Airtable`
161
+ );
162
+ }
163
+ }
164
+ }
165
+ const original = await fetchAttachmentBytes(site.headerImage.url);
166
+ const header = await prepareHeaderImage(original.bytes);
167
+ const bundled = await loadBundledImages();
168
+ const slug = siteSlug(site.name);
169
+ const cidName = `${slug}-header`;
170
+ const { html } = await renderReportHtml({
171
+ siteName: site.name,
172
+ siteUrl: site.url,
173
+ reportType: report.reportType,
174
+ completedOn: report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date(),
175
+ lighthouse: report.lighthouse,
176
+ gaUsersCurrent: report.gaUsersCurrent ?? void 0,
177
+ gaUsersPrevious: report.gaUsersPrevious ?? void 0,
178
+ searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
179
+ lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
180
+ commentary: report.commentary,
181
+ copy: resolveCopy(site),
182
+ headerImageCid: cidName,
183
+ headerWidth: header.displayWidth,
184
+ headerHeight: header.displayHeight,
185
+ headerBgColor: header.placeholderColor,
186
+ // Announcement-only: re-derive cadence + improvements from the site row so the SENT email
187
+ // keeps its cadence copy + improvement callouts. Without this the send-time re-render drops
188
+ // them entirely (they're not stored on the Reports row). Ignored by the other templates.
189
+ ...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
190
+ });
191
+ const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
192
+ const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
193
+ const attachments = [
194
+ toInlineAttachment({
195
+ bytes: header.bytes,
196
+ filename: `${cidName}.jpg`,
197
+ contentType: header.contentType,
198
+ cid: cidName
199
+ })
200
+ ];
201
+ for (const img of [bundled.check, bundled.blurred]) {
202
+ if (html.includes(`cid:${img.cid}`)) {
203
+ attachments.push(
204
+ toInlineAttachment({
205
+ bytes: img.bytes,
206
+ filename: img.filename,
207
+ contentType: img.contentType,
208
+ cid: img.cid
209
+ })
210
+ );
211
+ }
212
+ }
213
+ const payload = {
214
+ from: FROM_ADDRESS,
215
+ to,
216
+ replyTo: REPLY_TO,
217
+ subject,
218
+ html,
219
+ attachments,
220
+ // Stable across retries of the same row — if Airtable stamping fails after a
221
+ // successful Resend, the next --send-ready replays with the same key and
222
+ // Resend returns the original message id rather than sending a duplicate.
223
+ idempotencyKey: `report:${report.id}`
224
+ };
225
+ if (cc) payload.cc = cc;
226
+ let result;
227
+ try {
228
+ result = await client.send(payload);
229
+ } catch (err) {
230
+ if (isIdempotencyConflict(err)) {
231
+ await stampSent(base, report.id, /* @__PURE__ */ new Date(), null);
232
+ console.log(`\u21BB already sent (idempotency conflict), stamped: ${report.reportId}`);
233
+ return "idempotent-conflict";
234
+ }
235
+ throw err;
236
+ }
237
+ await stampSent(base, report.id, /* @__PURE__ */ new Date(), result.messageId);
238
+ return result.messageId;
239
+ }
240
+ function parseAddresses(field) {
241
+ if (!field) return null;
242
+ const seen = /* @__PURE__ */ new Set();
243
+ const list = [];
244
+ for (const raw of field.split(/[,\n]/)) {
245
+ const trimmed = raw.trim().toLowerCase();
246
+ if (!trimmed) continue;
247
+ if (seen.has(trimmed)) continue;
248
+ seen.add(trimmed);
249
+ list.push(trimmed);
250
+ }
251
+ return list.length > 0 ? list : null;
252
+ }
253
+ function isProbablyEmail(s) {
254
+ const at = s.indexOf("@");
255
+ if (at < 1 || at !== s.lastIndexOf("@")) return false;
256
+ const local = s.slice(0, at);
257
+ const domain = s.slice(at + 1);
258
+ if (!local || !domain) return false;
259
+ if (!domain.includes(".")) return false;
260
+ if (/\s/.test(s)) return false;
261
+ return true;
262
+ }
263
+
264
+ export {
265
+ sendApprovedReports,
266
+ parseAddresses,
267
+ isProbablyEmail
268
+ };
269
+ //# sourceMappingURL=chunk-VXSX2FHO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/maintenance-email/header-image.ts","../src/reports/send/orchestrate.ts"],"sourcesContent":["import sharp from \"sharp\";\n\nexport type PreparedHeaderImage = {\n /** Resized JPEG bytes to attach inline (CID) in place of the Airtable original. */\n bytes: Uint8Array;\n /** Always \"image/jpeg\" — we re-encode for predictable size and a flat white background. */\n contentType: string;\n /** CSS display width in px (≤ requested, never wider than the source has pixels for). */\n displayWidth: number;\n /** CSS display height in px, source aspect ratio preserved (no distortion). */\n displayHeight: number;\n /** Dominant-color hex (e.g. \"#cfc3a8\"), used as the loading/blocked placeholder box. */\n placeholderColor: string;\n};\n\nexport type PrepareHeaderImageOptions = {\n /** Intended CSS display width. The email body is 600px, so that's the default. */\n displayWidth?: number;\n};\n\nconst DEFAULT_DISPLAY_WIDTH = 600;\n/** Encode the source at 2× display width so it stays crisp on retina screens. */\nconst RETINA_SCALE = 2;\n/** Quality is for *resized* pixels — at 1200px the texture/text read as sharp; bytes are tiny. */\nconst JPEG_QUALITY = 82;\n\nfunction channelToHex(value: number): string {\n return Math.max(0, Math.min(255, Math.round(value)))\n .toString(16)\n .padStart(2, \"0\");\n}\n\n/**\n * Downscale an oversized header image for email: 2× the display width (retina) at most,\n * never upscaled, re-encoded as JPEG on a flat white background. Also reports the display\n * dimensions (so the template can reserve the box and stop reflow) and a dominant color\n * (so the reserved box shows a matched placeholder while the image loads).\n *\n * Root cause this addresses: Airtable headers can be multi-MB / 2400px+ while the email\n * renders them at ~600px — shipping ~16× more pixels than the display can use.\n */\nexport async function prepareHeaderImage(\n bytes: Uint8Array,\n options: PrepareHeaderImageOptions = {},\n): Promise<PreparedHeaderImage> {\n const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;\n const input = Buffer.from(bytes);\n\n const meta = await sharp(input).metadata();\n const origWidth = meta.width;\n const origHeight = meta.height;\n if (!origWidth || !origHeight) {\n throw new Error(\"prepareHeaderImage: could not read source image dimensions\");\n }\n\n // Never claim a wider display than the source can fill at 1×.\n const displayWidth = Math.min(requestedDisplayWidth, origWidth);\n const displayHeight = Math.round((displayWidth * origHeight) / origWidth);\n\n // Encode at 2× display for retina, but never enlarge a smaller original.\n const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);\n\n const out = await sharp(input)\n .resize({ width: targetSourceWidth, withoutEnlargement: true })\n .flatten({ background: \"#ffffff\" })\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n\n const { dominant } = await sharp(out).stats();\n const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;\n\n return {\n bytes: new Uint8Array(out),\n contentType: \"image/jpeg\",\n displayWidth,\n displayHeight,\n placeholderColor,\n };\n}\n","import { openBase, readAirtableConfig } from \"../airtable/client.js\";\nimport { listSendableReports, stampSent } from \"../airtable/reports.js\";\nimport { listWebsites, siteSlug, updateLaunched } from \"../airtable/websites.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport type { ReportRow } from \"../airtable/reports.js\";\nimport { fetchAttachmentBytes } from \"../airtable/attachments.js\";\nimport { renderReportHtml } from \"../render.js\";\nimport { resolveCopy } from \"../copy.js\";\nimport { announcementSiteExtras } from \"../announcement-email/template.js\";\nimport { loadBundledImages } from \"../maintenance-email/assets/index.js\";\nimport { prepareHeaderImage } from \"../maintenance-email/header-image.js\";\nimport { defaultResendClient, type ResendClient, type ResendSendInput } from \"./resend.js\";\nimport { isIdempotencyConflict } from \"./idempotency.js\";\nimport { checklistFor, isChecklistComplete } from \"../checklist.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\nconst MONTHS = [\n \"January\",\n \"February\",\n \"March\",\n \"April\",\n \"May\",\n \"June\",\n \"July\",\n \"August\",\n \"September\",\n \"October\",\n \"November\",\n \"December\",\n];\n\n/** \"May 2026\" — UTC month/year, consistent with the rest of the reports pipeline's dates. */\nfunction monthYear(d: Date): string {\n return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;\n}\n\ntype InlineAttachment = NonNullable<ResendSendInput[\"attachments\"]>[number];\n\n/** Build a Resend inline (CID-referenced) attachment from raw bytes — the header\n * image and both bundled images share this exact shape. */\nfunction toInlineAttachment(a: {\n bytes: Uint8Array;\n filename: string;\n contentType: string;\n cid: string;\n}): InlineAttachment {\n return {\n filename: a.filename,\n content: Buffer.from(a.bytes).toString(\"base64\"),\n contentType: a.contentType,\n inlineContentId: a.cid,\n };\n}\n\nexport type OrchestrateOptions = {\n resend?: ResendClient;\n};\n\nexport async function sendApprovedReports(\n options: OrchestrateOptions = {},\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const client = options.resend ?? defaultResendClient();\n\n const sendable = await listSendableReports(base);\n if (sendable.length === 0) return { output: \"No reports ready to send.\", code: 0 };\n\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const lines: string[] = [];\n let anyFailed = false;\n for (const report of sendable) {\n const site = sites.get(report.siteId);\n if (!site) {\n lines.push(`✗ ${report.reportId} — Site row not found for id=${report.siteId}`);\n anyFailed = true;\n continue;\n }\n try {\n const messageId = await sendOne(client, base, site, report);\n lines.push(`✓ sent: ${report.reportId} (${messageId})`);\n if (report.reportType === \"Launch\") {\n try {\n await updateLaunched(base, site.id, new Date().toISOString());\n lines.push(` ↳ launched: ${site.name} flipped to maintenance`);\n } catch (e) {\n lines.push(` ⚠ launch flip failed for ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n lines.push(`✗ ${report.reportId} — ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n return { output: lines.join(\"\\n\"), code: anyFailed ? 1 : 0 };\n}\n\nasync function sendOne(\n client: ResendClient,\n base: ReturnType<typeof openBase>,\n site: WebsiteRow,\n report: ReportRow,\n): Promise<string> {\n // Hard checklist gate: a Maintenance/Testing report whose operator checklist isn't\n // fully checked must never go out — even if \"Approved to send\" was ticked directly in\n // Airtable, bypassing the dashboard's approve gate. Throw so the report is skipped and\n // `Sent at` stays null (at-least-once retry preserved), exactly like the other sendOne\n // guards. Launch/Announcement have an empty checklist → vacuously complete, never gated.\n if (!isChecklistComplete(report)) {\n const items = checklistFor(report.reportType);\n const done = items.filter((i) => report.checklist[i.field] === true).length;\n throw new Error(\n `Report ${report.reportId} checklist incomplete — ${done}/${items.length} items checked`,\n );\n }\n if (!site.headerImage) {\n throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);\n }\n if (!report.lighthouse) {\n throw new Error(\n `Report ${report.reportId} has no Lighthouse scores — all four cells ` +\n `(Lighthouse — Performance / Accessibility / Best Practices / SEO) must be numeric ` +\n `on the Reports row; one non-numeric or blank cell nulls all four`,\n );\n }\n\n // Resolve + validate recipients BEFORE the expensive work (header fetch + sharp\n // downscale + full MJML render). A misconfigured-recipients site is a guaranteed\n // failure, so fail fast here rather than after burning that work. Same checks +\n // messages as before — only the position moved.\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n // Run pointOfContact through the parser too — operators sometimes paste\n // \"a@x, b@y\" into that single-line field.\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n throw new Error(\n `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`,\n );\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' recipient is malformed: ${addr} — use a bare address only ` +\n `(no \\`Name <addr>\\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`,\n );\n }\n }\n const cc = parseAddresses(site.reportRecipientsCc);\n if (cc) {\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' CC is malformed: ${addr} — fix Report recipients (CC) in Airtable`,\n );\n }\n }\n }\n\n const original = await fetchAttachmentBytes(site.headerImage.url);\n // Downscale the (often multi-MB / 2400px+) Airtable header to email display size, and get\n // back display dims + a placeholder color so the template can reserve the box.\n const header = await prepareHeaderImage(original.bytes);\n const bundled = await loadBundledImages();\n\n const slug = siteSlug(site.name);\n const cidName = `${slug}-header`;\n const { html } = await renderReportHtml({\n siteName: site.name,\n siteUrl: site.url,\n reportType: report.reportType,\n completedOn: report.completedOn ? new Date(report.completedOn) : new Date(),\n lighthouse: report.lighthouse,\n gaUsersCurrent: report.gaUsersCurrent ?? undefined,\n gaUsersPrevious: report.gaUsersPrevious ?? undefined,\n searchPosition:\n report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : undefined,\n lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,\n commentary: report.commentary,\n copy: resolveCopy(site),\n headerImageCid: cidName,\n headerWidth: header.displayWidth,\n headerHeight: header.displayHeight,\n headerBgColor: header.placeholderColor,\n // Announcement-only: re-derive cadence + improvements from the site row so the SENT email\n // keeps its cadence copy + improvement callouts. Without this the send-time re-render drops\n // them entirely (they're not stored on the Reports row). Ignored by the other templates.\n ...(report.reportType === \"Announcement\" ? announcementSiteExtras(site) : {}),\n });\n\n const reportDate = report.completedOn ? new Date(report.completedOn) : new Date();\n const subject =\n report.subjectOverride ?? `${site.name} — ${monthYear(reportDate)} ${report.reportType} Report`;\n\n // Inline attachments: the per-site header (every template renders it) plus only the bundled\n // images the rendered HTML actually references. The green check (cid:rd-check-png) appears in\n // every report's checklist/score rows EXCEPT Launch; the blurred-tests image\n // (cid:rd-blurred-tests-jpg) appears ONLY in the Maintenance email. Attaching a bundled image\n // the template doesn't reference leaves a dangling inline part that some mail clients surface as\n // a stray downloadable attachment — so gate each on its cid actually appearing in `html`. This\n // self-corrects if a template's image usage changes; there's no per-report-type list to keep in\n // sync. Self-contained inline (no external CDN, no image-blocked broken icons in webmail).\n const attachments: InlineAttachment[] = [\n toInlineAttachment({\n bytes: header.bytes,\n filename: `${cidName}.jpg`,\n contentType: header.contentType,\n cid: cidName,\n }),\n ];\n for (const img of [bundled.check, bundled.blurred]) {\n if (html.includes(`cid:${img.cid}`)) {\n attachments.push(\n toInlineAttachment({\n bytes: img.bytes,\n filename: img.filename,\n contentType: img.contentType,\n cid: img.cid,\n }),\n );\n }\n }\n\n const payload: Parameters<ResendClient[\"send\"]>[0] = {\n from: FROM_ADDRESS,\n to,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n // Stable across retries of the same row — if Airtable stamping fails after a\n // successful Resend, the next --send-ready replays with the same key and\n // Resend returns the original message id rather than sending a duplicate.\n idempotencyKey: `report:${report.id}`,\n };\n if (cc) payload.cc = cc;\n\n let result: Awaited<ReturnType<ResendClient[\"send\"]>>;\n try {\n result = await client.send(payload);\n } catch (err) {\n // The send path is at-least-once: client.send succeeds → stampSent writes\n // `Sent at` (the ONLY thing that removes the row from listSendableReports). If\n // stampSent threw on a PRIOR run (an Airtable blip), `Sent at` stayed null and\n // the row replays here. By replay time the rendered body has usually changed\n // (operator Commentary edit, `report --due` rewrote scores, or the header\n // re-encodes non-deterministically), so Resend rejects the same-key\n // (`report:<id>`) / different-body re-send with a 409 (`invalid_idempotent_request`).\n //\n // That 409 means the email ALREADY WENT OUT under this key on the prior run.\n // Do NOT re-throw and do NOT re-send (re-throwing leaves the row unstamped, and\n // after the 24h key TTL a SECOND real email would go out). Instead stamp the row\n // so it stops replaying, then return success so the caller runs the Launch flip —\n // which self-heals a launch that sent-but-never-flipped on the prior run.\n //\n // Any OTHER error (real network/Resend failure) re-throws, exactly as before, so\n // a genuine failure still fails loudly and the row replays next run.\n if (isIdempotencyConflict(err)) {\n // Stamp `Sent at` ONLY — the original send's messageId is unrecoverable on\n // the 409 path, so we leave `Resend message ID` null rather than writing a\n // sentinel that would masquerade as a real id and orphan webhook lookups.\n // Still return the sentinel string so the caller logs the already-sent path\n // and runs the Launch flip.\n await stampSent(base, report.id, new Date(), null);\n console.log(`↻ already sent (idempotency conflict), stamped: ${report.reportId}`);\n return \"idempotent-conflict\";\n }\n throw err;\n }\n await stampSent(base, report.id, new Date(), result.messageId);\n return result.messageId;\n}\n\n/**\n * Split a comma/newline-separated address field into a clean array.\n * Lowercases (case-insensitive dedupe) and removes empty entries. Returns\n * null if nothing survives. Does NOT understand `Display Name <email>` —\n * operators should put a bare address in the Airtable field, or use multiple\n * lines if needing multiple recipients.\n */\nexport function parseAddresses(field: string | null): string[] | null {\n if (!field) return null;\n const seen = new Set<string>();\n const list: string[] = [];\n for (const raw of field.split(/[,\\n]/)) {\n const trimmed = raw.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list.length > 0 ? list : null;\n}\n\n/**\n * Cheap email shape check — must contain exactly one @, with non-empty\n * local and domain parts and at least one dot in the domain. We're not\n * trying to be a full RFC validator; we're trying to catch operator\n * mistakes like \"ops at acme dot com\" or a missing @ before they 422\n * at Resend.\n */\nexport function isProbablyEmail(s: string): boolean {\n const at = s.indexOf(\"@\");\n if (at < 1 || at !== s.lastIndexOf(\"@\")) return false;\n const local = s.slice(0, at);\n const domain = s.slice(at + 1);\n if (!local || !domain) return false;\n if (!domain.includes(\".\")) return false;\n if (/\\s/.test(s)) return false;\n return true;\n}\n"],"mappings":"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","names":[]}