@10x-media/form-builder 0.1.0-beta.6 → 0.1.0-beta.8

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 (174) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/actions/body/serializeBody.d.ts +3 -1
  3. package/dist/actions/body/serializeBody.js.map +1 -1
  4. package/dist/actions/buildActionBlocks.js +12 -9
  5. package/dist/actions/buildActionBlocks.js.map +1 -1
  6. package/dist/actions/builtin/confirmation.js +13 -68
  7. package/dist/actions/builtin/confirmation.js.map +1 -1
  8. package/dist/actions/builtin/emailAction.d.ts +17 -0
  9. package/dist/actions/builtin/emailAction.js +83 -0
  10. package/dist/actions/builtin/emailAction.js.map +1 -0
  11. package/dist/actions/builtin/emailTeam.js +14 -74
  12. package/dist/actions/builtin/emailTeam.js.map +1 -1
  13. package/dist/actions/builtin/index.d.ts +9 -12
  14. package/dist/actions/builtin/index.js +11 -11
  15. package/dist/actions/builtin/index.js.map +1 -1
  16. package/dist/actions/defineAction.d.ts +2 -2
  17. package/dist/actions/defineAction.js.map +1 -1
  18. package/dist/actions/emailRecipients.js +75 -31
  19. package/dist/actions/emailRecipients.js.map +1 -1
  20. package/dist/actions/registry.d.ts +2 -1
  21. package/dist/actions/registry.js +2 -6
  22. package/dist/actions/registry.js.map +1 -1
  23. package/dist/actions/task.js +5 -3
  24. package/dist/actions/task.js.map +1 -1
  25. package/dist/calc/evaluate.js +1 -2
  26. package/dist/calc/evaluate.js.map +1 -1
  27. package/dist/calc/normalizeCalc.js +1 -2
  28. package/dist/calc/normalizeCalc.js.map +1 -1
  29. package/dist/calc/types.js.map +1 -1
  30. package/dist/collections/buttonFields.js +1 -1
  31. package/dist/collections/formSubmissions.js +2 -1
  32. package/dist/collections/formSubmissions.js.map +1 -1
  33. package/dist/collections/forms.js +32 -100
  34. package/dist/collections/forms.js.map +1 -1
  35. package/dist/collections/formsEndpoints.js +101 -0
  36. package/dist/collections/formsEndpoints.js.map +1 -0
  37. package/dist/conditions/evaluate.js +1 -1
  38. package/dist/conditions/evaluate.js.map +1 -1
  39. package/dist/conditions/fieldTypes.d.ts +3 -1
  40. package/dist/conditions/fieldTypes.js +4 -1
  41. package/dist/conditions/fieldTypes.js.map +1 -1
  42. package/dist/conditions/normalizeConditions.js +18 -5
  43. package/dist/conditions/normalizeConditions.js.map +1 -1
  44. package/dist/consent/captureConsent.d.ts +7 -0
  45. package/dist/consent/captureConsent.js +7 -0
  46. package/dist/consent/captureConsent.js.map +1 -1
  47. package/dist/consent/consentSourcesField.d.ts +4 -0
  48. package/dist/consent/consentSourcesField.js +5 -1
  49. package/dist/consent/consentSourcesField.js.map +1 -1
  50. package/dist/consent/resolveConsentEntries.d.ts +1 -1
  51. package/dist/consent/resolveConsentEntries.js +1 -4
  52. package/dist/consent/resolveConsentEntries.js.map +1 -1
  53. package/dist/consent/resolveConsentSourcesRequest.js +0 -1
  54. package/dist/consent/resolveConsentSourcesRequest.js.map +1 -1
  55. package/dist/consent/resolveConsentStatements.d.ts +1 -1
  56. package/dist/consent/resolveConsentStatements.js.map +1 -1
  57. package/dist/consent/types.d.ts +9 -1
  58. package/dist/email/departmentsField.js +1 -1
  59. package/dist/exports/react.d.ts +5 -4
  60. package/dist/exports/react.js +3 -2
  61. package/dist/exports/rsc.js +1 -1
  62. package/dist/exports/types.d.ts +1 -1
  63. package/dist/fields/builtin/email.js +1 -1
  64. package/dist/fields/builtin/email.js.map +1 -1
  65. package/dist/fields/builtin/message.js +1 -1
  66. package/dist/fields/builtin/repeater.js +1 -1
  67. package/dist/fields/builtin/select.js +1 -1
  68. package/dist/fields/registry.d.ts +2 -1
  69. package/dist/fields/registry.js +2 -6
  70. package/dist/fields/registry.js.map +1 -1
  71. package/dist/fields/sharedConfig.js +1 -1
  72. package/dist/flow/normalizeFlow.js +7 -3
  73. package/dist/flow/normalizeFlow.js.map +1 -1
  74. package/dist/index.d.ts +4 -201
  75. package/dist/index.js +11 -4
  76. package/dist/index.js.map +1 -1
  77. package/dist/options.d.ts +229 -0
  78. package/dist/plugin/applyRegistryConfig.d.ts +11 -0
  79. package/dist/plugin/applyRegistryConfig.js +20 -0
  80. package/dist/plugin/applyRegistryConfig.js.map +1 -0
  81. package/dist/plugin/registerCollections.js +2 -1
  82. package/dist/plugin/registerCollections.js.map +1 -1
  83. package/dist/poll/buildPollOptionSourceFields.js +2 -2
  84. package/dist/poll/buildPollOptionSourceFields.js.map +1 -1
  85. package/dist/poll/definePollOptionSource.d.ts +2 -2
  86. package/dist/poll/definePollOptionSource.js.map +1 -1
  87. package/dist/poll/pollTypeRegistry.js +2 -4
  88. package/dist/poll/pollTypeRegistry.js.map +1 -1
  89. package/dist/poll/registry.d.ts +2 -1
  90. package/dist/poll/registry.js +2 -6
  91. package/dist/poll/registry.js.map +1 -1
  92. package/dist/poll/resolvePollOptionsRequest.js +0 -1
  93. package/dist/poll/resolvePollOptionsRequest.js.map +1 -1
  94. package/dist/prefill/valuesFromSearchParams.js +2 -7
  95. package/dist/prefill/valuesFromSearchParams.js.map +1 -1
  96. package/dist/react/Form.d.ts +39 -3
  97. package/dist/react/Form.js +148 -164
  98. package/dist/react/Form.js.map +1 -1
  99. package/dist/react/FormContext.d.ts +7 -1
  100. package/dist/react/FormContext.js.map +1 -1
  101. package/dist/react/FormFields.d.ts +18 -0
  102. package/dist/react/FormFields.js +114 -0
  103. package/dist/react/FormFields.js.map +1 -0
  104. package/dist/react/FormLayout.d.ts +4 -2
  105. package/dist/react/FormLayout.js +3 -2
  106. package/dist/react/FormLayout.js.map +1 -1
  107. package/dist/react/Poll.js +15 -5
  108. package/dist/react/Poll.js.map +1 -1
  109. package/dist/react/presentation/DialogSurface.js +1 -4
  110. package/dist/react/presentation/DialogSurface.js.map +1 -1
  111. package/dist/react/renderers/calculation.js +1 -3
  112. package/dist/react/renderers/calculation.js.map +1 -1
  113. package/dist/react/renderers/checkbox.js +1 -3
  114. package/dist/react/renderers/checkbox.js.map +1 -1
  115. package/dist/react/renderers/consent.js +2 -3
  116. package/dist/react/renderers/consent.js.map +1 -1
  117. package/dist/react/renderers/date.js +1 -3
  118. package/dist/react/renderers/date.js.map +1 -1
  119. package/dist/react/renderers/email.js +1 -3
  120. package/dist/react/renderers/email.js.map +1 -1
  121. package/dist/react/renderers/file.js +6 -7
  122. package/dist/react/renderers/file.js.map +1 -1
  123. package/dist/react/renderers/message.js +8 -2
  124. package/dist/react/renderers/message.js.map +1 -1
  125. package/dist/react/renderers/number.js +1 -3
  126. package/dist/react/renderers/number.js.map +1 -1
  127. package/dist/react/renderers/region.js +1 -3
  128. package/dist/react/renderers/region.js.map +1 -1
  129. package/dist/react/renderers/repeater.js +20 -4
  130. package/dist/react/renderers/repeater.js.map +1 -1
  131. package/dist/react/renderers/select.js +1 -3
  132. package/dist/react/renderers/select.js.map +1 -1
  133. package/dist/react/renderers/text.js +1 -3
  134. package/dist/react/renderers/text.js.map +1 -1
  135. package/dist/react/renderers/textarea.js +1 -3
  136. package/dist/react/renderers/textarea.js.map +1 -1
  137. package/dist/react/state.d.ts +3 -0
  138. package/dist/react/state.js +1 -0
  139. package/dist/react/state.js.map +1 -1
  140. package/dist/spam/constants.js +7 -1
  141. package/dist/spam/constants.js.map +1 -1
  142. package/dist/spam/resolveSpam.js +2 -1
  143. package/dist/spam/resolveSpam.js.map +1 -1
  144. package/dist/spam/spamGuard.js +22 -6
  145. package/dist/spam/spamGuard.js.map +1 -1
  146. package/dist/spam/types.d.ts +17 -2
  147. package/dist/submissions/coerceBoolean.js +14 -0
  148. package/dist/submissions/coerceBoolean.js.map +1 -0
  149. package/dist/submissions/runSubmission.js +7 -7
  150. package/dist/submissions/runSubmission.js.map +1 -1
  151. package/dist/submissions/validateSubmission.js +3 -2
  152. package/dist/submissions/validateSubmission.js.map +1 -1
  153. package/dist/translations/de.js +10 -0
  154. package/dist/translations/de.js.map +1 -1
  155. package/dist/translations/en.js +10 -0
  156. package/dist/translations/en.js.map +1 -1
  157. package/dist/translations/keys.d.ts +10 -0
  158. package/dist/translations/keys.js +10 -0
  159. package/dist/translations/keys.js.map +1 -1
  160. package/dist/translations/server.js +8 -1
  161. package/dist/translations/server.js.map +1 -1
  162. package/dist/uploads/captureFileRef.d.ts +10 -3
  163. package/dist/uploads/captureFileRef.js +18 -9
  164. package/dist/uploads/captureFileRef.js.map +1 -1
  165. package/dist/validation/builtin/email.js +1 -1
  166. package/dist/validation/builtin/email.js.map +1 -1
  167. package/dist/validation/builtin/notAlreadySubmitted.js +1 -0
  168. package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
  169. package/dist/validation/emailPattern.js +10 -0
  170. package/dist/validation/emailPattern.js.map +1 -0
  171. package/dist/validation/registry.d.ts +2 -1
  172. package/dist/validation/registry.js +2 -9
  173. package/dist/validation/registry.js.map +1 -1
  174. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,89 @@
1
1
  # @10x-media/form-builder
2
2
 
