@reddoorla/maintenance 0.92.0 → 0.93.1

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.
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  hostsMatch
3
- } from "./chunk-5XMKJX74.js";
3
+ } from "./chunk-QF6IQQFP.js";
4
4
  import {
5
5
  operatorEmail
6
6
  } from "./chunk-5ML7FMBD.js";
7
7
  import {
8
8
  parseReplyCopy
9
- } from "./chunk-PJ23RDHT.js";
9
+ } from "./chunk-I7NHLVVI.js";
10
10
  import {
11
11
  escapeHtml
12
12
  } from "./chunk-BGTYPVLT.js";
@@ -60,6 +60,113 @@ function googleCalendarUrl(e) {
60
60
  return `https://calendar.google.com/calendar/render?${params.toString()}`;
61
61
  }
62
62
 
63
+ // src/forms/rich-text.ts
64
+ var SAFE_HREF = /^(https:\/\/|mailto:)/i;
65
+ var HEADING_TAG = { heading2: "h2", heading3: "h3" };
66
+ function wrap(escaped, active) {
67
+ let out = escaped;
68
+ const link = active.find((s) => s.type === "link" && s.url && SAFE_HREF.test(s.url));
69
+ if (link) out = `<a href="${escapeHtml(link.url)}">${out}</a>`;
70
+ if (active.some((s) => s.type === "em")) out = `<em>${out}</em>`;
71
+ if (active.some((s) => s.type === "strong")) out = `<strong>${out}</strong>`;
72
+ return out;
73
+ }
74
+ function renderText(text, spans) {
75
+ const usable = (spans ?? []).map((s) => ({ ...s, start: Math.max(0, s.start), end: Math.min(text.length, s.end) })).filter((s) => Number.isFinite(s.start) && Number.isFinite(s.end) && s.start < s.end);
76
+ if (usable.length === 0) return escapeHtml(text);
77
+ const bounds = /* @__PURE__ */ new Set([0, text.length]);
78
+ for (const s of usable) {
79
+ bounds.add(s.start);
80
+ bounds.add(s.end);
81
+ }
82
+ const points = [...bounds].sort((a, b) => a - b);
83
+ let out = "";
84
+ for (let i = 0; i < points.length - 1; i++) {
85
+ const from = points[i];
86
+ const to = points[i + 1];
87
+ if (from === to) continue;
88
+ const active = usable.filter((s) => s.start <= from && s.end >= to);
89
+ out += wrap(escapeHtml(text.slice(from, to)), active);
90
+ }
91
+ return out;
92
+ }
93
+ var isListItem = (t) => t === "list-item" || t === "o-list-item";
94
+ function renderBlocks(blocks) {
95
+ const usable = blocks.filter((b) => b.text.trim() !== "");
96
+ let out = "";
97
+ let openList = null;
98
+ const closeList = () => {
99
+ if (openList) {
100
+ out += openList === "list-item" ? "</ul>" : "</ol>";
101
+ openList = null;
102
+ }
103
+ };
104
+ for (const block of usable) {
105
+ const inner = renderText(block.text, block.spans);
106
+ if (isListItem(block.type)) {
107
+ if (openList !== block.type) {
108
+ closeList();
109
+ out += block.type === "list-item" ? "<ul>" : "<ol>";
110
+ openList = block.type;
111
+ }
112
+ out += `<li>${inner}</li>`;
113
+ continue;
114
+ }
115
+ closeList();
116
+ const heading = HEADING_TAG[block.type];
117
+ out += heading ? `<${heading}>${inner}</${heading}>` : `<p>${inner}</p>`;
118
+ }
119
+ closeList();
120
+ return out;
121
+ }
122
+
123
+ // src/forms/default-replies.ts
124
+ var REPLIES = {
125
+ contact: (site) => ({
126
+ subject: "We got your message",
127
+ paragraphs: [
128
+ `Thanks for getting in touch with ${site}.`,
129
+ "We've received your message and someone will reply as soon as we can."
130
+ ]
131
+ }),
132
+ inquiry: (site) => ({
133
+ subject: "Thanks for your inquiry",
134
+ paragraphs: [
135
+ `Thanks for your interest \u2014 your inquiry has reached the team at ${site}.`,
136
+ "Someone will be in touch shortly with the details you asked about."
137
+ ]
138
+ }),
139
+ newsletter: (site) => ({
140
+ // "Subscribed", not "on the list" — that phrasing belongs to the RSVP, and
141
+ // a subject line that could mean either is worse than a plain one. Accurate
142
+ // for single opt-in, which is what the Mailchimp fan-out does today.
143
+ subject: "You're subscribed",
144
+ paragraphs: [
145
+ `Thanks for subscribing to updates from ${site}.`,
146
+ "You'll hear from us when there's something worth sharing, and you can unsubscribe from any email."
147
+ ]
148
+ }),
149
+ rsvp: (site) => ({
150
+ // The event name, when the submission carries one, beats this in notify.ts.
151
+ subject: "You're on the list",
152
+ paragraphs: [
153
+ `Thanks for your RSVP \u2014 ${site} has you down.`,
154
+ "We'll be in touch if anything changes before the day."
155
+ ]
156
+ }),
157
+ reserve: (site) => ({
158
+ subject: "We've got your reservation request",
159
+ paragraphs: [
160
+ `Thanks \u2014 ${site} has received your reservation request.`,
161
+ "We'll confirm the details with you shortly."
162
+ ]
163
+ })
164
+ };
165
+ function defaultReply(formType, siteName) {
166
+ const build = REPLIES[formType] ?? REPLIES.contact;
167
+ return build(siteName);
168
+ }
169
+
63
170
  // src/forms/notify.ts
64
171
  var FORMS_FROM = "forms@reddoorla.com";
65
172
  var FALLBACK_REPLY_TO = "info@reddoorla.com";
