@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,137 @@
1
+ import { DEFAULT_HONEYPOT_FIELD, FORMS_HONEYPOT_KEY, FORMS_RESERVED_PREFIX, FORMS_SOURCE_KEY, FORMS_TARGET_FIELD, formFieldNameMessage, formFieldNameProblem, } from '../shared/submission.js';
2
+ function isFile(value) {
3
+ return typeof value !== 'string';
4
+ }
5
+ /**
6
+ * Read a form's entries (pure): repeated names become a list, the honeypot moves to
7
+ * `elytra:honeypot`, `elytra:form` (the no-JS target input) is not a field, and every non-empty file is
8
+ * an upload whose NAME is the field's value (what the inbox and the mail list). A file input
9
+ * left empty posts a nameless zero-byte part — it is skipped.
10
+ */
11
+ export function formPayload(entries, options = {}) {
12
+ const honeypot = options.honeypot ?? DEFAULT_HONEYPOT_FIELD;
13
+ const values = new Map();
14
+ const files = [];
15
+ let honeypotValue = '';
16
+ for (const [name, value] of entries) {
17
+ if (name === FORMS_TARGET_FIELD)
18
+ continue;
19
+ if (name === honeypot) {
20
+ if (typeof value === 'string')
21
+ honeypotValue = value;
22
+ continue;
23
+ }
24
+ if (isFile(value)) {
25
+ if (value.size === 0)
26
+ continue;
27
+ const fileName = value.name || 'file';
28
+ files.push({ field: name, file: value, name: fileName });
29
+ values.set(name, [...(values.get(name) ?? []), fileName]);
30
+ continue;
31
+ }
32
+ values.set(name, [...(values.get(name) ?? []), value]);
33
+ }
34
+ const data = {};
35
+ for (const [name, list] of values)
36
+ data[name] = list.length === 1 ? list[0] : list;
37
+ if (options.sourceUrl)
38
+ data[FORMS_SOURCE_KEY] = options.sourceUrl;
39
+ data[FORMS_HONEYPOT_KEY] = honeypotValue;
40
+ return { data, files };
41
+ }
42
+ async function readJson(response) {
43
+ try {
44
+ const parsed = (await response.json());
45
+ return parsed && typeof parsed === 'object' ? parsed : {};
46
+ }
47
+ catch {
48
+ return {};
49
+ }
50
+ }
51
+ function failure(stage, status, body, fallback) {
52
+ return {
53
+ ok: false,
54
+ stage,
55
+ status,
56
+ error: typeof body['error'] === 'string' ? body['error'] : fallback,
57
+ ...(Array.isArray(body['issues']) ? { issues: body['issues'] } : {}),
58
+ };
59
+ }
60
+ /**
61
+ * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file
62
+ * uploaded first, then the submission claiming them. The first failure ends it and is
63
+ * returned with its stage and status; nothing is retried here.
64
+ */
65
+ export async function sendFormSubmission(input) {
66
+ const { target, payload, transport } = input;
67
+ // A name the envelope (or the forms addon) would refuse is refused here, before anything is
68
+ // sent — with the same reason, as the same `['data', name]` issues the route's 400 carries.
69
+ const names = new Set([...Object.keys(payload.data), ...payload.files.map((file) => file.field)]);
70
+ const issues = [...names].flatMap((name) => {
71
+ if (name.startsWith(FORMS_RESERVED_PREFIX))
72
+ return [];
73
+ const problem = formFieldNameProblem(name);
74
+ return problem ? [{ path: ['data', name], message: formFieldNameMessage(name, problem) }] : [];
75
+ });
76
+ if (issues.length > 0) {
77
+ return { ok: false, stage: 'submission', status: 400, error: issues.map((issue) => issue.message).join(' '), issues };
78
+ }
79
+ const uploads = [];
80
+ for (const { field, file } of payload.files) {
81
+ // The type the browser reports; an unknown one is declared as an opaque binary.
82
+ const contentType = file.type || 'application/octet-stream';
83
+ const issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size });
84
+ const issuedBody = await readJson(issued);
85
+ if (!issued.ok)
86
+ return failure('upload', issued.status, issuedBody, 'The upload was refused.');
87
+ const uploadId = issuedBody['uploadId'];
88
+ const uploadUrl = issuedBody['uploadUrl'];
89
+ if (typeof uploadId !== 'string' || typeof uploadUrl !== 'string') {
90
+ return failure('upload', 502, {}, 'The upload answer carried no upload URL.');
91
+ }
92
+ const stored = await transport.store(uploadUrl, file, contentType);
93
+ const storedBody = await readJson(stored);
94
+ if (!stored.ok || typeof storedBody['storageId'] !== 'string') {
95
+ return failure('storage', stored.ok ? 502 : stored.status, storedBody, 'The file could not be stored.');
96
+ }
97
+ uploads.push({ uploadId, storageId: storedBody['storageId'] });
98
+ }
99
+ const sent = await transport.ingest({
100
+ kind: 'submission',
101
+ target,
102
+ data: payload.data,
103
+ ...(uploads.length > 0 ? { uploads } : {}),
104
+ });
105
+ const body = await readJson(sent);
106
+ if (!sent.ok)
107
+ return failure('submission', sent.status, body, 'The submission was refused.');
108
+ return { ok: true, id: typeof body['id'] === 'string' ? body['id'] : '' };
109
+ }
110
+ /**
111
+ * Submit a form from the BROWSER (a client component's submit handler): its fields and files
112
+ * through the site's ingest route (default `/api/ingest`), the files' bytes straight to the
113
+ * deployment's storage — so an upload is not bound by the site's function body limit.
114
+ *
115
+ * ```tsx
116
+ * const result = await submitForm({ form: event.currentTarget, target: 'contact' })
117
+ * ```
118
+ */
119
+ export function submitForm(input) {
120
+ const fetchImpl = input.fetchImpl ?? fetch;
121
+ const formData = input.form instanceof FormData ? input.form : new FormData(input.form);
122
+ const sourceUrl = input.sourceUrl ?? (typeof location === 'undefined' ? undefined : location.href);
123
+ const payload = formPayload(formData.entries(), {
124
+ ...(input.honeypot ? { honeypot: input.honeypot } : {}),
125
+ ...(sourceUrl ? { sourceUrl } : {}),
126
+ });
127
+ const endpoint = input.endpoint ?? '/api/ingest';
128
+ return sendFormSubmission({
129
+ target: input.target,
130
+ payload,
131
+ transport: {
132
+ ingest: (body) => fetchImpl(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
133
+ store: (uploadUrl, file, contentType) => fetchImpl(uploadUrl, { method: 'POST', headers: { 'content-type': contentType }, body: file }),
134
+ },
135
+ });
136
+ }
137
+ //# sourceMappingURL=submit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"submit.js","sourceRoot":"","sources":["../../src/next/submit.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,kBAAkB,EAClB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,sBAAsB,CAAA;AA0B7B,SAAS,MAAM,CAAC,KAAkB;IAChC,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAA;AAClC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,OAAwC,EAAE,UAA8B,EAAE;IACpG,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,sBAAsB,CAAA;IAC3D,MAAM,MAAM,GAAG,IAAI,GAAG,EAAoB,CAAA;IAC1C,MAAM,KAAK,GAAyB,EAAE,CAAA;IACtC,IAAI,aAAa,GAAG,EAAE,CAAA;IACtB,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,OAAO,EAAE,CAAC;QACpC,IAAI,IAAI,KAAK,kBAAkB;YAAE,SAAQ;QACzC,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtB,IAAI,OAAO,KAAK,KAAK,QAAQ;gBAAE,aAAa,GAAG,KAAK,CAAA;YACpD,SAAQ;QACV,CAAC;QACD,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;YAClB,IAAI,KAAK,CAAC,IAAI,KAAK,CAAC;gBAAE,SAAQ;YAC9B,MAAM,QAAQ,GAAI,KAAkC,CAAC,IAAI,IAAI,MAAM,CAAA;YACnE,KAAK,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAA;YACxD,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAA;YACzD,SAAQ;QACV,CAAC;QACD,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,CAAA;IACxD,CAAC;IACD,MAAM,IAAI,GAAwB,EAAE,CAAA;IACpC,KAAK,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,MAAM;QAAE,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAE,CAAC,CAAC,CAAC,IAAI,CAAA;IACnF,IAAI,OAAO,CAAC,SAAS;QAAE,IAAI,CAAC,gBAAgB,CAAC,GAAG,OAAO,CAAC,SAAS,CAAA;IACjE,IAAI,CAAC,kBAAkB,CAAC,GAAG,aAAa,CAAA;IACxC,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;AACxB,CAAC;AAsBD,KAAK,UAAU,QAAQ,CAAC,QAAkB;IACxC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAY,CAAA;QACjD,OAAO,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAE,MAAkC,CAAC,CAAC,CAAC,EAAE,CAAA;IACxF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC;AAED,SAAS,OAAO,CAAC,KAA0C,EAAE,MAAc,EAAE,IAA6B,EAAE,QAAgB;IAC1H,OAAO;QACL,EAAE,EAAE,KAAK;QACT,KAAK;QACL,MAAM;QACN,KAAK,EAAE,OAAO,IAAI,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,QAAQ;QACnE,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAqD,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACzH,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,KAA2E;IAClH,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,KAAK,CAAA;IAC5C,4FAA4F;IAC5F,4FAA4F;IAC5F,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IACjG,MAAM,MAAM,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE;QACzC,IAAI,IAAI,CAAC,UAAU,CAAC,qBAAqB,CAAC;YAAE,OAAO,EAAE,CAAA;QACrD,MAAM,OAAO,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAA;QAC1C,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,IAAI,CAAwB,EAAE,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;IACvH,CAAC,CAAC,CAAA;IACF,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,EAAE,CAAA;IACvH,CAAC;IACD,MAAM,OAAO,GAA8C,EAAE,CAAA;IAC7D,KAAK,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QAC5C,gFAAgF;QAChF,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,IAAI,0BAA0B,CAAA;QAC3D,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAA;QACtG,MAAM,UAAU,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAA;QACzC,IAAI,CAAC,MAAM,CAAC,EAAE;YAAE,OAAO,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,yBAAyB,CAAC,CAAA;QAC9F,MAAM,QAAQ,GAAG,UAAU,CAAC,UAAU,CAAC,CAAA;QACvC,MAAM,SAAS,GAAG,UAAU,CAAC,WAAW,CAAC,CAAA;QACzC,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;YAClE,OAAO,OAAO,CAAC,QAAQ,EAAE,GAAG,EAAE,EAAE,EAAE,0CAA0C,CAAC,CAAA;QAC/E,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,EAAE,WAAW,CAAC,CAAA;QAClE,MAAM,UAAU,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAA;QACzC,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,UAAU,CAAC,WAAW,CAAC,KAAK,QAAQ,EAAE,CAAC;YAC9D,OAAO,OAAO,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,+BAA+B,CAAC,CAAA;QACzG,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC,CAAA;IAChE,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC;QAClC,IAAI,EAAE,YAAY;QAClB,MAAM;QACN,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,GAAG,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC3C,CAAC,CAAA;IACF,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAA;IACjC,IAAI,CAAC,IAAI,CAAC,EAAE;QAAE,OAAO,OAAO,CAAC,YAAY,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,6BAA6B,CAAC,CAAA;IAC5F,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAA;AAC3E,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CAAC,KAS1B;IACC,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,KAAK,CAAA;IAC1C,MAAM,QAAQ,GAAG,KAAK,CAAC,IAAI,YAAY,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACvF,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,CAAC,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;IAClG,MAAM,OAAO,GAAG,WAAW,CAAC,QAAQ,CAAC,OAAO,EAAqC,EAAE;QACjF,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACpC,CAAC,CAAA;IACF,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,aAAa,CAAA;IAChD,OAAO,kBAAkB,CAAC;QACxB,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,OAAO;QACP,SAAS,EAAE;YACT,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CACf,SAAS,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC;YACtH,KAAK,EAAE,CAAC,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CACtC,SAAS,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,cAAc,EAAE,WAAW,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;SACjG;KACF,CAAC,CAAA;AACJ,CAAC","sourcesContent":["import {\n DEFAULT_HONEYPOT_FIELD,\n FORMS_HONEYPOT_KEY,\n FORMS_RESERVED_PREFIX,\n FORMS_SOURCE_KEY,\n FORMS_TARGET_FIELD,\n formFieldNameMessage,\n formFieldNameProblem,\n} from '../shared/submission'\n\n/**\n * EC-613 — a form post as the ingest seam's calls (EC-617): one `upload` per file (the route\n * asks the receiver for ONE short-lived storage URL, the bytes go straight to it), then one\n * `submission` that claims the stored files. The ONE flow, with injected transports, so the\n * browser (`submitForm`) and the site's no-JS form-post route (`createFormPostRoute`) send\n * exactly the same calls.\n */\n\n/** A posted value: text, or a file (a `File` / `Blob` with a name). */\ntype PostedValue = string | Blob\n\n/** The form's fields as the ingest payload carries them, and the files to upload. */\nexport interface FormPayload {\n data: Record<string, string | string[]>\n files: { field: string; file: Blob; name: string }[]\n}\n\nexport interface FormPayloadOptions {\n /** The honeypot field the form renders (default `website`); its value goes to `elytra:honeypot`. */\n honeypot?: string\n /** The page the form was submitted from (`$source`). */\n sourceUrl?: string\n}\n\nfunction isFile(value: PostedValue): value is Blob {\n return typeof value !== 'string'\n}\n\n/**\n * Read a form's entries (pure): repeated names become a list, the honeypot moves to\n * `elytra:honeypot`, `elytra:form` (the no-JS target input) is not a field, and every non-empty file is\n * an upload whose NAME is the field's value (what the inbox and the mail list). A file input\n * left empty posts a nameless zero-byte part — it is skipped.\n */\nexport function formPayload(entries: Iterable<[string, PostedValue]>, options: FormPayloadOptions = {}): FormPayload {\n const honeypot = options.honeypot ?? DEFAULT_HONEYPOT_FIELD\n const values = new Map<string, string[]>()\n const files: FormPayload['files'] = []\n let honeypotValue = ''\n for (const [name, value] of entries) {\n if (name === FORMS_TARGET_FIELD) continue\n if (name === honeypot) {\n if (typeof value === 'string') honeypotValue = value\n continue\n }\n if (isFile(value)) {\n if (value.size === 0) continue\n const fileName = (value as Blob & { name?: string }).name || 'file'\n files.push({ field: name, file: value, name: fileName })\n values.set(name, [...(values.get(name) ?? []), fileName])\n continue\n }\n values.set(name, [...(values.get(name) ?? []), value])\n }\n const data: FormPayload['data'] = {}\n for (const [name, list] of values) data[name] = list.length === 1 ? list[0]! : list\n if (options.sourceUrl) data[FORMS_SOURCE_KEY] = options.sourceUrl\n data[FORMS_HONEYPOT_KEY] = honeypotValue\n return { data, files }\n}\n\n/** What a submit came to. */\nexport type SubmitResult =\n | { ok: true; id: string }\n | {\n ok: false\n /** Which call failed: an upload URL, the bytes, or the submission. */\n stage: 'upload' | 'storage' | 'submission'\n status: number\n error: string\n issues?: { path: (string | number)[]; message: string }[]\n }\n\n/** The two transports the flow runs over. */\nexport interface SubmitTransport {\n /** Send one ingest payload through the site's ingest route; resolve with its answer. */\n ingest(payload: Record<string, unknown>): Promise<Response>\n /** Send one file's bytes to an issued storage URL; resolve with the storage's answer (`{ storageId }`). */\n store(uploadUrl: string, file: Blob, contentType: string): Promise<Response>\n}\n\nasync function readJson(response: Response): Promise<Record<string, unknown>> {\n try {\n const parsed = (await response.json()) as unknown\n return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}\n } catch {\n return {}\n }\n}\n\nfunction failure(stage: 'upload' | 'storage' | 'submission', status: number, body: Record<string, unknown>, fallback: string): SubmitResult {\n return {\n ok: false,\n stage,\n status,\n error: typeof body['error'] === 'string' ? body['error'] : fallback,\n ...(Array.isArray(body['issues']) ? { issues: body['issues'] as { path: (string | number)[]; message: string }[] } : {}),\n }\n}\n\n/**\n * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file\n * uploaded first, then the submission claiming them. The first failure ends it and is\n * returned with its stage and status; nothing is retried here.\n */\nexport async function sendFormSubmission(input: { target: string; payload: FormPayload; transport: SubmitTransport }): Promise<SubmitResult> {\n const { target, payload, transport } = input\n // A name the envelope (or the forms addon) would refuse is refused here, before anything is\n // sent — with the same reason, as the same `['data', name]` issues the route's 400 carries.\n const names = new Set([...Object.keys(payload.data), ...payload.files.map((file) => file.field)])\n const issues = [...names].flatMap((name) => {\n if (name.startsWith(FORMS_RESERVED_PREFIX)) return []\n const problem = formFieldNameProblem(name)\n return problem ? [{ path: ['data', name] as (string | number)[], message: formFieldNameMessage(name, problem) }] : []\n })\n if (issues.length > 0) {\n return { ok: false, stage: 'submission', status: 400, error: issues.map((issue) => issue.message).join(' '), issues }\n }\n const uploads: { uploadId: string; storageId: string }[] = []\n for (const { field, file } of payload.files) {\n // The type the browser reports; an unknown one is declared as an opaque binary.\n const contentType = file.type || 'application/octet-stream'\n const issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size })\n const issuedBody = await readJson(issued)\n if (!issued.ok) return failure('upload', issued.status, issuedBody, 'The upload was refused.')\n const uploadId = issuedBody['uploadId']\n const uploadUrl = issuedBody['uploadUrl']\n if (typeof uploadId !== 'string' || typeof uploadUrl !== 'string') {\n return failure('upload', 502, {}, 'The upload answer carried no upload URL.')\n }\n const stored = await transport.store(uploadUrl, file, contentType)\n const storedBody = await readJson(stored)\n if (!stored.ok || typeof storedBody['storageId'] !== 'string') {\n return failure('storage', stored.ok ? 502 : stored.status, storedBody, 'The file could not be stored.')\n }\n uploads.push({ uploadId, storageId: storedBody['storageId'] })\n }\n const sent = await transport.ingest({\n kind: 'submission',\n target,\n data: payload.data,\n ...(uploads.length > 0 ? { uploads } : {}),\n })\n const body = await readJson(sent)\n if (!sent.ok) return failure('submission', sent.status, body, 'The submission was refused.')\n return { ok: true, id: typeof body['id'] === 'string' ? body['id'] : '' }\n}\n\n/**\n * Submit a form from the BROWSER (a client component's submit handler): its fields and files\n * through the site's ingest route (default `/api/ingest`), the files' bytes straight to the\n * deployment's storage — so an upload is not bound by the site's function body limit.\n *\n * ```tsx\n * const result = await submitForm({ form: event.currentTarget, target: 'contact' })\n * ```\n */\nexport function submitForm(input: {\n form: HTMLFormElement | FormData\n target: string\n /** The site's ingest route (default `/api/ingest`). */\n endpoint?: string\n honeypot?: string\n /** Default: the current page. */\n sourceUrl?: string\n fetchImpl?: typeof fetch\n}): Promise<SubmitResult> {\n const fetchImpl = input.fetchImpl ?? fetch\n const formData = input.form instanceof FormData ? input.form : new FormData(input.form)\n const sourceUrl = input.sourceUrl ?? (typeof location === 'undefined' ? undefined : location.href)\n const payload = formPayload(formData.entries() as Iterable<[string, PostedValue]>, {\n ...(input.honeypot ? { honeypot: input.honeypot } : {}),\n ...(sourceUrl ? { sourceUrl } : {}),\n })\n const endpoint = input.endpoint ?? '/api/ingest'\n return sendFormSubmission({\n target: input.target,\n payload,\n transport: {\n ingest: (body) =>\n fetchImpl(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),\n store: (uploadUrl, file, contentType) =>\n fetchImpl(uploadUrl, { method: 'POST', headers: { 'content-type': contentType }, body: file }),\n },\n })\n}\n"]}
@@ -0,0 +1,154 @@
1
+ import { z } from 'zod';
2
+ import { INGEST_SIGNATURE_HEADER } from '@elytracms/core/ingest';
3
+ import type { DeliveryEntry, DeliveryState } from './kind.js';
4
+ /**
5
+ * EC-613 — DELIVERY: one scheduled action per submission (`forms:deliver`, Convex) asks the
6
+ * SITE to send the mail, and logs the answer per recipient on the row.
7
+ *
8
+ * Why the site sends it: the transport is the consumer's (Resend over `fetch`, SMTP over
9
+ * nodemailer, …) and the recipients live in the consumer's form document (`defineFormSource`).
10
+ * In Convex an SMTP transport would need a `"use node"` file of its own (only actions may
11
+ * live there) and both the vendor secrets and the form document's shape would move into the
12
+ * deployment; on the site they are where both consumers keep them today, and Convex stays
13
+ * data-only (EC-585 1e). The deployment keeps what the ruling asks of it: the scheduled
14
+ * action per row, the retry, and the per-recipient log — so a failed mail is a visible issue
15
+ * on a row that exists.
16
+ *
17
+ * The request is signed with the INGEST secret pair (`ELYTRA_INGEST_SECRET` on Convex =
18
+ * `INGEST_SECRET` on the site) through the one signer (`@elytracms/core/revalidation`, the
19
+ * same HMAC header as the ingest wire). A delivery body and an ingest body are refused by
20
+ * each other's strict schemas, so one cannot be replayed as the other.
21
+ */
22
+ /** The deployment env var naming the site's delivery route (`https://example.com/api/forms`). */
23
+ export declare const FORMS_DELIVERY_URL_ENV = "ELYTRA_FORMS_DELIVERY_URL";
24
+ /** Automatic attempts per run (an editor's retry starts a new run). */
25
+ export declare const DELIVERY_MAX_ATTEMPTS = 5;
26
+ /** The wait after attempt N failed (N = 1…4): one minute, ten minutes, one hour, six hours. */
27
+ export declare const DELIVERY_BACKOFF_MS: readonly number[];
28
+ /** One file as the site receives it (a URL it may attach or link). */
29
+ export declare const deliveryFileSchema: z.ZodObject<{
30
+ field: z.ZodString;
31
+ name: z.ZodOptional<z.ZodString>;
32
+ contentType: z.ZodString;
33
+ size: z.ZodNumber;
34
+ url: z.ZodString;
35
+ }, z.core.$strict>;
36
+ /** What the deployment sends the site's delivery route. */
37
+ export declare const formDeliveryRequestSchema: z.ZodObject<{
38
+ kind: z.ZodLiteral<"formDelivery">;
39
+ submissionId: z.ZodString;
40
+ form: z.ZodString;
41
+ payload: z.ZodArray<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
+ files: z.ZodArray<z.ZodObject<{
46
+ field: z.ZodString;
47
+ name: z.ZodOptional<z.ZodString>;
48
+ contentType: z.ZodString;
49
+ size: z.ZodNumber;
50
+ url: z.ZodString;
51
+ }, z.core.$strict>>;
52
+ sourceUrl: z.ZodOptional<z.ZodString>;
53
+ submittedAt: z.ZodString;
54
+ attempt: z.ZodNumber;
55
+ delivered: z.ZodArray<z.ZodString>;
56
+ }, z.core.$strict>;
57
+ export type FormDeliveryRequest = z.infer<typeof formDeliveryRequestSchema>;
58
+ /** One recipient's outcome, as the site reports it. */
59
+ export declare const deliveryResultSchema: z.ZodUnion<readonly [z.ZodObject<{
60
+ to: z.ZodString;
61
+ ok: z.ZodLiteral<true>;
62
+ id: z.ZodOptional<z.ZodString>;
63
+ }, z.core.$strict>, z.ZodObject<{
64
+ to: z.ZodString;
65
+ ok: z.ZodLiteral<false>;
66
+ error: z.ZodString;
67
+ }, z.core.$strict>]>;
68
+ export type DeliveryResult = z.infer<typeof deliveryResultSchema>;
69
+ /** The site's 200 answer: one result per recipient it tried (none for an inbox-only form). */
70
+ export declare const formDeliveryResponseSchema: z.ZodObject<{
71
+ results: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
72
+ to: z.ZodString;
73
+ ok: z.ZodLiteral<true>;
74
+ id: z.ZodOptional<z.ZodString>;
75
+ }, z.core.$strict>, z.ZodObject<{
76
+ to: z.ZodString;
77
+ ok: z.ZodLiteral<false>;
78
+ error: z.ZodString;
79
+ }, z.core.$strict>]>>;
80
+ }, z.core.$strict>;
81
+ export type FormDeliveryResponse = z.infer<typeof formDeliveryResponseSchema>;
82
+ /** Sign one delivery request (the deployment's half). */
83
+ export declare function signedFormDeliveryRequest(request: FormDeliveryRequest, secret: string): Promise<{
84
+ body: string;
85
+ headers: Record<string, string>;
86
+ }>;
87
+ /** Verify one delivery request (the site's half) — constant time, the one verifier. */
88
+ export declare function verifyFormDeliverySignature(rawBody: string, signature: string | null | undefined, secret: string): Promise<boolean>;
89
+ export { INGEST_SIGNATURE_HEADER as FORMS_DELIVERY_SIGNATURE_HEADER };
90
+ /** What one attempt came to. */
91
+ export type DeliveryOutcome =
92
+ /** The deployment has no delivery route configured. */
93
+ {
94
+ kind: 'unconfigured';
95
+ message: string;
96
+ }
97
+ /** The site could not be reached (network, timeout). Retryable. */
98
+ | {
99
+ kind: 'unreachable';
100
+ message: string;
101
+ }
102
+ /** The site answered non-2xx. Retryable for 5xx and 429; a 4xx is a configuration or content problem. */
103
+ | {
104
+ kind: 'refused';
105
+ status: number;
106
+ message: string;
107
+ }
108
+ /** The site answered with its per-recipient results. */
109
+ | {
110
+ kind: 'results';
111
+ results: readonly DeliveryResult[];
112
+ };
113
+ /** The outcome as it crosses from the delivery action into the mutation that logs it. */
114
+ export declare const deliveryOutcomeSchema: z.ZodType<DeliveryOutcome>;
115
+ /** The row's delivery after one attempt, and when to try again. */
116
+ export interface DeliveryStep {
117
+ deliveryState: DeliveryState;
118
+ deliveryLog: DeliveryEntry[];
119
+ /** Schedule the next attempt in this many ms (absent: the run is over). */
120
+ retryInMs?: number;
121
+ }
122
+ /**
123
+ * The recipients the row's mail already reached — every successful line of the log, across
124
+ * runs: an attempt (and an editor's retry) re-sends only to the recipients still missing,
125
+ * never twice to one who has it.
126
+ */
127
+ export declare function deliveredRecipients(log: readonly DeliveryEntry[]): string[];
128
+ /**
129
+ * Apply one attempt's outcome to a row's delivery (pure — the Convex mutation and the tests
130
+ * run the same function). `log` is the row's log before the attempt; `delivered` the
131
+ * recipients this run already reached.
132
+ */
133
+ export declare function applyDeliveryOutcome(input: {
134
+ log: readonly DeliveryEntry[];
135
+ attempt: number;
136
+ now: string;
137
+ delivered: readonly string[];
138
+ }, outcome: DeliveryOutcome): DeliveryStep;
139
+ /** How long the deployment waits for the site's answer. */
140
+ export declare const DELIVERY_TIMEOUT_MS = 30000;
141
+ /**
142
+ * One attempt from the deployment's side: sign the request, POST it to the site's delivery
143
+ * route, read the answer into an outcome. Never throws — every failure is an outcome the
144
+ * log records. `fetchImpl` is the transport (the action passes the runtime's `fetch`).
145
+ */
146
+ export declare function attemptFormDelivery(input: {
147
+ request: FormDeliveryRequest;
148
+ url: string | undefined;
149
+ secret: string | undefined;
150
+ fetchImpl?: typeof fetch;
151
+ timeoutMs?: number;
152
+ }): Promise<DeliveryOutcome>;
153
+ /** Does this delivery state need an editor's attention (the inbox's issue)? */
154
+ export declare function deliveryNeedsAttention(state: DeliveryState): boolean;
@@ -0,0 +1,218 @@
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.js';
5
+ /**
6
+ * EC-613 — DELIVERY: one scheduled action per submission (`forms:deliver`, Convex) asks the
7
+ * SITE to send the mail, and logs the answer per recipient on the row.
8
+ *
9
+ * Why the site sends it: the transport is the consumer's (Resend over `fetch`, SMTP over
10
+ * nodemailer, …) and the recipients live in the consumer's form document (`defineFormSource`).
11
+ * In Convex an SMTP transport would need a `"use node"` file of its own (only actions may
12
+ * live there) and both the vendor secrets and the form document's shape would move into the
13
+ * deployment; on the site they are where both consumers keep them today, and Convex stays
14
+ * data-only (EC-585 1e). The deployment keeps what the ruling asks of it: the scheduled
15
+ * action per row, the retry, and the per-recipient log — so a failed mail is a visible issue
16
+ * on a row that exists.
17
+ *
18
+ * The request is signed with the INGEST secret pair (`ELYTRA_INGEST_SECRET` on Convex =
19
+ * `INGEST_SECRET` on the site) through the one signer (`@elytracms/core/revalidation`, the
20
+ * same HMAC header as the ingest wire). A delivery body and an ingest body are refused by
21
+ * each other's strict schemas, so one cannot be replayed as the other.
22
+ */
23
+ /** The deployment env var naming the site's delivery route (`https://example.com/api/forms`). */
24
+ export const FORMS_DELIVERY_URL_ENV = 'ELYTRA_FORMS_DELIVERY_URL';
25
+ /** Automatic attempts per run (an editor's retry starts a new run). */
26
+ export const DELIVERY_MAX_ATTEMPTS = 5;
27
+ /** The wait after attempt N failed (N = 1…4): one minute, ten minutes, one hour, six hours. */
28
+ export const DELIVERY_BACKOFF_MS = [60_000, 10 * 60_000, 60 * 60_000, 6 * 60 * 60_000];
29
+ /** One file as the site receives it (a URL it may attach or link). */
30
+ export const deliveryFileSchema = z.strictObject({
31
+ field: z.string(),
32
+ name: z.string().optional(),
33
+ contentType: z.string(),
34
+ size: z.number(),
35
+ url: z.string(),
36
+ });
37
+ /** What the deployment sends the site's delivery route. */
38
+ export const formDeliveryRequestSchema = z.strictObject({
39
+ kind: z.literal('formDelivery'),
40
+ submissionId: z.string().min(1),
41
+ form: z.string().min(1),
42
+ payload: z.array(submissionFieldSchema),
43
+ files: z.array(deliveryFileSchema),
44
+ sourceUrl: z.string().optional(),
45
+ submittedAt: z.string(),
46
+ attempt: z.number().int().positive(),
47
+ /** Recipients an earlier attempt of this run already reached — never sent twice. */
48
+ delivered: z.array(z.string()),
49
+ });
50
+ /** One recipient's outcome, as the site reports it. */
51
+ export const deliveryResultSchema = z.union([
52
+ z.strictObject({ to: z.string(), ok: z.literal(true), id: z.string().optional() }),
53
+ z.strictObject({ to: z.string(), ok: z.literal(false), error: z.string() }),
54
+ ]);
55
+ /** The site's 200 answer: one result per recipient it tried (none for an inbox-only form). */
56
+ export const formDeliveryResponseSchema = z.strictObject({ results: z.array(deliveryResultSchema) });
57
+ /** Sign one delivery request (the deployment's half). */
58
+ export async function signedFormDeliveryRequest(request, secret) {
59
+ const body = JSON.stringify(formDeliveryRequestSchema.parse(request));
60
+ return { body, headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signRevalidationBody(body, secret) } };
61
+ }
62
+ /** Verify one delivery request (the site's half) — constant time, the one verifier. */
63
+ export function verifyFormDeliverySignature(rawBody, signature, secret) {
64
+ return verifyIngestSignature(rawBody, signature, secret);
65
+ }
66
+ export { INGEST_SIGNATURE_HEADER as FORMS_DELIVERY_SIGNATURE_HEADER };
67
+ /** The outcome as it crosses from the delivery action into the mutation that logs it. */
68
+ export const deliveryOutcomeSchema = z.discriminatedUnion('kind', [
69
+ z.strictObject({ kind: z.literal('unconfigured'), message: z.string() }),
70
+ z.strictObject({ kind: z.literal('unreachable'), message: z.string() }),
71
+ z.strictObject({ kind: z.literal('refused'), status: z.number().int(), message: z.string() }),
72
+ z.strictObject({ kind: z.literal('results'), results: z.array(deliveryResultSchema) }),
73
+ ]);
74
+ const ERROR_MAX = 500;
75
+ function clip(message) {
76
+ return message.length > ERROR_MAX ? `${message.slice(0, ERROR_MAX - 1)}…` : message;
77
+ }
78
+ /**
79
+ * The recipients the row's mail already reached — every successful line of the log, across
80
+ * runs: an attempt (and an editor's retry) re-sends only to the recipients still missing,
81
+ * never twice to one who has it.
82
+ */
83
+ export function deliveredRecipients(log) {
84
+ const delivered = new Set();
85
+ for (const entry of log)
86
+ if (entry.ok && entry.to)
87
+ delivered.add(entry.to);
88
+ return [...delivered];
89
+ }
90
+ function retryable(outcome) {
91
+ switch (outcome.kind) {
92
+ case 'unreachable':
93
+ return true;
94
+ case 'refused':
95
+ return outcome.status >= 500 || outcome.status === 429;
96
+ case 'results':
97
+ return outcome.results.some((result) => !result.ok);
98
+ case 'unconfigured':
99
+ return false;
100
+ }
101
+ }
102
+ /**
103
+ * Apply one attempt's outcome to a row's delivery (pure — the Convex mutation and the tests
104
+ * run the same function). `log` is the row's log before the attempt; `delivered` the
105
+ * recipients this run already reached.
106
+ */
107
+ export function applyDeliveryOutcome(input, outcome) {
108
+ const { attempt, now } = input;
109
+ const again = retryable(outcome) && attempt < DELIVERY_MAX_ATTEMPTS;
110
+ const retryInMs = again ? DELIVERY_BACKOFF_MS[attempt - 1] : undefined;
111
+ const retryAt = retryInMs !== undefined ? new Date(Date.parse(now) + retryInMs).toISOString() : undefined;
112
+ const line = (entry) => ({ at: now, attempt, ...entry });
113
+ const lines = [];
114
+ let reached = [...input.delivered];
115
+ switch (outcome.kind) {
116
+ case 'unconfigured':
117
+ case 'unreachable':
118
+ lines.push(line({ ok: false, error: clip(outcome.message), ...(retryAt ? { retryAt } : {}) }));
119
+ break;
120
+ case 'refused':
121
+ lines.push(line({ ok: false, error: clip(`HTTP ${outcome.status}: ${outcome.message}`), ...(retryAt ? { retryAt } : {}) }));
122
+ break;
123
+ case 'results':
124
+ if (outcome.results.length === 0 && reached.length === 0) {
125
+ lines.push(line({ ok: true, note: 'no recipients' }));
126
+ }
127
+ for (const result of outcome.results) {
128
+ if (result.ok) {
129
+ reached.push(result.to);
130
+ lines.push(line({ to: result.to, ok: true, ...(result.id ? { id: result.id } : {}) }));
131
+ }
132
+ else {
133
+ lines.push(line({ to: result.to, ok: false, error: clip(result.error), ...(retryAt ? { retryAt } : {}) }));
134
+ }
135
+ }
136
+ reached = [...new Set(reached)];
137
+ break;
138
+ }
139
+ const deliveryLog = [...input.log, ...lines].slice(-DELIVERY_LOG_LIMIT);
140
+ if (retryInMs !== undefined)
141
+ return { deliveryState: 'pending', deliveryLog, retryInMs };
142
+ const failedNow = outcome.kind !== 'results' || outcome.results.some((result) => !result.ok);
143
+ let deliveryState;
144
+ if (!failedNow)
145
+ deliveryState = reached.length > 0 ? 'sent' : 'skipped';
146
+ else
147
+ deliveryState = reached.length > 0 ? 'partial' : 'failed';
148
+ return { deliveryState, deliveryLog };
149
+ }
150
+ /** How long the deployment waits for the site's answer. */
151
+ export const DELIVERY_TIMEOUT_MS = 30_000;
152
+ /**
153
+ * One attempt from the deployment's side: sign the request, POST it to the site's delivery
154
+ * route, read the answer into an outcome. Never throws — every failure is an outcome the
155
+ * log records. `fetchImpl` is the transport (the action passes the runtime's `fetch`).
156
+ */
157
+ export async function attemptFormDelivery(input) {
158
+ if (!input.url) {
159
+ return { kind: 'unconfigured', message: `${FORMS_DELIVERY_URL_ENV} is not set on this deployment — no delivery route to send to.` };
160
+ }
161
+ if (!input.secret) {
162
+ return { kind: 'unconfigured', message: 'ELYTRA_INGEST_SECRET is not set on this deployment — the delivery request cannot be signed.' };
163
+ }
164
+ const signed = await signedFormDeliveryRequest(input.request, input.secret);
165
+ const controller = new AbortController();
166
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? DELIVERY_TIMEOUT_MS);
167
+ let response;
168
+ try {
169
+ response = await (input.fetchImpl ?? fetch)(input.url, {
170
+ method: 'POST',
171
+ headers: signed.headers,
172
+ body: signed.body,
173
+ signal: controller.signal,
174
+ });
175
+ }
176
+ catch (error) {
177
+ return { kind: 'unreachable', message: `The delivery route could not be reached: ${error instanceof Error ? error.message : String(error)}` };
178
+ }
179
+ finally {
180
+ clearTimeout(timer);
181
+ }
182
+ const text = await response.text().catch(() => '');
183
+ if (response.status === 401) {
184
+ return {
185
+ kind: 'refused',
186
+ status: 401,
187
+ message: "The site refused the deployment's signature — INGEST_SECRET on the site must equal ELYTRA_INGEST_SECRET.",
188
+ };
189
+ }
190
+ if (!response.ok) {
191
+ let message = text.slice(0, ERROR_MAX);
192
+ try {
193
+ const parsed = JSON.parse(text);
194
+ if (typeof parsed.error === 'string')
195
+ message = parsed.error;
196
+ }
197
+ catch {
198
+ // not JSON — keep the text
199
+ }
200
+ return { kind: 'refused', status: response.status, message: message || `HTTP ${response.status}` };
201
+ }
202
+ let parsed;
203
+ try {
204
+ parsed = JSON.parse(text);
205
+ }
206
+ catch {
207
+ return { kind: 'refused', status: 502, message: 'The delivery route answered 200 with a body that is not JSON.' };
208
+ }
209
+ const answer = formDeliveryResponseSchema.safeParse(parsed);
210
+ if (!answer.success)
211
+ return { kind: 'refused', status: 502, message: 'The delivery route answered 200 with an invalid body.' };
212
+ return { kind: 'results', results: answer.data.results };
213
+ }
214
+ /** Does this delivery state need an editor's attention (the inbox's issue)? */
215
+ export function deliveryNeedsAttention(state) {
216
+ return state === 'failed' || state === 'partial';
217
+ }
218
+ //# sourceMappingURL=delivery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery.js","sourceRoot":"","sources":["../../src/shared/delivery.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from 'zod'\nimport { INGEST_SIGNATURE_HEADER, verifyIngestSignature } from '@elytracms/core/ingest'\nimport { signRevalidationBody } from '@elytracms/core/revalidation'\nimport { DELIVERY_LOG_LIMIT, submissionFieldSchema } from './kind'\nimport type { DeliveryEntry, DeliveryState } from './kind'\n\n/**\n * EC-613 — DELIVERY: one scheduled action per submission (`forms:deliver`, Convex) asks the\n * SITE to send the mail, and logs the answer per recipient on the row.\n *\n * Why the site sends it: the transport is the consumer's (Resend over `fetch`, SMTP over\n * nodemailer, …) and the recipients live in the consumer's form document (`defineFormSource`).\n * In Convex an SMTP transport would need a `\"use node\"` file of its own (only actions may\n * live there) and both the vendor secrets and the form document's shape would move into the\n * deployment; on the site they are where both consumers keep them today, and Convex stays\n * data-only (EC-585 1e). The deployment keeps what the ruling asks of it: the scheduled\n * action per row, the retry, and the per-recipient log — so a failed mail is a visible issue\n * on a row that exists.\n *\n * The request is signed with the INGEST secret pair (`ELYTRA_INGEST_SECRET` on Convex =\n * `INGEST_SECRET` on the site) through the one signer (`@elytracms/core/revalidation`, the\n * same HMAC header as the ingest wire). A delivery body and an ingest body are refused by\n * each other's strict schemas, so one cannot be replayed as the other.\n */\n\n/** The deployment env var naming the site's delivery route (`https://example.com/api/forms`). */\nexport const FORMS_DELIVERY_URL_ENV = 'ELYTRA_FORMS_DELIVERY_URL'\n\n/** Automatic attempts per run (an editor's retry starts a new run). */\nexport const DELIVERY_MAX_ATTEMPTS = 5\n/** The wait after attempt N failed (N = 1…4): one minute, ten minutes, one hour, six hours. */\nexport const DELIVERY_BACKOFF_MS: readonly number[] = [60_000, 10 * 60_000, 60 * 60_000, 6 * 60 * 60_000]\n\n/** One file as the site receives it (a URL it may attach or link). */\nexport const deliveryFileSchema = z.strictObject({\n field: z.string(),\n name: z.string().optional(),\n contentType: z.string(),\n size: z.number(),\n url: z.string(),\n})\n\n/** What the deployment sends the site's delivery route. */\nexport const formDeliveryRequestSchema = z.strictObject({\n kind: z.literal('formDelivery'),\n submissionId: z.string().min(1),\n form: z.string().min(1),\n payload: z.array(submissionFieldSchema),\n files: z.array(deliveryFileSchema),\n sourceUrl: z.string().optional(),\n submittedAt: z.string(),\n attempt: z.number().int().positive(),\n /** Recipients an earlier attempt of this run already reached — never sent twice. */\n delivered: z.array(z.string()),\n})\nexport type FormDeliveryRequest = z.infer<typeof formDeliveryRequestSchema>\n\n/** One recipient's outcome, as the site reports it. */\nexport const deliveryResultSchema = z.union([\n z.strictObject({ to: z.string(), ok: z.literal(true), id: z.string().optional() }),\n z.strictObject({ to: z.string(), ok: z.literal(false), error: z.string() }),\n])\nexport type DeliveryResult = z.infer<typeof deliveryResultSchema>\n\n/** The site's 200 answer: one result per recipient it tried (none for an inbox-only form). */\nexport const formDeliveryResponseSchema = z.strictObject({ results: z.array(deliveryResultSchema) })\nexport type FormDeliveryResponse = z.infer<typeof formDeliveryResponseSchema>\n\n/** Sign one delivery request (the deployment's half). */\nexport async function signedFormDeliveryRequest(\n request: FormDeliveryRequest,\n secret: string,\n): Promise<{ body: string; headers: Record<string, string> }> {\n const body = JSON.stringify(formDeliveryRequestSchema.parse(request))\n return { body, headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signRevalidationBody(body, secret) } }\n}\n\n/** Verify one delivery request (the site's half) — constant time, the one verifier. */\nexport function verifyFormDeliverySignature(rawBody: string, signature: string | null | undefined, secret: string): Promise<boolean> {\n return verifyIngestSignature(rawBody, signature, secret)\n}\n\nexport { INGEST_SIGNATURE_HEADER as FORMS_DELIVERY_SIGNATURE_HEADER }\n\n// --- The state machine ---------------------------------------------------------------------\n\n/** What one attempt came to. */\nexport type DeliveryOutcome =\n /** The deployment has no delivery route configured. */\n | { kind: 'unconfigured'; message: string }\n /** The site could not be reached (network, timeout). Retryable. */\n | { kind: 'unreachable'; message: string }\n /** The site answered non-2xx. Retryable for 5xx and 429; a 4xx is a configuration or content problem. */\n | { kind: 'refused'; status: number; message: string }\n /** The site answered with its per-recipient results. */\n | { kind: 'results'; results: readonly DeliveryResult[] }\n\n/** The outcome as it crosses from the delivery action into the mutation that logs it. */\nexport const deliveryOutcomeSchema: z.ZodType<DeliveryOutcome> = z.discriminatedUnion('kind', [\n z.strictObject({ kind: z.literal('unconfigured'), message: z.string() }),\n z.strictObject({ kind: z.literal('unreachable'), message: z.string() }),\n z.strictObject({ kind: z.literal('refused'), status: z.number().int(), message: z.string() }),\n z.strictObject({ kind: z.literal('results'), results: z.array(deliveryResultSchema) }),\n])\n\n/** The row's delivery after one attempt, and when to try again. */\nexport interface DeliveryStep {\n deliveryState: DeliveryState\n deliveryLog: DeliveryEntry[]\n /** Schedule the next attempt in this many ms (absent: the run is over). */\n retryInMs?: number\n}\n\nconst ERROR_MAX = 500\n\nfunction clip(message: string): string {\n return message.length > ERROR_MAX ? `${message.slice(0, ERROR_MAX - 1)}…` : message\n}\n\n/**\n * The recipients the row's mail already reached — every successful line of the log, across\n * runs: an attempt (and an editor's retry) re-sends only to the recipients still missing,\n * never twice to one who has it.\n */\nexport function deliveredRecipients(log: readonly DeliveryEntry[]): string[] {\n const delivered = new Set<string>()\n for (const entry of log) if (entry.ok && entry.to) delivered.add(entry.to)\n return [...delivered]\n}\n\nfunction retryable(outcome: DeliveryOutcome): boolean {\n switch (outcome.kind) {\n case 'unreachable':\n return true\n case 'refused':\n return outcome.status >= 500 || outcome.status === 429\n case 'results':\n return outcome.results.some((result) => !result.ok)\n case 'unconfigured':\n return false\n }\n}\n\n/**\n * Apply one attempt's outcome to a row's delivery (pure — the Convex mutation and the tests\n * run the same function). `log` is the row's log before the attempt; `delivered` the\n * recipients this run already reached.\n */\nexport function applyDeliveryOutcome(\n input: { log: readonly DeliveryEntry[]; attempt: number; now: string; delivered: readonly string[] },\n outcome: DeliveryOutcome,\n): DeliveryStep {\n const { attempt, now } = input\n const again = retryable(outcome) && attempt < DELIVERY_MAX_ATTEMPTS\n const retryInMs = again ? DELIVERY_BACKOFF_MS[attempt - 1] : undefined\n const retryAt = retryInMs !== undefined ? new Date(Date.parse(now) + retryInMs).toISOString() : undefined\n const line = (entry: Omit<DeliveryEntry, 'at' | 'attempt'>): DeliveryEntry => ({ at: now, attempt, ...entry })\n\n const lines: DeliveryEntry[] = []\n let reached = [...input.delivered]\n switch (outcome.kind) {\n case 'unconfigured':\n case 'unreachable':\n lines.push(line({ ok: false, error: clip(outcome.message), ...(retryAt ? { retryAt } : {}) }))\n break\n case 'refused':\n lines.push(line({ ok: false, error: clip(`HTTP ${outcome.status}: ${outcome.message}`), ...(retryAt ? { retryAt } : {}) }))\n break\n case 'results':\n if (outcome.results.length === 0 && reached.length === 0) {\n lines.push(line({ ok: true, note: 'no recipients' }))\n }\n for (const result of outcome.results) {\n if (result.ok) {\n reached.push(result.to)\n lines.push(line({ to: result.to, ok: true, ...(result.id ? { id: result.id } : {}) }))\n } else {\n lines.push(line({ to: result.to, ok: false, error: clip(result.error), ...(retryAt ? { retryAt } : {}) }))\n }\n }\n reached = [...new Set(reached)]\n break\n }\n\n const deliveryLog = [...input.log, ...lines].slice(-DELIVERY_LOG_LIMIT)\n if (retryInMs !== undefined) return { deliveryState: 'pending', deliveryLog, retryInMs }\n const failedNow = outcome.kind !== 'results' || outcome.results.some((result) => !result.ok)\n let deliveryState: DeliveryState\n if (!failedNow) deliveryState = reached.length > 0 ? 'sent' : 'skipped'\n else deliveryState = reached.length > 0 ? 'partial' : 'failed'\n return { deliveryState, deliveryLog }\n}\n\n/** How long the deployment waits for the site's answer. */\nexport const DELIVERY_TIMEOUT_MS = 30_000\n\n/**\n * One attempt from the deployment's side: sign the request, POST it to the site's delivery\n * route, read the answer into an outcome. Never throws — every failure is an outcome the\n * log records. `fetchImpl` is the transport (the action passes the runtime's `fetch`).\n */\nexport async function attemptFormDelivery(input: {\n request: FormDeliveryRequest\n url: string | undefined\n secret: string | undefined\n fetchImpl?: typeof fetch\n timeoutMs?: number\n}): Promise<DeliveryOutcome> {\n if (!input.url) {\n return { kind: 'unconfigured', message: `${FORMS_DELIVERY_URL_ENV} is not set on this deployment — no delivery route to send to.` }\n }\n if (!input.secret) {\n return { kind: 'unconfigured', message: 'ELYTRA_INGEST_SECRET is not set on this deployment — the delivery request cannot be signed.' }\n }\n const signed = await signedFormDeliveryRequest(input.request, input.secret)\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? DELIVERY_TIMEOUT_MS)\n let response: Response\n try {\n response = await (input.fetchImpl ?? fetch)(input.url, {\n method: 'POST',\n headers: signed.headers,\n body: signed.body,\n signal: controller.signal,\n })\n } catch (error) {\n return { kind: 'unreachable', message: `The delivery route could not be reached: ${error instanceof Error ? error.message : String(error)}` }\n } finally {\n clearTimeout(timer)\n }\n const text = await response.text().catch(() => '')\n if (response.status === 401) {\n return {\n kind: 'refused',\n status: 401,\n message: \"The site refused the deployment's signature — INGEST_SECRET on the site must equal ELYTRA_INGEST_SECRET.\",\n }\n }\n if (!response.ok) {\n let message = text.slice(0, ERROR_MAX)\n try {\n const parsed = JSON.parse(text) as { error?: unknown }\n if (typeof parsed.error === 'string') message = parsed.error\n } catch {\n // not JSON — keep the text\n }\n return { kind: 'refused', status: response.status, message: message || `HTTP ${response.status}` }\n }\n let parsed: unknown\n try {\n parsed = JSON.parse(text)\n } catch {\n return { kind: 'refused', status: 502, message: 'The delivery route answered 200 with a body that is not JSON.' }\n }\n const answer = formDeliveryResponseSchema.safeParse(parsed)\n if (!answer.success) return { kind: 'refused', status: 502, message: 'The delivery route answered 200 with an invalid body.' }\n return { kind: 'results', results: answer.data.results }\n}\n\n/** Does this delivery state need an editor's attention (the inbox's issue)? */\nexport function deliveryNeedsAttention(state: DeliveryState): boolean {\n return state === 'failed' || state === 'partial'\n}\n"]}