3
+ ## 0.1.0-beta.8
4
+
5
+ ### Minor Changes
6
+
7
+ - Second feedback round: submit control, step field order, response editor, and host-rendered success.
8
+
9
+ - **Implicit Enter-submit is suppressed on multi-step forms.** A form-level key handler prevents a lone text input from submitting the whole form on Enter; only the explicit Submit control submits. Textareas (newline), selects (confirm), and buttons are exempt, and single-step forms keep native Enter-to-submit. The `<form>` is the plugin's even in `children` mode, so this is the only place a host could apply the guard.
10
+ - **Step fields render in `form.fields` order.** A step's `fields` list is treated as membership only; the render order follows the form's field order rather than the flow-builder entry order.
11
+ - **`richText.responseEditor`.** The success response message field's editor can now be set independently via `richText.responseEditor` (falling back to `richText.editor`), mirroring `bodyEditor` for action bodies. The message block stays on the plugin-wide `editor`.
12
+ - **Host-rendered success.** `onSuccess` gains a second argument, `{ response }`, carrying the recall-resolved success response (a redirect url, or the message serialized with the form's converters), so a host can toast or render it; `<Poll>` forwards the same payload to its host. A new `converters` prop on `<Form>` threads custom block converters (e.g. host `icon`/`badge` blocks) into the client serializer, so those blocks survive in the success message, the resolved response, and inline `message` blocks. `successBehavior: 'reset'` clears the form in place after a successful submit instead of swapping in the success screen (returning a multi-step form to its first step), so a host can keep the form usable and give feedback via `onSuccess`. Defaults (`onSuccess`'s first argument, `successBehavior: 'replace'`) are unchanged. New exported types: `FormSuccessResponse`, `FormSuccessResult`.
13
+
14
+ ## 0.1.0-beta.7
15
+
16
+ ### Minor Changes
17
+
18
+ - Composition seam, consent re-entrance safety, and server-side recipient enforcement.
19
+
20
+ - **`<FormFields>` + `<Form header>`.** The standard field loop is now an exported `<FormFields>`
21
+ component, so a custom `children` layout can compose `<Form><FormSteps /><FormFields />
22
+ <FormControls /></Form>` without reimplementing visibility, calc, recall, and step filtering. For
23
+ the common case, `<Form header={<FormSteps />} />` places chrome above the fields while keeping all
24
+ default behavior. `recall` is now available on the form context.
25
+ - **Consent resolver receives the whole form document.** A multi-tenant `consent.sources` resolver
26
+ derives its tenant from a field on the provided doc instead of reading the form back. The consent
27
+ `afterRead` hook now guards against resolver re-entrance (a per-id `Set` on `req.context`), so a
28
+ resolver that does read the form back can no longer recurse.
29
+ - **`email.recipients.allowCustom: false` is enforced on the server.** When set, a non-token
30
+ recipient must be one of the resolved department options, validated at save and fail-closed
31
+ (mirroring the `from` field). The default (`allowCustom` true) is unchanged.
32
+
33
+ - Two new controls for consent reads and upload ownership.
34
+
35
+ - **`consent.resolveOnRead`** (default `true`) gates the afterRead hook that resolves each form's consent statements on every read. Set it `false` to skip that per-read source lookup (N queries on a list view) and resolve statements yourself with the exported `resolveConsentStatements` when you render a form. Submit-time proof capture is unaffected either way, since it always re-resolves from the source.
36
+ - **`spam.uploadOwnership`** (default `'lenient'`) controls how strictly a submitted file's upload ownership is enforced. `'strict'` additionally rejects a stamped upload whenever the submitter cannot be identified, so a deployment that always identifies requests (a trusted proxy header, or a custom `identify`) never lets an unverifiable claim capture an owned upload. `'lenient'` keeps the current fail-open behavior for unidentifiable submitters.
37
+
38
+ ### Patch Changes
39
+
40
+ - Consolidate the built-in email actions. `emailTeam` and `confirmation` were ~80% identical (the same recipient fields, subject/body config, from-field insertion, and send logic) duplicated across two files, each built through a five-positional-argument builder that needed a `useMaxParams` lint suppression. Both now share one `buildEmailAction` skeleton and one `EmailActionOptions` object; each action supplies only what differs (its `to` target and whether a missing recipient throws or is skipped). Behavior is unchanged, verified by both actions' full existing test suites passing against the shared implementation.
41
+
42
+ - Condition and flow correctness.
43
+
44
+ - `file` and `repeater` fields now offer a presence-only condition (exists / does not exist) in the builder, instead of the text/select operators they cannot actually satisfy (a `FileRef` object or a row array only stringifies). A presence check also treats an empty repeater as absent.
45
+ - Flow transition `when` clauses are laundered the same way field conditions are: a transition referencing a field that was later deleted is dropped, rather than persisting as an always-true route that force-navigates the visitor. Valid transition conditions are canonicalized to the same OR-of-ANDs shape as field conditions.
46
+
47
+ - Correctness and security hardening from an internal audit.
48
+
49
+ - **Registry key forced** on custom `actions` and `poll.sources`: a definition registered under a key that differs from its `type` no longer silently never runs.
50
+ - **Recipient injection closed**: a resolved `{{field}}` recipient is sanitized to a single address, dropping CR/LF header injection and any extra addresses a crafted submission value could smuggle in.
51
+ - **`email.recipients.fieldTokens: false`** is now enforced on the server, not just the client.
52
+ - **Consent integrity**: prefill and submit-time boolean coercion share one truthy allow-list, so a stray string value (e.g. from a non-React client) is never read as consent.
53
+ - **Cross-tenant leak closed**: the consent-sources and poll-options endpoints load the form under the caller's own read access (no `overrideAccess`), so an authenticated user cannot enumerate another tenant's form.
54
+ - **Rate-limit skip surfaced**: when the request identity is null the limiter still fails open, but the skip is recorded on `meta.spam.rateLimit` and warned once instead of being silent.
55
+ - **Honeypot collision fixed**: the decoy is submitted under a fixed reserved key, so a real field sharing the decoy's DOM input name (e.g. `website`) is never stripped or mistaken for the honeypot.
56
+ - **Action failures logged**: a failed post-submit email or webhook is logged per action instead of being swallowed while the submission looks fully successful.
57
+
58
+ - DX and robustness cleanup from the package audit.
59
+
60
+ - **Renderers honor the passed `id` prop.** Eleven built-in field renderers minted their own `useId()` and ignored the `id` the render host already generates and passes, so each field ran two id hooks and the documented contract prop was dead. They now use the passed `id` (the host stays the single source; a custom host can control it). `repeater` and `message` already conformed.
61
+ - **The overlay backdrop no longer fires `onClose` twice.** A backdrop click was handled both by the backdrop's own `onClick` and by the surface's outside-pointerdown dismiss, so it closed twice; the backdrop's handler also ignored `closeOnOutsideClick: false`. The backdrop is now purely presentational and dismissal is owned solely by the dismiss hook, which respects the flag.
62
+ - **Polls surface a results-load failure.** A failed results fetch was stored as an empty array and rendered as a zero-count "no votes yet" table. It now shows a localized error instead of masking the failure.
63
+ - **`captureFileRef` gains unit coverage** for its ownership match/mismatch, unidentified-submitter fail-open, unstamped-upload passthrough, and failed-load cases.
64
+ - **The consent proof documents its time-of-check/time-of-use window** (wording is read at form load but re-resolved at submit) so the intended behavior is not mistaken for a bug.
65
+
66
+ - Split the two largest source files for maintainability, with no behavior change.
67
+
68
+ - The `FormBuilderPluginOptions` type (nearly 200 lines of the plugin's public option surface, with its doc comments) moves out of `index.ts` into a dedicated `src/options.ts`, leaving `index.ts` focused on the plugin factory and the public barrel. The type is still exported under the same name (and its `PluginOptions` alias).
69
+ - The forms collection's built-in endpoints (poll results/options/close, plus the optional consent-sources, from-addresses, and departments endpoints) move out of `buildFormsCollection` into `buildFormsEndpoints`, so the collection builder stays focused on field and hook composition.
70
+
71
+ `index.ts` drops from ~558 to ~346 lines and `forms.ts` from ~845 to ~743; the moved code is unchanged and every existing unit, integration, and cross-DB test passes against it.
72
+
73
+ - Localize the last hard-coded English strings in the public form renderer. The file field's attached-file indicator, in-flight "Uploading" status, upload-failure fallback, and remove control, plus the form's close-control label, success message, and submit-failure fallback, now resolve through the same `t` the rest of the renderer already uses (bundled `en`/`de`, host-overridable per locale) instead of baked-in English. A German (or any) `t` now localizes them; behavior under the default English `t` is unchanged.
74
+
75
+ - Consolidate the registry and constant plumbing.
76
+
77
+ - The field-type, validation-rule, action, poll-source, and poll-type registries all resolved their opt-in override map (`false` removes, `true` keeps, a definition adds/replaces with `type` forced to the key) with their own copy of the same loop. That loop now lives in one shared `applyRegistryConfig` helper, so the merge semantics are defined once instead of five times.
78
+ - Definition `label` contracts are aligned: actions and poll option sources now accept a per-locale `Record<string, string>` label in addition to a string key/literal, matching poll types. All three resolve through one shared `resolveDefinitionLabel` helper. Widening only, so existing string labels are unaffected.
79
+ - The email-format regex (duplicated between the `email` field type and the `email` validation rule) and the calc recursion-depth guard (duplicated between the calc parser and evaluator) are each a single shared constant now, so the pair can no longer drift.
80
+
81
+ - Repeater correctness fixes.
82
+
83
+ - Multi-step forms now validate a step's repeater sub-fields on **Next**, so an invalid required sub-field can no longer be skipped past; it surfaces inline, mirroring submit and the server. `goNext` also gains a re-entrancy guard so a double-click cannot push the same step onto history twice.
84
+ - Editing a repeater sub-field clears its stale server-side error immediately, instead of leaving it shown until the next submit.
85
+ - Repeater rows carry a stable React key, so removing a middle row no longer strands a stateful sub-renderer's local state (e.g. the file renderer's filename) onto the wrong row.
86
+
3
87
  ## 0.1.0-beta.6
4
88
 
5
89
  ### Minor Changes
@@ -33,13 +33,15 @@ type SerializeBodyArgs = {
33
33
  * off to a renderer like react-email). `editor` is the default Lexical/richText editor for every
34
34
  * plugin-authored richText field: message content, consent statement, the response message, and
35
35
  * the action body fields. `bodyEditor` overrides the action body fields specifically (emailTeam
36
- * and confirmation), falling back to `editor` when absent.
36
+ * and confirmation), and `responseEditor` overrides the success `response` message field; both fall
37
+ * back to `editor` when absent.
37
38
  */
38
39
  type RichTextBodyOption = {
39
40
  converters?: Record<string, BodyConverter>;
40
41
  serialize?: (args: SerializeBodyArgs) => Promise<string> | string;
41
42
  editor?: RichTextField['editor'];
42
43
  bodyEditor?: RichTextField['editor'];
44
+ responseEditor?: RichTextField['editor'];
43
45
  };