@@ -234,13 +341,10 @@ function buildAutoresponder(site, submission) {
234
341
  const extra = parseExtraFields(submission.extraFields);
235
342
  const reply = parseReplyCopy(extra._reply);
236
343
  const eventName = typeof extra.event === "string" ? extra.event.trim() : "";
237
- const subject = reply?.subject ?? (eventName ? `You're on the list for ${eventName}` : "We got your message");
238
- const paragraphs = reply?.paragraphs ?? [
239
- site.copyIntro ?? `Thanks for reaching out to ${site.name}.`,
240
- site.copyContact ?? "We've received your message and will be in touch soon."
241
- ];
344
+ const fallback = defaultReply(submission.formType, site.name);
345
+ const subject = reply?.subject ?? (eventName ? `You're on the list for ${eventName}` : fallback.subject);
242
346
  const signature = reply?.signature ?? site.copyFooter ?? site.name;
243
- const body = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join("");
347
+ const body = reply?.body ? renderBlocks(reply.body) : [site.copyIntro ?? fallback.paragraphs[0], site.copyContact ?? fallback.paragraphs[1]].map((p) => `<p>${escapeHtml(p)}</p>`).join("");
244
348
  const calendar = reply?.calendar;
245
349
  const calendarBlock = calendar ? `<p>Add it to your calendar: <a href="${escapeHtml(googleCalendarUrl(calendar))}">Google Calendar</a>. The attached invite works in Apple Calendar and Outlook.</p>` : "";
246
350
  const input = {
@@ -297,4 +401,4 @@ export {
297
401
  notifySubmission,
298
402
  makeNotify
299
403
  };
300
- //# sourceMappingURL=chunk-M2XF6JAI.js.map
404
+ //# sourceMappingURL=chunk-4763KQ6O.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms/ics.ts","../src/forms/rich-text.ts","../src/forms/default-replies.ts","../src/forms/notify.ts"],"sourcesContent":["/**\n * Calendar formats for the confirmation email: an RFC 5545 VEVENT and a Google\n * Calendar template URL. Kept apart from notify.ts because the escaping rules\n * are fiddly enough to deserve their own tests, and because a calendar format\n * has nothing to do with email.\n */\nimport type { ReplyCalendar } from \"./reply-copy.js\";\n\nconst DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000;\n\n/** `2026-09-12T18:00:00-07:00` → `20260913T010000Z`. Callers have already proven\n * the string parses (parseReplyCopy), so this never sees NaN. */\nfunction stamp(iso: string): string {\n return new Date(iso)\n .toISOString()\n .replace(/[-:]/g, \"\")\n .replace(/\\.\\d{3}/, \"\");\n}\n\nfunction endStamp(e: ReplyCalendar): string {\n if (e.end) return stamp(e.end);\n return stamp(new Date(Date.parse(e.start) + DEFAULT_DURATION_MS).toISOString());\n}\n\n/** RFC 5545 §3.3.11. Backslash FIRST — escaping it after the others would\n * double-escape the backslashes they just introduced. */\nfunction esc(v: string): string {\n return v\n .replace(/\\\\/g, \"\\\\\\\\\")\n .replace(/;/g, \"\\\\;\")\n .replace(/,/g, \"\\\\,\")\n .replace(/\\r?\\n/g, \"\\\\n\");\n}\n\n/** Stable per event, so a guest who receives a second copy sees their calendar\n * entry UPDATE rather than gain a duplicate. Derived from the fields that\n * identify the event, never from the send. */\nfunction uid(e: ReplyCalendar): string {\n const slug = e.title\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 40);\n return `${stamp(e.start)}-${slug || \"event\"}@reddoorla.com`;\n}\n\n/** A complete single-event calendar, CRLF-delimited per the spec. */\nexport function buildIcs(e: ReplyCalendar, now: Date = new Date()): string {\n const lines = [\n \"BEGIN:VCALENDAR\",\n \"VERSION:2.0\",\n \"PRODID:-//Reddoor Creative//Form Auto-Reply//EN\",\n \"CALSCALE:GREGORIAN\",\n \"METHOD:PUBLISH\",\n \"BEGIN:VEVENT\",\n `UID:${uid(e)}`,\n `DTSTAMP:${stamp(now.toISOString())}`,\n `DTSTART:${stamp(e.start)}`,\n `DTEND:${endStamp(e)}`,\n `SUMMARY:${esc(e.title)}`,\n ];\n if (e.location) lines.push(`LOCATION:${esc(e.location)}`);\n const description = [e.description, e.url].filter(Boolean).join(\"\\n\\n\");\n if (description) lines.push(`DESCRIPTION:${esc(description)}`);\n if (e.url) lines.push(`URL:${e.url}`);\n lines.push(\"END:VEVENT\", \"END:VCALENDAR\");\n return lines.join(\"\\r\\n\") + \"\\r\\n\";\n}\n\n/** The \"Add to Google Calendar\" link. Google reads the same UTC stamps. */\nexport function googleCalendarUrl(e: ReplyCalendar): string {\n const params = new URLSearchParams({\n action: \"TEMPLATE\",\n text: e.title,\n dates: `${stamp(e.start)}/${endStamp(e)}`,\n });\n if (e.location) params.set(\"location\", e.location);\n const details = [e.description, e.url].filter(Boolean).join(\"\\n\\n\");\n if (details) params.set(\"details\", details);\n return `https://calendar.google.com/calendar/render?${params.toString()}`;\n}\n","/**\n * Render the reply body's block/span AST to email-safe HTML.\n *\n * This is a WHITELIST renderer, and that is the entire point of the AST. The\n * envelope deliberately does not carry HTML: if it did, `parseReplyCopy` could\n * make no promise about what ends up in an email we send from a domain with\n * real sending reputation, and the day a site lets request data reach `_reply`\n * that becomes an injection vector in the worst possible place. Here the only\n * tags that can ever be emitted are the ones named below, so the worst a\n * hostile AST achieves is some bold text.\n *\n * Kept deliberately small for email clients: no images, no embeds, no classes,\n * no styles. Bold, italic, links, lists, two heading levels.\n */\nimport { escapeHtml } from \"../util/html.js\";\nimport type { ReplyBlock, ReplySpan } from \"./reply-copy.js\";\n\n/** Schemes allowed in an href. Everything else is dropped and the text kept —\n * a `javascript:` or `data:` URL arriving through a CMS field is not a link\n * anyone meant to write. */\nconst SAFE_HREF = /^(https:\\/\\/|mailto:)/i;\n\nconst HEADING_TAG: Record<string, string> = { heading2: \"h2\", heading3: \"h3\" };\n\n/** Wrap one already-escaped segment in the tags for the spans covering it.\n * Link innermost so nested emphasis reads naturally in every client. */\nfunction wrap(escaped: string, active: ReplySpan[]): string {\n let out = escaped;\n const link = active.find((s) => s.type === \"link\" && s.url && SAFE_HREF.test(s.url));\n if (link) out = `<a href=\"${escapeHtml(link.url as string)}\">${out}</a>`;\n if (active.some((s) => s.type === \"em\")) out = `<em>${out}</em>`;\n if (active.some((s) => s.type === \"strong\")) out = `<strong>${out}</strong>`;\n return out;\n}\n\n/**\n * Apply spans to text by OFFSET, then escape — in that order, per segment.\n *\n * The order is the whole trick. Offsets index the raw string, so escaping first\n * and slicing after silently shifts every span past the first `&`, `<` or `>` —\n * the classic way this kind of renderer goes subtly wrong, bolding the wrong\n * words only in copy that happens to contain an ampersand.\n */\nfunction renderText(text: string, spans: ReplySpan[] | undefined): string {\n const usable = (spans ?? [])\n // Clamp into range rather than trusting the CMS's arithmetic; drop anything\n // that still describes no characters.\n .map((s) => ({ ...s, start: Math.max(0, s.start), end: Math.min(text.length, s.end) }))\n .filter((s) => Number.isFinite(s.start) && Number.isFinite(s.end) && s.start < s.end);\n if (usable.length === 0) return escapeHtml(text);\n\n // Cut at every boundary, then decide which spans cover each piece. This is\n // what makes overlapping spans (bold across a range that a link only partly\n // covers) come out well-formed instead of interleaved.\n const bounds = new Set<number>([0, text.length]);\n for (const s of usable) {\n bounds.add(s.start);\n bounds.add(s.end);\n }\n const points = [...bounds].sort((a, b) => a - b);\n\n let out = \"\";\n for (let i = 0; i < points.length - 1; i++) {\n const from = points[i] as number;\n const to = points[i + 1] as number;\n if (from === to) continue;\n const active = usable.filter((s) => s.start <= from && s.end >= to);\n out += wrap(escapeHtml(text.slice(from, to)), active);\n }\n return out;\n}\n\nconst isListItem = (t: string): boolean => t === \"list-item\" || t === \"o-list-item\";\n\n/** Blocks → HTML. Empty blocks are dropped; consecutive list items of the same\n * kind collapse into a single list. */\nexport function renderBlocks(blocks: ReplyBlock[]): string {\n const usable = blocks.filter((b) => b.text.trim() !== \"\");\n let out = \"\";\n let openList: string | null = null;\n\n const closeList = () => {\n if (openList) {\n out += openList === \"list-item\" ? \"</ul>\" : \"</ol>\";\n openList = null;\n }\n };\n\n for (const block of usable) {\n const inner = renderText(block.text, block.spans);\n if (isListItem(block.type)) {\n if (openList !== block.type) {\n closeList();\n out += block.type === \"list-item\" ? \"<ul>\" : \"<ol>\";\n openList = block.type;\n }\n out += `<li>${inner}</li>`;\n continue;\n }\n closeList();\n const heading = HEADING_TAG[block.type];\n out += heading ? `<${heading}>${inner}</${heading}>` : `<p>${inner}</p>`;\n }\n closeList();\n return out;\n}\n","/**\n * What a confirmation email says when nobody has authored anything.\n *\n * Before this, every form type on every site sent one string — \"We got your\n * message\" over \"Thanks for reaching out to {site}.\" A newsletter signup and a\n * price-list inquiry are not the same event, and reading as though they are is\n * the tell that nothing behind the form is paying attention.\n *\n * These are the floor, not the ceiling: a site that authors copy in its CMS\n * overrides them, and a site with the legacy per-site columns set still uses\n * those. The aim is that a fleet site with NO configuration at all still sends\n * something a person would recognise as a real reply.\n *\n * Deliberately plain and brand-neutral — they stand in for a client's voice\n * without impersonating it, and they must read acceptably for a law firm, a\n * gallery and a dentist alike.\n */\nimport type { FormType } from \"./types.js\";\n\nexport type DefaultReply = {\n subject: string;\n /** Plain paragraphs. Our own text, so no formatting and nothing to sanitize\n * beyond the escaping every body gets. */\n paragraphs: [string, string];\n};\n\nconst REPLIES: Record<FormType, (site: string) => DefaultReply> = {\n contact: (site) => ({\n subject: \"We got your message\",\n paragraphs: [\n `Thanks for getting in touch with ${site}.`,\n \"We've received your message and someone will reply as soon as we can.\",\n ],\n }),\n inquiry: (site) => ({\n subject: \"Thanks for your inquiry\",\n paragraphs: [\n `Thanks for your interest — your inquiry has reached the team at ${site}.`,\n \"Someone will be in touch shortly with the details you asked about.\",\n ],\n }),\n newsletter: (site) => ({\n // \"Subscribed\", not \"on the list\" — that phrasing belongs to the RSVP, and\n // a subject line that could mean either is worse than a plain one. Accurate\n // for single opt-in, which is what the Mailchimp fan-out does today.\n subject: \"You're subscribed\",\n paragraphs: [\n `Thanks for subscribing to updates from ${site}.`,\n \"You'll hear from us when there's something worth sharing, and you can unsubscribe from any email.\",\n ],\n }),\n rsvp: (site) => ({\n // The event name, when the submission carries one, beats this in notify.ts.\n subject: \"You're on the list\",\n paragraphs: [\n `Thanks for your RSVP — ${site} has you down.`,\n \"We'll be in touch if anything changes before the day.\",\n ],\n }),\n reserve: (site) => ({\n subject: \"We've got your reservation request\",\n paragraphs: [\n `Thanks — ${site} has received your reservation request.`,\n \"We'll confirm the details with you shortly.\",\n ],\n }),\n};\n\n/** The default reply for a form type. An unrecognized type gets the contact\n * copy, which is the one wording that is true of any form: we received it. */\nexport function defaultReply(formType: string, siteName: string): DefaultReply {\n const build = REPLIES[formType as FormType] ?? REPLIES.contact;\n return build(siteName);\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 { parseReplyCopy } from \"./reply-copy.js\";\nimport { buildIcs, googleCalendarUrl } from \"./ics.js\";\nimport { renderBlocks } from \"./rich-text.js\";\nimport { defaultReply } from \"./default-replies.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 !== \"maintained\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launching\" to route tests to\n * yourself).\n * - Maintained + 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 * - Maintained, 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 !== \"maintained\") {\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 !== \"maintained\") {\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 \"maintained\" — 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 \"maintained\" 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 \"maintained\" — 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 \"maintained\" 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 \"maintained\" 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 (\n Object.entries(parseExtraFields(raw))\n // Underscore keys are reserved transport (see payload.ts), never lead data.\n // `_reply` is a whole confirmation email; rendering it here would put the\n // copy in the client's notification as an unreadable JSON row.\n .filter(([k]) => !k.startsWith(\"_\"))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string])\n );\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 extra = parseExtraFields(submission.extraFields);\n const reply = parseReplyCopy(extra._reply);\n const eventName = typeof extra.event === \"string\" ? extra.event.trim() : \"\";\n\n // Subject, best available first. The middle tier is why an RSVP reads like a\n // confirmation even before anyone writes a word of copy: the event name is\n // already on every submission that has one.\n const fallback = defaultReply(submission.formType, site.name);\n const subject =\n reply?.subject ?? (eventName ? `You're on the list for ${eventName}` : fallback.subject);\n\n // Body. The per-site trio is the last-resort net for sites that author\n // nothing — it has no editor since the Airtable freeze, so anything a client\n // can actually change now comes through the envelope.\n const signature = reply?.signature ?? site.copyFooter ?? site.name;\n\n // The legacy per-site columns still win over the built-in default where a\n // site has them set — they were written for that client. Where they're blank\n // (every site created since the Airtable freeze) the per-form-type default\n // fills in, which is strictly better than the one generic line it replaces.\n const body = reply?.body\n ? renderBlocks(reply.body)\n : [site.copyIntro ?? fallback.paragraphs[0], site.copyContact ?? fallback.paragraphs[1]]\n .map((p) => `<p>${escapeHtml(p)}</p>`)\n .join(\"\");\n // The one block assembled rather than escaped wholesale, because it carries an\n // anchor we build ourselves. Its only interpolated value is the Google URL,\n // escaped on the way in — and parseReplyCopy already refused any non-https\n // `url` that feeds it.\n const calendar = reply?.calendar;\n const calendarBlock = calendar\n ? `<p>Add it to your calendar: <a href=\"${escapeHtml(googleCalendarUrl(calendar))}\">Google Calendar</a>. ` +\n `The attached invite works in Apple Calendar and Outlook.</p>`\n : \"\";\n\n const input: ResendSendInput = {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject,\n html: `${body}${calendarBlock}<p>${escapeHtml(signature)}</p>`,\n };\n if (calendar) {\n // A bare Google link strands every Apple Mail reader; an .ics alone is a\n // file most people on a phone will not open. Both, once.\n input.attachments = [\n {\n filename: \"event.ics\",\n content: Buffer.from(buildIcs(calendar), \"utf8\").toString(\"base64\"),\n contentType: \"text/calendar\",\n },\n ];\n }\n return input;\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"],"mappings":"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","names":[]}
@@ -0,0 +1,89 @@
1
+ // src/forms/reply-copy.ts
2
+ var REPLY_BLOCK_TYPES = [
3
+ "paragraph",
4
+ "heading2",
5
+ "heading3",
6
+ "list-item",
7
+ "o-list-item"
8
+ ];
9
+ function str(v) {
10
+ if (typeof v !== "string") return void 0;
11
+ const t = v.trim();
12
+ return t === "" ? void 0 : t;
13
+ }
14
+ function date(v) {
15
+ const s = str(v);
16
+ return s && !Number.isNaN(Date.parse(s)) ? s : void 0;
17
+ }
18
+ function parseCalendar(raw) {
19
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
20
+ const c = raw;
21
+ const title = str(c.title);
22
+ const start = date(c.start);
23
+ if (!title || !start) return void 0;
24
+ const out = { title, start };
25
+ const end = date(c.end);
26
+ if (end) out.end = end;
27
+ const location = str(c.location);
28
+ if (location) out.location = location;
29
+ const url = str(c.url);
30
+ if (url && /^https:\/\//i.test(url)) out.url = url;
31
+ const description = str(c.description);
32
+ if (description) out.description = description;
33
+ return out;
34
+ }
35
+ function parseSpans(raw) {
36
+ if (!Array.isArray(raw)) return void 0;
37
+ const out = [];
38
+ for (const item of raw) {
39
+ if (!item || typeof item !== "object") continue;
40
+ const s = item;
41
+ const type = s.type;
42
+ if (type !== "strong" && type !== "em" && type !== "link") continue;
43
+ const start = typeof s.start === "number" ? s.start : NaN;
44
+ const end = typeof s.end === "number" ? s.end : NaN;
45
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
46
+ const span = { start, end, type };
47
+ const url = str(s.url);
48
+ if (url) span.url = url;
49
+ out.push(span);
50
+ }
51
+ return out.length > 0 ? out : void 0;
52
+ }
53
+ function parseBlocks(raw) {
54
+ if (!Array.isArray(raw)) return void 0;
55
+ const out = [];
56
+ for (const item of raw) {
57
+ if (!item || typeof item !== "object") continue;
58
+ const b = item;
59
+ const text = typeof b.text === "string" ? b.text : "";
60
+ if (text.trim() === "") continue;
61
+ const declared = b.type;
62
+ const type = REPLY_BLOCK_TYPES.includes(declared) ? declared : "paragraph";
63
+ const block = { type, text };
64
+ const spans = parseSpans(b.spans);
65
+ if (spans) block.spans = spans;
66
+ out.push(block);
67
+ }
68
+ return out.length > 0 ? out : void 0;
69
+ }
70
+ function parseReplyCopy(raw) {
71
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
72
+ const r = raw;
73
+ const out = {};
74
+ const subject = str(r.subject);
75
+ if (subject) out.subject = subject;
76
+ const signature = str(r.signature);
77
+ if (signature) out.signature = signature;
78
+ const body = parseBlocks(r.body);
79
+ if (body) out.body = body;
80
+ const calendar = parseCalendar(r.calendar);
81
+ if (calendar) out.calendar = calendar;
82
+ return Object.keys(out).length > 0 ? out : void 0;
83
+ }
84
+
85
+ export {
86
+ REPLY_BLOCK_TYPES,
87
+ parseReplyCopy
88
+ };
89
+ //# sourceMappingURL=chunk-I7NHLVVI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms/reply-copy.ts"],"sourcesContent":["/**\n * Copy for the submitter's confirmation email, resolved by the SITE from its own\n * CMS and forwarded in the reserved `_reply` envelope.\n *\n * Everything here arrives over an untrusted boundary twice — once off the wire,\n * once back out of the persisted `extraFields` JSON — so `parseReplyCopy` is the\n * single gate both paths go through. It drops field by field rather than\n * rejecting whole: a usable subject should still improve the email when the\n * calendar block is malformed.\n */\nexport type ReplyCalendar = {\n title: string;\n /** ISO 8601. Validated as parseable, not as any particular shape. */\n start: string;\n end?: string;\n location?: string;\n url?: string;\n description?: string;\n};\n\n/** Inline formatting, as an offset range over a block's raw `text`. */\nexport type ReplySpan = {\n start: number;\n end: number;\n type: \"strong\" | \"em\" | \"link\";\n /** Required for `link`; https: and mailto: only, enforced at render. */\n url?: string;\n};\n\nexport const REPLY_BLOCK_TYPES = [\n \"paragraph\",\n \"heading2\",\n \"heading3\",\n \"list-item\",\n \"o-list-item\",\n] as const;\nexport type ReplyBlockType = (typeof REPLY_BLOCK_TYPES)[number];\n\n/**\n * One block of body copy.\n *\n * Deliberately an AST and NOT an HTML string. The envelope crosses an untrusted\n * boundary twice, so a renderer that can only emit a fixed set of tags is what\n * keeps \"no attacker text reaches an outbound email\" true by construction\n * rather than by everyone downstream remembering to escape. See rich-text.ts.\n */\nexport type ReplyBlock = {\n type: ReplyBlockType;\n text: string;\n spans?: ReplySpan[];\n};\n\nexport type ReplyCopy = {\n subject?: string;\n body?: ReplyBlock[];\n signature?: string;\n calendar?: ReplyCalendar;\n};\n\nfunction str(v: unknown): string | undefined {\n if (typeof v !== \"string\") return undefined;\n const t = v.trim();\n return t === \"\" ? undefined : t;\n}\n\nfunction date(v: unknown): string | undefined {\n const s = str(v);\n return s && !Number.isNaN(Date.parse(s)) ? s : undefined;\n}\n\nfunction parseCalendar(raw: unknown): ReplyCalendar | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const c = raw as Record<string, unknown>;\n const title = str(c.title);\n const start = date(c.start);\n // Both are load-bearing: an event with no name or no start is not an event,\n // and half of one in a calendar client is worse than none.\n if (!title || !start) return undefined;\n const out: ReplyCalendar = { title, start };\n const end = date(c.end);\n if (end) out.end = end;\n const location = str(c.location);\n if (location) out.location = location;\n // https only. This becomes an href in an email we send; a javascript: or\n // data: URL arriving through a CMS field is not a link anyone meant to write.\n const url = str(c.url);\n if (url && /^https:\\/\\//i.test(url)) out.url = url;\n const description = str(c.description);\n if (description) out.description = description;\n return out;\n}\n\nfunction parseSpans(raw: unknown): ReplySpan[] | undefined {\n if (!Array.isArray(raw)) return undefined;\n const out: ReplySpan[] = [];\n for (const item of raw) {\n if (!item || typeof item !== \"object\") continue;\n const s = item as Record<string, unknown>;\n const type = s.type;\n if (type !== \"strong\" && type !== \"em\" && type !== \"link\") continue;\n const start = typeof s.start === \"number\" ? s.start : NaN;\n const end = typeof s.end === \"number\" ? s.end : NaN;\n if (!Number.isFinite(start) || !Number.isFinite(end)) continue;\n const span: ReplySpan = { start, end, type };\n const url = str(s.url);\n // A link with no usable url survives as plain text rather than vanishing —\n // the renderer drops the anchor and keeps the words.\n if (url) span.url = url;\n out.push(span);\n }\n return out.length > 0 ? out : undefined;\n}\n\nfunction parseBlocks(raw: unknown): ReplyBlock[] | undefined {\n if (!Array.isArray(raw)) return undefined;\n const out: ReplyBlock[] = [];\n for (const item of raw) {\n if (!item || typeof item !== \"object\") continue;\n const b = item as Record<string, unknown>;\n const text = typeof b.text === \"string\" ? b.text : \"\";\n if (text.trim() === \"\") continue;\n // An unrecognized block type degrades to a paragraph rather than being\n // dropped: losing a sentence is worse than losing its styling.\n const declared = b.type;\n const type = (REPLY_BLOCK_TYPES as readonly string[]).includes(declared as string)\n ? (declared as ReplyBlockType)\n : \"paragraph\";\n const block: ReplyBlock = { type, text };\n const spans = parseSpans(b.spans);\n if (spans) block.spans = spans;\n out.push(block);\n }\n return out.length > 0 ? out : undefined;\n}\n\n/** Validate untrusted envelope data. Undefined means \"nothing usable\" — callers\n * then fall back to the site's own copy rather than sending a blank email. */\nexport function parseReplyCopy(raw: unknown): ReplyCopy | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const r = raw as Record<string, unknown>;\n const out: ReplyCopy = {};\n const subject = str(r.subject);\n if (subject) out.subject = subject;\n const signature = str(r.signature);\n if (signature) out.signature = signature;\n const body = parseBlocks(r.body);\n if (body) out.body = body;\n const calendar = parseCalendar(r.calendar);\n if (calendar) out.calendar = calendar;\n return Object.keys(out).length > 0 ? out : undefined;\n}\n"],"mappings":"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","names":[]}
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-NO74DXDP.js";
4
4
  import {
5
5
  parseReplyCopy
6
- } from "./chunk-PJ23RDHT.js";
6
+ } from "./chunk-I7NHLVVI.js";
7
7
  import {
8
8
  SUBMISSION_FORM_TYPES
9
9
  } from "./chunk-2OP4JKIQ.js";
@@ -287,4 +287,4 @@ export {
287
287
  ingestSubmission,
288
288
  hostsMatch
289
289
  };
290
- //# sourceMappingURL=chunk-5XMKJX74.js.map
290
+ //# sourceMappingURL=chunk-QF6IQQFP.js.map
package/dist/cli/bin.js CHANGED
@@ -261,7 +261,7 @@ cli.command(
261
261
  "Show who a form submission would email, and optionally flip the pre-launch guard (read-back confirmed)."
262
262
  ).option("--set <on|off>", "Flip the guard: on = route to operator, off = restore.").option("--restore <status>", "Status to restore with --set off. Required; never inferred.").action(
263
263
  async (site, opts) => runOrExit(
264
- async () => (await import("../forms-notify-target-MNNOGSJ7.js")).runFormsNotifyTargetCommand(
264
+ async () => (await import("../forms-notify-target-RMZPKT6S.js")).runFormsNotifyTargetCommand(
265
265
  site,
266
266
  opts
267
267
  ),
@@ -373,7 +373,7 @@ cli.command(
373
373
  "--force",
374
374
  "import-airtable / sync: run despite the freeze (#643) \u2014 a deliberate rollback-window converge from the frozen Airtable shadow. Without it both refuse while Turso is authoritative, because an import overwrites authoritative rows."
375
375
  ).action(
376
- async (action, opts) => runOrExit(async () => (await import("../db-DJVW6ZSE.js")).runDbCommand(action, opts), opts)
376
+ async (action, opts) => runOrExit(async () => (await import("../db-HSZ6D5DU.js")).runDbCommand(action, opts), opts)
377
377
  );
378
378
  cli.command(
379
379
  "submissions <action>",
@@ -35,12 +35,12 @@ async function runDbCommand(action, opts, deps = {}) {
35
35
  listRecentSubmissionsForEmail,
36
36
  markSubmissionsSpamRetro
37
37
  } = await import("./submissions-3I3GAQAX.js");
38
- const { makeNotify } = await import("./notify-FYSOWKMJ.js");
38
+ const { makeNotify } = await import("./notify-IISTHNBR.js");
39
39
  const { classifySpam } = await import("./spam-classifier-T5H4VTP6.js");
40
40
  const { forwardNewsletterToWebhook } = await import("./webhook-XM6EV4XT.js");
41
41
  const { addMailchimpMember, mailchimpTagsFor } = await import("./mailchimp-XYCTEEJY.js");
42
42
  const { defaultResendClient } = await import("./resend-G2BJMAQV.js");
43
- const { replayDeadLetters } = await import("./replay-HV7MGKBU.js");
43
+ const { replayDeadLetters } = await import("./replay-PCSQOGCZ.js");
44
44
  let send = null;
45
45
  try {
46
46
  send = defaultResendClient().send;
@@ -350,4 +350,4 @@ export {
350
350
  freezeGuardsDbWrite,
351
351
  runDbCommand
352
352
  };
353
- //# sourceMappingURL=db-DJVW6ZSE.js.map
353
+ //# sourceMappingURL=db-HSZ6D5DU.js.map
@@ -106,8 +106,13 @@ type CreateIngestActionOptions = {
106
106
  /**
107
107
  * Map this form's fields to a payload. The factory's `formType` is always
108
108
  * authoritative and cannot be overridden by `buildPayload`.
109
+ *
110
+ * May be async, matching `createIngestEndpoint` — a site that resolves
111
+ * confirmation copy from its CMS does a read here (see
112
+ * `@reddoorla/maintenance/forms/prismic`). A rejection is handled exactly
113
+ * like a throw from a sync one: a failure result, never a 500.
109
114
  */
110
- buildPayload: (form: FormData, event: RequestEvent) => SubmissionPayload;
115
+ buildPayload: (form: FormData, event: RequestEvent) => SubmissionPayload | Promise<SubmissionPayload>;
111
116
  /** Honeypot input name. Default "bot-field". */
112
117
  botFieldName?: string;
113
118
  /** Hidden timestamp input name (planted in `load`). Default "ts". */
@@ -66,7 +66,7 @@ function createIngestAction(opts) {
66
66
  let payload;
67
67
  try {
68
68
  payload = {
69
- ...opts.buildPayload(form, event),
69
+ ...await opts.buildPayload(form, event),
70
70
  formType: opts.formType,
71
71
  _meta: buildSubmissionMeta(event, form.get(turnstileFieldName)?.toString())
72
72
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/forms/action.ts","../../src/forms/meta.ts","../../src/forms/endpoint.ts"],"sourcesContent":["import { fail, redirect, type ActionFailure, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\nimport { buildSubmissionMeta } from \"./meta.js\";\n\n/** Endpoint + token for the dashboard ingest, read per-request from site env. */\nexport type IngestActionConfig = { url?: string; token?: string };\n\nexport type CreateIngestActionOptions = {\n /** Stamped onto every payload as `formType` (a SUBMISSION_FORM_TYPES value). */\n formType: string;\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map this form's fields to a payload. The factory's `formType` is always\n * authoritative and cannot be overridden by `buildPayload`.\n */\n buildPayload: (form: FormData, event: RequestEvent) => SubmissionPayload;\n /** Honeypot input name. Default \"bot-field\". */\n botFieldName?: string;\n /** Hidden timestamp input name (planted in `load`). Default \"ts\". */\n tsFieldName?: string;\n /** Field carrying the Cloudflare Turnstile token. Default \"cf-turnstile-response\". */\n turnstileFieldName?: string;\n /** fail(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** fail(502) copy when the ingest endpoint rejects/errors. */\n errorMessage?: string;\n /** Injectable clock for tests. Default Date.now. */\n now?: () => number;\n /** If set, a successful OR bot-screened submission throws redirect(303, redirectTo)\n * instead of returning {success:true} (e.g. a dedicated /thank-you page). */\n redirectTo?: string;\n};\n\nexport type IngestActionData = { success: true } | ActionFailure<{ error: string }>;\n\n/**\n * Build a SvelteKit `default` form action that screens for bots, forwards the\n * submission to the dashboard ingest endpoint, and returns SvelteKit-shaped\n * results. The per-form field mapping is the only thing a site must supply.\n */\nexport function createIngestAction(\n opts: CreateIngestActionOptions,\n): (event: RequestEvent) => Promise<IngestActionData> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const tsFieldName = opts.tsFieldName ?? \"ts\";\n const turnstileFieldName = opts.turnstileFieldName ?? \"cf-turnstile-response\";\n const now = opts.now ?? Date.now;\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let form: FormData;\n try {\n form = await event.request.formData();\n } catch {\n console.error(`[forms-ingest] ${opts.formType}: could not parse form body`);\n return fail(400, { error: failed });\n }\n\n // Bot screen: a filled honeypot OR an implausibly fast fill is silently\n // accepted (return success, do NOT forward) so bots get no signal.\n const screen = screenSubmission({\n botField: form.get(botFieldName)?.toString() ?? null,\n elapsedMs: elapsedMs(form.get(tsFieldName), now),\n });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // succeed exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return succeed();\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${opts.formType}`);\n return fail(500, { error: unavailable });\n }\n\n // buildPayload runs on untrusted form data; a careless field access (e.g.\n // `form.get(\"email\")!.toString()` on an absent field) would otherwise escape\n // as an uncaught 500. Treat a throw as a malformed request (400), mirroring\n // endpoint.ts's guard and its \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...opts.buildPayload(form, event),\n formType: opts.formType,\n _meta: buildSubmissionMeta(event, form.get(turnstileFieldName)?.toString()),\n };\n } catch (err) {\n console.error(`[forms-ingest] ${opts.formType}: buildPayload threw: ${String(err)}`);\n return fail(400, { error: failed });\n }\n\n const result = await submitToIngest({\n url,\n token,\n fetch: event.fetch,\n payload,\n });\n if (!result.ok) {\n console.error(`[forms-ingest] ${opts.formType} → ${result.status}: ${result.error}`);\n return fail(502, { error: failed });\n }\n return succeed();\n };\n\n // Single success path: redirect when configured (e.g. a dedicated /thank-you\n // page), otherwise return the SvelteKit-shaped success. `redirect()` throws, so\n // the trailing `return` keeps the `{ success: true }` type.\n function succeed(): { success: true } {\n if (opts.redirectTo) redirect(303, opts.redirectTo);\n return { success: true };\n }\n}\n\n// `FormDataEntryValue` is a DOM-lib global; this package compiles with only the\n// ES2022 lib + @types/node, where it is not in scope. Derive the type from the\n// in-scope `FormData.get` return instead — same value, no DOM-lib dependency.\nfunction elapsedMs(tsRaw: ReturnType<FormData[\"get\"]>, now: () => number): number | null {\n const ts = Number(tsRaw);\n if (!Number.isFinite(ts) || ts <= 0) return null;\n // Clamp at 0: elapsed time can't be negative. A future `ts` (clock skew or a bot\n // forging one) would otherwise yield a negative value; clamping makes it read as\n // 0ms (effectively instant) so the MIN_FILL_MS gate still trips. Defense-in-depth\n // with screenSubmission, which also treats any sub-floor elapsed as too-fast.\n return Math.max(0, now() - ts);\n}\n","/** The reserved wire envelope a fleet site forwards alongside the lead fields.\n * (Older package versions also sent a `userAgent` — central never consumed it,\n * so it is no longer forwarded; `readMeta` simply ignores it from old senders.) */\nexport type SubmissionMeta = {\n turnstileToken?: string;\n clientIp?: string;\n};\n\nfunction str(v: unknown): string {\n return typeof v === \"string\" ? v.trim() : \"\";\n}\n\n/**\n * Defensively read the reserved `_meta` envelope off an untrusted ingest payload\n * (CENTRAL side, used by the ingest handler). Keeps only non-blank string fields\n * among the KNOWN keys, dropping everything else — a bot cannot smuggle a\n * non-string clientIp in, and unknown fields (e.g. the `userAgent` older package\n * versions still send) are silently ignored. The token/IP read here are used\n * transiently (Turnstile verify + `remoteip`) and are NEVER persisted; the token\n * is never stored. The SITE-side writer (`buildSubmissionMeta`) is added by the\n * site-factory task; `readMeta` is the single reader (there is no `parseMeta`).\n */\nexport function readMeta(payload: unknown): SubmissionMeta {\n const meta: SubmissionMeta = {};\n if (typeof payload !== \"object\" || payload === null) return meta;\n const raw = (payload as Record<string, unknown>)._meta;\n if (typeof raw !== \"object\" || raw === null) return meta;\n const m = raw as Record<string, unknown>;\n const token = str(m.turnstileToken);\n if (token) meta.turnstileToken = token;\n const ip = str(m.clientIp);\n if (ip) meta.clientIp = ip;\n return meta;\n}\n\n/**\n * SITE-side event shape `buildSubmissionMeta` reads. Structural (not SvelteKit's\n * `RequestEvent`) so this leaf stays SDK-free; a real `RequestEvent` is\n * structurally assignable (`getClientAddress: () => string`).\n */\ntype MetaEvent = {\n getClientAddress?: () => string;\n};\n\n/**\n * Build the transient `_meta` envelope a site forwards to central ingest:\n * `{ turnstileToken?, clientIp? }`. Returns `undefined` when no field yields a\n * value so callers can attach it unconditionally without polluting the payload\n * (an `undefined` value is dropped by `JSON.stringify`). `getClientAddress` is\n * guarded (some adapters lack a client address and can throw). The visitor's\n * user-agent is deliberately NOT forwarded — central never consumed it, so\n * shipping it was pure transient-PII surface. None of this is ever persisted.\n */\nexport function buildSubmissionMeta(\n event: MetaEvent,\n turnstileToken: string | null | undefined,\n): SubmissionMeta | undefined {\n const meta: SubmissionMeta = {};\n\n const token = typeof turnstileToken === \"string\" ? turnstileToken.trim() : \"\";\n if (token) meta.turnstileToken = token;\n\n if (typeof event.getClientAddress === \"function\") {\n try {\n const ip = event.getClientAddress();\n if (typeof ip === \"string\" && ip.trim()) meta.clientIp = ip.trim();\n } catch {\n // Some adapters have no client address and throw; drop clientIp silently.\n }\n }\n\n return Object.keys(meta).length > 0 ? meta : undefined;\n}\n","import { json, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\nimport { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { IngestActionConfig } from \"./action.js\";\nimport { buildSubmissionMeta } from \"./meta.js\";\n\n/**\n * Options for {@link createIngestEndpoint} — the JSON sibling of\n * `createIngestAction` for client-driven forms (modals / lightboxes / fetch)\n * that POST JSON to a `+server.ts` route instead of using a form action.\n */\nexport type CreateIngestEndpointOptions = {\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map the parsed JSON body to a payload. Must set `formType` UNLESS the fixed\n * `formType` option is provided (then that is authoritative and overrides it).\n *\n * May be async. A site that resolves confirmation copy from its own CMS does a\n * read here — see `@reddoorla/maintenance/forms/prismic`. A rejection is\n * handled exactly like a throw from a sync one: a 400, never a 500.\n */\n buildPayload: (\n body: Record<string, unknown>,\n event: RequestEvent,\n ) => SubmissionPayload | Promise<SubmissionPayload>;\n /** Fixed formType for single-type endpoints; omit for multi-type endpoints\n * where `buildPayload` derives formType from the body. */\n formType?: string;\n /** Honeypot field name in the JSON body. Default \"bot-field\". */\n botFieldName?: string;\n /** Field carrying the Cloudflare Turnstile token. Default \"cf-turnstile-response\". */\n turnstileFieldName?: string;\n /** json(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** json(400/502) copy for bad input / ingest failure. */\n errorMessage?: string;\n};\n\nfunction isFormType(v: unknown): v is FormType {\n return typeof v === \"string\" && (SUBMISSION_FORM_TYPES as readonly string[]).includes(v);\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n\n/**\n * Build a JSON `POST` handler that screens for bots, forwards the submission to\n * the dashboard ingest endpoint, and returns `{ ok }`-shaped JSON. The per-form\n * field mapping (`buildPayload`) is the only thing a site must supply. The\n * returned function is structurally a SvelteKit `RequestHandler`.\n */\nexport function createIngestEndpoint(\n opts: CreateIngestEndpointOptions,\n): (event: RequestEvent) => Promise<Response> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const turnstileFieldName = opts.turnstileFieldName ?? \"cf-turnstile-response\";\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await event.request.json();\n if (!parsed || typeof parsed !== \"object\") throw new Error(\"body is not an object\");\n body = parsed as Record<string, unknown>;\n } catch {\n console.error(\"[forms-ingest] could not parse JSON body\");\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n // Bot screen: honeypot only. A client POST carries no server-planted ts, and\n // screenSubmission treats a missing elapsedMs as OK. A filled honeypot is\n // silently accepted (return ok, do NOT forward) so bots get no signal.\n const screen = screenSubmission({ botField: str(body[botFieldName]) ?? null });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // return success exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return json({ ok: true });\n }\n\n // buildPayload runs on untrusted JSON; a careless field access (e.g.\n // `body.name.trim()` on a non-string) would otherwise escape as a 500. Treat\n // a throw as a malformed request (400), keeping the \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...(await opts.buildPayload(body, event)),\n ...(opts.formType ? { formType: opts.formType } : {}),\n _meta: buildSubmissionMeta(event, str(body[turnstileFieldName])),\n };\n } catch (err) {\n console.error(`[forms-ingest] buildPayload threw: ${String(err)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n if (!isFormType(payload.formType)) {\n console.error(`[forms-ingest] invalid formType: ${String(payload.formType)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${payload.formType}`);\n return json({ ok: false, error: unavailable }, { status: 500 });\n }\n\n const result = await submitToIngest({ url, token, fetch: event.fetch, payload });\n if (!result.ok) {\n console.error(`[forms-ingest] ${payload.formType} → ${result.status}: ${result.error}`);\n return json({ ok: false, error: failed }, { status: 502 });\n }\n return json({ ok: true });\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/forms/action.ts","../../src/forms/meta.ts","../../src/forms/endpoint.ts"],"sourcesContent":["import { fail, redirect, type ActionFailure, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\nimport { buildSubmissionMeta } from \"./meta.js\";\n\n/** Endpoint + token for the dashboard ingest, read per-request from site env. */\nexport type IngestActionConfig = { url?: string; token?: string };\n\nexport type CreateIngestActionOptions = {\n /** Stamped onto every payload as `formType` (a SUBMISSION_FORM_TYPES value). */\n formType: string;\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map this form's fields to a payload. The factory's `formType` is always\n * authoritative and cannot be overridden by `buildPayload`.\n *\n * May be async, matching `createIngestEndpoint` — a site that resolves\n * confirmation copy from its CMS does a read here (see\n * `@reddoorla/maintenance/forms/prismic`). A rejection is handled exactly\n * like a throw from a sync one: a failure result, never a 500.\n */\n buildPayload: (\n form: FormData,\n event: RequestEvent,\n ) => SubmissionPayload | Promise<SubmissionPayload>;\n /** Honeypot input name. Default \"bot-field\". */\n botFieldName?: string;\n /** Hidden timestamp input name (planted in `load`). Default \"ts\". */\n tsFieldName?: string;\n /** Field carrying the Cloudflare Turnstile token. Default \"cf-turnstile-response\". */\n turnstileFieldName?: string;\n /** fail(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** fail(502) copy when the ingest endpoint rejects/errors. */\n errorMessage?: string;\n /** Injectable clock for tests. Default Date.now. */\n now?: () => number;\n /** If set, a successful OR bot-screened submission throws redirect(303, redirectTo)\n * instead of returning {success:true} (e.g. a dedicated /thank-you page). */\n redirectTo?: string;\n};\n\nexport type IngestActionData = { success: true } | ActionFailure<{ error: string }>;\n\n/**\n * Build a SvelteKit `default` form action that screens for bots, forwards the\n * submission to the dashboard ingest endpoint, and returns SvelteKit-shaped\n * results. The per-form field mapping is the only thing a site must supply.\n */\nexport function createIngestAction(\n opts: CreateIngestActionOptions,\n): (event: RequestEvent) => Promise<IngestActionData> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const tsFieldName = opts.tsFieldName ?? \"ts\";\n const turnstileFieldName = opts.turnstileFieldName ?? \"cf-turnstile-response\";\n const now = opts.now ?? Date.now;\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let form: FormData;\n try {\n form = await event.request.formData();\n } catch {\n console.error(`[forms-ingest] ${opts.formType}: could not parse form body`);\n return fail(400, { error: failed });\n }\n\n // Bot screen: a filled honeypot OR an implausibly fast fill is silently\n // accepted (return success, do NOT forward) so bots get no signal.\n const screen = screenSubmission({\n botField: form.get(botFieldName)?.toString() ?? null,\n elapsedMs: elapsedMs(form.get(tsFieldName), now),\n });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // succeed exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return succeed();\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${opts.formType}`);\n return fail(500, { error: unavailable });\n }\n\n // buildPayload runs on untrusted form data; a careless field access (e.g.\n // `form.get(\"email\")!.toString()` on an absent field) would otherwise escape\n // as an uncaught 500. Treat a throw as a malformed request (400), mirroring\n // endpoint.ts's guard and its \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...(await opts.buildPayload(form, event)),\n formType: opts.formType,\n _meta: buildSubmissionMeta(event, form.get(turnstileFieldName)?.toString()),\n };\n } catch (err) {\n console.error(`[forms-ingest] ${opts.formType}: buildPayload threw: ${String(err)}`);\n return fail(400, { error: failed });\n }\n\n const result = await submitToIngest({\n url,\n token,\n fetch: event.fetch,\n payload,\n });\n if (!result.ok) {\n console.error(`[forms-ingest] ${opts.formType} → ${result.status}: ${result.error}`);\n return fail(502, { error: failed });\n }\n return succeed();\n };\n\n // Single success path: redirect when configured (e.g. a dedicated /thank-you\n // page), otherwise return the SvelteKit-shaped success. `redirect()` throws, so\n // the trailing `return` keeps the `{ success: true }` type.\n function succeed(): { success: true } {\n if (opts.redirectTo) redirect(303, opts.redirectTo);\n return { success: true };\n }\n}\n\n// `FormDataEntryValue` is a DOM-lib global; this package compiles with only the\n// ES2022 lib + @types/node, where it is not in scope. Derive the type from the\n// in-scope `FormData.get` return instead — same value, no DOM-lib dependency.\nfunction elapsedMs(tsRaw: ReturnType<FormData[\"get\"]>, now: () => number): number | null {\n const ts = Number(tsRaw);\n if (!Number.isFinite(ts) || ts <= 0) return null;\n // Clamp at 0: elapsed time can't be negative. A future `ts` (clock skew or a bot\n // forging one) would otherwise yield a negative value; clamping makes it read as\n // 0ms (effectively instant) so the MIN_FILL_MS gate still trips. Defense-in-depth\n // with screenSubmission, which also treats any sub-floor elapsed as too-fast.\n return Math.max(0, now() - ts);\n}\n","/** The reserved wire envelope a fleet site forwards alongside the lead fields.\n * (Older package versions also sent a `userAgent` — central never consumed it,\n * so it is no longer forwarded; `readMeta` simply ignores it from old senders.) */\nexport type SubmissionMeta = {\n turnstileToken?: string;\n clientIp?: string;\n};\n\nfunction str(v: unknown): string {\n return typeof v === \"string\" ? v.trim() : \"\";\n}\n\n/**\n * Defensively read the reserved `_meta` envelope off an untrusted ingest payload\n * (CENTRAL side, used by the ingest handler). Keeps only non-blank string fields\n * among the KNOWN keys, dropping everything else — a bot cannot smuggle a\n * non-string clientIp in, and unknown fields (e.g. the `userAgent` older package\n * versions still send) are silently ignored. The token/IP read here are used\n * transiently (Turnstile verify + `remoteip`) and are NEVER persisted; the token\n * is never stored. The SITE-side writer (`buildSubmissionMeta`) is added by the\n * site-factory task; `readMeta` is the single reader (there is no `parseMeta`).\n */\nexport function readMeta(payload: unknown): SubmissionMeta {\n const meta: SubmissionMeta = {};\n if (typeof payload !== \"object\" || payload === null) return meta;\n const raw = (payload as Record<string, unknown>)._meta;\n if (typeof raw !== \"object\" || raw === null) return meta;\n const m = raw as Record<string, unknown>;\n const token = str(m.turnstileToken);\n if (token) meta.turnstileToken = token;\n const ip = str(m.clientIp);\n if (ip) meta.clientIp = ip;\n return meta;\n}\n\n/**\n * SITE-side event shape `buildSubmissionMeta` reads. Structural (not SvelteKit's\n * `RequestEvent`) so this leaf stays SDK-free; a real `RequestEvent` is\n * structurally assignable (`getClientAddress: () => string`).\n */\ntype MetaEvent = {\n getClientAddress?: () => string;\n};\n\n/**\n * Build the transient `_meta` envelope a site forwards to central ingest:\n * `{ turnstileToken?, clientIp? }`. Returns `undefined` when no field yields a\n * value so callers can attach it unconditionally without polluting the payload\n * (an `undefined` value is dropped by `JSON.stringify`). `getClientAddress` is\n * guarded (some adapters lack a client address and can throw). The visitor's\n * user-agent is deliberately NOT forwarded — central never consumed it, so\n * shipping it was pure transient-PII surface. None of this is ever persisted.\n */\nexport function buildSubmissionMeta(\n event: MetaEvent,\n turnstileToken: string | null | undefined,\n): SubmissionMeta | undefined {\n const meta: SubmissionMeta = {};\n\n const token = typeof turnstileToken === \"string\" ? turnstileToken.trim() : \"\";\n if (token) meta.turnstileToken = token;\n\n if (typeof event.getClientAddress === \"function\") {\n try {\n const ip = event.getClientAddress();\n if (typeof ip === \"string\" && ip.trim()) meta.clientIp = ip.trim();\n } catch {\n // Some adapters have no client address and throw; drop clientIp silently.\n }\n }\n\n return Object.keys(meta).length > 0 ? meta : undefined;\n}\n","import { json, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\nimport { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { IngestActionConfig } from \"./action.js\";\nimport { buildSubmissionMeta } from \"./meta.js\";\n\n/**\n * Options for {@link createIngestEndpoint} — the JSON sibling of\n * `createIngestAction` for client-driven forms (modals / lightboxes / fetch)\n * that POST JSON to a `+server.ts` route instead of using a form action.\n */\nexport type CreateIngestEndpointOptions = {\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map the parsed JSON body to a payload. Must set `formType` UNLESS the fixed\n * `formType` option is provided (then that is authoritative and overrides it).\n *\n * May be async. A site that resolves confirmation copy from its own CMS does a\n * read here — see `@reddoorla/maintenance/forms/prismic`. A rejection is\n * handled exactly like a throw from a sync one: a 400, never a 500.\n */\n buildPayload: (\n body: Record<string, unknown>,\n event: RequestEvent,\n ) => SubmissionPayload | Promise<SubmissionPayload>;\n /** Fixed formType for single-type endpoints; omit for multi-type endpoints\n * where `buildPayload` derives formType from the body. */\n formType?: string;\n /** Honeypot field name in the JSON body. Default \"bot-field\". */\n botFieldName?: string;\n /** Field carrying the Cloudflare Turnstile token. Default \"cf-turnstile-response\". */\n turnstileFieldName?: string;\n /** json(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** json(400/502) copy for bad input / ingest failure. */\n errorMessage?: string;\n};\n\nfunction isFormType(v: unknown): v is FormType {\n return typeof v === \"string\" && (SUBMISSION_FORM_TYPES as readonly string[]).includes(v);\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n\n/**\n * Build a JSON `POST` handler that screens for bots, forwards the submission to\n * the dashboard ingest endpoint, and returns `{ ok }`-shaped JSON. The per-form\n * field mapping (`buildPayload`) is the only thing a site must supply. The\n * returned function is structurally a SvelteKit `RequestHandler`.\n */\nexport function createIngestEndpoint(\n opts: CreateIngestEndpointOptions,\n): (event: RequestEvent) => Promise<Response> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const turnstileFieldName = opts.turnstileFieldName ?? \"cf-turnstile-response\";\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await event.request.json();\n if (!parsed || typeof parsed !== \"object\") throw new Error(\"body is not an object\");\n body = parsed as Record<string, unknown>;\n } catch {\n console.error(\"[forms-ingest] could not parse JSON body\");\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n // Bot screen: honeypot only. A client POST carries no server-planted ts, and\n // screenSubmission treats a missing elapsedMs as OK. A filled honeypot is\n // silently accepted (return ok, do NOT forward) so bots get no signal.\n const screen = screenSubmission({ botField: str(body[botFieldName]) ?? null });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // return success exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return json({ ok: true });\n }\n\n // buildPayload runs on untrusted JSON; a careless field access (e.g.\n // `body.name.trim()` on a non-string) would otherwise escape as a 500. Treat\n // a throw as a malformed request (400), keeping the \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...(await opts.buildPayload(body, event)),\n ...(opts.formType ? { formType: opts.formType } : {}),\n _meta: buildSubmissionMeta(event, str(body[turnstileFieldName])),\n };\n } catch (err) {\n console.error(`[forms-ingest] buildPayload threw: ${String(err)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n if (!isFormType(payload.formType)) {\n console.error(`[forms-ingest] invalid formType: ${String(payload.formType)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${payload.formType}`);\n return json({ ok: false, error: unavailable }, { status: 500 });\n }\n\n const result = await submitToIngest({ url, token, fetch: event.fetch, payload });\n if (!result.ok) {\n console.error(`[forms-ingest] ${payload.formType} → ${result.status}: ${result.error}`);\n return json({ ok: false, error: failed }, { status: 502 });\n }\n return json({ ok: true });\n };\n}\n"],"mappings":"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","names":[]}
@@ -17,9 +17,32 @@ type ReplyCalendar = {
17
17
  url?: string;
18
18
  description?: string;
19
19
  };
20
+ /** Inline formatting, as an offset range over a block's raw `text`. */
21
+ type ReplySpan = {
22
+ start: number;
23
+ end: number;
24
+ type: "strong" | "em" | "link";
25
+ /** Required for `link`; https: and mailto: only, enforced at render. */
26
+ url?: string;
27
+ };
28
+ declare const REPLY_BLOCK_TYPES: readonly ["paragraph", "heading2", "heading3", "list-item", "o-list-item"];
29
+ type ReplyBlockType = (typeof REPLY_BLOCK_TYPES)[number];
30
+ /**
31
+ * One block of body copy.
32
+ *
33
+ * Deliberately an AST and NOT an HTML string. The envelope crosses an untrusted
34
+ * boundary twice, so a renderer that can only emit a fixed set of tags is what
35
+ * keeps "no attacker text reaches an outbound email" true by construction
36
+ * rather than by everyone downstream remembering to escape. See rich-text.ts.
37
+ */
38
+ type ReplyBlock = {
39
+ type: ReplyBlockType;
40
+ text: string;
41
+ spans?: ReplySpan[];
42
+ };
20
43
  type ReplyCopy = {
21
44
  subject?: string;
22
- paragraphs?: string[];
45
+ body?: ReplyBlock[];
23
46
  signature?: string;
24
47
  calendar?: ReplyCalendar;
25
48
  };
@@ -1,6 +1,7 @@
1
1
  import {
2
+ REPLY_BLOCK_TYPES,
2
3
  parseReplyCopy
3
- } from "../chunk-PJ23RDHT.js";
4
+ } from "../chunk-I7NHLVVI.js";
4
5
 
5
6
  // src/forms/prismic.ts
6
7
  var DEFAULT_DURATION_MS = 2 * 60 * 60 * 1e3;
@@ -12,11 +13,39 @@ function text(v) {
12
13
  const t = v.trim();
13
14
  return t === "" ? void 0 : t;
14
15
  }
15
- function paragraphs(v) {
16
+ function blocks(v) {
16
17
  if (!Array.isArray(v)) return void 0;
17
- const out = v.map((b) => text(record(b).text)).filter((t) => t !== void 0);
18
+ const out = [];
19
+ for (const raw of v) {
20
+ const b = record(raw);
21
+ const body = typeof b.text === "string" ? b.text : "";
22
+ if (body.trim() === "") continue;
23
+ const declared = typeof b.type === "string" ? b.type : "paragraph";
24
+ const type = REPLY_BLOCK_TYPES.includes(declared) ? declared : "paragraph";
25
+ const block = { type, text: body };
26
+ const spans = Array.isArray(b.spans) ? b.spans.map(record) : [];
27
+ const mapped = [];
28
+ for (const s of spans) {
29
+ const kind = s.type === "hyperlink" ? "link" : s.type;
30
+ if (kind !== "strong" && kind !== "em" && kind !== "link") continue;
31
+ const start = typeof s.start === "number" ? s.start : NaN;
32
+ const end = typeof s.end === "number" ? s.end : NaN;
33
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
34
+ const span = { start, end, type: kind };
35
+ const url = text(record(s.data).url);
36
+ if (url) span.url = url;
37
+ mapped.push(span);
38
+ }
39
+ if (mapped.length > 0) block.spans = mapped;
40
+ out.push(block);
41
+ }
18
42
  return out.length > 0 ? out : void 0;
19
43
  }
44
+ function flatText(v) {
45
+ if (!Array.isArray(v)) return void 0;
46
+ const out = v.map((b) => text(record(b).text)).filter((t) => t !== void 0);
47
+ return out.length > 0 ? out.join(" ") : void 0;
48
+ }
20
49
  function timestamp(v) {
21
50
  const s = text(v);
22
51
  if (!s) return void 0;
@@ -49,12 +78,13 @@ async function resolveReplyCopy(client, opts) {
49
78
  await attempt(() => client.getSingle(opts.settingsType ?? "form_replies"))
50
79
  );
51
80
  const settingsData = record(settings.data);
52
- const entries = Array.isArray(settingsData.replies) ? settingsData.replies : [];
53
- const entry = record(entries.find((e) => record(e).form_type === opts.formType));
81
+ const entries = (Array.isArray(settingsData.replies) ? settingsData.replies : []).map(record);
82
+ const matches = entries.filter((e) => e.form_type === opts.formType);
83
+ const entry = matches.find((e) => text(e.subject) !== void 0 || blocks(e.body) !== void 0) ?? matches[0] ?? {};
54
84
  const draft = {
55
85
  subject: text(entry.subject),
56
- paragraphs: paragraphs(entry.body),
57
- signature: paragraphs(settingsData.signature)?.join(" ")
86
+ body: blocks(entry.body),
87
+ signature: flatText(settingsData.signature)
58
88
  };
59
89
  if (opts.eventUid) {
60
90
  const event = record(
@@ -63,7 +93,7 @@ async function resolveReplyCopy(client, opts) {
63
93
  const data = record(event.data);
64
94
  if (Object.keys(data).length > 0) {
65
95
  draft.subject = text(data.reply_subject) ?? draft.subject;
66
- draft.paragraphs = paragraphs(data.reply_body) ?? draft.paragraphs;
96
+ draft.body = blocks(data.reply_body) ?? draft.body;
67
97
  draft.calendar = calendarFrom(data, opts);
68
98
  }
69
99
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/forms/prismic.ts"],"sourcesContent":["/**\n * Resolve a submitter's confirmation copy from a Prismic repository.\n *\n * Published as its own subpath (`@reddoorla/maintenance/forms/prismic`) so the\n * CMS-agnostic core stays that way: `./forms` must remain importable by a site\n * that uses no CMS at all.\n *\n * The client is STRUCTURALLY typed rather than imported from\n * `@prismicio/client`. The two reads below are the whole surface, every fleet\n * site already constructs its own client, and duck-typing keeps this package's\n * dependency list — and a consuming site's bundle — untouched. It also makes the\n * tests a two-line object instead of a mocked SDK.\n *\n * Every read is wrapped: a CMS failure must cost the visitor nothing, because\n * the submission itself is captured either way.\n */\nimport { parseReplyCopy, type ReplyCalendar, type ReplyCopy } from \"./reply-copy.js\";\n\nexport type PrismicReader = {\n getSingle: (type: string) => Promise<unknown>;\n getByUID: (type: string, uid: string) => Promise<unknown>;\n};\n\nexport type ResolveReplyCopyOptions = {\n /** The submission's form type; selects the entry in the singleton. */\n formType: string;\n /** Present for event forms only. A miss is not an error. */\n eventUid?: string;\n /** Custom type holding events. Default \"rsvp\". */\n eventType?: string;\n /** Singleton holding site-level defaults. Default \"form_replies\". */\n settingsType?: string;\n /** Calendar location when the event names none — usually the venue address. */\n defaultLocation?: string;\n /** Canonical URL of the event page, attached to the calendar entry. */\n eventUrl?: string;\n};\n\nconst DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000;\n\nfunction record(v: unknown): Record<string, unknown> {\n return v && typeof v === \"object\" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};\n}\n\nfunction text(v: unknown): string | undefined {\n if (typeof v !== \"string\") return undefined;\n const t = v.trim();\n return t === \"\" ? undefined : t;\n}\n\n/** Prismic Rich Text is an array of blocks each carrying a flat `.text`. That\n * flat field is exactly what a plain-text email wants, so no renderer is\n * needed — and no rich-text HTML can escape into the message. */\nfunction paragraphs(v: unknown): string[] | undefined {\n if (!Array.isArray(v)) return undefined;\n const out = v.map((b) => text(record(b).text)).filter((t): t is string => t !== undefined);\n return out.length > 0 ? out : undefined;\n}\n\n/** A Prismic Timestamp (\"2026-09-12T18:00:00+0000\") as an ISO string, or\n * undefined when the field is blank or unparseable. */\nfunction timestamp(v: unknown): string | undefined {\n const s = text(v);\n if (!s) return undefined;\n const ms = Date.parse(s);\n return Number.isNaN(ms) ? undefined : new Date(ms).toISOString();\n}\n\n/** Never let a CMS read fail the caller. */\nasync function attempt<T>(run: () => Promise<T>): Promise<T | undefined> {\n try {\n return await run();\n } catch {\n return undefined;\n }\n}\n\nfunction calendarFrom(\n data: Record<string, unknown>,\n opts: ResolveReplyCopyOptions,\n): ReplyCalendar | undefined {\n const start = timestamp(data.start_time);\n const title = text(data.name);\n // No start, no calendar — and deliberately no guess. An invite for the wrong\n // evening is worse for a guest than no invite at all.\n if (!start || !title) return undefined;\n const cal: ReplyCalendar = {\n title,\n start,\n end:\n timestamp(data.end_time) ?? new Date(Date.parse(start) + DEFAULT_DURATION_MS).toISOString(),\n };\n const location = text(data.location) ?? opts.defaultLocation;\n if (location) cal.location = location;\n if (opts.eventUrl) cal.url = opts.eventUrl;\n return cal;\n}\n\n/**\n * Site defaults for `formType`, overridden field-by-field by the event document\n * when `eventUid` names one. Undefined means \"nothing authored\" — the caller\n * omits `_reply` entirely and the shared package's own fallbacks apply.\n */\nexport async function resolveReplyCopy(\n client: PrismicReader,\n opts: ResolveReplyCopyOptions,\n): Promise<ReplyCopy | undefined> {\n const settings = record(\n await attempt(() => client.getSingle(opts.settingsType ?? \"form_replies\")),\n );\n const settingsData = record(settings.data);\n const entries = Array.isArray(settingsData.replies) ? settingsData.replies : [];\n const entry = record(entries.find((e) => record(e).form_type === opts.formType));\n\n const draft: Record<string, unknown> = {\n subject: text(entry.subject),\n paragraphs: paragraphs(entry.body),\n signature: paragraphs(settingsData.signature)?.join(\" \"),\n };\n\n if (opts.eventUid) {\n const event = record(\n await attempt(() => client.getByUID(opts.eventType ?? \"rsvp\", opts.eventUid as string)),\n );\n const data = record(event.data);\n if (Object.keys(data).length > 0) {\n draft.subject = text(data.reply_subject) ?? draft.subject;\n draft.paragraphs = paragraphs(data.reply_body) ?? draft.paragraphs;\n draft.calendar = calendarFrom(data, opts);\n }\n }\n\n return parseReplyCopy(draft);\n}\n"],"mappings":";;;;;AAsCA,IAAM,sBAAsB,IAAI,KAAK,KAAK;AAE1C,SAAS,OAAO,GAAqC;AACnD,SAAO,KAAK,OAAO,MAAM,YAAY,CAAC,MAAM,QAAQ,CAAC,IAAK,IAAgC,CAAC;AAC7F;AAEA,SAAS,KAAK,GAAgC;AAC5C,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,IAAI,EAAE,KAAK;AACjB,SAAO,MAAM,KAAK,SAAY;AAChC;AAKA,SAAS,WAAW,GAAkC;AACpD,MAAI,CAAC,MAAM,QAAQ,CAAC,EAAG,QAAO;AAC9B,QAAM,MAAM,EAAE,IAAI,CAAC,MAAM,KAAK,OAAO,CAAC,EAAE,IAAI,CAAC,EAAE,OAAO,CAAC,MAAmB,MAAM,MAAS;AACzF,SAAO,IAAI,SAAS,IAAI,MAAM;AAChC;AAIA,SAAS,UAAU,GAAgC;AACjD,QAAM,IAAI,KAAK,CAAC;AAChB,MAAI,CAAC,EAAG,QAAO;AACf,QAAM,KAAK,KAAK,MAAM,CAAC;AACvB,SAAO,OAAO,MAAM,EAAE,IAAI,SAAY,IAAI,KAAK,EAAE,EAAE,YAAY;AACjE;AAGA,eAAe,QAAW,KAA+C;AACvE,MAAI;AACF,WAAO,MAAM,IAAI;AAAA,EACnB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aACP,MACA,MAC2B;AAC3B,QAAM,QAAQ,UAAU,KAAK,UAAU;AACvC,QAAM,QAAQ,KAAK,KAAK,IAAI;AAG5B,MAAI,CAAC,SAAS,CAAC,MAAO,QAAO;AAC7B,QAAM,MAAqB;AAAA,IACzB;AAAA,IACA;AAAA,IACA,KACE,UAAU,KAAK,QAAQ,KAAK,IAAI,KAAK,KAAK,MAAM,KAAK,IAAI,mBAAmB,EAAE,YAAY;AAAA,EAC9F;AACA,QAAM,WAAW,KAAK,KAAK,QAAQ,KAAK,KAAK;AAC7C,MAAI,SAAU,KAAI,WAAW;AAC7B,MAAI,KAAK,SAAU,KAAI,MAAM,KAAK;AAClC,SAAO;AACT;AAOA,eAAsB,iBACpB,QACA,MACgC;AAChC,QAAM,WAAW;AAAA,IACf,MAAM,QAAQ,MAAM,OAAO,UAAU,KAAK,gBAAgB,cAAc,CAAC;AAAA,EAC3E;AACA,QAAM,eAAe,OAAO,SAAS,IAAI;AACzC,QAAM,UAAU,MAAM,QAAQ,aAAa,OAAO,IAAI,aAAa,UAAU,CAAC;AAC9E,QAAM,QAAQ,OAAO,QAAQ,KAAK,CAAC,MAAM,OAAO,CAAC,EAAE,cAAc,KAAK,QAAQ,CAAC;AAE/E,QAAM,QAAiC;AAAA,IACrC,SAAS,KAAK,MAAM,OAAO;AAAA,IAC3B,YAAY,WAAW,MAAM,IAAI;AAAA,IACjC,WAAW,WAAW,aAAa,SAAS,GAAG,KAAK,GAAG;AAAA,EACzD;AAEA,MAAI,KAAK,UAAU;AACjB,UAAM,QAAQ;AAAA,MACZ,MAAM,QAAQ,MAAM,OAAO,SAAS,KAAK,aAAa,QAAQ,KAAK,QAAkB,CAAC;AAAA,IACxF;AACA,UAAM,OAAO,OAAO,MAAM,IAAI;AAC9B,QAAI,OAAO,KAAK,IAAI,EAAE,SAAS,GAAG;AAChC,YAAM,UAAU,KAAK,KAAK,aAAa,KAAK,MAAM;AAClD,YAAM,aAAa,WAAW,KAAK,UAAU,KAAK,MAAM;AACxD,YAAM,WAAW,aAAa,MAAM,IAAI;AAAA,IAC1C;AAAA,EACF;AAEA,SAAO,eAAe,KAAK;AAC7B;","names":[]}
1
+ {"version":3,"sources":["../../src/forms/prismic.ts"],"sourcesContent":["/**\n * Resolve a submitter's confirmation copy from a Prismic repository.\n *\n * Published as its own subpath (`@reddoorla/maintenance/forms/prismic`) so the\n * CMS-agnostic core stays that way: `./forms` must remain importable by a site\n * that uses no CMS at all.\n *\n * The client is STRUCTURALLY typed rather than imported from\n * `@prismicio/client`. The two reads below are the whole surface, every fleet\n * site already constructs its own client, and duck-typing keeps this package's\n * dependency list — and a consuming site's bundle — untouched. It also makes the\n * tests a two-line object instead of a mocked SDK.\n *\n * Every read is wrapped: a CMS failure must cost the visitor nothing, because\n * the submission itself is captured either way.\n */\nimport {\n parseReplyCopy,\n REPLY_BLOCK_TYPES,\n type ReplyBlock,\n type ReplyCalendar,\n type ReplyCopy,\n type ReplySpan,\n} from \"./reply-copy.js\";\n\nexport type PrismicReader = {\n getSingle: (type: string) => Promise<unknown>;\n getByUID: (type: string, uid: string) => Promise<unknown>;\n};\n\nexport type ResolveReplyCopyOptions = {\n /** The submission's form type; selects the entry in the singleton. */\n formType: string;\n /** Present for event forms only. A miss is not an error. */\n eventUid?: string;\n /** Custom type holding events. Default \"rsvp\". */\n eventType?: string;\n /** Singleton holding site-level defaults. Default \"form_replies\". */\n settingsType?: string;\n /** Calendar location when the event names none — usually the venue address. */\n defaultLocation?: string;\n /** Canonical URL of the event page, attached to the calendar entry. */\n eventUrl?: string;\n};\n\nconst DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000;\n\nfunction record(v: unknown): Record<string, unknown> {\n return v && typeof v === \"object\" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};\n}\n\nfunction text(v: unknown): string | undefined {\n if (typeof v !== \"string\") return undefined;\n const t = v.trim();\n return t === \"\" ? undefined : t;\n}\n\n/** Prismic Rich Text is already a block/span AST, and `ReplyBlock` is modelled\n * on it — so this is close to a pass-through, mapping Prismic's `hyperlink`\n * onto our `link` and dropping any block or span kind the renderer does not\n * know. Nothing is converted to HTML here: the AST crosses the wire and the\n * shared renderer is the only thing that ever emits a tag. */\nfunction blocks(v: unknown): ReplyBlock[] | undefined {\n if (!Array.isArray(v)) return undefined;\n const out: ReplyBlock[] = [];\n for (const raw of v) {\n const b = record(raw);\n const body = typeof b.text === \"string\" ? b.text : \"\";\n if (body.trim() === \"\") continue;\n const declared = typeof b.type === \"string\" ? b.type : \"paragraph\";\n const type = (REPLY_BLOCK_TYPES as readonly string[]).includes(declared)\n ? (declared as ReplyBlock[\"type\"])\n : \"paragraph\";\n const block: ReplyBlock = { type, text: body };\n const spans = Array.isArray(b.spans) ? b.spans.map(record) : [];\n const mapped: ReplySpan[] = [];\n for (const s of spans) {\n const kind = s.type === \"hyperlink\" ? \"link\" : s.type;\n if (kind !== \"strong\" && kind !== \"em\" && kind !== \"link\") continue;\n const start = typeof s.start === \"number\" ? s.start : NaN;\n const end = typeof s.end === \"number\" ? s.end : NaN;\n if (!Number.isFinite(start) || !Number.isFinite(end)) continue;\n const span: ReplySpan = { start, end, type: kind };\n const url = text(record(s.data).url);\n if (url) span.url = url;\n mapped.push(span);\n }\n if (mapped.length > 0) block.spans = mapped;\n out.push(block);\n }\n return out.length > 0 ? out : undefined;\n}\n\n/** The signature is a short sign-off, so it stays flat text. */\nfunction flatText(v: unknown): string | undefined {\n if (!Array.isArray(v)) return undefined;\n const out = v.map((b) => text(record(b).text)).filter((t): t is string => t !== undefined);\n return out.length > 0 ? out.join(\" \") : undefined;\n}\n\n/** A Prismic Timestamp (\"2026-09-12T18:00:00+0000\") as an ISO string, or\n * undefined when the field is blank or unparseable. */\nfunction timestamp(v: unknown): string | undefined {\n const s = text(v);\n if (!s) return undefined;\n const ms = Date.parse(s);\n return Number.isNaN(ms) ? undefined : new Date(ms).toISOString();\n}\n\n/** Never let a CMS read fail the caller. */\nasync function attempt<T>(run: () => Promise<T>): Promise<T | undefined> {\n try {\n return await run();\n } catch {\n return undefined;\n }\n}\n\nfunction calendarFrom(\n data: Record<string, unknown>,\n opts: ResolveReplyCopyOptions,\n): ReplyCalendar | undefined {\n const start = timestamp(data.start_time);\n const title = text(data.name);\n // No start, no calendar — and deliberately no guess. An invite for the wrong\n // evening is worse for a guest than no invite at all.\n if (!start || !title) return undefined;\n const cal: ReplyCalendar = {\n title,\n start,\n end:\n timestamp(data.end_time) ?? new Date(Date.parse(start) + DEFAULT_DURATION_MS).toISOString(),\n };\n const location = text(data.location) ?? opts.defaultLocation;\n if (location) cal.location = location;\n if (opts.eventUrl) cal.url = opts.eventUrl;\n return cal;\n}\n\n/**\n * Site defaults for `formType`, overridden field-by-field by the event document\n * when `eventUid` names one. Undefined means \"nothing authored\" — the caller\n * omits `_reply` entirely and the shared package's own fallbacks apply.\n */\nexport async function resolveReplyCopy(\n client: PrismicReader,\n opts: ResolveReplyCopyOptions,\n): Promise<ReplyCopy | undefined> {\n const settings = record(\n await attempt(() => client.getSingle(opts.settingsType ?? \"form_replies\")),\n );\n const settingsData = record(settings.data);\n const entries = (Array.isArray(settingsData.replies) ? settingsData.replies : []).map(record);\n // Prefer the first row for this form type that actually SAYS something.\n //\n // A repeatable group hands the editor a new row with the Select unset or\n // defaulted, so a document part-way through being filled in genuinely does\n // contain several blank rows all claiming the same form type. Taking the\n // first match outright meant one stray blank row above the real copy\n // silently discarded it — the reply fell back to the built-in default and\n // looked, from the client's side, exactly like the feature not working.\n const matches = entries.filter((e) => e.form_type === opts.formType);\n const entry =\n matches.find((e) => text(e.subject) !== undefined || blocks(e.body) !== undefined) ??\n matches[0] ??\n {};\n\n const draft: Record<string, unknown> = {\n subject: text(entry.subject),\n body: blocks(entry.body),\n signature: flatText(settingsData.signature),\n };\n\n if (opts.eventUid) {\n const event = record(\n await attempt(() => client.getByUID(opts.eventType ?? \"rsvp\", opts.eventUid as string)),\n );\n const data = record(event.data);\n if (Object.keys(data).length > 0) {\n draft.subject = text(data.reply_subject) ?? draft.subject;\n draft.body = blocks(data.reply_body) ?? draft.body;\n draft.calendar = calendarFrom(data, opts);\n }\n }\n\n return parseReplyCopy(draft);\n}\n"],"mappings":"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","names":[]}
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  describeNotifyTarget
3
- } from "./chunk-M2XF6JAI.js";
4
- import "./chunk-5XMKJX74.js";
3
+ } from "./chunk-4763KQ6O.js";
4
+ import "./chunk-QF6IQQFP.js";
5
5
  import "./chunk-5ML7FMBD.js";
6
6
  import "./chunk-NO74DXDP.js";
7
- import "./chunk-PJ23RDHT.js";
7
+ import "./chunk-I7NHLVVI.js";
8
8
  import {
9
9
  openBase,
10
10
  readAirtableConfig
@@ -164,4 +164,4 @@ export {
164
164
  formatNotifyTarget,
165
165
  runFormsNotifyTargetCommand
166
166
  };
167
- //# sourceMappingURL=forms-notify-target-MNNOGSJ7.js.map
167
+ //# sourceMappingURL=forms-notify-target-RMZPKT6S.js.map
@@ -5,11 +5,11 @@ import {
5
5
  makeNotify,
6
6
  notifySubmission,
7
7
  resolveRecipients
8
- } from "./chunk-M2XF6JAI.js";
9
- import "./chunk-5XMKJX74.js";
8
+ } from "./chunk-4763KQ6O.js";
9
+ import "./chunk-QF6IQQFP.js";
10
10
  import "./chunk-5ML7FMBD.js";
11
11
  import "./chunk-NO74DXDP.js";
12
- import "./chunk-PJ23RDHT.js";
12
+ import "./chunk-I7NHLVVI.js";
13
13
  import "./chunk-BGTYPVLT.js";
14
14
  import "./chunk-2OP4JKIQ.js";
15
15
  export {
@@ -20,4 +20,4 @@ export {
20
20
  notifySubmission,
21
21
  resolveRecipients
22
22
  };
23
- //# sourceMappingURL=notify-FYSOWKMJ.js.map
23
+ //# sourceMappingURL=notify-IISTHNBR.js.map
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  ingestSubmission
3
- } from "./chunk-5XMKJX74.js";
3
+ } from "./chunk-QF6IQQFP.js";
4
4
  import "./chunk-NO74DXDP.js";
5
- import "./chunk-PJ23RDHT.js";
5
+ import "./chunk-I7NHLVVI.js";
6
6
  import "./chunk-2OP4JKIQ.js";
7
7
 
8
8
  // src/db/deadletter.ts
@@ -64,4 +64,4 @@ async function replayOne(db, deps, row) {
64
64
  export {
65
65
  replayDeadLetters
66
66
  };
67
- //# sourceMappingURL=replay-HV7MGKBU.js.map
67
+ //# sourceMappingURL=replay-PCSQOGCZ.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddoorla/maintenance",
3
- "version": "0.92.0",
3
+ "version": "0.93.1",
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/forms/ics.ts","../src/forms/notify.ts"],"sourcesContent":["/**\n * Calendar formats for the confirmation email: an RFC 5545 VEVENT and a Google\n * Calendar template URL. Kept apart from notify.ts because the escaping rules\n * are fiddly enough to deserve their own tests, and because a calendar format\n * has nothing to do with email.\n */\nimport type { ReplyCalendar } from \"./reply-copy.js\";\n\nconst DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000;\n\n/** `2026-09-12T18:00:00-07:00` → `20260913T010000Z`. Callers have already proven\n * the string parses (parseReplyCopy), so this never sees NaN. */\nfunction stamp(iso: string): string {\n return new Date(iso)\n .toISOString()\n .replace(/[-:]/g, \"\")\n .replace(/\\.\\d{3}/, \"\");\n}\n\nfunction endStamp(e: ReplyCalendar): string {\n if (e.end) return stamp(e.end);\n return stamp(new Date(Date.parse(e.start) + DEFAULT_DURATION_MS).toISOString());\n}\n\n/** RFC 5545 §3.3.11. Backslash FIRST — escaping it after the others would\n * double-escape the backslashes they just introduced. */\nfunction esc(v: string): string {\n return v\n .replace(/\\\\/g, \"\\\\\\\\\")\n .replace(/;/g, \"\\\\;\")\n .replace(/,/g, \"\\\\,\")\n .replace(/\\r?\\n/g, \"\\\\n\");\n}\n\n/** Stable per event, so a guest who receives a second copy sees their calendar\n * entry UPDATE rather than gain a duplicate. Derived from the fields that\n * identify the event, never from the send. */\nfunction uid(e: ReplyCalendar): string {\n const slug = e.title\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 40);\n return `${stamp(e.start)}-${slug || \"event\"}@reddoorla.com`;\n}\n\n/** A complete single-event calendar, CRLF-delimited per the spec. */\nexport function buildIcs(e: ReplyCalendar, now: Date = new Date()): string {\n const lines = [\n \"BEGIN:VCALENDAR\",\n \"VERSION:2.0\",\n \"PRODID:-//Reddoor Creative//Form Auto-Reply//EN\",\n \"CALSCALE:GREGORIAN\",\n \"METHOD:PUBLISH\",\n \"BEGIN:VEVENT\",\n `UID:${uid(e)}`,\n `DTSTAMP:${stamp(now.toISOString())}`,\n `DTSTART:${stamp(e.start)}`,\n `DTEND:${endStamp(e)}`,\n `SUMMARY:${esc(e.title)}`,\n ];\n if (e.location) lines.push(`LOCATION:${esc(e.location)}`);\n const description = [e.description, e.url].filter(Boolean).join(\"\\n\\n\");\n if (description) lines.push(`DESCRIPTION:${esc(description)}`);\n if (e.url) lines.push(`URL:${e.url}`);\n lines.push(\"END:VEVENT\", \"END:VCALENDAR\");\n return lines.join(\"\\r\\n\") + \"\\r\\n\";\n}\n\n/** The \"Add to Google Calendar\" link. Google reads the same UTC stamps. */\nexport function googleCalendarUrl(e: ReplyCalendar): string {\n const params = new URLSearchParams({\n action: \"TEMPLATE\",\n text: e.title,\n dates: `${stamp(e.start)}/${endStamp(e)}`,\n });\n if (e.location) params.set(\"location\", e.location);\n const details = [e.description, e.url].filter(Boolean).join(\"\\n\\n\");\n if (details) params.set(\"details\", details);\n return `https://calendar.google.com/calendar/render?${params.toString()}`;\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 { parseReplyCopy } from \"./reply-copy.js\";\nimport { buildIcs, googleCalendarUrl } from \"./ics.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 !== \"maintained\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launching\" to route tests to\n * yourself).\n * - Maintained + 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 * - Maintained, 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 !== \"maintained\") {\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 !== \"maintained\") {\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 \"maintained\" — 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 \"maintained\" 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 \"maintained\" — 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 \"maintained\" 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 \"maintained\" 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 (\n Object.entries(parseExtraFields(raw))\n // Underscore keys are reserved transport (see payload.ts), never lead data.\n // `_reply` is a whole confirmation email; rendering it here would put the\n // copy in the client's notification as an unreadable JSON row.\n .filter(([k]) => !k.startsWith(\"_\"))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string])\n );\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 extra = parseExtraFields(submission.extraFields);\n const reply = parseReplyCopy(extra._reply);\n const eventName = typeof extra.event === \"string\" ? extra.event.trim() : \"\";\n\n // Subject, best available first. The middle tier is why an RSVP reads like a\n // confirmation even before anyone writes a word of copy: the event name is\n // already on every submission that has one.\n const subject =\n reply?.subject ?? (eventName ? `You're on the list for ${eventName}` : \"We got your message\");\n\n // Body. The per-site trio is the last-resort net for sites that author\n // nothing — it has no editor since the Airtable freeze, so anything a client\n // can actually change now comes through the envelope.\n const paragraphs = reply?.paragraphs ?? [\n site.copyIntro ?? `Thanks for reaching out to ${site.name}.`,\n site.copyContact ?? \"We've received your message and will be in touch soon.\",\n ];\n const signature = reply?.signature ?? site.copyFooter ?? site.name;\n\n const body = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join(\"\");\n // The one block assembled rather than escaped wholesale, because it carries an\n // anchor we build ourselves. Its only interpolated value is the Google URL,\n // escaped on the way in — and parseReplyCopy already refused any non-https\n // `url` that feeds it.\n const calendar = reply?.calendar;\n const calendarBlock = calendar\n ? `<p>Add it to your calendar: <a href=\"${escapeHtml(googleCalendarUrl(calendar))}\">Google Calendar</a>. ` +\n `The attached invite works in Apple Calendar and Outlook.</p>`\n : \"\";\n\n const input: ResendSendInput = {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject,\n html: `${body}${calendarBlock}<p>${escapeHtml(signature)}</p>`,\n };\n if (calendar) {\n // A bare Google link strands every Apple Mail reader; an .ics alone is a\n // file most people on a phone will not open. Both, once.\n input.attachments = [\n {\n filename: \"event.ics\",\n content: Buffer.from(buildIcs(calendar), \"utf8\").toString(\"base64\"),\n contentType: \"text/calendar\",\n },\n ];\n }\n return input;\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"],"mappings":";;;;;;;;;;;;;;AAQA,IAAM,sBAAsB,IAAI,KAAK,KAAK;AAI1C,SAAS,MAAM,KAAqB;AAClC,SAAO,IAAI,KAAK,GAAG,EAChB,YAAY,EACZ,QAAQ,SAAS,EAAE,EACnB,QAAQ,WAAW,EAAE;AAC1B;AAEA,SAAS,SAAS,GAA0B;AAC1C,MAAI,EAAE,IAAK,QAAO,MAAM,EAAE,GAAG;AAC7B,SAAO,MAAM,IAAI,KAAK,KAAK,MAAM,EAAE,KAAK,IAAI,mBAAmB,EAAE,YAAY,CAAC;AAChF;AAIA,SAAS,IAAI,GAAmB;AAC9B,SAAO,EACJ,QAAQ,OAAO,MAAM,EACrB,QAAQ,MAAM,KAAK,EACnB,QAAQ,MAAM,KAAK,EACnB,QAAQ,UAAU,KAAK;AAC5B;AAKA,SAAS,IAAI,GAA0B;AACrC,QAAM,OAAO,EAAE,MACZ,YAAY,EACZ,QAAQ,eAAe,GAAG,EAC1B,QAAQ,UAAU,EAAE,EACpB,MAAM,GAAG,EAAE;AACd,SAAO,GAAG,MAAM,EAAE,KAAK,CAAC,IAAI,QAAQ,OAAO;AAC7C;AAGO,SAAS,SAAS,GAAkB,MAAY,oBAAI,KAAK,GAAW;AACzE,QAAM,QAAQ;AAAA,IACZ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,IAAI,CAAC,CAAC;AAAA,IACb,WAAW,MAAM,IAAI,YAAY,CAAC,CAAC;AAAA,IACnC,WAAW,MAAM,EAAE,KAAK,CAAC;AAAA,IACzB,SAAS,SAAS,CAAC,CAAC;AAAA,IACpB,WAAW,IAAI,EAAE,KAAK,CAAC;AAAA,EACzB;AACA,MAAI,EAAE,SAAU,OAAM,KAAK,YAAY,IAAI,EAAE,QAAQ,CAAC,EAAE;AACxD,QAAM,cAAc,CAAC,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,OAAO,EAAE,KAAK,MAAM;AACtE,MAAI,YAAa,OAAM,KAAK,eAAe,IAAI,WAAW,CAAC,EAAE;AAC7D,MAAI,EAAE,IAAK,OAAM,KAAK,OAAO,EAAE,GAAG,EAAE;AACpC,QAAM,KAAK,cAAc,eAAe;AACxC,SAAO,MAAM,KAAK,MAAM,IAAI;AAC9B;AAGO,SAAS,kBAAkB,GAA0B;AAC1D,QAAM,SAAS,IAAI,gBAAgB;AAAA,IACjC,QAAQ;AAAA,IACR,MAAM,EAAE;AAAA,IACR,OAAO,GAAG,MAAM,EAAE,KAAK,CAAC,IAAI,SAAS,CAAC,CAAC;AAAA,EACzC,CAAC;AACD,MAAI,EAAE,SAAU,QAAO,IAAI,YAAY,EAAE,QAAQ;AACjD,QAAM,UAAU,CAAC,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,OAAO,EAAE,KAAK,MAAM;AAClE,MAAI,QAAS,QAAO,IAAI,WAAW,OAAO;AAC1C,SAAO,+CAA+C,OAAO,SAAS,CAAC;AACzE;;;ACvEA,IAAM,aAAa;AAGnB,IAAM,oBAAoB;AAG1B,SAAS,YAAY,KAAqB;AACxC,SAAO,IAAI,QAAQ,YAAY,EAAE,EAAE,KAAK,KAAK;AAC/C;AAEA,SAAS,WAAW,MAAiC;AACnD,SAAO,KAAK,kBAAkB,KAAK,sBAAsB;AAC3D;AAGA,SAAS,oBAAoB,GAA4C;AACvE,QAAM,MAAM,MAAM,QAAQ,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC;AAC9C,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,MAAgB,CAAC;AACvB,aAAW,OAAO,KAAK;AACrB,QAAI,OAAO,QAAQ,SAAU;AAC7B,UAAM,IAAI,IAAI,KAAK;AACnB,QAAI,KAAK,CAAC,KAAK,IAAI,CAAC,GAAG;AACrB,WAAK,IAAI,CAAC;AACV,UAAI,KAAK,CAAC;AAAA,IACZ;AAAA,EACF;AACA,SAAO;AACT;AAeO,SAAS,kBAAkB,MAAkB,YAA8C;AAChG,MAAI,KAAK,WAAW,cAAc;AAChC,WAAO,EAAE,IAAI,CAAC,cAAc,CAAC,GAAG,IAAI,CAAC,EAAE;AAAA,EACzC;AACA,QAAM,UAAU,KAAK;AACrB,MAAI,SAAS;AACX,UAAM,QAAQ,iBAAiB,WAAW,WAAW,EAAE,QAAQ,KAAK;AACpE,UAAM,QAAQ,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK,IAAI;AAClE,UAAM,KAAK,oBAAoB,SAAS,QAAQ,OAAO;AACvD,QAAI,GAAG,SAAS,EAAG,QAAO,EAAE,IAAI,IAAI,oBAAoB,QAAQ,EAAE,EAAE;AAAA,EAEtE;AACA,QAAM,MAAM,WAAW,IAAI;AAC3B,SAAO,MAAM,EAAE,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,IAAI;AACvC;AAgBA,SAAS,gBAAgB,aAA2C;AAClE,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,cAAc;AAAA,IACd,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,MAAM;AAAA,IACN,OAAO;AAAA,IACP,OAAO;AAAA,IACP,SAAS;AAAA,IACT;AAAA,IACA,WAAW;AAAA,IACX,KAAK;AAAA,IACL,aAAa;AAAA,IACb,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,iBAAiB;AAAA,EACnB;AACF;AAmBO,SAAS,qBAAqB,MAAgC;AACnE,MAAI,KAAK,WAAW,cAAc;AAChC,UAAM,UAAU,kBAAkB,MAAM,gBAAgB,IAAI,CAAC;AAC7D,WAAO;AAAA,MACL,UAAU;AAAA,MACV,IAAI,SAAS,MAAM,CAAC;AAAA,MACpB,IAAI,CAAC;AAAA,MACL,QAAQ,aAAa,KAAK,WAAW,OAAO,UAAU,IAAI,KAAK,MAAM,GAAG;AAAA,IAC1E;AAAA,EACF;AAEA,QAAM,UAAU,KAAK;AACrB,MAAI,CAAC,SAAS;AACZ,UAAM,SAAS,kBAAkB,MAAM,gBAAgB,IAAI,CAAC;AAC5D,QAAI,CAAC,UAAU,OAAO,GAAG,WAAW,GAAG;AACrC,aAAO;AAAA,QACL,UAAU;AAAA,QACV,IAAI,CAAC;AAAA,QACL,IAAI,CAAC;AAAA,QACL,QAAQ;AAAA,MACV;AAAA,IACF;AACA,WAAO;AAAA,MACL,UAAU;AAAA,MACV,IAAI,OAAO;AAAA,MACX,IAAI,OAAO;AAAA,MACX,QAAQ;AAAA,IACV;AAAA,EACF;AAIA,QAAM,KAAK,oBAAI,IAAY;AAC3B,QAAM,KAAK,oBAAI,IAAY;AAC3B,QAAM,SAAS,CAAC,GAAG,OAAO,KAAK,QAAQ,MAAM,GAAG,iBAAgB;AAChE,aAAW,SAAS,QAAQ;AAC1B,UAAM,IAAI,kBAAkB,MAAM,gBAAgB,KAAK,UAAU,EAAE,CAAC,QAAQ,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC;AAC7F,OAAG,GAAG,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAC9B,OAAG,GAAG,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAAA,EAChC;AACA,MAAI,GAAG,SAAS,GAAG;AACjB,WAAO;AAAA,MACL,UAAU;AAAA,MACV,IAAI,CAAC;AAAA,MACL,IAAI,CAAC;AAAA,MACL,QAAQ,kDAAkD,QAAQ,KAAK;AAAA,IACzE;AAAA,EACF;AACA,SAAO;AAAA,IACL,UAAU;AAAA,IACV,IAAI,CAAC,GAAG,EAAE;AAAA,IACV,IAAI,CAAC,GAAG,EAAE;AAAA,IACV,QAAQ,kDAAkD,QAAQ,KAAK;AAAA,EACzE;AACF;AAGA,SAAS,YAAY,GAAmB;AACtC,QAAM,SAAS,EAAE,QAAQ,UAAU,GAAG,EAAE,KAAK;AAC7C,SAAO,SAAS,OAAO,OAAO,CAAC,EAAE,YAAY,IAAI,OAAO,MAAM,CAAC,IAAI;AACrE;AAEA,SAAS,YAAY,GAAoB;AACvC,MAAI,MAAM,QAAQ,MAAM,OAAW,QAAO;AAC1C,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,MAAI,OAAO,MAAM,YAAY,OAAO,MAAM,UAAW,QAAO,OAAO,CAAC;AACpE,SAAO,KAAK,UAAU,CAAC;AACzB;AAIA,SAAS,iBAAiB,KAA6C;AACrE,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QAAI,UAAU,OAAO,WAAW,YAAY,CAAC,MAAM,QAAQ,MAAM,GAAG;AAClE,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO,CAAC;AACV;AAEA,SAAS,eAAe,KAA6C;AACnE,SACE,OAAO,QAAQ,iBAAiB,GAAG,CAAC,EAIjC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,CAAC,EAClC,OAAO,CAAC,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,MAAM,YAAY,EAAE,KAAK,MAAM,GAAG,EAC7D,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,YAAY,CAAC,GAAG,YAAY,CAAC,CAAC,CAAqB;AAE3E;AAEA,SAAS,YAAY,YAAmC;AACtD,QAAM,OAAgC;AAAA,IACpC,CAAC,QAAQ,WAAW,QAAQ;AAAA,IAC5B,CAAC,QAAQ,WAAW,QAAQ,QAAG;AAAA,IAC/B,CAAC,SAAS,WAAW,SAAS,QAAG;AAAA,EACnC;AACA,MAAI,WAAW,MAAO,MAAK,KAAK,CAAC,SAAS,WAAW,KAAK,CAAC;AAI3D,OAAK,KAAK,GAAG,eAAe,WAAW,WAAW,CAAC;AACnD,MAAI,WAAW,UAAW,MAAK,KAAK,CAAC,QAAQ,WAAW,SAAS,CAAC;AAClE,MAAI,WAAW,IAAK,MAAK,KAAK,CAAC,OAAO,WAAW,GAAG,CAAC;AACrD,QAAM,OAAO,KACV,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,mBAAmB,WAAW,CAAC,CAAC,qBAAqB,WAAW,CAAC,CAAC,YAAY,EAC9F,KAAK,EAAE;AACV,QAAM,UAAU,WAAW,UACvB,mCAAmC,WAAW,WAAW,OAAO,CAAC,SACjE;AACJ,SAAO,UAAU,IAAI,WAAW,OAAO;AACzC;AAGO,SAAS,qBACd,MACA,YACwB;AACxB,MAAI,WAAW,WAAW,eAAe,WAAW,WAAW,OAAQ,QAAO;AAC9E,QAAM,aAAa,kBAAkB,MAAM,UAAU;AACrD,MAAI,CAAC,cAAc,WAAW,GAAG,WAAW,EAAG,QAAO;AACtD,QAAM,QAAyB;AAAA,IAC7B,MAAM,GAAG,YAAY,KAAK,IAAI,CAAC,WAAW,UAAU;AAAA,IACpD,IAAI,WAAW;AAAA,IACf,SAAS,OAAO,WAAW,QAAQ,SAAS,KAAK,IAAI;AAAA,IACrD,MAAM,WAAW,WAAW,WAAW,QAAQ,CAAC,sBAAiB;AAAA,MAC/D,KAAK;AAAA,IACP,CAAC,QAAQ,YAAY,UAAU,CAAC;AAAA,EAClC;AACA,MAAI,WAAW,GAAG,SAAS,EAAG,OAAM,KAAK,WAAW;AAEpD,MAAI,WAAW,MAAO,OAAM,UAAU,WAAW;AACjD,SAAO;AACT;AAGO,SAAS,mBACd,MACA,YACwB;AACxB,MAAI,WAAW,WAAW,eAAe,WAAW,WAAW,OAAQ,QAAO;AAC9E,MAAI,CAAC,WAAW,MAAO,QAAO;AAO9B,QAAM,cAAc,WAAW,MAAM,MAAM,GAAG,EAAE,IAAI,KAAK;AACzD,MAAI,WAAW;AACf,MAAI;AACF,eAAW,IAAI,IAAI,KAAK,GAAG,EAAE;AAAA,EAC/B,QAAQ;AAAA,EAER;AACA,MAAI,YAAY,WAAW,aAAa,QAAQ,EAAG,QAAO;AAC1D,QAAM,QAAQ,iBAAiB,WAAW,WAAW;AACrD,QAAM,QAAQ,eAAe,MAAM,MAAM;AACzC,QAAM,YAAY,OAAO,MAAM,UAAU,WAAW,MAAM,MAAM,KAAK,IAAI;AAKzE,QAAM,UACJ,OAAO,YAAY,YAAY,0BAA0B,SAAS,KAAK;AAKzE,QAAM,aAAa,OAAO,cAAc;AAAA,IACtC,KAAK,aAAa,8BAA8B,KAAK,IAAI;AAAA,IACzD,KAAK,eAAe;AAAA,EACtB;AACA,QAAM,YAAY,OAAO,aAAa,KAAK,cAAc,KAAK;AAE9D,QAAM,OAAO,WAAW,IAAI,CAAC,MAAM,MAAM,WAAW,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE;AAKrE,QAAM,WAAW,OAAO;AACxB,QAAM,gBAAgB,WAClB,wCAAwC,WAAW,kBAAkB,QAAQ,CAAC,CAAC,wFAE/E;AAEJ,QAAM,QAAyB;AAAA,IAC7B,MAAM,GAAG,YAAY,KAAK,IAAI,CAAC,KAAK,UAAU;AAAA,IAC9C,IAAI,CAAC,WAAW,KAAK;AAAA,IACrB,SAAS,kBAAkB,MAAM,UAAU,GAAG,GAAG,CAAC,KAAK;AAAA,IACvD;AAAA,IACA,MAAM,GAAG,IAAI,GAAG,aAAa,MAAM,WAAW,SAAS,CAAC;AAAA,EAC1D;AACA,MAAI,UAAU;AAGZ,UAAM,cAAc;AAAA,MAClB;AAAA,QACE,UAAU;AAAA,QACV,SAAS,OAAO,KAAK,SAAS,QAAQ,GAAG,MAAM,EAAE,SAAS,QAAQ;AAAA,QAClE,aAAa;AAAA,MACf;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAcA,eAAsB,iBACpB,MACA,MACA,YACwB;AACxB,QAAM,MAAM,qBAAqB,MAAM,UAAU;AACjD,MAAI;AACJ,MAAI,CAAC,KAAK;AACR,cAAU,EAAE,QAAQ,WAAW,WAAW,KAAK;AAAA,EACjD,OAAO;AACL,QAAI;AACF,YAAM,EAAE,UAAU,IAAI,MAAM,KAAK,KAAK,GAAG;AACzC,gBAAU,EAAE,QAAQ,QAAQ,UAAU;AAAA,IACxC,SAAS,KAAK;AACZ,cAAQ,MAAM,0CAA0C,OAAO,GAAG,CAAC,EAAE;AACrE,gBAAU,EAAE,QAAQ,UAAU,WAAW,KAAK;AAAA,IAChD;AAAA,EACF;AACA,QAAM,OAAO,mBAAmB,MAAM,UAAU;AAChD,MAAI,MAAM;AACR,QAAI;AACF,YAAM,KAAK,KAAK,IAAI;AAAA,IACtB,SAAS,KAAK;AACZ,cAAQ,MAAM,uCAAuC,OAAO,GAAG,CAAC,EAAE;AAAA,IACpE;AAAA,EACF;AACA,SAAO;AACT;AASO,SAAS,WACd,MACyE;AACzE,SAAO,CAAC,MAAM,eACZ,OACI,iBAAiB,EAAE,KAAK,GAAG,MAAM,UAAU,IAC3C,QAAQ,QAAQ,EAAE,QAAQ,UAAU,WAAW,KAAK,CAAC;AAC7D;","names":[]}
@@ -1,48 +0,0 @@
1
- // src/forms/reply-copy.ts
2
- function str(v) {
3
- if (typeof v !== "string") return void 0;
4
- const t = v.trim();
5
- return t === "" ? void 0 : t;
6
- }
7
- function date(v) {
8
- const s = str(v);
9
- return s && !Number.isNaN(Date.parse(s)) ? s : void 0;
10
- }
11
- function parseCalendar(raw) {
12
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
13
- const c = raw;
14
- const title = str(c.title);
15
- const start = date(c.start);
16
- if (!title || !start) return void 0;
17
- const out = { title, start };
18
- const end = date(c.end);
19
- if (end) out.end = end;
20
- const location = str(c.location);
21
- if (location) out.location = location;
22
- const url = str(c.url);
23
- if (url && /^https:\/\//i.test(url)) out.url = url;
24
- const description = str(c.description);
25
- if (description) out.description = description;
26
- return out;
27
- }
28
- function parseReplyCopy(raw) {
29
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
30
- const r = raw;
31
- const out = {};
32
- const subject = str(r.subject);
33
- if (subject) out.subject = subject;
34
- const signature = str(r.signature);
35
- if (signature) out.signature = signature;
36
- if (Array.isArray(r.paragraphs)) {
37
- const ps = r.paragraphs.map(str).filter((p) => p !== void 0);
38
- if (ps.length > 0) out.paragraphs = ps;
39
- }
40
- const calendar = parseCalendar(r.calendar);
41
- if (calendar) out.calendar = calendar;
42
- return Object.keys(out).length > 0 ? out : void 0;
43
- }
44
-
45
- export {
46
- parseReplyCopy
47
- };
48
- //# sourceMappingURL=chunk-PJ23RDHT.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/forms/reply-copy.ts"],"sourcesContent":["/**\n * Copy for the submitter's confirmation email, resolved by the SITE from its own\n * CMS and forwarded in the reserved `_reply` envelope.\n *\n * Everything here arrives over an untrusted boundary twice — once off the wire,\n * once back out of the persisted `extraFields` JSON — so `parseReplyCopy` is the\n * single gate both paths go through. It drops field by field rather than\n * rejecting whole: a usable subject should still improve the email when the\n * calendar block is malformed.\n */\nexport type ReplyCalendar = {\n title: string;\n /** ISO 8601. Validated as parseable, not as any particular shape. */\n start: string;\n end?: string;\n location?: string;\n url?: string;\n description?: string;\n};\n\nexport type ReplyCopy = {\n subject?: string;\n paragraphs?: string[];\n signature?: string;\n calendar?: ReplyCalendar;\n};\n\nfunction str(v: unknown): string | undefined {\n if (typeof v !== \"string\") return undefined;\n const t = v.trim();\n return t === \"\" ? undefined : t;\n}\n\nfunction date(v: unknown): string | undefined {\n const s = str(v);\n return s && !Number.isNaN(Date.parse(s)) ? s : undefined;\n}\n\nfunction parseCalendar(raw: unknown): ReplyCalendar | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const c = raw as Record<string, unknown>;\n const title = str(c.title);\n const start = date(c.start);\n // Both are load-bearing: an event with no name or no start is not an event,\n // and half of one in a calendar client is worse than none.\n if (!title || !start) return undefined;\n const out: ReplyCalendar = { title, start };\n const end = date(c.end);\n if (end) out.end = end;\n const location = str(c.location);\n if (location) out.location = location;\n // https only. This becomes an href in an email we send; a javascript: or\n // data: URL arriving through a CMS field is not a link anyone meant to write.\n const url = str(c.url);\n if (url && /^https:\\/\\//i.test(url)) out.url = url;\n const description = str(c.description);\n if (description) out.description = description;\n return out;\n}\n\n/** Validate untrusted envelope data. Undefined means \"nothing usable\" — callers\n * then fall back to the site's own copy rather than sending a blank email. */\nexport function parseReplyCopy(raw: unknown): ReplyCopy | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const r = raw as Record<string, unknown>;\n const out: ReplyCopy = {};\n const subject = str(r.subject);\n if (subject) out.subject = subject;\n const signature = str(r.signature);\n if (signature) out.signature = signature;\n if (Array.isArray(r.paragraphs)) {\n const ps = r.paragraphs.map(str).filter((p): p is string => p !== undefined);\n if (ps.length > 0) out.paragraphs = ps;\n }\n const calendar = parseCalendar(r.calendar);\n if (calendar) out.calendar = calendar;\n return Object.keys(out).length > 0 ? out : undefined;\n}\n"],"mappings":";AA2BA,SAAS,IAAI,GAAgC;AAC3C,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,IAAI,EAAE,KAAK;AACjB,SAAO,MAAM,KAAK,SAAY;AAChC;AAEA,SAAS,KAAK,GAAgC;AAC5C,QAAM,IAAI,IAAI,CAAC;AACf,SAAO,KAAK,CAAC,OAAO,MAAM,KAAK,MAAM,CAAC,CAAC,IAAI,IAAI;AACjD;AAEA,SAAS,cAAc,KAAyC;AAC9D,MAAI,CAAC,OAAO,OAAO,QAAQ,YAAY,MAAM,QAAQ,GAAG,EAAG,QAAO;AAClE,QAAM,IAAI;AACV,QAAM,QAAQ,IAAI,EAAE,KAAK;AACzB,QAAM,QAAQ,KAAK,EAAE,KAAK;AAG1B,MAAI,CAAC,SAAS,CAAC,MAAO,QAAO;AAC7B,QAAM,MAAqB,EAAE,OAAO,MAAM;AAC1C,QAAM,MAAM,KAAK,EAAE,GAAG;AACtB,MAAI,IAAK,KAAI,MAAM;AACnB,QAAM,WAAW,IAAI,EAAE,QAAQ;AAC/B,MAAI,SAAU,KAAI,WAAW;AAG7B,QAAM,MAAM,IAAI,EAAE,GAAG;AACrB,MAAI,OAAO,eAAe,KAAK,GAAG,EAAG,KAAI,MAAM;AAC/C,QAAM,cAAc,IAAI,EAAE,WAAW;AACrC,MAAI,YAAa,KAAI,cAAc;AACnC,SAAO;AACT;AAIO,SAAS,eAAe,KAAqC;AAClE,MAAI,CAAC,OAAO,OAAO,QAAQ,YAAY,MAAM,QAAQ,GAAG,EAAG,QAAO;AAClE,QAAM,IAAI;AACV,QAAM,MAAiB,CAAC;AACxB,QAAM,UAAU,IAAI,EAAE,OAAO;AAC7B,MAAI,QAAS,KAAI,UAAU;AAC3B,QAAM,YAAY,IAAI,EAAE,SAAS;AACjC,MAAI,UAAW,KAAI,YAAY;AAC/B,MAAI,MAAM,QAAQ,EAAE,UAAU,GAAG;AAC/B,UAAM,KAAK,EAAE,WAAW,IAAI,GAAG,EAAE,OAAO,CAAC,MAAmB,MAAM,MAAS;AAC3E,QAAI,GAAG,SAAS,EAAG,KAAI,aAAa;AAAA,EACtC;AACA,QAAM,WAAW,cAAc,EAAE,QAAQ;AACzC,MAAI,SAAU,KAAI,WAAW;AAC7B,SAAO,OAAO,KAAK,GAAG,EAAE,SAAS,IAAI,MAAM;AAC7C;","names":[]}
File without changes