@elytracms/forms 0.0.0-stage → 0.0.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +28 -0
  3. package/dist/index.js +24 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/next/index.d.ts +20 -0
  6. package/dist/next/index.js +19 -0
  7. package/dist/next/index.js.map +1 -0
  8. package/dist/next/routes.d.ts +145 -0
  9. package/dist/next/routes.js +214 -0
  10. package/dist/next/routes.js.map +1 -0
  11. package/dist/next/submit.d.ts +83 -0
  12. package/dist/next/submit.js +137 -0
  13. package/dist/next/submit.js.map +1 -0
  14. package/dist/shared/delivery.d.ts +154 -0
  15. package/dist/shared/delivery.js +218 -0
  16. package/dist/shared/delivery.js.map +1 -0
  17. package/dist/shared/kind.d.ts +120 -0
  18. package/dist/shared/kind.js +98 -0
  19. package/dist/shared/kind.js.map +1 -0
  20. package/dist/shared/mail.d.ts +35 -0
  21. package/dist/shared/mail.js +47 -0
  22. package/dist/shared/mail.js.map +1 -0
  23. package/dist/shared/source.d.ts +70 -0
  24. package/dist/shared/source.js +40 -0
  25. package/dist/shared/source.js.map +1 -0
  26. package/dist/shared/submission.d.ts +83 -0
  27. package/dist/shared/submission.js +146 -0
  28. package/dist/shared/submission.js.map +1 -0
  29. package/dist/studio/appendix.d.ts +11 -0
  30. package/dist/studio/appendix.js +50 -0
  31. package/dist/studio/appendix.js.map +1 -0
  32. package/dist/studio/inbox.d.ts +39 -0
  33. package/dist/studio/inbox.js +211 -0
  34. package/dist/studio/inbox.js.map +1 -0
  35. package/dist/studio/index.d.ts +15 -0
  36. package/dist/studio/index.js +13 -0
  37. package/dist/studio/index.js.map +1 -0
  38. package/dist/studio/lens.d.ts +68 -0
  39. package/dist/studio/lens.js +149 -0
  40. package/dist/studio/lens.js.map +1 -0
  41. package/package.json +75 -3
  42. package/src/convex/forms.convex.test.ts +552 -0
  43. package/src/convex/index.ts +340 -0
  44. package/src/shared/delivery.test.ts +122 -0
  45. package/src/shared/delivery.ts +263 -0
  46. package/src/shared/kind.ts +120 -0
  47. package/src/shared/mail.ts +67 -0
  48. package/src/shared/source.test.ts +83 -0
  49. package/src/shared/source.ts +107 -0
  50. package/src/shared/submission.test.ts +97 -0
  51. package/src/shared/submission.ts +163 -0
  52. package/README.md +0 -3