44
46
  /**
45
47
  * Serialize an action's `body` config into HTML. A legacy string body is interpolated as-is
@@ -1 +1 @@
1
- {"version":3,"file":"serializeBody.js","names":[],"sources":["../../../src/actions/body/serializeBody.ts"],"sourcesContent":["import type { PayloadRequest, RichTextField } from 'payload'\nimport { interpolate } from '../../recall/interpolate'\nimport type { SubmissionDescriptor, SubmissionValue } from '../../submissions/types'\nimport type { BodyConverter, BodyRender } from './converters'\nimport { defaultBodyConverters } from './converters'\nimport { escapeHtml } from './escapeHtml'\nimport { serializeSlate } from './serializeSlate'\nimport { renderAllValues, renderAllValuesTable } from './wildcards'\n\n/** Minimal form identity threaded alongside a rendered body (e.g. per-tenant template lookups). */\ntype SerializeBodyForm = { id: number | string; title?: string }\n\n/** Submission data plus optional converter overrides available while serializing a body. */\nexport type BodyContext = {\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tconverters?: Record<string, BodyConverter>\n}\n\n/**\n * Args a custom `richText.serialize` replacement receives per rendered body. Always populated by\n * `makeRenderBody` (the action-body pipeline): `form` and `req` enable per-tenant template\n * lookups or handing the body off to a renderer like react-email.\n */\nexport type SerializeBodyArgs = {\n\tbody: unknown\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tform: SerializeBodyForm\n\treq?: PayloadRequest\n}\n\n/**\n * Customizes how the plugin's rich text is authored and rendered. `converters` spread over the\n * default Lexical node converters; `serialize` replaces the whole action-body pipeline (for\n * non-HTML channels like chat or plain text, or to hand the body plus the submitted `form`/`req`\n * off to a renderer like react-email). `editor` is the default Lexical/richText editor for every\n * plugin-authored richText field: message content, consent statement, the response message, and\n * the action body fields. `bodyEditor` overrides the action body fields specifically (emailTeam\n * and confirmation), falling back to `editor` when absent.\n */\nexport type RichTextBodyOption = {\n\tconverters?: Record<string, BodyConverter>\n\tserialize?: (args: SerializeBodyArgs) => Promise<string> | string\n\teditor?: RichTextField['editor']\n\tbodyEditor?: RichTextField['editor']\n}\n\n/** Recall resolver over submission values: field name to stringified value, `''` when absent. */\nexport const resolverFor =\n\t(values: SubmissionValue[]) =>\n\t(name: string): string => {\n\t\tconst entry = values.find((value) => value.field === name)\n\t\treturn entry == null ? '' : String(entry.value ?? '')\n\t}\n\nconst renderFor = (ctx: BodyContext): BodyRender => {\n\tconst resolve = resolverFor(ctx.values)\n\tconst htmlResolve = (name: string): string => {\n\t\tif (name === '*') {\n\t\t\treturn renderAllValues(ctx.values, ctx.descriptors)\n\t\t}\n\t\tif (name === '*:table') {\n\t\t\treturn renderAllValuesTable(ctx.values, ctx.descriptors)\n\t\t}\n\t\treturn escapeHtml(resolve(name))\n\t}\n\treturn {\n\t\ttext: (raw) => interpolate(escapeHtml(raw), htmlResolve),\n\t\tinterpolate: (raw) => interpolate(raw, resolve),\n\t}\n}\n\nconst serializeNodes = (\n\tnodes: unknown[],\n\tconverters: Record<string, BodyConverter>,\n\trender: BodyRender\n): string =>\n\tnodes\n\t\t.map((node) => {\n\t\t\tif (node == null || typeof node !== 'object') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst lexicalNode = node as Record<string, unknown>\n\t\t\tconst children = Array.isArray(lexicalNode.children)\n\t\t\t\t? serializeNodes(lexicalNode.children, converters, render)\n\t\t\t\t: ''\n\t\t\tconst converter =\n\t\t\t\ttypeof lexicalNode.type === 'string' ? converters[lexicalNode.type] : undefined\n\t\t\treturn converter ? converter({ node: lexicalNode, children, render }) : children\n\t\t})\n\t\t.join('')\n\nconst lexicalRootOf = (body: unknown): Record<string, unknown> | null => {\n\tif (body == null || typeof body !== 'object' || Array.isArray(body)) {\n\t\treturn null\n\t}\n\tconst root = (body as { root?: unknown }).root\n\treturn root != null && typeof root === 'object' ? (root as Record<string, unknown>) : null\n}\n\n/**\n * Serialize an action's `body` config into HTML. A legacy string body is interpolated as-is\n * (pre-richText behavior, no escaping); a Lexical state walks the converter registry; a Slate\n * array uses the minimal legacy serializer; anything else yields `''`. Rendered text is\n * HTML-escaped and supports `{{ name|fallback }}`, `{{*}}`, and `{{*:table}}` tokens.\n */\nexport const serializeBody = (body: unknown, ctx: BodyContext): string => {\n\tif (typeof body === 'string') {\n\t\treturn interpolate(body, resolverFor(ctx.values))\n\t}\n\tconst render = renderFor(ctx)\n\tif (Array.isArray(body)) {\n\t\treturn serializeSlate(body, render)\n\t}\n\tconst root = lexicalRootOf(body)\n\tif (root) {\n\t\tconst converters = { ...defaultBodyConverters, ...(ctx.converters ?? {}) }\n\t\treturn serializeNodes(Array.isArray(root.children) ? root.children : [], converters, render)\n\t}\n\treturn ''\n}\n\n/** Build the `renderBody` passed to actions, honoring a plugin-level `richText` customization. */\nexport const makeRenderBody =\n\t(args: {\n\t\tvalues: SubmissionValue[]\n\t\tdescriptors: SubmissionDescriptor[]\n\t\tform: SerializeBodyForm\n\t\treq?: PayloadRequest\n\t\trichText?: RichTextBodyOption\n\t}) =>\n\tasync (body: unknown): Promise<string> => {\n\t\tif (args.richText?.serialize) {\n\t\t\treturn await args.richText.serialize({\n\t\t\t\tbody,\n\t\t\t\tvalues: args.values,\n\t\t\t\tdescriptors: args.descriptors,\n\t\t\t\tform: args.form,\n\t\t\t\treq: args.req,\n\t\t\t})\n\t\t}\n\t\treturn serializeBody(body, {\n\t\t\tvalues: args.values,\n\t\t\tdescriptors: args.descriptors,\n\t\t\tconverters: args.richText?.converters,\n\t\t})\n\t}\n"],"mappings":";;;;;;;AAiDA,MAAa,eACX,YACA,SAAyB;CACzB,MAAM,QAAQ,OAAO,MAAM,UAAU,MAAM,UAAU,IAAI;CACzD,OAAO,SAAS,OAAO,KAAK,OAAO,MAAM,SAAS,EAAE;AACrD;AAED,MAAM,aAAa,QAAiC;CACnD,MAAM,UAAU,YAAY,IAAI,MAAM;CACtC,MAAM,eAAe,SAAyB;EAC7C,IAAI,SAAS,KACZ,OAAO,gBAAgB,IAAI,QAAQ,IAAI,WAAW;EAEnD,IAAI,SAAS,WACZ,OAAO,qBAAqB,IAAI,QAAQ,IAAI,WAAW;EAExD,OAAO,WAAW,QAAQ,IAAI,CAAC;CAChC;CACA,OAAO;EACN,OAAO,QAAQ,YAAY,WAAW,GAAG,GAAG,WAAW;EACvD,cAAc,QAAQ,YAAY,KAAK,OAAO;CAC/C;AACD;AAEA,MAAM,kBACL,OACA,YACA,WAEA,MACE,KAAK,SAAS;CACd,IAAI,QAAQ,QAAQ,OAAO,SAAS,UACnC,OAAO;CAER,MAAM,cAAc;CACpB,MAAM,WAAW,MAAM,QAAQ,YAAY,QAAQ,IAChD,eAAe,YAAY,UAAU,YAAY,MAAM,IACvD;CACH,MAAM,YACL,OAAO,YAAY,SAAS,WAAW,WAAW,YAAY,QAAQ,KAAA;CACvE,OAAO,YAAY,UAAU;EAAE,MAAM;EAAa;EAAU;CAAO,CAAC,IAAI;AACzE,CAAC,EACA,KAAK,EAAE;AAEV,MAAM,iBAAiB,SAAkD;CACxE,IAAI,QAAQ,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,GACjE,OAAO;CAER,MAAM,OAAQ,KAA4B;CAC1C,OAAO,QAAQ,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACvF;;;;;;;AAQA,MAAa,iBAAiB,MAAe,QAA6B;CACzE,IAAI,OAAO,SAAS,UACnB,OAAO,YAAY,MAAM,YAAY,IAAI,MAAM,CAAC;CAEjD,MAAM,SAAS,UAAU,GAAG;CAC5B,IAAI,MAAM,QAAQ,IAAI,GACrB,OAAO,eAAe,MAAM,MAAM;CAEnC,MAAM,OAAO,cAAc,IAAI;CAC/B,IAAI,MAAM;EACT,MAAM,aAAa;GAAE,GAAG;GAAuB,GAAI,IAAI,cAAc,CAAC;EAAG;EACzE,OAAO,eAAe,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,WAAW,CAAC,GAAG,YAAY,MAAM;CAC5F;CACA,OAAO;AACR;;AAGA,MAAa,kBACX,SAOD,OAAO,SAAmC;CACzC,IAAI,KAAK,UAAU,WAClB,OAAO,MAAM,KAAK,SAAS,UAAU;EACpC;EACA,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,MAAM,KAAK;EACX,KAAK,KAAK;CACX,CAAC;CAEF,OAAO,cAAc,MAAM;EAC1B,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,YAAY,KAAK,UAAU;CAC5B,CAAC;AACF"}
1
+ {"version":3,"file":"serializeBody.js","names":[],"sources":["../../../src/actions/body/serializeBody.ts"],"sourcesContent":["import type { PayloadRequest, RichTextField } from 'payload'\nimport { interpolate } from '../../recall/interpolate'\nimport type { SubmissionDescriptor, SubmissionValue } from '../../submissions/types'\nimport type { BodyConverter, BodyRender } from './converters'\nimport { defaultBodyConverters } from './converters'\nimport { escapeHtml } from './escapeHtml'\nimport { serializeSlate } from './serializeSlate'\nimport { renderAllValues, renderAllValuesTable } from './wildcards'\n\n/** Minimal form identity threaded alongside a rendered body (e.g. per-tenant template lookups). */\ntype SerializeBodyForm = { id: number | string; title?: string }\n\n/** Submission data plus optional converter overrides available while serializing a body. */\nexport type BodyContext = {\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tconverters?: Record<string, BodyConverter>\n}\n\n/**\n * Args a custom `richText.serialize` replacement receives per rendered body. Always populated by\n * `makeRenderBody` (the action-body pipeline): `form` and `req` enable per-tenant template\n * lookups or handing the body off to a renderer like react-email.\n */\nexport type SerializeBodyArgs = {\n\tbody: unknown\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tform: SerializeBodyForm\n\treq?: PayloadRequest\n}\n\n/**\n * Customizes how the plugin's rich text is authored and rendered. `converters` spread over the\n * default Lexical node converters; `serialize` replaces the whole action-body pipeline (for\n * non-HTML channels like chat or plain text, or to hand the body plus the submitted `form`/`req`\n * off to a renderer like react-email). `editor` is the default Lexical/richText editor for every\n * plugin-authored richText field: message content, consent statement, the response message, and\n * the action body fields. `bodyEditor` overrides the action body fields specifically (emailTeam\n * and confirmation), and `responseEditor` overrides the success `response` message field; both fall\n * back to `editor` when absent.\n */\nexport type RichTextBodyOption = {\n\tconverters?: Record<string, BodyConverter>\n\tserialize?: (args: SerializeBodyArgs) => Promise<string> | string\n\teditor?: RichTextField['editor']\n\tbodyEditor?: RichTextField['editor']\n\tresponseEditor?: RichTextField['editor']\n}\n\n/** Recall resolver over submission values: field name to stringified value, `''` when absent. */\nexport const resolverFor =\n\t(values: SubmissionValue[]) =>\n\t(name: string): string => {\n\t\tconst entry = values.find((value) => value.field === name)\n\t\treturn entry == null ? '' : String(entry.value ?? '')\n\t}\n\nconst renderFor = (ctx: BodyContext): BodyRender => {\n\tconst resolve = resolverFor(ctx.values)\n\tconst htmlResolve = (name: string): string => {\n\t\tif (name === '*') {\n\t\t\treturn renderAllValues(ctx.values, ctx.descriptors)\n\t\t}\n\t\tif (name === '*:table') {\n\t\t\treturn renderAllValuesTable(ctx.values, ctx.descriptors)\n\t\t}\n\t\treturn escapeHtml(resolve(name))\n\t}\n\treturn {\n\t\ttext: (raw) => interpolate(escapeHtml(raw), htmlResolve),\n\t\tinterpolate: (raw) => interpolate(raw, resolve),\n\t}\n}\n\nconst serializeNodes = (\n\tnodes: unknown[],\n\tconverters: Record<string, BodyConverter>,\n\trender: BodyRender\n): string =>\n\tnodes\n\t\t.map((node) => {\n\t\t\tif (node == null || typeof node !== 'object') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst lexicalNode = node as Record<string, unknown>\n\t\t\tconst children = Array.isArray(lexicalNode.children)\n\t\t\t\t? serializeNodes(lexicalNode.children, converters, render)\n\t\t\t\t: ''\n\t\t\tconst converter =\n\t\t\t\ttypeof lexicalNode.type === 'string' ? converters[lexicalNode.type] : undefined\n\t\t\treturn converter ? converter({ node: lexicalNode, children, render }) : children\n\t\t})\n\t\t.join('')\n\nconst lexicalRootOf = (body: unknown): Record<string, unknown> | null => {\n\tif (body == null || typeof body !== 'object' || Array.isArray(body)) {\n\t\treturn null\n\t}\n\tconst root = (body as { root?: unknown }).root\n\treturn root != null && typeof root === 'object' ? (root as Record<string, unknown>) : null\n}\n\n/**\n * Serialize an action's `body` config into HTML. A legacy string body is interpolated as-is\n * (pre-richText behavior, no escaping); a Lexical state walks the converter registry; a Slate\n * array uses the minimal legacy serializer; anything else yields `''`. Rendered text is\n * HTML-escaped and supports `{{ name|fallback }}`, `{{*}}`, and `{{*:table}}` tokens.\n */\nexport const serializeBody = (body: unknown, ctx: BodyContext): string => {\n\tif (typeof body === 'string') {\n\t\treturn interpolate(body, resolverFor(ctx.values))\n\t}\n\tconst render = renderFor(ctx)\n\tif (Array.isArray(body)) {\n\t\treturn serializeSlate(body, render)\n\t}\n\tconst root = lexicalRootOf(body)\n\tif (root) {\n\t\tconst converters = { ...defaultBodyConverters, ...(ctx.converters ?? {}) }\n\t\treturn serializeNodes(Array.isArray(root.children) ? root.children : [], converters, render)\n\t}\n\treturn ''\n}\n\n/** Build the `renderBody` passed to actions, honoring a plugin-level `richText` customization. */\nexport const makeRenderBody =\n\t(args: {\n\t\tvalues: SubmissionValue[]\n\t\tdescriptors: SubmissionDescriptor[]\n\t\tform: SerializeBodyForm\n\t\treq?: PayloadRequest\n\t\trichText?: RichTextBodyOption\n\t}) =>\n\tasync (body: unknown): Promise<string> => {\n\t\tif (args.richText?.serialize) {\n\t\t\treturn await args.richText.serialize({\n\t\t\t\tbody,\n\t\t\t\tvalues: args.values,\n\t\t\t\tdescriptors: args.descriptors,\n\t\t\t\tform: args.form,\n\t\t\t\treq: args.req,\n\t\t\t})\n\t\t}\n\t\treturn serializeBody(body, {\n\t\t\tvalues: args.values,\n\t\t\tdescriptors: args.descriptors,\n\t\t\tconverters: args.richText?.converters,\n\t\t})\n\t}\n"],"mappings":";;;;;;;AAmDA,MAAa,eACX,YACA,SAAyB;CACzB,MAAM,QAAQ,OAAO,MAAM,UAAU,MAAM,UAAU,IAAI;CACzD,OAAO,SAAS,OAAO,KAAK,OAAO,MAAM,SAAS,EAAE;AACrD;AAED,MAAM,aAAa,QAAiC;CACnD,MAAM,UAAU,YAAY,IAAI,MAAM;CACtC,MAAM,eAAe,SAAyB;EAC7C,IAAI,SAAS,KACZ,OAAO,gBAAgB,IAAI,QAAQ,IAAI,WAAW;EAEnD,IAAI,SAAS,WACZ,OAAO,qBAAqB,IAAI,QAAQ,IAAI,WAAW;EAExD,OAAO,WAAW,QAAQ,IAAI,CAAC;CAChC;CACA,OAAO;EACN,OAAO,QAAQ,YAAY,WAAW,GAAG,GAAG,WAAW;EACvD,cAAc,QAAQ,YAAY,KAAK,OAAO;CAC/C;AACD;AAEA,MAAM,kBACL,OACA,YACA,WAEA,MACE,KAAK,SAAS;CACd,IAAI,QAAQ,QAAQ,OAAO,SAAS,UACnC,OAAO;CAER,MAAM,cAAc;CACpB,MAAM,WAAW,MAAM,QAAQ,YAAY,QAAQ,IAChD,eAAe,YAAY,UAAU,YAAY,MAAM,IACvD;CACH,MAAM,YACL,OAAO,YAAY,SAAS,WAAW,WAAW,YAAY,QAAQ,KAAA;CACvE,OAAO,YAAY,UAAU;EAAE,MAAM;EAAa;EAAU;CAAO,CAAC,IAAI;AACzE,CAAC,EACA,KAAK,EAAE;AAEV,MAAM,iBAAiB,SAAkD;CACxE,IAAI,QAAQ,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,GACjE,OAAO;CAER,MAAM,OAAQ,KAA4B;CAC1C,OAAO,QAAQ,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACvF;;;;;;;AAQA,MAAa,iBAAiB,MAAe,QAA6B;CACzE,IAAI,OAAO,SAAS,UACnB,OAAO,YAAY,MAAM,YAAY,IAAI,MAAM,CAAC;CAEjD,MAAM,SAAS,UAAU,GAAG;CAC5B,IAAI,MAAM,QAAQ,IAAI,GACrB,OAAO,eAAe,MAAM,MAAM;CAEnC,MAAM,OAAO,cAAc,IAAI;CAC/B,IAAI,MAAM;EACT,MAAM,aAAa;GAAE,GAAG;GAAuB,GAAI,IAAI,cAAc,CAAC;EAAG;EACzE,OAAO,eAAe,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,WAAW,CAAC,GAAG,YAAY,MAAM;CAC5F;CACA,OAAO;AACR;;AAGA,MAAa,kBACX,SAOD,OAAO,SAAmC;CACzC,IAAI,KAAK,UAAU,WAClB,OAAO,MAAM,KAAK,SAAS,UAAU;EACpC;EACA,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,MAAM,KAAK;EACX,KAAK,KAAK;CACX,CAAC;CAEF,OAAO,cAAc,MAAM;EAC1B,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,YAAY,KAAK,UAAU;CAC5B,CAAC;AACF"}
@@ -1,16 +1,19 @@
1
- import { labelFor } from "../translations/server.js";
1
+ import { resolveDefinitionLabel } from "../translations/server.js";
2
2
  //#region src/actions/buildActionBlocks.ts
