@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,120 @@
1
+ import { z } from 'zod';
2
+ import type { RecordEntryOf } from '@elytracms/core/persistence';
3
+ /**
4
+ * EC-613 — the SUBMISSION record kind: every form post a site receives, one row in the
5
+ * engine's record store (EC-616), declared ONCE here and read by all three planes — the
6
+ * Convex functions that store it (`@elytracms/forms/convex`), the studio inbox that lists
7
+ * and edits it (`@elytracms/forms/studio`) and the site helpers that send it
8
+ * (`@elytracms/forms/next`).
9
+ *
10
+ * Its functions live in the Convex module `forms` — the consumer's ONE re-export file is
11
+ * `convex/forms.ts`: `export * from '@elytracms/forms/convex'`.
12
+ *
13
+ * Who writes what:
14
+ * - the SITE (through the ingest receiver's handler, `forms:ingest`): `form`, `payload`,
15
+ * `files`, `sourceUrl`, `spam`, `submittedAt` — what the visitor sent and what the checks
16
+ * said. Never edited afterwards;
17
+ * - the EDITOR (through the inbox): `status`, `assignee`, `notes` — the inbox lifecycle (the
18
+ * SMB CRM of the ruling; a sales pipeline is the developer's structure, not this kind);
19
+ * - the DELIVERY (the scheduled `forms:deliver` action): `deliveryState`, `deliveryLog`.
20
+ *
21
+ * The payload is a LIST of `{ name, value }` (sorted by name), not an object keyed by field
22
+ * name: a visitor's field names never become stored object keys. Its display order is the
23
+ * FORM's (`labelPayload` over the form source's `fields`). A value is a string, a number, a
24
+ * boolean or a list of strings, so the stored depth is bounded by construction.
25
+ */
26
+ /** The editor's inbox lifecycle. */
27
+ export declare const SUBMISSION_STATUSES: readonly ["new", "open", "done", "spam"];
28
+ export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
29
+ /** What a submission's delivery is doing — see `@elytracms/forms` `applyDeliveryOutcome`. */
30
+ export declare const DELIVERY_STATES: readonly ["pending", "sent", "partial", "failed", "skipped"];
31
+ export type DeliveryState = (typeof DELIVERY_STATES)[number];
32
+ /** What the spam checks said: nothing, or the form's honeypot was filled. */
33
+ export declare const SPAM_VERDICTS: readonly ["clean", "honeypot"];
34
+ export type SpamVerdict = (typeof SPAM_VERDICTS)[number];
35
+ /** The longest text value one field may carry. */
36
+ export declare const FORM_VALUE_MAX_LENGTH = 10000;
37
+ /** One submitted value: a string, a number, a boolean, or a list of strings (a multi-select). */
38
+ export declare const formValueSchema: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>;
39
+ export type FormValue = z.infer<typeof formValueSchema>;
40
+ /** One field of a submission's payload. */
41
+ export declare const submissionFieldSchema: z.ZodObject<{
42
+ name: z.ZodString;
43
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>;
44
+ }, z.core.$strict>;
45
+ export type SubmissionField = z.infer<typeof submissionFieldSchema>;
46
+ /** One uploaded file a submission claimed (EC-617's upload intent, checked against the stored file). */
47
+ export declare const submissionFileSchema: z.ZodObject<{
48
+ field: z.ZodString;
49
+ name: z.ZodOptional<z.ZodString>;
50
+ storageId: z.ZodString;
51
+ contentType: z.ZodString;
52
+ size: z.ZodNumber;
53
+ url: z.ZodOptional<z.ZodString>;
54
+ }, z.core.$strict>;
55
+ export type SubmissionFile = z.infer<typeof submissionFileSchema>;
56
+ /** One line of the delivery log: one recipient (or the whole request) in one attempt. */
57
+ export declare const deliveryEntrySchema: z.ZodObject<{
58
+ at: z.ZodString;
59
+ attempt: z.ZodNumber;
60
+ to: z.ZodOptional<z.ZodString>;
61
+ ok: z.ZodBoolean;
62
+ error: z.ZodOptional<z.ZodString>;
63
+ note: z.ZodOptional<z.ZodString>;
64
+ id: z.ZodOptional<z.ZodString>;
65
+ retryAt: z.ZodOptional<z.ZodString>;
66
+ }, z.core.$strict>;
67
+ export type DeliveryEntry = z.infer<typeof deliveryEntrySchema>;
68
+ /** The most log lines a row keeps (the oldest are dropped first). */
69
+ export declare const DELIVERY_LOG_LIMIT = 100;
70
+ export declare const submissionRecordKind: import("@elytracms/core/persistence").RecordKind<{
71
+ /** The form's key — the ingest `target` the site posted to (see `defineFormSource`'s `key`). */
72
+ form: z.ZodString;
73
+ status: z.ZodDefault<z.ZodEnum<{
74
+ new: "new";
75
+ open: "open";
76
+ done: "done";
77
+ spam: "spam";
78
+ }>>;
79
+ assignee: z.ZodOptional<z.ZodString>;
80
+ notes: z.ZodOptional<z.ZodString>;
81
+ payload: z.ZodArray<z.ZodObject<{
82
+ name: z.ZodString;
83
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>;
84
+ }, z.core.$strict>>;
85
+ files: z.ZodOptional<z.ZodArray<z.ZodObject<{
86
+ field: z.ZodString;
87
+ name: z.ZodOptional<z.ZodString>;
88
+ storageId: z.ZodString;
89
+ contentType: z.ZodString;
90
+ size: z.ZodNumber;
91
+ url: z.ZodOptional<z.ZodString>;
92
+ }, z.core.$strict>>>;
93
+ sourceUrl: z.ZodOptional<z.ZodString>;
94
+ spam: z.ZodEnum<{
95
+ clean: "clean";
96
+ honeypot: "honeypot";
97
+ }>;
98
+ deliveryState: z.ZodEnum<{
99
+ pending: "pending";
100
+ sent: "sent";
101
+ partial: "partial";
102
+ failed: "failed";
103
+ skipped: "skipped";
104
+ }>;
105
+ deliveryLog: z.ZodOptional<z.ZodArray<z.ZodObject<{
106
+ at: z.ZodString;
107
+ attempt: z.ZodNumber;
108
+ to: z.ZodOptional<z.ZodString>;
109
+ ok: z.ZodBoolean;
110
+ error: z.ZodOptional<z.ZodString>;
111
+ note: z.ZodOptional<z.ZodString>;
112
+ id: z.ZodOptional<z.ZodString>;
113
+ retryAt: z.ZodOptional<z.ZodString>;
114
+ }, z.core.$strict>>>;
115
+ submittedAt: z.ZodString;
116
+ }>;
117
+ export type SubmissionRecord = RecordEntryOf<typeof submissionRecordKind>;
118
+ /** The columns the EDITOR owns — the only ones the inbox ever writes. */
119
+ export declare const SUBMISSION_EDITOR_COLUMNS: readonly ["status", "assignee", "notes"];
120
+ export type SubmissionEditorColumn = (typeof SUBMISSION_EDITOR_COLUMNS)[number];
@@ -0,0 +1,98 @@
1
+ import { z } from 'zod';
2
+ import { defineRecordKind } from '@elytracms/core/persistence';
3
+ /**
4
+ * EC-613 — the SUBMISSION record kind: every form post a site receives, one row in the
5
+ * engine's record store (EC-616), declared ONCE here and read by all three planes — the
6
+ * Convex functions that store it (`@elytracms/forms/convex`), the studio inbox that lists
7
+ * and edits it (`@elytracms/forms/studio`) and the site helpers that send it
8
+ * (`@elytracms/forms/next`).
9
+ *
10
+ * Its functions live in the Convex module `forms` — the consumer's ONE re-export file is
11
+ * `convex/forms.ts`: `export * from '@elytracms/forms/convex'`.
12
+ *
13
+ * Who writes what:
14
+ * - the SITE (through the ingest receiver's handler, `forms:ingest`): `form`, `payload`,
15
+ * `files`, `sourceUrl`, `spam`, `submittedAt` — what the visitor sent and what the checks
16
+ * said. Never edited afterwards;
17
+ * - the EDITOR (through the inbox): `status`, `assignee`, `notes` — the inbox lifecycle (the
18
+ * SMB CRM of the ruling; a sales pipeline is the developer's structure, not this kind);
19
+ * - the DELIVERY (the scheduled `forms:deliver` action): `deliveryState`, `deliveryLog`.
20
+ *
21
+ * The payload is a LIST of `{ name, value }` (sorted by name), not an object keyed by field
22
+ * name: a visitor's field names never become stored object keys. Its display order is the
23
+ * FORM's (`labelPayload` over the form source's `fields`). A value is a string, a number, a
24
+ * boolean or a list of strings, so the stored depth is bounded by construction.
25
+ */
26
+ /** The editor's inbox lifecycle. */
27
+ export const SUBMISSION_STATUSES = ['new', 'open', 'done', 'spam'];
28
+ /** What a submission's delivery is doing — see `@elytracms/forms` `applyDeliveryOutcome`. */
29
+ export const DELIVERY_STATES = ['pending', 'sent', 'partial', 'failed', 'skipped'];
30
+ /** What the spam checks said: nothing, or the form's honeypot was filled. */
31
+ export const SPAM_VERDICTS = ['clean', 'honeypot'];
32
+ /** The longest text value one field may carry. */
33
+ export const FORM_VALUE_MAX_LENGTH = 10_000;
34
+ /** One submitted value: a string, a number, a boolean, or a list of strings (a multi-select). */
35
+ export const formValueSchema = z.union([
36
+ z.string().max(FORM_VALUE_MAX_LENGTH),
37
+ z.number(),
38
+ z.boolean(),
39
+ z.array(z.string().max(FORM_VALUE_MAX_LENGTH)).max(50),
40
+ ]);
41
+ /** One field of a submission's payload. */
42
+ export const submissionFieldSchema = z.strictObject({
43
+ name: z.string().min(1).max(200),
44
+ value: formValueSchema,
45
+ });
46
+ /** One uploaded file a submission claimed (EC-617's upload intent, checked against the stored file). */
47
+ export const submissionFileSchema = z.strictObject({
48
+ /** The form field it was uploaded for. */
49
+ field: z.string().min(1).max(200),
50
+ /** The file name the visitor's browser reported (display only). */
51
+ name: z.string().max(300).optional(),
52
+ storageId: z.string().min(1),
53
+ contentType: z.string().min(1),
54
+ size: z.number().int().nonnegative(),
55
+ /** The storage URL (`https://<deployment>.convex.cloud/api/storage/<id>`), unguessable, valid while the file exists. */
56
+ url: z.string().optional(),
57
+ });
58
+ /** One line of the delivery log: one recipient (or the whole request) in one attempt. */
59
+ export const deliveryEntrySchema = z.strictObject({
60
+ at: z.string().min(1),
61
+ attempt: z.number().int().positive(),
62
+ /** The recipient — absent for an entry about the whole attempt (unreachable site, refused request). */
63
+ to: z.string().optional(),
64
+ ok: z.boolean(),
65
+ /** Why it failed (truncated). */
66
+ error: z.string().optional(),
67
+ /** A note on a non-failure without a recipient (`no recipients`). */
68
+ note: z.string().optional(),
69
+ /** The transport's message id, when it reported one. */
70
+ id: z.string().optional(),
71
+ /** When the next automatic attempt runs, if one is scheduled. */
72
+ retryAt: z.string().optional(),
73
+ });
74
+ /** The most log lines a row keeps (the oldest are dropped first). */
75
+ export const DELIVERY_LOG_LIMIT = 100;
76
+ export const submissionRecordKind = defineRecordKind({
77
+ kind: 'submission',
78
+ module: 'forms',
79
+ keys: ['form', 'status'],
80
+ time: 'submittedAt',
81
+ fields: {
82
+ /** The form's key — the ingest `target` the site posted to (see `defineFormSource`'s `key`). */
83
+ form: z.string().min(1).max(200),
84
+ status: z.enum(SUBMISSION_STATUSES).default('new'),
85
+ assignee: z.string().max(200).optional(),
86
+ notes: z.string().max(20_000).optional(),
87
+ payload: z.array(submissionFieldSchema).max(200),
88
+ files: z.array(submissionFileSchema).max(20).optional(),
89
+ sourceUrl: z.string().max(2000).optional(),
90
+ spam: z.enum(SPAM_VERDICTS),
91
+ deliveryState: z.enum(DELIVERY_STATES),
92
+ deliveryLog: z.array(deliveryEntrySchema).max(DELIVERY_LOG_LIMIT).optional(),
93
+ submittedAt: z.string().min(1),
94
+ },
95
+ });
96
+ /** The columns the EDITOR owns — the only ones the inbox ever writes. */
97
+ export const SUBMISSION_EDITOR_COLUMNS = ['status', 'assignee', 'notes'];
98
+ //# sourceMappingURL=kind.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kind.js","sourceRoot":"","sources":["../../src/shared/kind.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from 'zod'\nimport { defineRecordKind } from '@elytracms/core/persistence'\nimport type { RecordEntryOf } from '@elytracms/core/persistence'\n\n/**\n * EC-613 — the SUBMISSION record kind: every form post a site receives, one row in the\n * engine's record store (EC-616), declared ONCE here and read by all three planes — the\n * Convex functions that store it (`@elytracms/forms/convex`), the studio inbox that lists\n * and edits it (`@elytracms/forms/studio`) and the site helpers that send it\n * (`@elytracms/forms/next`).\n *\n * Its functions live in the Convex module `forms` — the consumer's ONE re-export file is\n * `convex/forms.ts`: `export * from '@elytracms/forms/convex'`.\n *\n * Who writes what:\n * - the SITE (through the ingest receiver's handler, `forms:ingest`): `form`, `payload`,\n * `files`, `sourceUrl`, `spam`, `submittedAt` — what the visitor sent and what the checks\n * said. Never edited afterwards;\n * - the EDITOR (through the inbox): `status`, `assignee`, `notes` — the inbox lifecycle (the\n * SMB CRM of the ruling; a sales pipeline is the developer's structure, not this kind);\n * - the DELIVERY (the scheduled `forms:deliver` action): `deliveryState`, `deliveryLog`.\n *\n * The payload is a LIST of `{ name, value }` (sorted by name), not an object keyed by field\n * name: a visitor's field names never become stored object keys. Its display order is the\n * FORM's (`labelPayload` over the form source's `fields`). A value is a string, a number, a\n * boolean or a list of strings, so the stored depth is bounded by construction.\n */\n\n/** The editor's inbox lifecycle. */\nexport const SUBMISSION_STATUSES = ['new', 'open', 'done', 'spam'] as const\nexport type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number]\n\n/** What a submission's delivery is doing — see `@elytracms/forms` `applyDeliveryOutcome`. */\nexport const DELIVERY_STATES = ['pending', 'sent', 'partial', 'failed', 'skipped'] as const\nexport type DeliveryState = (typeof DELIVERY_STATES)[number]\n\n/** What the spam checks said: nothing, or the form's honeypot was filled. */\nexport const SPAM_VERDICTS = ['clean', 'honeypot'] as const\nexport type SpamVerdict = (typeof SPAM_VERDICTS)[number]\n\n/** The longest text value one field may carry. */\nexport const FORM_VALUE_MAX_LENGTH = 10_000\n\n/** One submitted value: a string, a number, a boolean, or a list of strings (a multi-select). */\nexport const formValueSchema = z.union([\n z.string().max(FORM_VALUE_MAX_LENGTH),\n z.number(),\n z.boolean(),\n z.array(z.string().max(FORM_VALUE_MAX_LENGTH)).max(50),\n])\nexport type FormValue = z.infer<typeof formValueSchema>\n\n/** One field of a submission's payload. */\nexport const submissionFieldSchema = z.strictObject({\n name: z.string().min(1).max(200),\n value: formValueSchema,\n})\nexport type SubmissionField = z.infer<typeof submissionFieldSchema>\n\n/** One uploaded file a submission claimed (EC-617's upload intent, checked against the stored file). */\nexport const submissionFileSchema = z.strictObject({\n /** The form field it was uploaded for. */\n field: z.string().min(1).max(200),\n /** The file name the visitor's browser reported (display only). */\n name: z.string().max(300).optional(),\n storageId: z.string().min(1),\n contentType: z.string().min(1),\n size: z.number().int().nonnegative(),\n /** The storage URL (`https://<deployment>.convex.cloud/api/storage/<id>`), unguessable, valid while the file exists. */\n url: z.string().optional(),\n})\nexport type SubmissionFile = z.infer<typeof submissionFileSchema>\n\n/** One line of the delivery log: one recipient (or the whole request) in one attempt. */\nexport const deliveryEntrySchema = z.strictObject({\n at: z.string().min(1),\n attempt: z.number().int().positive(),\n /** The recipient — absent for an entry about the whole attempt (unreachable site, refused request). */\n to: z.string().optional(),\n ok: z.boolean(),\n /** Why it failed (truncated). */\n error: z.string().optional(),\n /** A note on a non-failure without a recipient (`no recipients`). */\n note: z.string().optional(),\n /** The transport's message id, when it reported one. */\n id: z.string().optional(),\n /** When the next automatic attempt runs, if one is scheduled. */\n retryAt: z.string().optional(),\n})\nexport type DeliveryEntry = z.infer<typeof deliveryEntrySchema>\n\n/** The most log lines a row keeps (the oldest are dropped first). */\nexport const DELIVERY_LOG_LIMIT = 100\n\nexport const submissionRecordKind = defineRecordKind({\n kind: 'submission',\n module: 'forms',\n keys: ['form', 'status'],\n time: 'submittedAt',\n fields: {\n /** The form's key — the ingest `target` the site posted to (see `defineFormSource`'s `key`). */\n form: z.string().min(1).max(200),\n status: z.enum(SUBMISSION_STATUSES).default('new'),\n assignee: z.string().max(200).optional(),\n notes: z.string().max(20_000).optional(),\n payload: z.array(submissionFieldSchema).max(200),\n files: z.array(submissionFileSchema).max(20).optional(),\n sourceUrl: z.string().max(2000).optional(),\n spam: z.enum(SPAM_VERDICTS),\n deliveryState: z.enum(DELIVERY_STATES),\n deliveryLog: z.array(deliveryEntrySchema).max(DELIVERY_LOG_LIMIT).optional(),\n submittedAt: z.string().min(1),\n },\n})\n\nexport type SubmissionRecord = RecordEntryOf<typeof submissionRecordKind>\n\n/** The columns the EDITOR owns — the only ones the inbox ever writes. */\nexport const SUBMISSION_EDITOR_COLUMNS = ['status', 'assignee', 'notes'] as const\nexport type SubmissionEditorColumn = (typeof SUBMISSION_EDITOR_COLUMNS)[number]\n"]}
@@ -0,0 +1,35 @@
1
+ import type { LocalizedString } from '@elytracms/core/cms-core';
2
+ import type { FormFieldLabel } from './source.js';
3
+ import type { SubmissionField } from './kind.js';
4
+ /**
5
+ * EC-613 — a submission LABELLED by its form (pure): the studio inbox shows it, the site's
6
+ * delivery route writes it into the mail. A payload field the form does not (or no longer)
7
+ * declares keeps its submitted name — what the visitor sent is never hidden.
8
+ */
9
+ /** One payload line with the label an editor reads. */
10
+ export interface LabelledField {
11
+ name: string;
12
+ label: LocalizedString;
13
+ value: SubmissionField['value'];
14
+ }
15
+ /**
16
+ * The payload in the FORM's order (its declared fields first, as declared), then any field
17
+ * the form does not declare, in submitted order.
18
+ */
19
+ export declare function labelPayload(payload: readonly SubmissionField[], fields?: readonly FormFieldLabel[]): LabelledField[];
20
+ /** A label as plain text for a mail (a localized label in `locale`, else its first entry). */
21
+ export declare function plainLabel(label: LocalizedString, locale?: string): string;
22
+ /** The plain-text mail body of a submission. */
23
+ export declare function submissionMailText(input: {
24
+ formTitle?: string;
25
+ fields: readonly LabelledField[];
26
+ files: readonly {
27
+ field: string;
28
+ name?: string;
29
+ url: string;
30
+ size: number;
31
+ }[];
32
+ sourceUrl?: string;
33
+ submittedAt: string;
34
+ locale?: string;
35
+ }): string;
@@ -0,0 +1,47 @@
1
+ import { formatFormValue } from './submission.js';
2
+ /**
3
+ * The payload in the FORM's order (its declared fields first, as declared), then any field
4
+ * the form does not declare, in submitted order.
5
+ */
6
+ export function labelPayload(payload, fields = []) {
7
+ const byName = new Map(payload.map((field) => [field.name, field]));
8
+ const out = [];
9
+ const used = new Set();
10
+ for (const declared of fields) {
11
+ const field = byName.get(declared.name);
12
+ if (!field || used.has(declared.name))
13
+ continue;
14
+ used.add(declared.name);
15
+ out.push({ name: field.name, label: declared.label ?? field.name, value: field.value });
16
+ }
17
+ for (const field of payload) {
18
+ if (used.has(field.name))
19
+ continue;
20
+ used.add(field.name);
21
+ out.push({ name: field.name, label: field.name, value: field.value });
22
+ }
23
+ return out;
24
+ }
25
+ /** A label as plain text for a mail (a localized label in `locale`, else its first entry). */
26
+ export function plainLabel(label, locale) {
27
+ if (typeof label === 'string')
28
+ return label;
29
+ return (locale ? label[locale] : undefined) ?? Object.values(label)[0] ?? '';
30
+ }
31
+ /** The plain-text mail body of a submission. */
32
+ export function submissionMailText(input) {
33
+ const lines = [];
34
+ for (const field of input.fields)
35
+ lines.push(`${plainLabel(field.label, input.locale)}: ${formatFormValue(field.value)}`);
36
+ if (input.files.length > 0) {
37
+ lines.push('', 'Files:');
38
+ for (const file of input.files) {
39
+ lines.push(`- ${file.name ?? file.field} (${Math.max(1, Math.round(file.size / 1024))} KB): ${file.url}`);
40
+ }
41
+ }
42
+ lines.push('', `Submitted: ${input.submittedAt}`);
43
+ if (input.sourceUrl)
44
+ lines.push(`Page: ${input.sourceUrl}`);
45
+ return lines.join('\n');
46
+ }
47
+ //# sourceMappingURL=mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mail.js","sourceRoot":"","sources":["../../src/shared/mail.ts"],"names":[],"mappings":"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","sourcesContent":["import type { LocalizedString } from '@elytracms/core/cms-core'\nimport type { FormFieldLabel } from './source'\nimport type { SubmissionField } from './kind'\nimport { formatFormValue } from './submission'\n\n/**\n * EC-613 — a submission LABELLED by its form (pure): the studio inbox shows it, the site's\n * delivery route writes it into the mail. A payload field the form does not (or no longer)\n * declares keeps its submitted name — what the visitor sent is never hidden.\n */\n\n/** One payload line with the label an editor reads. */\nexport interface LabelledField {\n name: string\n label: LocalizedString\n value: SubmissionField['value']\n}\n\n/**\n * The payload in the FORM's order (its declared fields first, as declared), then any field\n * the form does not declare, in submitted order.\n */\nexport function labelPayload(payload: readonly SubmissionField[], fields: readonly FormFieldLabel[] = []): LabelledField[] {\n const byName = new Map(payload.map((field) => [field.name, field]))\n const out: LabelledField[] = []\n const used = new Set<string>()\n for (const declared of fields) {\n const field = byName.get(declared.name)\n if (!field || used.has(declared.name)) continue\n used.add(declared.name)\n out.push({ name: field.name, label: declared.label ?? field.name, value: field.value })\n }\n for (const field of payload) {\n if (used.has(field.name)) continue\n used.add(field.name)\n out.push({ name: field.name, label: field.name, value: field.value })\n }\n return out\n}\n\n/** A label as plain text for a mail (a localized label in `locale`, else its first entry). */\nexport function plainLabel(label: LocalizedString, locale?: string): string {\n if (typeof label === 'string') return label\n return (locale ? label[locale] : undefined) ?? Object.values(label)[0] ?? ''\n}\n\n/** The plain-text mail body of a submission. */\nexport function submissionMailText(input: {\n formTitle?: string\n fields: readonly LabelledField[]\n files: readonly { field: string; name?: string; url: string; size: number }[]\n sourceUrl?: string\n submittedAt: string\n locale?: string\n}): string {\n const lines: string[] = []\n for (const field of input.fields) lines.push(`${plainLabel(field.label, input.locale)}: ${formatFormValue(field.value)}`)\n if (input.files.length > 0) {\n lines.push('', 'Files:')\n for (const file of input.files) {\n lines.push(`- ${file.name ?? file.field} (${Math.max(1, Math.round(file.size / 1024))} KB): ${file.url}`)\n }\n }\n lines.push('', `Submitted: ${input.submittedAt}`)\n if (input.sourceUrl) lines.push(`Page: ${input.sourceUrl}`)\n return lines.join('\\n')\n}\n"]}
@@ -0,0 +1,70 @@
1
+ import type { LocalizedString } from '@elytracms/core/cms-core';
2
+ import type { SubmissionField } from './kind.js';
3
+ /**
4
+ * EC-613 — the FORM SOURCE: how the addon reads the consumer's own form documents, declared
5
+ * ONCE by the consumer and read by the two planes that know the project — the studio inbox
6
+ * (a submission's field labels, the form's title) and the site's delivery route (the
7
+ * recipients, the labels in the mail). Convex never reads it: the deployment stays
8
+ * data-only (EC-585 1e), and the recipients are resolved from the PUBLISHED form document on
9
+ * the site — never from anything a visitor sent (a recipient from the payload would make the
10
+ * site an open mail relay).
11
+ *
12
+ * The form collection, its field types and the renderer stay the consumer's (the ruling):
13
+ * this declaration only says where in a form document the addon finds what it needs.
14
+ *
15
+ * ```ts
16
+ * // cms/forms.ts — anywhere; the studio config and the site's delivery route import it
17
+ * export const contactForms = defineFormSource({
18
+ * collection: 'form',
19
+ * key: 'internalId', // the site posts this value as the submission's target
20
+ * title: (form) => form.title,
21
+ * recipients: (form) => [form.recipient],
22
+ * fields: (form) => form.fields.map((field) => ({ name: field.name, label: field.label })),
23
+ * })
24
+ * ```
25
+ */
26
+ /** One field of a form, as the addon labels a submission's payload with it. */
27
+ export interface FormFieldLabel {
28
+ /** The submitted name (the input's `name`). */
29
+ name: string;
30
+ /** What an editor reads instead of the name; a plain string or `{ en, de, … }`. */
31
+ label?: LocalizedString;
32
+ }
33
+ /** A form document as the addon sees it: its id and its values (the consumer's shape). */
34
+ export interface FormDocument<V = Record<string, unknown>> {
35
+ id: string;
36
+ values: V;
37
+ }
38
+ export interface FormSourceConfig<V = Record<string, unknown>> {
39
+ /** The collection that holds the forms. */
40
+ collection: string;
41
+ /**
42
+ * How a site addresses a form — the submission's `target` and the row's `form` column: the
43
+ * name of a text field of the form document (`'internalId'`), or a function. Default: the
44
+ * document's id.
45
+ */
46
+ key?: string | ((form: FormDocument<V>) => string | undefined);
47
+ /** The form's display title (the inbox column, the mail subject). */
48
+ title?: (values: V) => string | undefined;
49
+ /** Who receives a submission's mail. An empty list delivers nothing (an inbox-only form). */
50
+ recipients: (values: V) => readonly (string | undefined | null)[];
51
+ /** The form's fields, in order, for labelling a payload. Absent → the submitted names. */
52
+ fields?: (values: V) => readonly FormFieldLabel[];
53
+ /** The address a reply goes to (default: the first payload value of a field named like `email`). */
54
+ replyTo?: (payload: readonly SubmissionField[]) => string | undefined;
55
+ }
56
+ /** A declared form source (see the module header). */
57
+ export interface FormSource<V = Record<string, unknown>> extends FormSourceConfig<V> {
58
+ /** The key of one form document — `key` applied, else its id. */
59
+ keyOf(form: FormDocument<V>): string | undefined;
60
+ /** The form among `forms` a submission's `form` column names. */
61
+ find(forms: readonly FormDocument<V>[], key: string): FormDocument<V> | undefined;
62
+ /** The recipients of one form: trimmed, valid addresses only, each once. */
63
+ recipientsOf(form: FormDocument<V>): string[];
64
+ }
65
+ /** Is this a deliverable address (the same shape both consumers' routes check)? */
66
+ export declare function isEmailAddress(value: unknown): value is string;
67
+ /** Declare how the addon reads your form documents (see the module header). */
68
+ export declare function defineFormSource<V = Record<string, unknown>>(config: FormSourceConfig<V>): FormSource<V>;
69
+ /** The default reply-to: the first payload value of a field named like `email`, if it is an address. */
70
+ export declare function defaultReplyTo(payload: readonly SubmissionField[]): string | undefined;
@@ -0,0 +1,40 @@
1
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2
+ /** Is this a deliverable address (the same shape both consumers' routes check)? */
3
+ export function isEmailAddress(value) {
4
+ return typeof value === 'string' && EMAIL.test(value.trim());
5
+ }
6
+ /** Declare how the addon reads your form documents (see the module header). */
7
+ export function defineFormSource(config) {
8
+ if (!config.collection)
9
+ throw new Error('defineFormSource: `collection` is required.');
10
+ const keyOf = (form) => {
11
+ if (config.key === undefined)
12
+ return form.id;
13
+ if (typeof config.key === 'function')
14
+ return config.key(form);
15
+ const value = form.values[config.key];
16
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
17
+ };
18
+ return {
19
+ ...config,
20
+ keyOf,
21
+ find: (forms, key) => forms.find((form) => keyOf(form) === key),
22
+ recipientsOf: (form) => {
23
+ const seen = new Set();
24
+ for (const entry of config.recipients(form.values)) {
25
+ if (isEmailAddress(entry))
26
+ seen.add(entry.trim());
27
+ }
28
+ return [...seen];
29
+ },
30
+ };
31
+ }
32
+ /** The default reply-to: the first payload value of a field named like `email`, if it is an address. */
33
+ export function defaultReplyTo(payload) {
34
+ for (const field of payload) {
35
+ if (/e-?mail/i.test(field.name) && isEmailAddress(field.value))
36
+ return field.value.trim();
37
+ }
38
+ return undefined;
39
+ }
40
+ //# sourceMappingURL=source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.js","sourceRoot":"","sources":["../../src/shared/source.ts"],"names":[],"mappings":"AAsEA,MAAM,KAAK,GAAG,4BAA4B,CAAA;AAE1C,mFAAmF;AACnF,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAA;AAC9D,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,gBAAgB,CAA8B,MAA2B;IACvF,IAAI,CAAC,MAAM,CAAC,UAAU;QAAE,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAA;IACtF,MAAM,KAAK,GAAG,CAAC,IAAqB,EAAsB,EAAE;QAC1D,IAAI,MAAM,CAAC,GAAG,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC,EAAE,CAAA;QAC5C,IAAI,OAAO,MAAM,CAAC,GAAG,KAAK,UAAU;YAAE,OAAO,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC7D,MAAM,KAAK,GAAI,IAAI,CAAC,MAAkC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAClE,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAA;IAC1E,CAAC,CAAA;IACD,OAAO;QACL,GAAG,MAAM;QACT,KAAK;QACL,IAAI,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC;QAC/D,YAAY,EAAE,CAAC,IAAI,EAAE,EAAE;YACrB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;YAC9B,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;gBACnD,IAAI,cAAc,CAAC,KAAK,CAAC;oBAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAA;YACnD,CAAC;YACD,OAAO,CAAC,GAAG,IAAI,CAAC,CAAA;QAClB,CAAC;KACF,CAAA;AACH,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,cAAc,CAAC,OAAmC;IAChE,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,cAAc,CAAC,KAAK,CAAC,KAAK,CAAC;YAAE,OAAQ,KAAK,CAAC,KAAgB,CAAC,IAAI,EAAE,CAAA;IACvG,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC","sourcesContent":["import type { LocalizedString } from '@elytracms/core/cms-core'\nimport type { SubmissionField } from './kind'\n\n/**\n * EC-613 — the FORM SOURCE: how the addon reads the consumer's own form documents, declared\n * ONCE by the consumer and read by the two planes that know the project — the studio inbox\n * (a submission's field labels, the form's title) and the site's delivery route (the\n * recipients, the labels in the mail). Convex never reads it: the deployment stays\n * data-only (EC-585 1e), and the recipients are resolved from the PUBLISHED form document on\n * the site — never from anything a visitor sent (a recipient from the payload would make the\n * site an open mail relay).\n *\n * The form collection, its field types and the renderer stay the consumer's (the ruling):\n * this declaration only says where in a form document the addon finds what it needs.\n *\n * ```ts\n * // cms/forms.ts — anywhere; the studio config and the site's delivery route import it\n * export const contactForms = defineFormSource({\n * collection: 'form',\n * key: 'internalId', // the site posts this value as the submission's target\n * title: (form) => form.title,\n * recipients: (form) => [form.recipient],\n * fields: (form) => form.fields.map((field) => ({ name: field.name, label: field.label })),\n * })\n * ```\n */\n\n/** One field of a form, as the addon labels a submission's payload with it. */\nexport interface FormFieldLabel {\n /** The submitted name (the input's `name`). */\n name: string\n /** What an editor reads instead of the name; a plain string or `{ en, de, … }`. */\n label?: LocalizedString\n}\n\n/** A form document as the addon sees it: its id and its values (the consumer's shape). */\nexport interface FormDocument<V = Record<string, unknown>> {\n id: string\n values: V\n}\n\nexport interface FormSourceConfig<V = Record<string, unknown>> {\n /** The collection that holds the forms. */\n collection: string\n /**\n * How a site addresses a form — the submission's `target` and the row's `form` column: the\n * name of a text field of the form document (`'internalId'`), or a function. Default: the\n * document's id.\n */\n key?: string | ((form: FormDocument<V>) => string | undefined)\n /** The form's display title (the inbox column, the mail subject). */\n title?: (values: V) => string | undefined\n /** Who receives a submission's mail. An empty list delivers nothing (an inbox-only form). */\n recipients: (values: V) => readonly (string | undefined | null)[]\n /** The form's fields, in order, for labelling a payload. Absent → the submitted names. */\n fields?: (values: V) => readonly FormFieldLabel[]\n /** The address a reply goes to (default: the first payload value of a field named like `email`). */\n replyTo?: (payload: readonly SubmissionField[]) => string | undefined\n}\n\n/** A declared form source (see the module header). */\nexport interface FormSource<V = Record<string, unknown>> extends FormSourceConfig<V> {\n /** The key of one form document — `key` applied, else its id. */\n keyOf(form: FormDocument<V>): string | undefined\n /** The form among `forms` a submission's `form` column names. */\n find(forms: readonly FormDocument<V>[], key: string): FormDocument<V> | undefined\n /** The recipients of one form: trimmed, valid addresses only, each once. */\n recipientsOf(form: FormDocument<V>): string[]\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/** Is this a deliverable address (the same shape both consumers' routes check)? */\nexport function isEmailAddress(value: unknown): value is string {\n return typeof value === 'string' && EMAIL.test(value.trim())\n}\n\n/** Declare how the addon reads your form documents (see the module header). */\nexport function defineFormSource<V = Record<string, unknown>>(config: FormSourceConfig<V>): FormSource<V> {\n if (!config.collection) throw new Error('defineFormSource: `collection` is required.')\n const keyOf = (form: FormDocument<V>): string | undefined => {\n if (config.key === undefined) return form.id\n if (typeof config.key === 'function') return config.key(form)\n const value = (form.values as Record<string, unknown>)[config.key]\n return typeof value === 'string' && value.length > 0 ? value : undefined\n }\n return {\n ...config,\n keyOf,\n find: (forms, key) => forms.find((form) => keyOf(form) === key),\n recipientsOf: (form) => {\n const seen = new Set<string>()\n for (const entry of config.recipients(form.values)) {\n if (isEmailAddress(entry)) seen.add(entry.trim())\n }\n return [...seen]\n },\n }\n}\n\n/** The default reply-to: the first payload value of a field named like `email`, if it is an address. */\nexport function defaultReplyTo(payload: readonly SubmissionField[]): string | undefined {\n for (const field of payload) {\n if (/e-?mail/i.test(field.name) && isEmailAddress(field.value)) return (field.value as string).trim()\n }\n return undefined\n}\n"]}
@@ -0,0 +1,83 @@
1
+ import type { IngestPayload } from '@elytracms/core/ingest';
2
+ import type { FormValue, SpamVerdict, SubmissionField } from './kind.js';
3
+ /**
4
+ * EC-613 — the FORMS WIRE inside the ingest envelope (EC-617): what a site's `submission`
5
+ * payload carries for this addon, and the ONE reader the receiver's handler turns it into a
6
+ * row with. Pure (no Convex), so the rules are unit-tested on their own.
7
+ *
8
+ * `data` holds the form's fields by name, plus RESERVED keys the addon's site helpers fill,
9
+ * in the `elytra:` namespace:
10
+ *
11
+ * - `elytra:source` — the page the form was submitted from (the row's `sourceUrl`);
12
+ * - `elytra:honeypot` — the value of the form's honeypot field (a real visitor leaves it empty).
13
+ *
14
+ * Any other `elytra:` key is refused — never silently dropped. Empty values (`''`, `[]`) are
15
+ * not answers and are not stored; a submission with no answer and no file is refused.
16
+ *
17
+ * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
18
+ * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
19
+ * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
20
+ * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
21
+ * site helpers refuse a name before sending, with the same reason the envelope would give.
22
+ */
23
+ /** The reserved namespace of the forms wire. */
24
+ export declare const FORMS_RESERVED_PREFIX = "elytra:";
25
+ /** The page a submission came from. */
26
+ export declare const FORMS_SOURCE_KEY = "elytra:source";
27
+ /** The honeypot's value. */
28
+ export declare const FORMS_HONEYPOT_KEY = "elytra:honeypot";
29
+ /**
30
+ * The hidden input a native (no-JS) form post names its form with — read by the `./next`
31
+ * form-post route, never sent on: it becomes the ingest payload's `target`.
32
+ */
33
+ export declare const FORMS_TARGET_FIELD = "elytra:form";
34
+ /** The forms addon's own cap on a field name (the stored payload's `name`) — below Convex's 1024. */
35
+ export declare const FORM_FIELD_NAME_MAX = 200;
36
+ /**
37
+ * Why a form field may not be named `name`, or `undefined` when it may: the ingest envelope's
38
+ * rule first (`convexFieldNameProblem` — the ONE rule, read, never restated), then the forms
39
+ * addon's own limits (not empty, at most {@link FORM_FIELD_NAME_MAX} characters). Phrased to
40
+ * follow `The field name “<name>” …`, exactly as the envelope's 400 issue reads.
41
+ */
42
+ export declare function formFieldNameProblem(name: string): string | undefined;
43
+ /** The issue message for a refused field name — the envelope's wording (`@elytracms/core/ingest`). */
44
+ export declare function formFieldNameMessage(name: string, problem: string): string;
45
+ /** The honeypot field both consumers' forms already render (overridable in the site helpers). */
46
+ export declare const DEFAULT_HONEYPOT_FIELD = "website";
47
+ /**
48
+ * The file types a form upload may have — documents and raster images. Anything that a
49
+ * browser would run when opened from the deployment's storage URL (HTML, SVG, scripts) is
50
+ * refused by the receiver's handler (415), and so is every type not listed.
51
+ */
52
+ export declare const FORMS_UPLOAD_CONTENT_TYPES: readonly string[];
53
+ /** What a site's submission becomes in the row (the visitor's part — see the kind's header). */
54
+ export interface SubmissionColumns {
55
+ form: string;
56
+ payload: SubmissionField[];
57
+ sourceUrl?: string;
58
+ spam: SpamVerdict;
59
+ }
60
+ export type SubmissionRead = {
61
+ ok: true;
62
+ columns: SubmissionColumns;
63
+ } | {
64
+ ok: false;
65
+ status: 422;
66
+ message: string;
67
+ issues: {
68
+ path: (string | number)[];
69
+ message: string;
70
+ }[];
71
+ };
72
+ /**
73
+ * Read a `submission` payload into the row's visitor columns (fields sorted by name). Refuses (422, with the issues)
74
+ * a value that is not a form value, an unknown reserved key, an over-long source URL, and a
75
+ * submission with no answer — unless it carries files (`hasFiles`).
76
+ */
77
+ export declare function readSubmissionPayload(payload: Extract<IngestPayload, {
78
+ kind: 'submission';
79
+ }>, options?: {
80
+ hasFiles?: boolean;
81
+ }): SubmissionRead;
82
+ /** The display text of one value (a list joined by commas, a boolean as yes/no). */
83
+ export declare function formatFormValue(value: FormValue): string;