@@ -0,0 +1,263 @@
1
+ import { z } from 'zod'
2
+ import { INGEST_SIGNATURE_HEADER, verifyIngestSignature } from '@elytracms/core/ingest'
3
+ import { signRevalidationBody } from '@elytracms/core/revalidation'
4
+ import { DELIVERY_LOG_LIMIT, submissionFieldSchema } from './kind'
5
+ import type { DeliveryEntry, DeliveryState } from './kind'
6
+
7
+ /**
8
+ * EC-613 — DELIVERY: one scheduled action per submission (`forms:deliver`, Convex) asks the
9
+ * SITE to send the mail, and logs the answer per recipient on the row.
10
+ *
11
+ * Why the site sends it: the transport is the consumer's (Resend over `fetch`, SMTP over
12
+ * nodemailer, …) and the recipients live in the consumer's form document (`defineFormSource`).
13
+ * In Convex an SMTP transport would need a `"use node"` file of its own (only actions may
14
+ * live there) and both the vendor secrets and the form document's shape would move into the
15
+ * deployment; on the site they are where both consumers keep them today, and Convex stays
16
+ * data-only (EC-585 1e). The deployment keeps what the ruling asks of it: the scheduled
17
+ * action per row, the retry, and the per-recipient log — so a failed mail is a visible issue
18
+ * on a row that exists.
19
+ *
20
+ * The request is signed with the INGEST secret pair (`ELYTRA_INGEST_SECRET` on Convex =
21
+ * `INGEST_SECRET` on the site) through the one signer (`@elytracms/core/revalidation`, the
22
+ * same HMAC header as the ingest wire). A delivery body and an ingest body are refused by
23
+ * each other's strict schemas, so one cannot be replayed as the other.
24
+ */
25
+
26
+ /** The deployment env var naming the site's delivery route (`https://example.com/api/forms`). */
27
+ export const FORMS_DELIVERY_URL_ENV = 'ELYTRA_FORMS_DELIVERY_URL'
28
+
29
+ /** Automatic attempts per run (an editor's retry starts a new run). */
30
+ export const DELIVERY_MAX_ATTEMPTS = 5
31
+ /** The wait after attempt N failed (N = 1…4): one minute, ten minutes, one hour, six hours. */
32
+ export const DELIVERY_BACKOFF_MS: readonly number[] = [60_000, 10 * 60_000, 60 * 60_000, 6 * 60 * 60_000]
33
+
34
+ /** One file as the site receives it (a URL it may attach or link). */
35
+ export const deliveryFileSchema = z.strictObject({
36
+ field: z.string(),
37
+ name: z.string().optional(),
38
+ contentType: z.string(),
39
+ size: z.number(),
40
+ url: z.string(),
41
+ })
42
+
43
+ /** What the deployment sends the site's delivery route. */
44
+ export const formDeliveryRequestSchema = z.strictObject({
45
+ kind: z.literal('formDelivery'),
46
+ submissionId: z.string().min(1),
47
+ form: z.string().min(1),
48
+ payload: z.array(submissionFieldSchema),
49
+ files: z.array(deliveryFileSchema),
50
+ sourceUrl: z.string().optional(),
51
+ submittedAt: z.string(),
52
+ attempt: z.number().int().positive(),
53
+ /** Recipients an earlier attempt of this run already reached — never sent twice. */
54
+ delivered: z.array(z.string()),
55
+ })
56
+ export type FormDeliveryRequest = z.infer<typeof formDeliveryRequestSchema>
57
+
58
+ /** One recipient's outcome, as the site reports it. */
59
+ export const deliveryResultSchema = z.union([
60
+ z.strictObject({ to: z.string(), ok: z.literal(true), id: z.string().optional() }),
61
+ z.strictObject({ to: z.string(), ok: z.literal(false), error: z.string() }),
62
+ ])
63
+ export type DeliveryResult = z.infer<typeof deliveryResultSchema>
64
+
65
+ /** The site's 200 answer: one result per recipient it tried (none for an inbox-only form). */
66
+ export const formDeliveryResponseSchema = z.strictObject({ results: z.array(deliveryResultSchema) })
67
+ export type FormDeliveryResponse = z.infer<typeof formDeliveryResponseSchema>
68
+
69
+ /** Sign one delivery request (the deployment's half). */
70
+ export async function signedFormDeliveryRequest(
71
+ request: FormDeliveryRequest,
72
+ secret: string,
73
+ ): Promise<{ body: string; headers: Record<string, string> }> {
74
+ const body = JSON.stringify(formDeliveryRequestSchema.parse(request))
75
+ return { body, headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signRevalidationBody(body, secret) } }
76
+ }
77
+
78
+ /** Verify one delivery request (the site's half) — constant time, the one verifier. */
79
+ export function verifyFormDeliverySignature(rawBody: string, signature: string | null | undefined, secret: string): Promise<boolean> {
80
+ return verifyIngestSignature(rawBody, signature, secret)
81
+ }
82
+
83
+ export { INGEST_SIGNATURE_HEADER as FORMS_DELIVERY_SIGNATURE_HEADER }
84
+
85
+ // --- The state machine ---------------------------------------------------------------------
86
+
87
+ /** What one attempt came to. */
88
+ export type DeliveryOutcome =
89
+ /** The deployment has no delivery route configured. */
90
+ | { kind: 'unconfigured'; message: string }
91
+ /** The site could not be reached (network, timeout). Retryable. */
92
+ | { kind: 'unreachable'; message: string }
93
+ /** The site answered non-2xx. Retryable for 5xx and 429; a 4xx is a configuration or content problem. */
94
+ | { kind: 'refused'; status: number; message: string }
95
+ /** The site answered with its per-recipient results. */
96
+ | { kind: 'results'; results: readonly DeliveryResult[] }
97
+
98
+ /** The outcome as it crosses from the delivery action into the mutation that logs it. */
99
+ export const deliveryOutcomeSchema: z.ZodType<DeliveryOutcome> = z.discriminatedUnion('kind', [
100
+ z.strictObject({ kind: z.literal('unconfigured'), message: z.string() }),
101
+ z.strictObject({ kind: z.literal('unreachable'), message: z.string() }),
102
+ z.strictObject({ kind: z.literal('refused'), status: z.number().int(), message: z.string() }),
103
+ z.strictObject({ kind: z.literal('results'), results: z.array(deliveryResultSchema) }),
104
+ ])
105
+
106
+ /** The row's delivery after one attempt, and when to try again. */
107
+ export interface DeliveryStep {
108
+ deliveryState: DeliveryState
109
+ deliveryLog: DeliveryEntry[]
110
+ /** Schedule the next attempt in this many ms (absent: the run is over). */
111
+ retryInMs?: number
112
+ }
113
+
114
+ const ERROR_MAX = 500
115
+
116
+ function clip(message: string): string {
117
+ return message.length > ERROR_MAX ? `${message.slice(0, ERROR_MAX - 1)}…` : message
118
+ }
119
+
120
+ /**
121
+ * The recipients the row's mail already reached — every successful line of the log, across
122
+ * runs: an attempt (and an editor's retry) re-sends only to the recipients still missing,
123
+ * never twice to one who has it.
124
+ */
125
+ export function deliveredRecipients(log: readonly DeliveryEntry[]): string[] {
126
+ const delivered = new Set<string>()
127
+ for (const entry of log) if (entry.ok && entry.to) delivered.add(entry.to)
128
+ return [...delivered]
129
+ }
130
+
131
+ function retryable(outcome: DeliveryOutcome): boolean {
132
+ switch (outcome.kind) {
133
+ case 'unreachable':
134
+ return true
135
+ case 'refused':
136
+ return outcome.status >= 500 || outcome.status === 429
137
+ case 'results':
138
+ return outcome.results.some((result) => !result.ok)
139
+ case 'unconfigured':
140
+ return false
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Apply one attempt's outcome to a row's delivery (pure — the Convex mutation and the tests
146
+ * run the same function). `log` is the row's log before the attempt; `delivered` the
147
+ * recipients this run already reached.
148
+ */
149
+ export function applyDeliveryOutcome(
150
+ input: { log: readonly DeliveryEntry[]; attempt: number; now: string; delivered: readonly string[] },
151
+ outcome: DeliveryOutcome,
152
+ ): DeliveryStep {
153
+ const { attempt, now } = input
154
+ const again = retryable(outcome) && attempt < DELIVERY_MAX_ATTEMPTS
155
+ const retryInMs = again ? DELIVERY_BACKOFF_MS[attempt - 1] : undefined
156
+ const retryAt = retryInMs !== undefined ? new Date(Date.parse(now) + retryInMs).toISOString() : undefined
157
+ const line = (entry: Omit<DeliveryEntry, 'at' | 'attempt'>): DeliveryEntry => ({ at: now, attempt, ...entry })
158
+
159
+ const lines: DeliveryEntry[] = []
160
+ let reached = [...input.delivered]
161
+ switch (outcome.kind) {
162
+ case 'unconfigured':
163
+ case 'unreachable':
164
+ lines.push(line({ ok: false, error: clip(outcome.message), ...(retryAt ? { retryAt } : {}) }))
165
+ break
166
+ case 'refused':
167
+ lines.push(line({ ok: false, error: clip(`HTTP ${outcome.status}: ${outcome.message}`), ...(retryAt ? { retryAt } : {}) }))
168
+ break
169
+ case 'results':
170
+ if (outcome.results.length === 0 && reached.length === 0) {
171
+ lines.push(line({ ok: true, note: 'no recipients' }))
172
+ }
173
+ for (const result of outcome.results) {
174
+ if (result.ok) {
175
+ reached.push(result.to)
176
+ lines.push(line({ to: result.to, ok: true, ...(result.id ? { id: result.id } : {}) }))
177
+ } else {
178
+ lines.push(line({ to: result.to, ok: false, error: clip(result.error), ...(retryAt ? { retryAt } : {}) }))
179
+ }
180
+ }
181
+ reached = [...new Set(reached)]
182
+ break
183
+ }
184
+
185
+ const deliveryLog = [...input.log, ...lines].slice(-DELIVERY_LOG_LIMIT)
186
+ if (retryInMs !== undefined) return { deliveryState: 'pending', deliveryLog, retryInMs }
187
+ const failedNow = outcome.kind !== 'results' || outcome.results.some((result) => !result.ok)
188
+ let deliveryState: DeliveryState
189
+ if (!failedNow) deliveryState = reached.length > 0 ? 'sent' : 'skipped'
190
+ else deliveryState = reached.length > 0 ? 'partial' : 'failed'
191
+ return { deliveryState, deliveryLog }
192
+ }
193
+
194
+ /** How long the deployment waits for the site's answer. */
195
+ export const DELIVERY_TIMEOUT_MS = 30_000
196
+
197
+ /**
198
+ * One attempt from the deployment's side: sign the request, POST it to the site's delivery
199
+ * route, read the answer into an outcome. Never throws — every failure is an outcome the
200
+ * log records. `fetchImpl` is the transport (the action passes the runtime's `fetch`).
201
+ */
202
+ export async function attemptFormDelivery(input: {
203
+ request: FormDeliveryRequest
204
+ url: string | undefined
205
+ secret: string | undefined
206
+ fetchImpl?: typeof fetch
207
+ timeoutMs?: number
208
+ }): Promise<DeliveryOutcome> {
209
+ if (!input.url) {
210
+ return { kind: 'unconfigured', message: `${FORMS_DELIVERY_URL_ENV} is not set on this deployment — no delivery route to send to.` }
211
+ }
212
+ if (!input.secret) {
213
+ return { kind: 'unconfigured', message: 'ELYTRA_INGEST_SECRET is not set on this deployment — the delivery request cannot be signed.' }
214
+ }
215
+ const signed = await signedFormDeliveryRequest(input.request, input.secret)
216
+ const controller = new AbortController()
217
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? DELIVERY_TIMEOUT_MS)
218
+ let response: Response
219
+ try {
220
+ response = await (input.fetchImpl ?? fetch)(input.url, {
221
+ method: 'POST',
222
+ headers: signed.headers,
223
+ body: signed.body,
224
+ signal: controller.signal,
225
+ })
226
+ } catch (error) {
227
+ return { kind: 'unreachable', message: `The delivery route could not be reached: ${error instanceof Error ? error.message : String(error)}` }
228
+ } finally {
229
+ clearTimeout(timer)
230
+ }
231
+ const text = await response.text().catch(() => '')
232
+ if (response.status === 401) {
233
+ return {
234
+ kind: 'refused',
235
+ status: 401,
236
+ message: "The site refused the deployment's signature — INGEST_SECRET on the site must equal ELYTRA_INGEST_SECRET.",
237
+ }
238
+ }
239
+ if (!response.ok) {
240
+ let message = text.slice(0, ERROR_MAX)
241
+ try {
242
+ const parsed = JSON.parse(text) as { error?: unknown }
243
+ if (typeof parsed.error === 'string') message = parsed.error
244
+ } catch {
245
+ // not JSON — keep the text
246
+ }
247
+ return { kind: 'refused', status: response.status, message: message || `HTTP ${response.status}` }
248
+ }
249
+ let parsed: unknown
250
+ try {
251
+ parsed = JSON.parse(text)
252
+ } catch {
253
+ return { kind: 'refused', status: 502, message: 'The delivery route answered 200 with a body that is not JSON.' }
254
+ }
255
+ const answer = formDeliveryResponseSchema.safeParse(parsed)
256
+ if (!answer.success) return { kind: 'refused', status: 502, message: 'The delivery route answered 200 with an invalid body.' }
257
+ return { kind: 'results', results: answer.data.results }
258
+ }
259
+
260
+ /** Does this delivery state need an editor's attention (the inbox's issue)? */
261
+ export function deliveryNeedsAttention(state: DeliveryState): boolean {
262
+ return state === 'failed' || state === 'partial'
263
+ }
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod'
2
+ import { defineRecordKind } from '@elytracms/core/persistence'
3
+ import type { RecordEntryOf } from '@elytracms/core/persistence'
4
+
5
+ /**
6
+ * EC-613 — the SUBMISSION record kind: every form post a site receives, one row in the
7
+ * engine's record store (EC-616), declared ONCE here and read by all three planes — the
8
+ * Convex functions that store it (`@elytracms/forms/convex`), the studio inbox that lists
9
+ * and edits it (`@elytracms/forms/studio`) and the site helpers that send it
10
+ * (`@elytracms/forms/next`).
11
+ *
12
+ * Its functions live in the Convex module `forms` — the consumer's ONE re-export file is
13
+ * `convex/forms.ts`: `export * from '@elytracms/forms/convex'`.
14
+ *
15
+ * Who writes what:
16
+ * - the SITE (through the ingest receiver's handler, `forms:ingest`): `form`, `payload`,
17
+ * `files`, `sourceUrl`, `spam`, `submittedAt` — what the visitor sent and what the checks
18
+ * said. Never edited afterwards;
19
+ * - the EDITOR (through the inbox): `status`, `assignee`, `notes` — the inbox lifecycle (the
20
+ * SMB CRM of the ruling; a sales pipeline is the developer's structure, not this kind);
21
+ * - the DELIVERY (the scheduled `forms:deliver` action): `deliveryState`, `deliveryLog`.
22
+ *
23
+ * The payload is a LIST of `{ name, value }` (sorted by name), not an object keyed by field
24
+ * name: a visitor's field names never become stored object keys. Its display order is the
25
+ * FORM's (`labelPayload` over the form source's `fields`). A value is a string, a number, a
26
+ * boolean or a list of strings, so the stored depth is bounded by construction.
27
+ */
28
+
29
+ /** The editor's inbox lifecycle. */
30
+ export const SUBMISSION_STATUSES = ['new', 'open', 'done', 'spam'] as const
31
+ export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number]
32
+
33
+ /** What a submission's delivery is doing — see `@elytracms/forms` `applyDeliveryOutcome`. */
34
+ export const DELIVERY_STATES = ['pending', 'sent', 'partial', 'failed', 'skipped'] as const
35
+ export type DeliveryState = (typeof DELIVERY_STATES)[number]
36
+
37
+ /** What the spam checks said: nothing, or the form's honeypot was filled. */
38
+ export const SPAM_VERDICTS = ['clean', 'honeypot'] as const
39
+ export type SpamVerdict = (typeof SPAM_VERDICTS)[number]
40
+
41
+ /** The longest text value one field may carry. */
42
+ export const FORM_VALUE_MAX_LENGTH = 10_000
43
+
44
+ /** One submitted value: a string, a number, a boolean, or a list of strings (a multi-select). */
45
+ export const formValueSchema = z.union([
46
+ z.string().max(FORM_VALUE_MAX_LENGTH),
47
+ z.number(),
48
+ z.boolean(),
49
+ z.array(z.string().max(FORM_VALUE_MAX_LENGTH)).max(50),
50
+ ])
51
+ export type FormValue = z.infer<typeof formValueSchema>
52
+
53
+ /** One field of a submission's payload. */
54
+ export const submissionFieldSchema = z.strictObject({
55
+ name: z.string().min(1).max(200),
56
+ value: formValueSchema,
57
+ })
58
+ export type SubmissionField = z.infer<typeof submissionFieldSchema>
59
+
60
+ /** One uploaded file a submission claimed (EC-617's upload intent, checked against the stored file). */
61
+ export const submissionFileSchema = z.strictObject({
62
+ /** The form field it was uploaded for. */
63
+ field: z.string().min(1).max(200),
64
+ /** The file name the visitor's browser reported (display only). */
65
+ name: z.string().max(300).optional(),
66
+ storageId: z.string().min(1),
67
+ contentType: z.string().min(1),
68
+ size: z.number().int().nonnegative(),
69
+ /** The storage URL (`https://<deployment>.convex.cloud/api/storage/<id>`), unguessable, valid while the file exists. */
70
+ url: z.string().optional(),
71
+ })
72
+ export type SubmissionFile = z.infer<typeof submissionFileSchema>
73
+
74
+ /** One line of the delivery log: one recipient (or the whole request) in one attempt. */
75
+ export const deliveryEntrySchema = z.strictObject({
76
+ at: z.string().min(1),
77
+ attempt: z.number().int().positive(),
78
+ /** The recipient — absent for an entry about the whole attempt (unreachable site, refused request). */
79
+ to: z.string().optional(),
80
+ ok: z.boolean(),
81
+ /** Why it failed (truncated). */
82
+ error: z.string().optional(),
83
+ /** A note on a non-failure without a recipient (`no recipients`). */
84
+ note: z.string().optional(),
85
+ /** The transport's message id, when it reported one. */
86
+ id: z.string().optional(),
87
+ /** When the next automatic attempt runs, if one is scheduled. */
88
+ retryAt: z.string().optional(),
89
+ })
90
+ export type DeliveryEntry = z.infer<typeof deliveryEntrySchema>
91
+
92
+ /** The most log lines a row keeps (the oldest are dropped first). */
93
+ export const DELIVERY_LOG_LIMIT = 100
94
+
95
+ export const submissionRecordKind = defineRecordKind({
96
+ kind: 'submission',
97
+ module: 'forms',
98
+ keys: ['form', 'status'],
99
+ time: 'submittedAt',
100
+ fields: {
101
+ /** The form's key — the ingest `target` the site posted to (see `defineFormSource`'s `key`). */
102
+ form: z.string().min(1).max(200),
103
+ status: z.enum(SUBMISSION_STATUSES).default('new'),
104
+ assignee: z.string().max(200).optional(),
105
+ notes: z.string().max(20_000).optional(),
106
+ payload: z.array(submissionFieldSchema).max(200),
107
+ files: z.array(submissionFileSchema).max(20).optional(),
108
+ sourceUrl: z.string().max(2000).optional(),
109
+ spam: z.enum(SPAM_VERDICTS),
110
+ deliveryState: z.enum(DELIVERY_STATES),
111
+ deliveryLog: z.array(deliveryEntrySchema).max(DELIVERY_LOG_LIMIT).optional(),
112
+ submittedAt: z.string().min(1),
113
+ },
114
+ })
115
+
116
+ export type SubmissionRecord = RecordEntryOf<typeof submissionRecordKind>
117
+
118
+ /** The columns the EDITOR owns — the only ones the inbox ever writes. */
119
+ export const SUBMISSION_EDITOR_COLUMNS = ['status', 'assignee', 'notes'] as const
120
+ export type SubmissionEditorColumn = (typeof SUBMISSION_EDITOR_COLUMNS)[number]
@@ -0,0 +1,67 @@
1
+ import type { LocalizedString } from '@elytracms/core/cms-core'
2
+ import type { FormFieldLabel } from './source'
3
+ import type { SubmissionField } from './kind'
4
+ import { formatFormValue } from './submission'
5
+
6
+ /**
7
+ * EC-613 — a submission LABELLED by its form (pure): the studio inbox shows it, the site's
8
+ * delivery route writes it into the mail. A payload field the form does not (or no longer)
9
+ * declares keeps its submitted name — what the visitor sent is never hidden.
10
+ */
11
+
12
+ /** One payload line with the label an editor reads. */
13
+ export interface LabelledField {
14
+ name: string
15
+ label: LocalizedString
16
+ value: SubmissionField['value']
17
+ }
18
+
19
+ /**
20
+ * The payload in the FORM's order (its declared fields first, as declared), then any field
21
+ * the form does not declare, in submitted order.
22
+ */
23
+ export function labelPayload(payload: readonly SubmissionField[], fields: readonly FormFieldLabel[] = []): LabelledField[] {
24
+ const byName = new Map(payload.map((field) => [field.name, field]))
25
+ const out: LabelledField[] = []
26
+ const used = new Set<string>()
27
+ for (const declared of fields) {
28
+ const field = byName.get(declared.name)
29
+ if (!field || used.has(declared.name)) continue
30
+ used.add(declared.name)
31
+ out.push({ name: field.name, label: declared.label ?? field.name, value: field.value })
32
+ }
33
+ for (const field of payload) {
34
+ if (used.has(field.name)) continue
35
+ used.add(field.name)
36
+ out.push({ name: field.name, label: field.name, value: field.value })
37
+ }
38
+ return out
39
+ }
40
+
41
+ /** A label as plain text for a mail (a localized label in `locale`, else its first entry). */
42
+ export function plainLabel(label: LocalizedString, locale?: string): string {
43
+ if (typeof label === 'string') return label
44
+ return (locale ? label[locale] : undefined) ?? Object.values(label)[0] ?? ''
45
+ }
46
+
47
+ /** The plain-text mail body of a submission. */
48
+ export function submissionMailText(input: {
49
+ formTitle?: string
50
+ fields: readonly LabelledField[]
51
+ files: readonly { field: string; name?: string; url: string; size: number }[]
52
+ sourceUrl?: string
53
+ submittedAt: string
54
+ locale?: string
55
+ }): string {
56
+ const lines: string[] = []
57
+ for (const field of input.fields) lines.push(`${plainLabel(field.label, input.locale)}: ${formatFormValue(field.value)}`)
58
+ if (input.files.length > 0) {
59
+ lines.push('', 'Files:')
60
+ for (const file of input.files) {
61
+ lines.push(`- ${file.name ?? file.field} (${Math.max(1, Math.round(file.size / 1024))} KB): ${file.url}`)
62
+ }
63
+ }
64
+ lines.push('', `Submitted: ${input.submittedAt}`)
65
+ if (input.sourceUrl) lines.push(`Page: ${input.sourceUrl}`)
66
+ return lines.join('\n')
67
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { defaultReplyTo, defineFormSource } from './source'
3
+ import { labelPayload, submissionMailText } from './mail'
4
+
5
+ /** EC-613 — the consumer's form source (one declaration, read by the studio and the site) and the labelled payload. */
6
+
7
+ interface FormValues {
8
+ internalId?: string
9
+ title?: string
10
+ recipient?: string
11
+ cc?: string[]
12
+ fields?: { name: string; label?: string }[]
13
+ }
14
+
15
+ const forms = defineFormSource<FormValues>({
16
+ collection: 'form',
17
+ key: 'internalId',
18
+ title: (form) => form.title,
19
+ recipients: (form) => [form.recipient, ...(form.cc ?? [])],
20
+ fields: (form) => form.fields ?? [],
21
+ })
22
+
23
+ describe('defineFormSource', () => {
24
+ const contact = { id: 'form-1', values: { internalId: 'contact', title: 'Contact', recipient: ' shop@example.test ', cc: ['shop@example.test', 'not-an-address', 'boss@example.test'] } }
25
+
26
+ it('finds a form by the key a site posts (not the document id)', () => {
27
+ expect(forms.find([contact], 'contact')).toBe(contact)
28
+ expect(forms.find([contact], 'form-1')).toBeUndefined()
29
+ // Control: without `key`, the document id addresses it.
30
+ const byId = defineFormSource({ collection: 'form', recipients: () => [] })
31
+ expect(byId.find([contact], 'form-1')).toBe(contact)
32
+ })
33
+
34
+ it('recipients are trimmed, deliverable addresses, each once — from the FORM, never the payload', () => {
35
+ expect(forms.recipientsOf(contact)).toEqual(['shop@example.test', 'boss@example.test'])
36
+ })
37
+
38
+ it('refuses a source without a collection at declaration', () => {
39
+ expect(() => defineFormSource({ collection: '', recipients: () => [] })).toThrow(/collection/)
40
+ })
41
+ })
42
+
43
+ describe('labelPayload / submissionMailText', () => {
44
+ const payload = [
45
+ { name: 'message', value: 'Hello' },
46
+ { name: 'email', value: 'ada@example.test' },
47
+ { name: 'extra', value: ['a', 'b'] },
48
+ ]
49
+
50
+ it('orders by the form’s fields with their labels; an undeclared field keeps its submitted name', () => {
51
+ expect(labelPayload(payload, [{ name: 'email', label: 'E-mail' }, { name: 'message', label: { en: 'Message', de: 'Nachricht' } }])).toEqual([
52
+ { name: 'email', label: 'E-mail', value: 'ada@example.test' },
53
+ { name: 'message', label: { en: 'Message', de: 'Nachricht' }, value: 'Hello' },
54
+ { name: 'extra', label: 'extra', value: ['a', 'b'] },
55
+ ])
56
+ })
57
+
58
+ it('writes the plain-text body: labelled lines, files, time and page', () => {
59
+ const text = submissionMailText({
60
+ fields: labelPayload(payload, [{ name: 'message', label: { en: 'Message', de: 'Nachricht' } }]),
61
+ files: [{ field: 'cv', name: 'cv.pdf', url: 'https://x.test/cv', size: 2048 }],
62
+ sourceUrl: 'https://site.test/contact',
63
+ submittedAt: '2026-10-26T12:00:00.000Z',
64
+ locale: 'de',
65
+ })
66
+ expect(text.split('\n')).toEqual([
67
+ 'Nachricht: Hello',
68
+ 'email: ada@example.test',
69
+ 'extra: a, b',
70
+ '',
71
+ 'Files:',
72
+ '- cv.pdf (2 KB): https://x.test/cv',
73
+ '',
74
+ 'Submitted: 2026-10-26T12:00:00.000Z',
75
+ 'Page: https://site.test/contact',
76
+ ])
77
+ })
78
+
79
+ it('the default reply-to is the first e-mail-named field holding an address', () => {
80
+ expect(defaultReplyTo(payload)).toBe('ada@example.test')
81
+ expect(defaultReplyTo([{ name: 'email', value: 'not an address' }])).toBeUndefined()
82
+ })
83
+ })
@@ -0,0 +1,107 @@
1
+ import type { LocalizedString } from '@elytracms/core/cms-core'
2
+ import type { SubmissionField } from './kind'
3
+
4
+ /**
5
+ * EC-613 — the FORM SOURCE: how the addon reads the consumer's own form documents, declared
6
+ * ONCE by the consumer and read by the two planes that know the project — the studio inbox
7
+ * (a submission's field labels, the form's title) and the site's delivery route (the
8
+ * recipients, the labels in the mail). Convex never reads it: the deployment stays
9
+ * data-only (EC-585 1e), and the recipients are resolved from the PUBLISHED form document on
10
+ * the site — never from anything a visitor sent (a recipient from the payload would make the
11
+ * site an open mail relay).
12
+ *
13
+ * The form collection, its field types and the renderer stay the consumer's (the ruling):
14
+ * this declaration only says where in a form document the addon finds what it needs.
15
+ *
16
+ * ```ts
17
+ * // cms/forms.ts — anywhere; the studio config and the site's delivery route import it
18
+ * export const contactForms = defineFormSource({
19
+ * collection: 'form',
20
+ * key: 'internalId', // the site posts this value as the submission's target
21
+ * title: (form) => form.title,
22
+ * recipients: (form) => [form.recipient],
23
+ * fields: (form) => form.fields.map((field) => ({ name: field.name, label: field.label })),
24
+ * })
25
+ * ```
26
+ */
27
+
28
+ /** One field of a form, as the addon labels a submission's payload with it. */
29
+ export interface FormFieldLabel {
30
+ /** The submitted name (the input's `name`). */
31
+ name: string
32
+ /** What an editor reads instead of the name; a plain string or `{ en, de, … }`. */
33
+ label?: LocalizedString
34
+ }
35
+
36
+ /** A form document as the addon sees it: its id and its values (the consumer's shape). */
37
+ export interface FormDocument<V = Record<string, unknown>> {
38
+ id: string
39
+ values: V
40
+ }
41
+
42
+ export interface FormSourceConfig<V = Record<string, unknown>> {
43
+ /** The collection that holds the forms. */
44
+ collection: string
45
+ /**
46
+ * How a site addresses a form — the submission's `target` and the row's `form` column: the
47
+ * name of a text field of the form document (`'internalId'`), or a function. Default: the
48
+ * document's id.
49
+ */
50
+ key?: string | ((form: FormDocument<V>) => string | undefined)
51
+ /** The form's display title (the inbox column, the mail subject). */
52
+ title?: (values: V) => string | undefined
53
+ /** Who receives a submission's mail. An empty list delivers nothing (an inbox-only form). */
54
+ recipients: (values: V) => readonly (string | undefined | null)[]
55
+ /** The form's fields, in order, for labelling a payload. Absent → the submitted names. */
56
+ fields?: (values: V) => readonly FormFieldLabel[]
57
+ /** The address a reply goes to (default: the first payload value of a field named like `email`). */
58
+ replyTo?: (payload: readonly SubmissionField[]) => string | undefined
59
+ }
60
+
61
+ /** A declared form source (see the module header). */
62
+ export interface FormSource<V = Record<string, unknown>> extends FormSourceConfig<V> {
63
+ /** The key of one form document — `key` applied, else its id. */
64
+ keyOf(form: FormDocument<V>): string | undefined
65
+ /** The form among `forms` a submission's `form` column names. */
66
+ find(forms: readonly FormDocument<V>[], key: string): FormDocument<V> | undefined
67
+ /** The recipients of one form: trimmed, valid addresses only, each once. */
68
+ recipientsOf(form: FormDocument<V>): string[]
69
+ }
70
+
71
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
72
+
73
+ /** Is this a deliverable address (the same shape both consumers' routes check)? */
74
+ export function isEmailAddress(value: unknown): value is string {
75
+ return typeof value === 'string' && EMAIL.test(value.trim())
76
+ }
77
+
78
+ /** Declare how the addon reads your form documents (see the module header). */
79
+ export function defineFormSource<V = Record<string, unknown>>(config: FormSourceConfig<V>): FormSource<V> {
80
+ if (!config.collection) throw new Error('defineFormSource: `collection` is required.')
81
+ const keyOf = (form: FormDocument<V>): string | undefined => {
82
+ if (config.key === undefined) return form.id
83
+ if (typeof config.key === 'function') return config.key(form)
84
+ const value = (form.values as Record<string, unknown>)[config.key]
85
+ return typeof value === 'string' && value.length > 0 ? value : undefined
86
+ }
87
+ return {
88
+ ...config,
89
+ keyOf,
90
+ find: (forms, key) => forms.find((form) => keyOf(form) === key),
91
+ recipientsOf: (form) => {
92
+ const seen = new Set<string>()
93
+ for (const entry of config.recipients(form.values)) {
94
+ if (isEmailAddress(entry)) seen.add(entry.trim())
95
+ }
96
+ return [...seen]
97
+ },
98
+ }
99
+ }
100
+
101
+ /** The default reply-to: the first payload value of a field named like `email`, if it is an address. */
102
+ export function defaultReplyTo(payload: readonly SubmissionField[]): string | undefined {
103
+ for (const field of payload) {
104
+ if (/e-?mail/i.test(field.name) && isEmailAddress(field.value)) return (field.value as string).trim()
105
+ }
106
+ return undefined
107
+ }