3
3
  /** One authoring block per registered action type: the action's own config fields. */
4
4
  const buildActionBlocks = (registry) => {
5
5
  const blocks = [];
6
- for (const definition of registry.values()) blocks.push({
7
- slug: definition.type,
8
- labels: {
9
- singular: labelFor(definition.label),
10
- plural: labelFor(definition.label)
11
- },
12
- fields: definition.config ?? []
13
- });
6
+ for (const definition of registry.values()) {
7
+ const label = resolveDefinitionLabel(definition.label);
8
+ blocks.push({
9
+ slug: definition.type,
10
+ labels: {
11
+ singular: label,
12
+ plural: label
13
+ },
14
+ fields: definition.config ?? []
15
+ });
16
+ }
14
17
  return blocks;
15
18
  };
16
19
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"buildActionBlocks.js","names":[],"sources":["../../src/actions/buildActionBlocks.ts"],"sourcesContent":["import type { Block } from 'payload'\nimport { labelFor } from '../translations/server'\nimport type { ActionRegistry } from './registry'\n\n/** One authoring block per registered action type: the action's own config fields. */\nexport const buildActionBlocks = (registry: ActionRegistry): Block[] => {\n\tconst blocks: Block[] = []\n\tfor (const definition of registry.values()) {\n\t\tblocks.push({\n\t\t\tslug: definition.type,\n\t\t\tlabels: { singular: labelFor(definition.label), plural: labelFor(definition.label) },\n\t\t\tfields: definition.config ?? [],\n\t\t})\n\t}\n\treturn blocks\n}\n"],"mappings":";;;AAKA,MAAa,qBAAqB,aAAsC;CACvE,MAAM,SAAkB,CAAC;CACzB,KAAK,MAAM,cAAc,SAAS,OAAO,GACxC,OAAO,KAAK;EACX,MAAM,WAAW;EACjB,QAAQ;GAAE,UAAU,SAAS,WAAW,KAAK;GAAG,QAAQ,SAAS,WAAW,KAAK;EAAE;EACnF,QAAQ,WAAW,UAAU,CAAC;CAC/B,CAAC;CAEF,OAAO;AACR"}
1
+ {"version":3,"file":"buildActionBlocks.js","names":[],"sources":["../../src/actions/buildActionBlocks.ts"],"sourcesContent":["import type { Block } from 'payload'\nimport { resolveDefinitionLabel } from '../translations/server'\nimport type { ActionRegistry } from './registry'\n\n/** One authoring block per registered action type: the action's own config fields. */\nexport const buildActionBlocks = (registry: ActionRegistry): Block[] => {\n\tconst blocks: Block[] = []\n\tfor (const definition of registry.values()) {\n\t\tconst label = resolveDefinitionLabel(definition.label)\n\t\tblocks.push({\n\t\t\tslug: definition.type,\n\t\t\tlabels: { singular: label, plural: label },\n\t\t\tfields: definition.config ?? [],\n\t\t})\n\t}\n\treturn blocks\n}\n"],"mappings":";;;AAKA,MAAa,qBAAqB,aAAsC;CACvE,MAAM,SAAkB,CAAC;CACzB,KAAK,MAAM,cAAc,SAAS,OAAO,GAAG;EAC3C,MAAM,QAAQ,uBAAuB,WAAW,KAAK;EACrD,OAAO,KAAK;GACX,MAAM,WAAW;GACjB,QAAQ;IAAE,UAAU;IAAO,QAAQ;GAAM;GACzC,QAAQ,WAAW,UAAU,CAAC;EAC/B,CAAC;CACF;CACA,OAAO;AACR"}
@@ -1,12 +1,8 @@
1
1
  import { fieldNamesOfType } from "../../fields/fieldNamesOfType.js";
2
- import { localizedIf } from "../../fields/localizedIf.js";
3
- import { interpolate } from "../../recall/interpolate.js";
4
2
  import { keys } from "../../translations/keys.js";
5
3
  import { asTranslate, labelFor } from "../../translations/server.js";
6
- import { resolverFor } from "../body/serializeBody.js";
7
- import { defineAction } from "../defineAction.js";
8
- import { buildRecipientField, resolveRecipients } from "../emailRecipients.js";
9
- import { buildFromField } from "../fromAddresses.js";
4
+ import { firstAddress } from "../emailRecipients.js";
5
+ import { buildEmailAction } from "./emailAction.js";
10
6
  //#region src/actions/builtin/confirmation.ts
11
7
  const TO_FIELD_REF = "@10x-media/form-builder/client#FieldNameSelect";
12
8
  /**
@@ -18,20 +14,13 @@ const validateToField = (value, { data, req }) => {
18
14
  return fieldNamesOfType(data && typeof data === "object" ? data.fields : void 0, ["email"]).includes(value) ? true : asTranslate(req.t)(keys.validationEmailFieldUnknown);
19
15
  };
20
16
  /**
21
- * `toField` names the form's own email field the confirmation is sent to (a `FieldNameSelect`, not a
22
- * recipient list). `replyTo`, `cc`, and `bcc` are recipient lists (`RecipientsSelect`, `string[]`):
23
- * literal emails, picked department addresses (when `departments` is set), or `{{field}}` tokens
24
- * resolved from the submission at send. `from` is a single address, added only when `fromAddresses`
25
- * is set. `subject`/`body` are content; `editor` overrides the body editor; `recipients` narrows the
26
- * recipient fields' behavior. Content and recipient fields carry `localized: true` when `localize`.
17
+ * The `confirmation` action: its target is `toField`, naming the form's own email field the
18
+ * confirmation is sent to (a `FieldNameSelect`, not a recipient list). The resolved address is
19
+ * sanitized to a single one (dropping CR/LF header injection and any smuggled extra addresses); an
20
+ * unset field or an unresolvable value is a silent skip. Everything else (from/cc/bcc/replyTo,
21
+ * subject, body, localization) is the shared email-action skeleton.
27
22
  */
28
- const buildConfirmation = (localize, editor, fromAddresses, departments, recipients) => {
29
- const endpoint = departments ? "departments" : void 0;
30
- const recip = (name, labelKey) => buildRecipientField(name, labelKey, localize, {
31
- endpoint,
32
- recipients,
33
- width: "50%"
34
- });
23
+ const buildConfirmation = (options) => {
35
24
  const toField = {
36
25
  name: "toField",
37
26
  type: "text",
@@ -48,59 +37,15 @@ const buildConfirmation = (localize, editor, fromAddresses, departments, recipie
48
37
  },
49
38
  validate: validateToField
50
39
  };
51
- return defineAction({
40
+ return buildEmailAction(options, {
52
41
  type: "confirmation",
53
42
  label: keys.actionConfirmation,
54
- config: [
55
- {
56
- type: "row",
57
- fields: [toField, recip("replyTo", keys.actionConfigReplyTo)]
58
- },
59
- ...fromAddresses ? [buildFromField(fromAddresses)] : [],
60
- {
61
- type: "row",
62
- fields: [recip("cc", keys.actionConfigCc), recip("bcc", keys.actionConfigBcc)]
63
- },
64
- {
65
- name: "subject",
66
- type: "text",
67
- label: labelFor(keys.actionConfigSubject),
68
- ...localizedIf(localize)
69
- },
70
- {
71
- name: "body",
72
- type: "richText",
73
- label: labelFor(keys.actionConfigBody),
74
- admin: { description: labelFor(keys.actionConfigBodyDescription) },
75
- ...localizedIf(localize),
76
- ...editor ? { editor } : {}
77
- }
78
- ],
79
- run: async (args) => {
80
- const { config, values, payload, renderBody } = args;
81
- if (!config.toField) return;
82
- const resolve = resolverFor(values);
83
- const to = resolve(config.toField);
84
- if (!to) return;
85
- if (typeof payload.sendEmail !== "function") throw new Error("confirmation: no email adapter configured");
86
- const subject = interpolate(config.subject ?? "", resolve);
87
- const html = await renderBody(config.body);
88
- const cc = resolveRecipients(config.cc, resolve);
89
- const bcc = resolveRecipients(config.bcc, resolve);
90
- const replyTo = resolveRecipients(config.replyTo, resolve);
91
- await payload.sendEmail({
92
- to,
93
- subject,
94
- html,
95
- ...config.from ? { from: config.from } : {},
96
- ...cc ? { cc } : {},
97
- ...bcc ? { bcc } : {},
98
- ...replyTo ? { replyTo } : {}
99
- });
100
- }
43
+ target: () => toField,
44
+ resolveTo: (config, resolve) => config.toField ? firstAddress(resolve(config.toField)) : void 0,
45
+ onMissingTo: "skip"
101
46
  });
102
47
  };
103
- buildConfirmation(true);
48
+ buildConfirmation({ localize: true });
104
49
  //#endregion
105
50
  export { buildConfirmation };
106
51
 
@@ -1 +1 @@
1
- {"version":3,"file":"confirmation.js","names":[],"sources":["../../../src/actions/builtin/confirmation.ts"],"sourcesContent":["import type { Field, PayloadRequest, RichTextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../../email/departments'\nimport { fieldNamesOfType } from '../../fields/fieldNamesOfType'\nimport { localizedIf } from '../../fields/localizedIf'\nimport { interpolate } from '../../recall/interpolate'\nimport { keys } from '../../translations/keys'\nimport { asTranslate, labelFor } from '../../translations/server'\nimport { resolverFor } from '../body/serializeBody'\nimport { defineAction } from '../defineAction'\nimport { buildRecipientField, type RecipientsConfig, resolveRecipients } from '../emailRecipients'\nimport { buildFromField, type FromAddressesResolver } from '../fromAddresses'\n\ntype ConfirmationConfig = {\n\ttoField?: string\n\tfrom?: string\n\tcc?: string[]\n\tbcc?: string[]\n\treplyTo?: string[]\n\tsubject?: string\n\tbody?: unknown\n}\n\nconst TO_FIELD_REF = '@10x-media/form-builder/client#FieldNameSelect'\n\n/**\n * Validates the confirmation action's `toField`: unset is fine, otherwise it must name an\n * existing `email`-type field on the form. Exported for unit testing.\n */\nexport const validateToField = (\n\tvalue: unknown,\n\t{ data, req }: { data?: unknown; req: PayloadRequest }\n): string | true => {\n\tif (typeof value !== 'string' || value.length === 0) {\n\t\treturn true\n\t}\n\tconst fields =\n\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\treturn fieldNamesOfType(fields, ['email']).includes(value)\n\t\t? true\n\t\t: asTranslate(req.t)(keys.validationEmailFieldUnknown)\n}\n\n/**\n * `toField` names the form's own email field the confirmation is sent to (a `FieldNameSelect`, not a\n * recipient list). `replyTo`, `cc`, and `bcc` are recipient lists (`RecipientsSelect`, `string[]`):\n * literal emails, picked department addresses (when `departments` is set), or `{{field}}` tokens\n * resolved from the submission at send. `from` is a single address, added only when `fromAddresses`\n * is set. `subject`/`body` are content; `editor` overrides the body editor; `recipients` narrows the\n * recipient fields' behavior. Content and recipient fields carry `localized: true` when `localize`.\n */\n// biome-ignore lint/complexity/useMaxParams: positional args thread plugin options (localize, editor, fromAddresses, departments, recipients)\nexport const buildConfirmation = (\n\tlocalize: boolean,\n\teditor?: RichTextField['editor'],\n\tfromAddresses?: FromAddressesResolver,\n\tdepartments?: DepartmentEmailsResolver,\n\trecipients?: RecipientsConfig\n) => {\n\tconst endpoint = departments ? 'departments' : undefined\n\tconst recip = (name: string, labelKey: string) =>\n\t\tbuildRecipientField(name, labelKey, localize, { endpoint, recipients, width: '50%' })\n\tconst toField: Field = {\n\t\tname: 'toField',\n\t\ttype: 'text',\n\t\tlabel: labelFor(keys.actionConfigToField),\n\t\tadmin: {\n\t\t\twidth: '50%',\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: TO_FIELD_REF,\n\t\t\t\t\tclientProps: { types: ['email'], descriptionKey: keys.actionConfigToFieldDescription },\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tvalidate: validateToField,\n\t}\n\treturn defineAction<ConfirmationConfig>({\n\t\ttype: 'confirmation',\n\t\tlabel: keys.actionConfirmation,\n\t\tconfig: [\n\t\t\t{ type: 'row', fields: [toField, recip('replyTo', keys.actionConfigReplyTo)] },\n\t\t\t...(fromAddresses ? [buildFromField(fromAddresses)] : []),\n\t\t\t{\n\t\t\t\ttype: 'row',\n\t\t\t\tfields: [recip('cc', keys.actionConfigCc), recip('bcc', keys.actionConfigBcc)],\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'subject',\n\t\t\t\ttype: 'text',\n\t\t\t\tlabel: labelFor(keys.actionConfigSubject),\n\t\t\t\t...localizedIf(localize),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'body',\n\t\t\t\ttype: 'richText',\n\t\t\t\tlabel: labelFor(keys.actionConfigBody),\n\t\t\t\tadmin: { description: labelFor(keys.actionConfigBodyDescription) },\n\t\t\t\t...localizedIf(localize),\n\t\t\t\t...(editor ? { editor } : {}),\n\t\t\t},\n\t\t],\n\t\trun: async (args) => {\n\t\t\tconst { config, values, payload, renderBody } = args\n\n\t\t\tif (!config.toField) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst resolve = resolverFor(values)\n\t\t\tconst to = resolve(config.toField)\n\n\t\t\tif (!to) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (typeof payload.sendEmail !== 'function') {\n\t\t\t\tthrow new Error('confirmation: no email adapter configured')\n\t\t\t}\n\n\t\t\tconst subject = interpolate(config.subject ?? '', resolve)\n\t\t\tconst html = await renderBody(config.body)\n\t\t\tconst cc = resolveRecipients(config.cc, resolve)\n\t\t\tconst bcc = resolveRecipients(config.bcc, resolve)\n\t\t\tconst replyTo = resolveRecipients(config.replyTo, resolve)\n\n\t\t\t// `from` was validated at save time against `fromAddresses(req)`; not re-checked here\n\t\t\t// (the job's `req` may differ from the authoring admin's, and the config is\n\t\t\t// admin-authored, not visitor-controlled), so the stored value is forwarded verbatim.\n\t\t\tawait payload.sendEmail({\n\t\t\t\tto,\n\t\t\t\tsubject,\n\t\t\t\thtml,\n\t\t\t\t...(config.from ? { from: config.from } : {}),\n\t\t\t\t...(cc ? { cc } : {}),\n\t\t\t\t...(bcc ? { bcc } : {}),\n\t\t\t\t...(replyTo ? { replyTo } : {}),\n\t\t\t})\n\t\t},\n\t})\n}\n\nexport const confirmation = buildConfirmation(true)\n"],"mappings":";;;;;;;;;;AAsBA,MAAM,eAAe;;;;;AAMrB,MAAa,mBACZ,OACA,EAAE,MAAM,UACW;CACnB,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GACjD,OAAO;CAIR,OAAO,iBADN,QAAQ,OAAO,SAAS,WAAY,KAAiC,SAAS,KAAA,GAC/C,CAAC,OAAO,CAAC,EAAE,SAAS,KAAK,IACtD,OACA,YAAY,IAAI,CAAC,EAAE,KAAK,2BAA2B;AACvD;;;;;;;;;AAWA,MAAa,qBACZ,UACA,QACA,eACA,aACA,eACI;CACJ,MAAM,WAAW,cAAc,gBAAgB,KAAA;CAC/C,MAAM,SAAS,MAAc,aAC5B,oBAAoB,MAAM,UAAU,UAAU;EAAE;EAAU;EAAY,OAAO;CAAM,CAAC;CACrF,MAAM,UAAiB;EACtB,MAAM;EACN,MAAM;EACN,OAAO,SAAS,KAAK,mBAAmB;EACxC,OAAO;GACN,OAAO;GACP,YAAY,EACX,OAAO;IACN,MAAM;IACN,aAAa;KAAE,OAAO,CAAC,OAAO;KAAG,gBAAgB,KAAK;IAA+B;GACtF,EACD;EACD;EACA,UAAU;CACX;CACA,OAAO,aAAiC;EACvC,MAAM;EACN,OAAO,KAAK;EACZ,QAAQ;GACP;IAAE,MAAM;IAAO,QAAQ,CAAC,SAAS,MAAM,WAAW,KAAK,mBAAmB,CAAC;GAAE;GAC7E,GAAI,gBAAgB,CAAC,eAAe,aAAa,CAAC,IAAI,CAAC;GACvD;IACC,MAAM;IACN,QAAQ,CAAC,MAAM,MAAM,KAAK,cAAc,GAAG,MAAM,OAAO,KAAK,eAAe,CAAC;GAC9E;GACA;IACC,MAAM;IACN,MAAM;IACN,OAAO,SAAS,KAAK,mBAAmB;IACxC,GAAG,YAAY,QAAQ;GACxB;GACA;IACC,MAAM;IACN,MAAM;IACN,OAAO,SAAS,KAAK,gBAAgB;IACrC,OAAO,EAAE,aAAa,SAAS,KAAK,2BAA2B,EAAE;IACjE,GAAG,YAAY,QAAQ;IACvB,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;GAC5B;EACD;EACA,KAAK,OAAO,SAAS;GACpB,MAAM,EAAE,QAAQ,QAAQ,SAAS,eAAe;GAEhD,IAAI,CAAC,OAAO,SACX;GAGD,MAAM,UAAU,YAAY,MAAM;GAClC,MAAM,KAAK,QAAQ,OAAO,OAAO;GAEjC,IAAI,CAAC,IACJ;GAGD,IAAI,OAAO,QAAQ,cAAc,YAChC,MAAM,IAAI,MAAM,2CAA2C;GAG5D,MAAM,UAAU,YAAY,OAAO,WAAW,IAAI,OAAO;GACzD,MAAM,OAAO,MAAM,WAAW,OAAO,IAAI;GACzC,MAAM,KAAK,kBAAkB,OAAO,IAAI,OAAO;GAC/C,MAAM,MAAM,kBAAkB,OAAO,KAAK,OAAO;GACjD,MAAM,UAAU,kBAAkB,OAAO,SAAS,OAAO;GAKzD,MAAM,QAAQ,UAAU;IACvB;IACA;IACA;IACA,GAAI,OAAO,OAAO,EAAE,MAAM,OAAO,KAAK,IAAI,CAAC;IAC3C,GAAI,KAAK,EAAE,GAAG,IAAI,CAAC;IACnB,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;IACrB,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;GAC9B,CAAC;EACF;CACD,CAAC;AACF;AAE4B,kBAAkB,IAAI"}
1
+ {"version":3,"file":"confirmation.js","names":[],"sources":["../../../src/actions/builtin/confirmation.ts"],"sourcesContent":["import type { Field, PayloadRequest } from 'payload'\nimport { fieldNamesOfType } from '../../fields/fieldNamesOfType'\nimport { keys } from '../../translations/keys'\nimport { asTranslate, labelFor } from '../../translations/server'\nimport { firstAddress } from '../emailRecipients'\nimport { buildEmailAction, type EmailActionConfig, type EmailActionOptions } from './emailAction'\n\ntype ConfirmationConfig = EmailActionConfig & { toField?: string }\n\nconst TO_FIELD_REF = '@10x-media/form-builder/client#FieldNameSelect'\n\n/**\n * Validates the confirmation action's `toField`: unset is fine, otherwise it must name an\n * existing `email`-type field on the form. Exported for unit testing.\n */\nexport const validateToField = (\n\tvalue: unknown,\n\t{ data, req }: { data?: unknown; req: PayloadRequest }\n): string | true => {\n\tif (typeof value !== 'string' || value.length === 0) {\n\t\treturn true\n\t}\n\tconst fields =\n\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\treturn fieldNamesOfType(fields, ['email']).includes(value)\n\t\t? true\n\t\t: asTranslate(req.t)(keys.validationEmailFieldUnknown)\n}\n\n/**\n * The `confirmation` action: its target is `toField`, naming the form's own email field the\n * confirmation is sent to (a `FieldNameSelect`, not a recipient list). The resolved address is\n * sanitized to a single one (dropping CR/LF header injection and any smuggled extra addresses); an\n * unset field or an unresolvable value is a silent skip. Everything else (from/cc/bcc/replyTo,\n * subject, body, localization) is the shared email-action skeleton.\n */\nexport const buildConfirmation = (options: EmailActionOptions) => {\n\tconst toField: Field = {\n\t\tname: 'toField',\n\t\ttype: 'text',\n\t\tlabel: labelFor(keys.actionConfigToField),\n\t\tadmin: {\n\t\t\twidth: '50%',\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: TO_FIELD_REF,\n\t\t\t\t\tclientProps: { types: ['email'], descriptionKey: keys.actionConfigToFieldDescription },\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tvalidate: validateToField,\n\t}\n\treturn buildEmailAction<ConfirmationConfig>(options, {\n\t\ttype: 'confirmation',\n\t\tlabel: keys.actionConfirmation,\n\t\ttarget: () => toField,\n\t\tresolveTo: (config, resolve) =>\n\t\t\tconfig.toField ? firstAddress(resolve(config.toField)) : undefined,\n\t\tonMissingTo: 'skip',\n\t})\n}\n\nexport const confirmation = buildConfirmation({ localize: true })\n"],"mappings":";;;;;;AASA,MAAM,eAAe;;;;;AAMrB,MAAa,mBACZ,OACA,EAAE,MAAM,UACW;CACnB,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GACjD,OAAO;CAIR,OAAO,iBADN,QAAQ,OAAO,SAAS,WAAY,KAAiC,SAAS,KAAA,GAC/C,CAAC,OAAO,CAAC,EAAE,SAAS,KAAK,IACtD,OACA,YAAY,IAAI,CAAC,EAAE,KAAK,2BAA2B;AACvD;;;;;;;;AASA,MAAa,qBAAqB,YAAgC;CACjE,MAAM,UAAiB;EACtB,MAAM;EACN,MAAM;EACN,OAAO,SAAS,KAAK,mBAAmB;EACxC,OAAO;GACN,OAAO;GACP,YAAY,EACX,OAAO;IACN,MAAM;IACN,aAAa;KAAE,OAAO,CAAC,OAAO;KAAG,gBAAgB,KAAK;IAA+B;GACtF,EACD;EACD;EACA,UAAU;CACX;CACA,OAAO,iBAAqC,SAAS;EACpD,MAAM;EACN,OAAO,KAAK;EACZ,cAAc;EACd,YAAY,QAAQ,YACnB,OAAO,UAAU,aAAa,QAAQ,OAAO,OAAO,CAAC,IAAI,KAAA;EAC1D,aAAa;CACd,CAAC;AACF;AAE4B,kBAAkB,EAAE,UAAU,KAAK,CAAC"}
@@ -0,0 +1,17 @@
1
+ import { FromAddressesResolver } from "../fromAddresses.js";
2
+ import { DepartmentEmailsResolver } from "../../email/departments.js";
3
+ import { RecipientsConfig } from "../emailRecipients.js";
4
+ import { Field, RichTextField } from "payload";
5
+
6
+ //#region src/actions/builtin/emailAction.d.ts
7
+ /** The plugin-derived options every built-in email action is built from (was five positional args). */
8
+ type EmailActionOptions = {
9
+ localize: boolean;
10
+ editor?: RichTextField['editor'];
11
+ fromAddresses?: FromAddressesResolver;
12
+ departments?: DepartmentEmailsResolver;
13
+ recipients?: RecipientsConfig;
14
+ };
15
+ //#endregion
16
+ export { EmailActionOptions };
17
+ //# sourceMappingURL=emailAction.d.ts.map
@@ -0,0 +1,83 @@
1
+ import { keys } from "../../translations/keys.js";
2
+ import { labelFor } from "../../translations/server.js";
3
+ import { localizedIf } from "../../fields/localizedIf.js";
4
+ import { interpolate } from "../../recall/interpolate.js";
5
+ import { buildRecipientField, resolveRecipients } from "../emailRecipients.js";
6
+ import { resolverFor } from "../body/serializeBody.js";
7
+ import { defineAction } from "../defineAction.js";
8
+ import { buildFromField } from "../fromAddresses.js";
9
+ //#region src/actions/builtin/emailAction.ts
10
+ /**
11
+ * The shared skeleton of the built-in email actions (`emailTeam`, `confirmation`): an identical
12
+ * config (a first row pairing the action's target with `replyTo`, an optional `from` select, a
13
+ * cc/bcc row, a subject, and a rich text body, content and recipient fields carrying `localized`
14
+ * when `localize`) and an identical send (interpolate the subject, render the body, resolve
15
+ * cc/bcc/replyTo, and hand a single comma-joined string per list to `payload.sendEmail`). Only the
16
+ * primary `to` target and its missing-value behavior differ, threaded through `spec`.
17
+ */
18
+ const buildEmailAction = (options, spec) => {
19
+ const { localize, editor, fromAddresses, departments, recipients } = options;
20
+ const endpoint = departments ? "departments" : void 0;
21
+ const recip = (name, labelKey) => buildRecipientField(name, labelKey, localize, {
22
+ endpoint,
23
+ recipients,
24
+ width: "50%",
25
+ departments
26
+ });
27
+ return defineAction({
28
+ type: spec.type,
29
+ label: spec.label,
30
+ config: [
31
+ {
32
+ type: "row",
33
+ fields: [spec.target(recip), recip("replyTo", keys.actionConfigReplyTo)]
34
+ },
35
+ ...fromAddresses ? [buildFromField(fromAddresses)] : [],
36
+ {
37
+ type: "row",
38
+ fields: [recip("cc", keys.actionConfigCc), recip("bcc", keys.actionConfigBcc)]
39
+ },
40
+ {
41
+ name: "subject",
42
+ type: "text",
43
+ label: labelFor(keys.actionConfigSubject),
44
+ ...localizedIf(localize)
45
+ },
46
+ {
47
+ name: "body",
48
+ type: "richText",
49
+ label: labelFor(keys.actionConfigBody),
50
+ admin: { description: labelFor(keys.actionConfigBodyDescription) },
51
+ ...localizedIf(localize),
52
+ ...editor ? { editor } : {}
53
+ }
54
+ ],
55
+ run: async ({ config, values, payload, renderBody }) => {
56
+ const resolve = resolverFor(values);
57
+ const to = spec.resolveTo(config, resolve);
58
+ if (!to) {
59
+ if (spec.onMissingTo === "throw") throw new Error(`${spec.type}: missing "to" address`);
60
+ return;
61
+ }
62
+ if (typeof payload.sendEmail !== "function") throw new Error(`${spec.type}: no email adapter configured`);
63
+ const subject = interpolate(config.subject ?? "", resolve);
64
+ const html = await renderBody(config.body);
65
+ const cc = resolveRecipients(config.cc, resolve);
66
+ const bcc = resolveRecipients(config.bcc, resolve);
67
+ const replyTo = resolveRecipients(config.replyTo, resolve);
68
+ await payload.sendEmail({
69
+ to,
70
+ subject,
71
+ html,
72
+ ...config.from ? { from: config.from } : {},
73
+ ...cc ? { cc } : {},
74
+ ...bcc ? { bcc } : {},
75
+ ...replyTo ? { replyTo } : {}
76
+ });
77
+ }
78
+ });
79
+ };
80
+ //#endregion
81
+ export { buildEmailAction };
82
+
83
+ //# sourceMappingURL=emailAction.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emailAction.js","names":[],"sources":["../../../src/actions/builtin/emailAction.ts"],"sourcesContent":["import type { Field, RichTextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../../email/departments'\nimport { localizedIf } from '../../fields/localizedIf'\nimport { interpolate } from '../../recall/interpolate'\nimport { keys } from '../../translations/keys'\nimport { labelFor } from '../../translations/server'\nimport { resolverFor } from '../body/serializeBody'\nimport { type ActionDefinition, defineAction } from '../defineAction'\nimport { buildRecipientField, type RecipientsConfig, resolveRecipients } from '../emailRecipients'\nimport { buildFromField, type FromAddressesResolver } from '../fromAddresses'\n\n/** The plugin-derived options every built-in email action is built from (was five positional args). */\nexport type EmailActionOptions = {\n\tlocalize: boolean\n\teditor?: RichTextField['editor']\n\tfromAddresses?: FromAddressesResolver\n\tdepartments?: DepartmentEmailsResolver\n\trecipients?: RecipientsConfig\n}\n\n/** The config fields shared by every built-in email action (each action adds its own `to` target). */\nexport type EmailActionConfig = {\n\tfrom?: string\n\tcc?: string[]\n\tbcc?: string[]\n\treplyTo?: string[]\n\tsubject?: string\n\tbody?: unknown\n}\n\n/** Builds a recipient-list field (`to`/`cc`/`bcc`/`replyTo`) with the shared width, endpoint, and options. */\ntype RecipientFieldBuilder = (name: string, labelKey: string) => Field\n\ntype Resolver = ReturnType<typeof resolverFor>\n\n/** What distinguishes one email action from another: identity, its primary target, and how it resolves/guards that target. */\ntype EmailActionSpec<TConfig extends EmailActionConfig> = {\n\ttype: string\n\tlabel: string\n\t/** The first cell of the opening row (paired with `replyTo`): a recipient list, or an email-field select. */\n\ttarget: (recip: RecipientFieldBuilder) => Field\n\t/** Resolve the primary `to` from the stored config, or `undefined` when there is no recipient. */\n\tresolveTo: (config: TConfig, resolve: Resolver) => string | undefined\n\t/** With no resolved `to`, `emailTeam` treats it as a misconfiguration (`throw`), `confirmation` as a silent skip. */\n\tonMissingTo: 'throw' | 'skip'\n}\n\n/**\n * The shared skeleton of the built-in email actions (`emailTeam`, `confirmation`): an identical\n * config (a first row pairing the action's target with `replyTo`, an optional `from` select, a\n * cc/bcc row, a subject, and a rich text body, content and recipient fields carrying `localized`\n * when `localize`) and an identical send (interpolate the subject, render the body, resolve\n * cc/bcc/replyTo, and hand a single comma-joined string per list to `payload.sendEmail`). Only the\n * primary `to` target and its missing-value behavior differ, threaded through `spec`.\n */\nexport const buildEmailAction = <TConfig extends EmailActionConfig>(\n\toptions: EmailActionOptions,\n\tspec: EmailActionSpec<TConfig>\n): ActionDefinition<TConfig> => {\n\tconst { localize, editor, fromAddresses, departments, recipients } = options\n\tconst endpoint = departments ? 'departments' : undefined\n\tconst recip: RecipientFieldBuilder = (name, labelKey) =>\n\t\tbuildRecipientField(name, labelKey, localize, {\n\t\t\tendpoint,\n\t\t\trecipients,\n\t\t\twidth: '50%',\n\t\t\tdepartments,\n\t\t})\n\treturn defineAction<TConfig>({\n\t\ttype: spec.type,\n\t\tlabel: spec.label,\n\t\tconfig: [\n\t\t\t{\n\t\t\t\ttype: 'row',\n\t\t\t\tfields: [spec.target(recip), recip('replyTo', keys.actionConfigReplyTo)],\n\t\t\t},\n\t\t\t...(fromAddresses ? [buildFromField(fromAddresses)] : []),\n\t\t\t{\n\t\t\t\ttype: 'row',\n\t\t\t\tfields: [recip('cc', keys.actionConfigCc), recip('bcc', keys.actionConfigBcc)],\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'subject',\n\t\t\t\ttype: 'text',\n\t\t\t\tlabel: labelFor(keys.actionConfigSubject),\n\t\t\t\t...localizedIf(localize),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'body',\n\t\t\t\ttype: 'richText',\n\t\t\t\tlabel: labelFor(keys.actionConfigBody),\n\t\t\t\tadmin: { description: labelFor(keys.actionConfigBodyDescription) },\n\t\t\t\t...localizedIf(localize),\n\t\t\t\t...(editor ? { editor } : {}),\n\t\t\t},\n\t\t],\n\t\trun: async ({ config, values, payload, renderBody }) => {\n\t\t\tconst resolve = resolverFor(values)\n\t\t\tconst to = spec.resolveTo(config, resolve)\n\t\t\tif (!to) {\n\t\t\t\tif (spec.onMissingTo === 'throw') {\n\t\t\t\t\tthrow new Error(`${spec.type}: missing \"to\" address`)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (typeof payload.sendEmail !== 'function') {\n\t\t\t\tthrow new Error(`${spec.type}: no email adapter configured`)\n\t\t\t}\n\n\t\t\tconst subject = interpolate(config.subject ?? '', resolve)\n\t\t\tconst html = await renderBody(config.body)\n\t\t\tconst cc = resolveRecipients(config.cc, resolve)\n\t\t\tconst bcc = resolveRecipients(config.bcc, resolve)\n\t\t\tconst replyTo = resolveRecipients(config.replyTo, resolve)\n\n\t\t\t// `from` was validated at save time against `fromAddresses(req)`; not re-checked here\n\t\t\t// (the job's `req` may differ from the authoring admin's, and the config is admin-authored,\n\t\t\t// not visitor-controlled), so the stored value is forwarded verbatim.\n\t\t\tawait payload.sendEmail({\n\t\t\t\tto,\n\t\t\t\tsubject,\n\t\t\t\thtml,\n\t\t\t\t...(config.from ? { from: config.from } : {}),\n\t\t\t\t...(cc ? { cc } : {}),\n\t\t\t\t...(bcc ? { bcc } : {}),\n\t\t\t\t...(replyTo ? { replyTo } : {}),\n\t\t\t})\n\t\t},\n\t})\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAuDA,MAAa,oBACZ,SACA,SAC+B;CAC/B,MAAM,EAAE,UAAU,QAAQ,eAAe,aAAa,eAAe;CACrE,MAAM,WAAW,cAAc,gBAAgB,KAAA;CAC/C,MAAM,SAAgC,MAAM,aAC3C,oBAAoB,MAAM,UAAU,UAAU;EAC7C;EACA;EACA,OAAO;EACP;CACD,CAAC;CACF,OAAO,aAAsB;EAC5B,MAAM,KAAK;EACX,OAAO,KAAK;EACZ,QAAQ;GACP;IACC,MAAM;IACN,QAAQ,CAAC,KAAK,OAAO,KAAK,GAAG,MAAM,WAAW,KAAK,mBAAmB,CAAC;GACxE;GACA,GAAI,gBAAgB,CAAC,eAAe,aAAa,CAAC,IAAI,CAAC;GACvD;IACC,MAAM;IACN,QAAQ,CAAC,MAAM,MAAM,KAAK,cAAc,GAAG,MAAM,OAAO,KAAK,eAAe,CAAC;GAC9E;GACA;IACC,MAAM;IACN,MAAM;IACN,OAAO,SAAS,KAAK,mBAAmB;IACxC,GAAG,YAAY,QAAQ;GACxB;GACA;IACC,MAAM;IACN,MAAM;IACN,OAAO,SAAS,KAAK,gBAAgB;IACrC,OAAO,EAAE,aAAa,SAAS,KAAK,2BAA2B,EAAE;IACjE,GAAG,YAAY,QAAQ;IACvB,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;GAC5B;EACD;EACA,KAAK,OAAO,EAAE,QAAQ,QAAQ,SAAS,iBAAiB;GACvD,MAAM,UAAU,YAAY,MAAM;GAClC,MAAM,KAAK,KAAK,UAAU,QAAQ,OAAO;GACzC,IAAI,CAAC,IAAI;IACR,IAAI,KAAK,gBAAgB,SACxB,MAAM,IAAI,MAAM,GAAG,KAAK,KAAK,uBAAuB;IAErD;GACD;GACA,IAAI,OAAO,QAAQ,cAAc,YAChC,MAAM,IAAI,MAAM,GAAG,KAAK,KAAK,8BAA8B;GAG5D,MAAM,UAAU,YAAY,OAAO,WAAW,IAAI,OAAO;GACzD,MAAM,OAAO,MAAM,WAAW,OAAO,IAAI;GACzC,MAAM,KAAK,kBAAkB,OAAO,IAAI,OAAO;GAC/C,MAAM,MAAM,kBAAkB,OAAO,KAAK,OAAO;GACjD,MAAM,UAAU,kBAAkB,OAAO,SAAS,OAAO;GAKzD,MAAM,QAAQ,UAAU;IACvB;IACA;IACA;IACA,GAAI,OAAO,OAAO,EAAE,MAAM,OAAO,KAAK,IAAI,CAAC;IAC3C,GAAI,KAAK,EAAE,GAAG,IAAI,CAAC;IACnB,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;IACrB,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;GAC9B,CAAC;EACF;CACD,CAAC;AACF"}
@@ -1,81 +1,21 @@
1
- import { localizedIf } from "../../fields/localizedIf.js";
2
- import { interpolate } from "../../recall/interpolate.js";
3
1
  import { keys } from "../../translations/keys.js";
4
- import { labelFor } from "../../translations/server.js";
5
- import { resolverFor } from "../body/serializeBody.js";
6
- import { defineAction } from "../defineAction.js";
7
- import { buildRecipientField, resolveRecipients } from "../emailRecipients.js";
8
- import { buildFromField } from "../fromAddresses.js";
2
+ import { resolveRecipients } from "../emailRecipients.js";
3
+ import { buildEmailAction } from "./emailAction.js";
9
4
  //#region src/actions/builtin/emailTeam.ts
10
5
  /**
11
- * `to`, `replyTo`, `cc`, and `bcc` are recipient lists (`RecipientsSelect`, stored as `string[]`):
12
- * each entry is a literal email, a picked department address (when `departments` is set its options
13
- * back the select), or a `{{field}}` token resolved from the submission at send. `from` is a single
14
- * address that never localizes, added only when `fromAddresses` is set. `subject` and `body` are email
15
- * content; `editor` overrides the body's richText editor. `recipients` narrows the recipient fields'
16
- * behavior (free-typed emails, field tokens). Content and recipient fields carry `localized: true`
17
- * when `localize` is true. At send, every recipient list is interpolated, empties dropped, and joined
18
- * into the comma-separated string `payload.sendEmail` (nodemailer) accepts.
6
+ * The `emailTeam` action: its target is a `to` recipient list (`RecipientsSelect`, `string[]`) of
7
+ * literal emails, picked department addresses, or `{{field}}` tokens resolved from the submission at
8
+ * send. An empty `to` is a misconfiguration and throws. Everything else (from/cc/bcc/replyTo, subject,
9
+ * body, localization) is the shared email-action skeleton.
19
10
  */
20
- const buildEmailTeam = (localize, editor, fromAddresses, departments, recipients) => {
21
- const endpoint = departments ? "departments" : void 0;
22
- const recip = (name, labelKey) => buildRecipientField(name, labelKey, localize, {
23
- endpoint,
24
- recipients,
25
- width: "50%"
26
- });
27
- return defineAction({
28
- type: "emailTeam",
29
- label: keys.actionEmailTeam,
30
- config: [
31
- {
32
- type: "row",
33
- fields: [recip("to", keys.actionConfigTo), recip("replyTo", keys.actionConfigReplyTo)]
34
- },
35
- ...fromAddresses ? [buildFromField(fromAddresses)] : [],
36
- {
37
- type: "row",
38
- fields: [recip("cc", keys.actionConfigCc), recip("bcc", keys.actionConfigBcc)]
39
- },
40
- {
41
- name: "subject",
42
- type: "text",
43
- label: labelFor(keys.actionConfigSubject),
44
- ...localizedIf(localize)
45
- },
46
- {
47
- name: "body",
48
- type: "richText",
49
- label: labelFor(keys.actionConfigBody),
50
- admin: { description: labelFor(keys.actionConfigBodyDescription) },
51
- ...localizedIf(localize),
52
- ...editor ? { editor } : {}
53
- }
54
- ],
55
- run: async (args) => {
56
- const { config, values, payload, renderBody } = args;
57
- const resolve = resolverFor(values);
58
- const to = resolveRecipients(config.to, resolve);
59
- if (!to) throw new Error("emailTeam: missing \"to\" address");
60
- if (typeof payload.sendEmail !== "function") throw new Error("emailTeam: no email adapter configured");
61
- const subject = interpolate(config.subject ?? "", resolve);
62
- const html = await renderBody(config.body);
63
- const cc = resolveRecipients(config.cc, resolve);
64
- const bcc = resolveRecipients(config.bcc, resolve);
65
- const replyTo = resolveRecipients(config.replyTo, resolve);
66
- await payload.sendEmail({
67
- to,
68
- subject,
69
- html,
70
- ...config.from ? { from: config.from } : {},
71
- ...cc ? { cc } : {},
72
- ...bcc ? { bcc } : {},
73
- ...replyTo ? { replyTo } : {}
74
- });
75
- }
76
- });
77
- };
78
- buildEmailTeam(true);
11
+ const buildEmailTeam = (options) => buildEmailAction(options, {
12
+ type: "emailTeam",
13
+ label: keys.actionEmailTeam,
14
+ target: (recip) => recip("to", keys.actionConfigTo),
15
+ resolveTo: (config, resolve) => resolveRecipients(config.to, resolve),
16
+ onMissingTo: "throw"
17
+ });
18
+ buildEmailTeam({ localize: true });
79
19
  //#endregion
80
20
  export { buildEmailTeam };
81
21
 
@@ -1 +1 @@
1
- {"version":3,"file":"emailTeam.js","names":[],"sources":["../../../src/actions/builtin/emailTeam.ts"],"sourcesContent":["import type { RichTextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../../email/departments'\nimport { localizedIf } from '../../fields/localizedIf'\nimport { interpolate } from '../../recall/interpolate'\nimport { keys } from '../../translations/keys'\nimport { labelFor } from '../../translations/server'\nimport { resolverFor } from '../body/serializeBody'\nimport { defineAction } from '../defineAction'\nimport { buildRecipientField, type RecipientsConfig, resolveRecipients } from '../emailRecipients'\nimport { buildFromField, type FromAddressesResolver } from '../fromAddresses'\n\ntype EmailTeamConfig = {\n\tto?: string[]\n\tfrom?: string\n\tcc?: string[]\n\tbcc?: string[]\n\treplyTo?: string[]\n\tsubject?: string\n\tbody?: unknown\n}\n\n/**\n * `to`, `replyTo`, `cc`, and `bcc` are recipient lists (`RecipientsSelect`, stored as `string[]`):\n * each entry is a literal email, a picked department address (when `departments` is set its options\n * back the select), or a `{{field}}` token resolved from the submission at send. `from` is a single\n * address that never localizes, added only when `fromAddresses` is set. `subject` and `body` are email\n * content; `editor` overrides the body's richText editor. `recipients` narrows the recipient fields'\n * behavior (free-typed emails, field tokens). Content and recipient fields carry `localized: true`\n * when `localize` is true. At send, every recipient list is interpolated, empties dropped, and joined\n * into the comma-separated string `payload.sendEmail` (nodemailer) accepts.\n */\n// biome-ignore lint/complexity/useMaxParams: positional args thread plugin options (localize, editor, fromAddresses, departments, recipients)\nexport const buildEmailTeam = (\n\tlocalize: boolean,\n\teditor?: RichTextField['editor'],\n\tfromAddresses?: FromAddressesResolver,\n\tdepartments?: DepartmentEmailsResolver,\n\trecipients?: RecipientsConfig\n) => {\n\tconst endpoint = departments ? 'departments' : undefined\n\tconst recip = (name: string, labelKey: string) =>\n\t\tbuildRecipientField(name, labelKey, localize, { endpoint, recipients, width: '50%' })\n\treturn defineAction<EmailTeamConfig>({\n\t\ttype: 'emailTeam',\n\t\tlabel: keys.actionEmailTeam,\n\t\tconfig: [\n\t\t\t{\n\t\t\t\ttype: 'row',\n\t\t\t\tfields: [recip('to', keys.actionConfigTo), recip('replyTo', keys.actionConfigReplyTo)],\n\t\t\t},\n\t\t\t...(fromAddresses ? [buildFromField(fromAddresses)] : []),\n\t\t\t{\n\t\t\t\ttype: 'row',\n\t\t\t\tfields: [recip('cc', keys.actionConfigCc), recip('bcc', keys.actionConfigBcc)],\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'subject',\n\t\t\t\ttype: 'text',\n\t\t\t\tlabel: labelFor(keys.actionConfigSubject),\n\t\t\t\t...localizedIf(localize),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'body',\n\t\t\t\ttype: 'richText',\n\t\t\t\tlabel: labelFor(keys.actionConfigBody),\n\t\t\t\tadmin: { description: labelFor(keys.actionConfigBodyDescription) },\n\t\t\t\t...localizedIf(localize),\n\t\t\t\t...(editor ? { editor } : {}),\n\t\t\t},\n\t\t],\n\t\trun: async (args) => {\n\t\t\tconst { config, values, payload, renderBody } = args\n\n\t\t\tconst resolve = resolverFor(values)\n\t\t\tconst to = resolveRecipients(config.to, resolve)\n\t\t\tif (!to) {\n\t\t\t\tthrow new Error('emailTeam: missing \"to\" address')\n\t\t\t}\n\t\t\tif (typeof payload.sendEmail !== 'function') {\n\t\t\t\tthrow new Error('emailTeam: no email adapter configured')\n\t\t\t}\n\n\t\t\tconst subject = interpolate(config.subject ?? '', resolve)\n\t\t\tconst html = await renderBody(config.body)\n\t\t\tconst cc = resolveRecipients(config.cc, resolve)\n\t\t\tconst bcc = resolveRecipients(config.bcc, resolve)\n\t\t\tconst replyTo = resolveRecipients(config.replyTo, resolve)\n\n\t\t\t// `from` was validated at save time against `fromAddresses(req)`; not re-checked here\n\t\t\t// (the job's `req` may differ from the authoring admin's, and the config is\n\t\t\t// admin-authored, not visitor-controlled), so the stored value is forwarded verbatim.\n\t\t\tawait payload.sendEmail({\n\t\t\t\tto,\n\t\t\t\tsubject,\n\t\t\t\thtml,\n\t\t\t\t...(config.from ? { from: config.from } : {}),\n\t\t\t\t...(cc ? { cc } : {}),\n\t\t\t\t...(bcc ? { bcc } : {}),\n\t\t\t\t...(replyTo ? { replyTo } : {}),\n\t\t\t})\n\t\t},\n\t})\n}\n\nexport const emailTeam = buildEmailTeam(true)\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAgCA,MAAa,kBACZ,UACA,QACA,eACA,aACA,eACI;CACJ,MAAM,WAAW,cAAc,gBAAgB,KAAA;CAC/C,MAAM,SAAS,MAAc,aAC5B,oBAAoB,MAAM,UAAU,UAAU;EAAE;EAAU;EAAY,OAAO;CAAM,CAAC;CACrF,OAAO,aAA8B;EACpC,MAAM;EACN,OAAO,KAAK;EACZ,QAAQ;GACP;IACC,MAAM;IACN,QAAQ,CAAC,MAAM,MAAM,KAAK,cAAc,GAAG,MAAM,WAAW,KAAK,mBAAmB,CAAC;GACtF;GACA,GAAI,gBAAgB,CAAC,eAAe,aAAa,CAAC,IAAI,CAAC;GACvD;IACC,MAAM;IACN,QAAQ,CAAC,MAAM,MAAM,KAAK,cAAc,GAAG,MAAM,OAAO,KAAK,eAAe,CAAC;GAC9E;GACA;IACC,MAAM;IACN,MAAM;IACN,OAAO,SAAS,KAAK,mBAAmB;IACxC,GAAG,YAAY,QAAQ;GACxB;GACA;IACC,MAAM;IACN,MAAM;IACN,OAAO,SAAS,KAAK,gBAAgB;IACrC,OAAO,EAAE,aAAa,SAAS,KAAK,2BAA2B,EAAE;IACjE,GAAG,YAAY,QAAQ;IACvB,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;GAC5B;EACD;EACA,KAAK,OAAO,SAAS;GACpB,MAAM,EAAE,QAAQ,QAAQ,SAAS,eAAe;GAEhD,MAAM,UAAU,YAAY,MAAM;GAClC,MAAM,KAAK,kBAAkB,OAAO,IAAI,OAAO;GAC/C,IAAI,CAAC,IACJ,MAAM,IAAI,MAAM,mCAAiC;GAElD,IAAI,OAAO,QAAQ,cAAc,YAChC,MAAM,IAAI,MAAM,wCAAwC;GAGzD,MAAM,UAAU,YAAY,OAAO,WAAW,IAAI,OAAO;GACzD,MAAM,OAAO,MAAM,WAAW,OAAO,IAAI;GACzC,MAAM,KAAK,kBAAkB,OAAO,IAAI,OAAO;GAC/C,MAAM,MAAM,kBAAkB,OAAO,KAAK,OAAO;GACjD,MAAM,UAAU,kBAAkB,OAAO,SAAS,OAAO;GAKzD,MAAM,QAAQ,UAAU;IACvB;IACA;IACA;IACA,GAAI,OAAO,OAAO,EAAE,MAAM,OAAO,KAAK,IAAI,CAAC;IAC3C,GAAI,KAAK,EAAE,GAAG,IAAI,CAAC;IACnB,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;IACrB,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;GAC9B,CAAC;EACF;CACD,CAAC;AACF;AAEyB,eAAe,IAAI"}
1
+ {"version":3,"file":"emailTeam.js","names":[],"sources":["../../../src/actions/builtin/emailTeam.ts"],"sourcesContent":["import { keys } from '../../translations/keys'\nimport { resolveRecipients } from '../emailRecipients'\nimport { buildEmailAction, type EmailActionConfig, type EmailActionOptions } from './emailAction'\n\ntype EmailTeamConfig = EmailActionConfig & { to?: string[] }\n\n/**\n * The `emailTeam` action: its target is a `to` recipient list (`RecipientsSelect`, `string[]`) of\n * literal emails, picked department addresses, or `{{field}}` tokens resolved from the submission at\n * send. An empty `to` is a misconfiguration and throws. Everything else (from/cc/bcc/replyTo, subject,\n * body, localization) is the shared email-action skeleton.\n */\nexport const buildEmailTeam = (options: EmailActionOptions) =>\n\tbuildEmailAction<EmailTeamConfig>(options, {\n\t\ttype: 'emailTeam',\n\t\tlabel: keys.actionEmailTeam,\n\t\ttarget: (recip) => recip('to', keys.actionConfigTo),\n\t\tresolveTo: (config, resolve) => resolveRecipients(config.to, resolve),\n\t\tonMissingTo: 'throw',\n\t})\n\nexport const emailTeam = buildEmailTeam({ localize: true })\n"],"mappings":";;;;;;;;;;AAYA,MAAa,kBAAkB,YAC9B,iBAAkC,SAAS;CAC1C,MAAM;CACN,OAAO,KAAK;CACZ,SAAS,UAAU,MAAM,MAAM,KAAK,cAAc;CAClD,YAAY,QAAQ,YAAY,kBAAkB,OAAO,IAAI,OAAO;CACpE,aAAa;AACd,CAAC;AAEuB,eAAe,EAAE,UAAU,KAAK,CAAC"}
@@ -1,23 +1,20 @@
1
1
  import { AnyActionDefinition } from "../defineAction.js";
2
- import { FromAddressesResolver } from "../fromAddresses.js";
3
- import { RecipientsConfig } from "../emailRecipients.js";
4
- import { DepartmentEmailsResolver } from "../../email/departments.js";
5
- import { RichTextField } from "payload";
2
+ import { EmailActionOptions } from "./emailAction.js";
6
3
 
7
4
  //#region src/actions/builtin/index.d.ts
8
5
  /**
9
6
  * Built-in action definitions with content-bearing config fields (email subjects and rich text
10
- * bodies) carrying `localized: true` when `localize` is true. `editor`, when given, overrides
11
- * the Lexical/richText editor on both action bodies (the plugin passes
12
- * `richText.bodyEditor ?? richText.editor`). `fromAddresses`, when given (the plugin's
13
- * `email.fromAddresses` option), adds a `from` select to both email actions. `departments`, when
14
- * given (the plugin's `email.departments` option), turns the `emailTeam` `to` into a select over the
15
- * host's resolved departments. `signedWebhook` has no content fields and is shared static.
7
+ * bodies) carrying `localized: true` when `options.localize` is true. `options.editor`, when given,
8
+ * overrides the Lexical/richText editor on both action bodies (the plugin passes
9
+ * `richText.bodyEditor ?? richText.editor`). `options.fromAddresses` (the plugin's
10
+ * `email.fromAddresses` option) adds a `from` select to both email actions. `options.departments`
11
+ * (the plugin's `email.departments` option) turns the recipient selects into pickers over the host's
12
+ * resolved departments. `signedWebhook` has no content fields and is shared static.
16
13
  */
17
- declare const buildDefaultActionDefinitions: (localize: boolean, editor?: RichTextField["editor"], fromAddresses?: FromAddressesResolver, departments?: DepartmentEmailsResolver, recipients?: RecipientsConfig) => Record<string, AnyActionDefinition>;
14
+ declare const buildDefaultActionDefinitions: (options: EmailActionOptions) => Record<string, AnyActionDefinition>;
18
15
  /**
19
16
  * Prebuilt with content localization on; with `localizeContent: false`, derive overrides from
20
- * `buildDefaultActionDefinitions(false)` instead so a spread does not reintroduce `localized`.
17
+ * `buildDefaultActionDefinitions({ localize: false })` instead so a spread does not reintroduce `localized`.
21
18
  */
22
19
  declare const defaultActionDefinitions: Record<string, AnyActionDefinition>;
23
20
  //#endregion