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

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 (182) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/actions/assertNoBlockCollision.js +18 -0
  3. package/dist/actions/assertNoBlockCollision.js.map +1 -0
  4. package/dist/actions/builtin/confirmation.js +2 -1
  5. package/dist/actions/builtin/confirmation.js.map +1 -1
  6. package/dist/actions/builtin/emailAction.d.ts +3 -1
  7. package/dist/actions/builtin/emailAction.js +43 -12
  8. package/dist/actions/builtin/emailAction.js.map +1 -1
  9. package/dist/actions/builtin/emailTeam.js +11 -5
  10. package/dist/actions/builtin/emailTeam.js.map +1 -1
  11. package/dist/actions/defineAction.d.ts +3 -1
  12. package/dist/actions/defineAction.js.map +1 -1
  13. package/dist/actions/dispatch.js +1 -1
  14. package/dist/actions/dispatch.js.map +1 -1
  15. package/dist/actions/emailRecipients.js +34 -7
  16. package/dist/actions/emailRecipients.js.map +1 -1
  17. package/dist/actions/recipientSources.d.ts +34 -0
  18. package/dist/actions/recipientSources.js +11 -0
  19. package/dist/actions/recipientSources.js.map +1 -0
  20. package/dist/actions/task.js +20 -0
  21. package/dist/actions/task.js.map +1 -1
  22. package/dist/aggregation/resolveResultsRequest.d.ts +6 -0
  23. package/dist/aggregation/resolveResultsRequest.js +32 -3
  24. package/dist/aggregation/resolveResultsRequest.js.map +1 -1
  25. package/dist/calc/computeCalcFields.d.ts +5 -3
  26. package/dist/calc/computeCalcFields.js +4 -3
  27. package/dist/calc/computeCalcFields.js.map +1 -1
  28. package/dist/calc/evaluate.d.ts +26 -3
  29. package/dist/calc/evaluate.js +33 -11
  30. package/dist/calc/evaluate.js.map +1 -1
  31. package/dist/calc/formatCalc.d.ts +11 -0
  32. package/dist/calc/formatCalc.js +35 -0
  33. package/dist/calc/formatCalc.js.map +1 -0
  34. package/dist/calc/formatCalcValue.d.ts +17 -0
  35. package/dist/calc/formatCalcValue.js +17 -0
  36. package/dist/calc/formatCalcValue.js.map +1 -0
  37. package/dist/calc/normalizeCalc.d.ts +17 -3
  38. package/dist/calc/normalizeCalc.js +39 -17
  39. package/dist/calc/normalizeCalc.js.map +1 -1
  40. package/dist/calc/registry.d.ts +43 -0
  41. package/dist/calc/registry.js +27 -0
  42. package/dist/calc/registry.js.map +1 -0
  43. package/dist/calc/resolveCalcContext.d.ts +36 -0
  44. package/dist/calc/resolveCalcContext.js +87 -0
  45. package/dist/calc/resolveCalcContext.js.map +1 -0
  46. package/dist/calc/types.d.ts +18 -3
  47. package/dist/calc/types.js +2 -1
  48. package/dist/calc/types.js.map +1 -1
  49. package/dist/client/CalcExpressionBuilder.d.ts +20 -0
  50. package/dist/client/CalcExpressionBuilder.js +890 -0
  51. package/dist/client/CalcExpressionBuilder.js.map +1 -0
  52. package/dist/client/RecipientsSelect.d.ts +5 -1
  53. package/dist/client/RecipientsSelect.js +22 -3
  54. package/dist/client/RecipientsSelect.js.map +1 -1
  55. package/dist/client/admin.css +282 -0
  56. package/dist/collections/formSubmissions.js +34 -16
  57. package/dist/collections/formSubmissions.js.map +1 -1
  58. package/dist/collections/forms.js +59 -27
  59. package/dist/collections/forms.js.map +1 -1
  60. package/dist/collections/formsEndpoints.js +4 -2
  61. package/dist/collections/formsEndpoints.js.map +1 -1
  62. package/dist/collections/settingsFields.d.ts +31 -0
  63. package/dist/collections/settingsFields.js +45 -0
  64. package/dist/collections/settingsFields.js.map +1 -0
  65. package/dist/context/formContext.d.ts +30 -0
  66. package/dist/context/formContext.js +54 -0
  67. package/dist/context/formContext.js.map +1 -0
  68. package/dist/exports/client.d.ts +2 -1
  69. package/dist/exports/client.js +2 -1
  70. package/dist/exports/react.d.ts +7 -3
  71. package/dist/exports/react.js +6 -2
  72. package/dist/exports/rsc.d.ts +2 -1
  73. package/dist/exports/rsc.js +3 -2
  74. package/dist/exports/types.d.ts +1 -1
  75. package/dist/fields/buildFieldBlocks.js +2 -1
  76. package/dist/fields/buildFieldBlocks.js.map +1 -1
  77. package/dist/fields/builtin/calculation.d.ts +17 -0
  78. package/dist/fields/builtin/calculation.js +170 -115
  79. package/dist/fields/builtin/calculation.js.map +1 -1
  80. package/dist/fields/builtin/checkbox.js +15 -0
  81. package/dist/fields/builtin/checkbox.js.map +1 -1
  82. package/dist/fields/builtin/email.js +7 -0
  83. package/dist/fields/builtin/email.js.map +1 -1
  84. package/dist/fields/builtin/index.d.ts +6 -2
  85. package/dist/fields/builtin/index.js +9 -4
  86. package/dist/fields/builtin/index.js.map +1 -1
  87. package/dist/fields/builtin/select.js +20 -1
  88. package/dist/fields/builtin/select.js.map +1 -1
  89. package/dist/fields/builtin/text.js +7 -0
  90. package/dist/fields/builtin/text.js.map +1 -1
  91. package/dist/fields/instanceOptions.js +1 -1
  92. package/dist/fields/instanceOptions.js.map +1 -1
  93. package/dist/fields/sharedConfig.js +15 -11
  94. package/dist/fields/sharedConfig.js.map +1 -1
  95. package/dist/fields/types.d.ts +8 -2
  96. package/dist/form/toFormDocument.d.ts +7 -0
  97. package/dist/form/toFormDocument.js +2 -1
  98. package/dist/form/toFormDocument.js.map +1 -1
  99. package/dist/form/types.d.ts +7 -0
  100. package/dist/index.d.ts +14 -4
  101. package/dist/index.js +40 -9
  102. package/dist/index.js.map +1 -1
  103. package/dist/options.d.ts +42 -0
  104. package/dist/plugin/registerCollections.js +29 -19
  105. package/dist/plugin/registerCollections.js.map +1 -1
  106. package/dist/poll/closeJob.d.ts +4 -3
  107. package/dist/poll/closeJob.js +6 -4
  108. package/dist/poll/closeJob.js.map +1 -1
  109. package/dist/poll/pollTypeRegistry.js +1 -1
  110. package/dist/poll/resolvePollCloseRequest.js +2 -1
  111. package/dist/poll/resolvePollCloseRequest.js.map +1 -1
  112. package/dist/poll/resolvePollOutcome.d.ts +6 -0
  113. package/dist/poll/resolvePollOutcome.js +17 -3
  114. package/dist/poll/resolvePollOutcome.js.map +1 -1
  115. package/dist/poll/votes/aggregateFromVotes.d.ts +27 -0
  116. package/dist/poll/votes/aggregateFromVotes.js +53 -0
  117. package/dist/poll/votes/aggregateFromVotes.js.map +1 -0
  118. package/dist/poll/votes/bumpPollVote.js +72 -0
  119. package/dist/poll/votes/bumpPollVote.js.map +1 -0
  120. package/dist/poll/votes/recountPollVotes.d.ts +20 -0
  121. package/dist/poll/votes/recountPollVotes.js +63 -0
  122. package/dist/poll/votes/recountPollVotes.js.map +1 -0
  123. package/dist/poll/votes/voteTallyHook.js +58 -0
  124. package/dist/poll/votes/voteTallyHook.js.map +1 -0
  125. package/dist/poll/votes/votesCollection.d.ts +19 -0
  126. package/dist/poll/votes/votesCollection.js +89 -0
  127. package/dist/poll/votes/votesCollection.js.map +1 -0
  128. package/dist/react/Form.d.ts +15 -1
  129. package/dist/react/Form.js +24 -3
  130. package/dist/react/Form.js.map +1 -1
  131. package/dist/react/FormContext.d.ts +8 -1
  132. package/dist/react/FormContext.js.map +1 -1
  133. package/dist/react/primitives/Checkbox.d.ts +4 -2
  134. package/dist/react/primitives/Checkbox.js +3 -2
  135. package/dist/react/primitives/Checkbox.js.map +1 -1
  136. package/dist/react/primitives/ChoiceGroup.d.ts +40 -0
  137. package/dist/react/primitives/ChoiceGroup.js +33 -0
  138. package/dist/react/primitives/ChoiceGroup.js.map +1 -0
  139. package/dist/react/primitives/FieldShell.d.ts +11 -1
  140. package/dist/react/primitives/FieldShell.js +43 -33
  141. package/dist/react/primitives/FieldShell.js.map +1 -1
  142. package/dist/react/primitives/Input.d.ts +3 -1
  143. package/dist/react/primitives/Input.js +2 -1
  144. package/dist/react/primitives/Input.js.map +1 -1
  145. package/dist/react/renderers/calculation.js +6 -1
  146. package/dist/react/renderers/calculation.js.map +1 -1
  147. package/dist/react/renderers/checkbox.js +2 -1
  148. package/dist/react/renderers/checkbox.js.map +1 -1
  149. package/dist/react/renderers/email.js +3 -1
  150. package/dist/react/renderers/email.js.map +1 -1
  151. package/dist/react/renderers/select.js +20 -2
  152. package/dist/react/renderers/select.js.map +1 -1
  153. package/dist/react/renderers/text.js +3 -1
  154. package/dist/react/renderers/text.js.map +1 -1
  155. package/dist/react/useCalcValues.d.ts +10 -0
  156. package/dist/react/useCalcValues.js +13 -0
  157. package/dist/react/useCalcValues.js.map +1 -0
  158. package/dist/recall/resolver.js +1 -1
  159. package/dist/recall/resolver.js.map +1 -1
  160. package/dist/spam/constants.js +7 -1
  161. package/dist/spam/constants.js.map +1 -1
  162. package/dist/submissions/formIdOf.js +13 -0
  163. package/dist/submissions/formIdOf.js.map +1 -0
  164. package/dist/submissions/runSubmission.js +3 -3
  165. package/dist/submissions/runSubmission.js.map +1 -1
  166. package/dist/submissions/validateSubmission.js +26 -2
  167. package/dist/submissions/validateSubmission.js.map +1 -1
  168. package/dist/submissions/verifyContext.js +35 -0
  169. package/dist/submissions/verifyContext.js.map +1 -0
  170. package/dist/translations/de.js +62 -3
  171. package/dist/translations/de.js.map +1 -1
  172. package/dist/translations/en.js +62 -3
  173. package/dist/translations/en.js.map +1 -1
  174. package/dist/translations/keys.d.ts +60 -1
  175. package/dist/translations/keys.js +60 -1
  176. package/dist/translations/keys.js.map +1 -1
  177. package/dist/validation/builtin/index.js +2 -0
  178. package/dist/validation/builtin/index.js.map +1 -1
  179. package/dist/validation/builtin/integer.js +15 -0
  180. package/dist/validation/builtin/integer.js.map +1 -0
  181. package/package.json +11 -5
  182. package/styles/form-builder.css +84 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @10x-media/form-builder
2
2
 
3
+ ## 0.1.0-beta.12
4
+
5
+ ### Minor Changes
6
+
7
+ - Authoring UX round: a visual calculation builder, display variants, an optional-by-default field config surface, and a poll vote tally store.
8
+
9
+ - **Visual calculation builder.** The calculation field's expression is authored in a native left-to-right chain editor: start with a value (Field, Number, Weighted field, Function, or a registered source), add operation steps, optionally finish with a function, with a live readable preview and honest remove affordances throughout. The stored expression tree is unchanged and any API-written expression loads as the equivalent chain; read-only and error states are wired, and field pickers only offer numeric fields (calculations restricted to earlier ones, matching evaluation order).
10
+ - **Calculation extensibility.** A new `calc` option opens the expression engine to host data: `calc.sources` registers named value resolvers (`resolve` returns one number per render/submission, e.g. a tax rate or member discount; `resolveWeights` returns per-option numbers for a chosen field, e.g. each selectable product's live price) that resolve server-side, ride the form document for the client preview, and re-resolve authoritatively at submit; `calc.functions` registers custom calculation functions (client previews additionally accept the same map via `<Form calcFunctions>`). The calculation field also gains display formatting (`decimals`, localized `prefix`/`suffix`) applied in the renderer, admin answers, and email output. New utilities: `defineCalcSource`, `defineCalcFunction`, `resolveCalcContext`, `formatCalc`, `formatCalcValue`, and a `useCalcValues()` hook for composed frontends reading live computed values.
11
+ - **Display variants.** Select fields gain an optional `display` setting (`dropdown` default, `radio`, `buttons`) rendered as an accessible radio group with the invalid-state focus routing intact; checkbox fields gain `checkbox` or `switch`. The `ChoiceGroup` primitive is exported for custom renderers.
12
+ - **Optional field settings.** Field width and select option labels are no longer required: width defaults to full (the renderer already falls back), and a missing or blank option label falls back to the value everywhere (renderer, admin answers, aggregation, recall). Width option labels are localized.
13
+ - **Sidebar form flags with a compose seam.** `multistep`, `pollEnabled`, and `persistSubmissions` are stacked sidebar checkboxes, composed through the new `settings.fields` option so a host can relocate, wrap, extend, or drop them. Forms now registers before form-submissions in the admin nav.
14
+ - **Advanced tab seam.** Field types can append settings to the Advanced tab via `advancedConfig`; text and email use it for an `autocomplete` autofill hint. The `hidden` flag documents that hidden fields are still validated. New `integer` validation rule for number fields; flow validation messages are localized.
15
+ - **Poll vote store (default on).** Votes are counted into a hidden append-only tally collection (`form-poll-votes`) when a submission completes: results reads and `mostVoted` resolution become one indexed find (no scan, no 10k truncation), tallies survive `persistSubmissions: false` and admin deletions (votes are permanent), rows are pure aggregate so a persist-off poll retains zero per-visitor data, and writes are atomic, transaction-joined, and sharded against concurrent write conflicts. `poll.votes: false` restores scan-based results and rejects persist-off polls at save. Exported: `recountPollVotes`, `aggregateFromVotes`, `POLL_VOTES_SLUG`, `RESPONDENTS_VALUE`, `VOTE_SHARDS`. Postgres hosts create the new `form_poll_votes` table with their next migration; `@10x-media/form-builder` declares `@payloadcms/db-postgres` as an optional peer for the tally write path.
16
+
17
+ ## 0.1.0-beta.11
18
+
19
+ ### Minor Changes
20
+
21
+ - Server-resolved recipients with a signed form context, opt-out submission storage, and an action-block collision guard.
22
+
23
+ - **Recipient sources.** A new `email.recipientSources` option registers recipients the plugin resolves server-side at send time, offered in every recipient field (**To**/**Cc**/**Bcc**/**Reply-To**) as their own option group. A source's namespaced `value` cannot collide with an address, its `resolve` returns zero or more addresses (each sanitized to one, so a source cannot inject headers), an empty resolve is dropped, and a send with every recipient empty is skipped rather than sent with an empty **To**. This lets one reusable form email "the person this page is about" without an address ever reaching the browser.
24
+ - **Signed form context.** `signFormContext({ payload, relationTo, value })` (exported from `/rsc` and root) mints a signed, expiring reference to the document a form was rendered for; `<Form context={token} />` carries it, the plugin verifies it on submit (rejecting a tampered or expired one rather than dropping it), stores the verified `{ relationTo, value }` on the submission, and hands it to each source's `resolve`. The reference is signed, not encrypted (the id is already public); it never appears in the answers, the admin answers view, or `{{*}}` body output.
25
+ - **`persistSubmissions`.** A per-form "Store submissions" flag (default on). Unchecked, the plugin deletes the submission after its actions run, for a pure signup form that only POSTs to a provider. Deletion is once, after the whole action pass, regardless of individual action success; pruning does not cascade to uploaded files (documented).
26
+ - **Action-block collision guard.** The plugin now throws a clear error at boot if a registered action `type` collides with a host Payload block slug (which Payload would otherwise silently merge), instead of corrupting the stored action shape. Action block slugs are unchanged, so existing actions keep working.
27
+
3
28
  ## 0.1.0-beta.10
4
29
 
5
30
  ### Minor Changes
@@ -0,0 +1,18 @@
1
+ //#region src/actions/assertNoBlockCollision.ts
2
+ /**
3
+ * Fail fast at boot when a registered action type collides with a host block slug. Payload resolves
4
+ * blocks globally by slug, so a shared slug makes a saved action document read back with the content
5
+ * block's fields merged in, silently polluting the stored shape and admin tabs. Rather than let that
6
+ * happen quietly, throw a clear error naming the collision so the author renames one side. Action block
7
+ * slugs stay the raw type (renaming them would break every already-stored action), so this detection is
8
+ * the non-breaking guard.
9
+ */
10
+ const assertNoActionBlockCollision = (config, registry) => {
11
+ const hostSlugs = /* @__PURE__ */ new Set();
12
+ for (const block of Array.isArray(config.blocks) ? config.blocks : []) if (block && typeof block === "object" && typeof block.slug === "string") hostSlugs.add(block.slug);
13
+ for (const type of registry.keys()) if (hostSlugs.has(type)) throw new Error(`@10x-media/form-builder: action type "${type}" collides with an existing Payload block slug "${type}". Rename the action type or the block.`);
14
+ };
15
+ //#endregion
16
+ export { assertNoActionBlockCollision };
17
+
18
+ //# sourceMappingURL=assertNoBlockCollision.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assertNoBlockCollision.js","names":[],"sources":["../../src/actions/assertNoBlockCollision.ts"],"sourcesContent":["import type { Config } from 'payload'\nimport type { ActionRegistry } from './registry'\n\n/**\n * Fail fast at boot when a registered action type collides with a host block slug. Payload resolves\n * blocks globally by slug, so a shared slug makes a saved action document read back with the content\n * block's fields merged in, silently polluting the stored shape and admin tabs. Rather than let that\n * happen quietly, throw a clear error naming the collision so the author renames one side. Action block\n * slugs stay the raw type (renaming them would break every already-stored action), so this detection is\n * the non-breaking guard.\n */\nexport const assertNoActionBlockCollision = (config: Config, registry: ActionRegistry): void => {\n\tconst hostSlugs = new Set<string>()\n\tfor (const block of Array.isArray(config.blocks) ? config.blocks : []) {\n\t\tif (block && typeof block === 'object' && typeof block.slug === 'string') {\n\t\t\thostSlugs.add(block.slug)\n\t\t}\n\t}\n\tfor (const type of registry.keys()) {\n\t\tif (hostSlugs.has(type)) {\n\t\t\tthrow new Error(\n\t\t\t\t`@10x-media/form-builder: action type \"${type}\" collides with an existing Payload block slug \"${type}\". Rename the action type or the block.`\n\t\t\t)\n\t\t}\n\t}\n}\n"],"mappings":";;;;;;;;;AAWA,MAAa,gCAAgC,QAAgB,aAAmC;CAC/F,MAAM,4BAAY,IAAI,IAAY;CAClC,KAAK,MAAM,SAAS,MAAM,QAAQ,OAAO,MAAM,IAAI,OAAO,SAAS,CAAC,GACnE,IAAI,SAAS,OAAO,UAAU,YAAY,OAAO,MAAM,SAAS,UAC/D,UAAU,IAAI,MAAM,IAAI;CAG1B,KAAK,MAAM,QAAQ,SAAS,KAAK,GAChC,IAAI,UAAU,IAAI,IAAI,GACrB,MAAM,IAAI,MACT,yCAAyC,KAAK,kDAAkD,KAAK,wCACtG;AAGH"}
@@ -41,7 +41,8 @@ const buildConfirmation = (options) => {
41
41
  type: "confirmation",
42
42
  label: keys.actionConfirmation,
43
43
  target: () => toField,
44
- resolveTo: (config, resolve) => config.toField ? firstAddress(resolve(config.toField)) : void 0,
44
+ resolveTo: ({ config, resolve }) => config.toField ? firstAddress(resolve(config.toField)) : "",
45
+ hasTarget: (config) => Boolean(config.toField),
45
46
  onMissingTo: "skip"
46
47
  });
47
48
  };
@@ -1 +1 @@
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"}
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)) : '',\n\t\thasTarget: (config) => Boolean(config.toField),\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,EAAE,QAAQ,cACrB,OAAO,UAAU,aAAa,QAAQ,OAAO,OAAO,CAAC,IAAI;EAC1D,YAAY,WAAW,QAAQ,OAAO,OAAO;EAC7C,aAAa;CACd,CAAC;AACF;AAE4B,kBAAkB,EAAE,UAAU,KAAK,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import { FromAddressesResolver } from "../fromAddresses.js";
2
2
  import { DepartmentEmailsResolver } from "../../email/departments.js";
3
+ import { RecipientSourceRegistry } from "../recipientSources.js";
3
4
  import { RecipientsConfig } from "../emailRecipients.js";
4
5
  import { Field, RichTextField } from "payload";
5
6
 
@@ -10,7 +11,8 @@ type EmailActionOptions = {
10
11
  editor?: RichTextField['editor'];
11
12
  fromAddresses?: FromAddressesResolver;
12
13
  departments?: DepartmentEmailsResolver;
13
- recipients?: RecipientsConfig;
14
+ recipients?: RecipientsConfig; /** Server-resolved recipient sources offered in every recipient list (plugin option `email.recipientSources`). */
15
+ recipientSources?: RecipientSourceRegistry;
14
16
  };
15
17
  //#endregion
16
18
  export { EmailActionOptions };
@@ -2,10 +2,11 @@ import { keys } from "../../translations/keys.js";
2
2
  import { labelFor } from "../../translations/server.js";
3
3
  import { localizedIf } from "../../fields/localizedIf.js";
4
4
  import { interpolate } from "../../recall/interpolate.js";
5
- import { buildRecipientField, resolveRecipients } from "../emailRecipients.js";
5
+ import { buildRecipientField, resolveRecipientEntries } from "../emailRecipients.js";
6
6
  import { resolverFor } from "../body/serializeBody.js";
7
7
  import { defineAction } from "../defineAction.js";
8
8
  import { buildFromField } from "../fromAddresses.js";
9
+ import { sourcesByValue } from "../recipientSources.js";
9
10
  //#region src/actions/builtin/emailAction.ts
10
11
  /**
11
12
  * The shared skeleton of the built-in email actions (`emailTeam`, `confirmation`): an identical
@@ -16,13 +17,14 @@ import { buildFromField } from "../fromAddresses.js";
16
17
  * primary `to` target and its missing-value behavior differ, threaded through `spec`.
17
18
  */
18
19
  const buildEmailAction = (options, spec) => {
19
- const { localize, editor, fromAddresses, departments, recipients } = options;
20
+ const { localize, editor, fromAddresses, departments, recipients, recipientSources } = options;
20
21
  const endpoint = departments ? "departments" : void 0;
21
22
  const recip = (name, labelKey) => buildRecipientField(name, labelKey, localize, {
22
23
  endpoint,
23
24
  recipients,
24
25
  width: "50%",
25
- departments
26
+ departments,
27
+ sources: recipientSources
26
28
  });
27
29
  return defineAction({
28
30
  type: spec.type,
@@ -52,20 +54,49 @@ const buildEmailAction = (options, spec) => {
52
54
  ...editor ? { editor } : {}
53
55
  }
54
56
  ],
55
- run: async ({ config, values, payload, renderBody }) => {
57
+ run: async (args) => {
58
+ const { config, values } = args;
56
59
  const resolve = resolverFor(values);
57
- const to = spec.resolveTo(config, resolve);
60
+ const sources = sourcesByValue(options.recipientSources);
61
+ const sourceArgs = {
62
+ context: args.context,
63
+ values,
64
+ descriptors: args.descriptors,
65
+ form: args.form,
66
+ submissionId: args.submissionId,
67
+ payload: args.payload,
68
+ req: args.req,
69
+ locale: args.locale
70
+ };
71
+ const to = await spec.resolveTo({
72
+ config,
73
+ resolve,
74
+ sources,
75
+ sourceArgs
76
+ });
58
77
  if (!to) {
59
- if (spec.onMissingTo === "throw") throw new Error(`${spec.type}: missing "to" address`);
78
+ if (!spec.hasTarget(config) && spec.onMissingTo === "throw") throw new Error(`${spec.type}: missing "to" address`);
60
79
  return;
61
80
  }
62
- if (typeof payload.sendEmail !== "function") throw new Error(`${spec.type}: no email adapter configured`);
81
+ if (typeof args.payload.sendEmail !== "function") throw new Error(`${spec.type}: no email adapter configured`);
63
82
  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({
83
+ const html = await args.renderBody(config.body);
84
+ const cc = (await resolveRecipientEntries(config.cc, {
85
+ resolve,
86
+ sources,
87
+ sourceArgs
88
+ })).join(", ");
89
+ const bcc = (await resolveRecipientEntries(config.bcc, {
90
+ resolve,
91
+ sources,
92
+ sourceArgs
93
+ })).join(", ");
94
+ const replyTo = (await resolveRecipientEntries(config.replyTo, {
95
+ resolve,
96
+ sources,
97
+ sourceArgs
98
+ })).join(", ");
99
+ await args.payload.sendEmail({
69
100
  to,
70
101
  subject,
71
102
  html,
@@ -1 +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
+ {"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 {\n\tbuildRecipientField,\n\ttype RecipientsConfig,\n\tresolveRecipientEntries,\n} from '../emailRecipients'\nimport { buildFromField, type FromAddressesResolver } from '../fromAddresses'\nimport {\n\ttype RecipientResolveArgs,\n\ttype RecipientSource,\n\ttype RecipientSourceRegistry,\n\tsourcesByValue,\n} from '../recipientSources'\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\t/** Server-resolved recipient sources offered in every recipient list (plugin option `email.recipientSources`). */\n\trecipientSources?: RecipientSourceRegistry\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 `resolveTo` needs to compute the primary target, including server-resolved sources. */\ntype ResolveToArgs<TConfig extends EmailActionConfig> = {\n\tconfig: TConfig\n\tresolve: Resolver\n\tsources: Map<string, RecipientSource>\n\tsourceArgs: RecipientResolveArgs\n}\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` to a comma-joined address string, or `''` when nothing resolves. */\n\tresolveTo: (args: ResolveToArgs<TConfig>) => Promise<string> | string\n\t/**\n\t * Whether the author configured any target at all. A configured target that resolves empty (e.g. a\n\t * source returned `[]`) is a normal skip; only a target the author never set is a misconfiguration.\n\t */\n\thasTarget: (config: TConfig) => boolean\n\t/** With no target authored, `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, recipientSources } = 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\tsources: recipientSources,\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 (args) => {\n\t\t\tconst { config, values } = args\n\t\t\tconst resolve = resolverFor(values)\n\t\t\tconst sources = sourcesByValue(options.recipientSources)\n\t\t\tconst sourceArgs: RecipientResolveArgs = {\n\t\t\t\tcontext: args.context,\n\t\t\t\tvalues,\n\t\t\t\tdescriptors: args.descriptors,\n\t\t\t\tform: args.form,\n\t\t\t\tsubmissionId: args.submissionId,\n\t\t\t\tpayload: args.payload,\n\t\t\t\treq: args.req,\n\t\t\t\tlocale: args.locale,\n\t\t\t}\n\t\t\tconst to = await spec.resolveTo({ config, resolve, sources, sourceArgs })\n\t\t\tif (!to) {\n\t\t\t\t// Nothing to send to. A target the author never configured is a misconfiguration (emailTeam\n\t\t\t\t// throws); a configured target that resolved empty (e.g. a source returned []) is a normal skip.\n\t\t\t\tif (!spec.hasTarget(config) && 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 args.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 args.renderBody(config.body)\n\t\t\tconst cc = (await resolveRecipientEntries(config.cc, { resolve, sources, sourceArgs })).join(\n\t\t\t\t', '\n\t\t\t)\n\t\t\tconst bcc = (\n\t\t\t\tawait resolveRecipientEntries(config.bcc, { resolve, sources, sourceArgs })\n\t\t\t).join(', ')\n\t\t\tconst replyTo = (\n\t\t\t\tawait resolveRecipientEntries(config.replyTo, { resolve, sources, sourceArgs })\n\t\t\t).join(', ')\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 args.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":";;;;;;;;;;;;;;;;;;AAgFA,MAAa,oBACZ,SACA,SAC+B;CAC/B,MAAM,EAAE,UAAU,QAAQ,eAAe,aAAa,YAAY,qBAAqB;CACvF,MAAM,WAAW,cAAc,gBAAgB,KAAA;CAC/C,MAAM,SAAgC,MAAM,aAC3C,oBAAoB,MAAM,UAAU,UAAU;EAC7C;EACA;EACA,OAAO;EACP;EACA,SAAS;CACV,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,SAAS;GACpB,MAAM,EAAE,QAAQ,WAAW;GAC3B,MAAM,UAAU,YAAY,MAAM;GAClC,MAAM,UAAU,eAAe,QAAQ,gBAAgB;GACvD,MAAM,aAAmC;IACxC,SAAS,KAAK;IACd;IACA,aAAa,KAAK;IAClB,MAAM,KAAK;IACX,cAAc,KAAK;IACnB,SAAS,KAAK;IACd,KAAK,KAAK;IACV,QAAQ,KAAK;GACd;GACA,MAAM,KAAK,MAAM,KAAK,UAAU;IAAE;IAAQ;IAAS;IAAS;GAAW,CAAC;GACxE,IAAI,CAAC,IAAI;IAGR,IAAI,CAAC,KAAK,UAAU,MAAM,KAAK,KAAK,gBAAgB,SACnD,MAAM,IAAI,MAAM,GAAG,KAAK,KAAK,uBAAuB;IAErD;GACD;GACA,IAAI,OAAO,KAAK,QAAQ,cAAc,YACrC,MAAM,IAAI,MAAM,GAAG,KAAK,KAAK,8BAA8B;GAG5D,MAAM,UAAU,YAAY,OAAO,WAAW,IAAI,OAAO;GACzD,MAAM,OAAO,MAAM,KAAK,WAAW,OAAO,IAAI;GAC9C,MAAM,MAAM,MAAM,wBAAwB,OAAO,IAAI;IAAE;IAAS;IAAS;GAAW,CAAC,GAAG,KACvF,IACD;GACA,MAAM,OACL,MAAM,wBAAwB,OAAO,KAAK;IAAE;IAAS;IAAS;GAAW,CAAC,GACzE,KAAK,IAAI;GACX,MAAM,WACL,MAAM,wBAAwB,OAAO,SAAS;IAAE;IAAS;IAAS;GAAW,CAAC,GAC7E,KAAK,IAAI;GAKX,MAAM,KAAK,QAAQ,UAAU;IAC5B;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,18 +1,24 @@
1
1
  import { keys } from "../../translations/keys.js";
2
- import { resolveRecipients } from "../emailRecipients.js";
2
+ import { resolveRecipientEntries } from "../emailRecipients.js";
3
3
  import { buildEmailAction } from "./emailAction.js";
4
4
  //#region src/actions/builtin/emailTeam.ts
5
5
  /**
6
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.
7
+ * literal emails, picked department addresses, `{{field}}` tokens, or registered recipient sources,
8
+ * resolved at send. A `to` the author never configured is a misconfiguration and throws; a configured
9
+ * `to` that resolves empty (e.g. every source returned nothing) skips the send. Everything else
10
+ * (from/cc/bcc/replyTo, subject, body, localization) is the shared email-action skeleton.
10
11
  */
11
12
  const buildEmailTeam = (options) => buildEmailAction(options, {
12
13
  type: "emailTeam",
13
14
  label: keys.actionEmailTeam,
14
15
  target: (recip) => recip("to", keys.actionConfigTo),
15
- resolveTo: (config, resolve) => resolveRecipients(config.to, resolve),
16
+ resolveTo: async ({ config, resolve, sources, sourceArgs }) => (await resolveRecipientEntries(config.to, {
17
+ resolve,
18
+ sources,
19
+ sourceArgs
20
+ })).join(", "),
21
+ hasTarget: (config) => Array.isArray(config.to) ? config.to.length > 0 : Boolean(config.to),
16
22
  onMissingTo: "throw"
17
23
  });
18
24
  buildEmailTeam({ localize: true });
@@ -1 +1 @@
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
+ {"version":3,"file":"emailTeam.js","names":[],"sources":["../../../src/actions/builtin/emailTeam.ts"],"sourcesContent":["import { keys } from '../../translations/keys'\nimport { resolveRecipientEntries } 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, `{{field}}` tokens, or registered recipient sources,\n * resolved at send. A `to` the author never configured is a misconfiguration and throws; a configured\n * `to` that resolves empty (e.g. every source returned nothing) skips the send. Everything else\n * (from/cc/bcc/replyTo, subject, 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: async ({ config, resolve, sources, sourceArgs }) =>\n\t\t\t(await resolveRecipientEntries(config.to, { resolve, sources, sourceArgs })).join(', '),\n\t\thasTarget: (config) => (Array.isArray(config.to) ? config.to.length > 0 : Boolean(config.to)),\n\t\tonMissingTo: 'throw',\n\t})\n\nexport const emailTeam = buildEmailTeam({ localize: true })\n"],"mappings":";;;;;;;;;;;AAaA,MAAa,kBAAkB,YAC9B,iBAAkC,SAAS;CAC1C,MAAM;CACN,OAAO,KAAK;CACZ,SAAS,UAAU,MAAM,MAAM,KAAK,cAAc;CAClD,WAAW,OAAO,EAAE,QAAQ,SAAS,SAAS,kBAC5C,MAAM,wBAAwB,OAAO,IAAI;EAAE;EAAS;EAAS;CAAW,CAAC,GAAG,KAAK,IAAI;CACvF,YAAY,WAAY,MAAM,QAAQ,OAAO,EAAE,IAAI,OAAO,GAAG,SAAS,IAAI,QAAQ,OAAO,EAAE;CAC3F,aAAa;AACd,CAAC;AAEuB,eAAe,EAAE,UAAU,KAAK,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import { SubmissionDescriptor, SubmissionValue } from "../submissions/types.js";
2
2
  import { Translate } from "../fields/types.js";
3
+ import { FormContextReference } from "../context/formContext.js";
3
4
  import { Field, Payload, PayloadRequest } from "payload";
4
5
 
5
6
  //#region src/actions/defineAction.d.ts
@@ -11,7 +12,8 @@ type ActionRunArgs<TConfig extends Record<string, unknown> = Record<string, unkn
11
12
  };
12
13
  submissionId: number | string;
13
14
  values: SubmissionValue[];
14
- descriptors: SubmissionDescriptor[];
15
+ descriptors: SubmissionDescriptor[]; /** The verified form-context reference the submission was made through, or null when it had none. */
16
+ context: FormContextReference | null;
15
17
  config: TConfig;
16
18
  payload: Payload;
17
19
  req?: PayloadRequest;
@@ -1 +1 @@
1
- {"version":3,"file":"defineAction.js","names":[],"sources":["../../src/actions/defineAction.ts"],"sourcesContent":["import type { Field, Payload, PayloadRequest } from 'payload'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\n\n/** Context passed to an action's `run` when a submission completes. */\nexport type ActionRunArgs<TConfig extends Record<string, unknown> = Record<string, unknown>> = {\n\tform: { id: number | string; title?: string }\n\tsubmissionId: number | string\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tconfig: TConfig\n\tpayload: Payload\n\treq?: PayloadRequest\n\tlocale: string\n\tt: Translate\n\t/** Serialize a rich text (or legacy string) body config into channel-ready HTML. */\n\trenderBody: (body: unknown) => Promise<string>\n}\n\n/**\n * A post-submit action type, authored once: `config` is the admin `Field[]` for authoring;\n * `run` executes when a submission completes. Built-ins use this same primitive.\n */\nexport type ActionDefinition<TConfig extends Record<string, unknown> = Record<string, unknown>> = {\n\ttype: string\n\t/** i18n-key or literal (resolved like a field label), or a per-locale record. */\n\tlabel: string | Record<string, string>\n\tconfig?: Field[]\n\trun: (args: ActionRunArgs<TConfig>) => Promise<void> | void\n}\n\n/** Erased shape stored in the registry; config re-narrows per matched type at execution. */\nexport type AnyActionDefinition = ActionDefinition<Record<string, unknown>>\n\nexport const defineAction = <TConfig extends Record<string, unknown> = Record<string, unknown>>(\n\tdefinition: ActionDefinition<TConfig>\n): ActionDefinition<TConfig> => definition\n"],"mappings":";AAkCA,MAAa,gBACZ,eAC+B"}
1
+ {"version":3,"file":"defineAction.js","names":[],"sources":["../../src/actions/defineAction.ts"],"sourcesContent":["import type { Field, Payload, PayloadRequest } from 'payload'\nimport type { FormContextReference } from '../context/formContext'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\n\n/** Context passed to an action's `run` when a submission completes. */\nexport type ActionRunArgs<TConfig extends Record<string, unknown> = Record<string, unknown>> = {\n\tform: { id: number | string; title?: string }\n\tsubmissionId: number | string\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\t/** The verified form-context reference the submission was made through, or null when it had none. */\n\tcontext: FormContextReference | null\n\tconfig: TConfig\n\tpayload: Payload\n\treq?: PayloadRequest\n\tlocale: string\n\tt: Translate\n\t/** Serialize a rich text (or legacy string) body config into channel-ready HTML. */\n\trenderBody: (body: unknown) => Promise<string>\n}\n\n/**\n * A post-submit action type, authored once: `config` is the admin `Field[]` for authoring;\n * `run` executes when a submission completes. Built-ins use this same primitive.\n */\nexport type ActionDefinition<TConfig extends Record<string, unknown> = Record<string, unknown>> = {\n\ttype: string\n\t/** i18n-key or literal (resolved like a field label), or a per-locale record. */\n\tlabel: string | Record<string, string>\n\tconfig?: Field[]\n\trun: (args: ActionRunArgs<TConfig>) => Promise<void> | void\n}\n\n/** Erased shape stored in the registry; config re-narrows per matched type at execution. */\nexport type AnyActionDefinition = ActionDefinition<Record<string, unknown>>\n\nexport const defineAction = <TConfig extends Record<string, unknown> = Record<string, unknown>>(\n\tdefinition: ActionDefinition<TConfig>\n): ActionDefinition<TConfig> => definition\n"],"mappings":";AAqCA,MAAa,gBACZ,eAC+B"}
@@ -12,7 +12,7 @@ const deadline = (ms) => new Promise((resolve) => {
12
12
  */
13
13
  const dispatchActions = async (args) => {
14
14
  const { payload, registry, req, formId, submissionId } = args;
15
- if ((args.actions ?? []).length === 0) return;
15
+ if ((args.actions ?? []).length === 0 && args.persistSubmissions !== false) return;
16
16
  if (args.hasRunner && canQueue(payload)) {
17
17
  try {
18
18
  await payload.jobs.queue({
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.js","names":[],"sources":["../../src/actions/dispatch.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport type { ActionRegistry } from './registry'\nimport type { ActionInstance } from './runActions'\nimport { ACTIONS_TASK_SLUG, runActionsForSubmission } from './task'\n\n/** Default cap on inline action work so a missing worker never hangs the submission response. */\nexport const INLINE_DISPATCH_DEADLINE_MS = 5_000\n\nexport type DispatchActionsArgs = {\n\tactions: ActionInstance[] | null | undefined\n\tformId: number | string\n\tsubmissionId: number | string\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\t/** Whether a job runner is likely present (queued path); otherwise the bounded-inline fallback runs. */\n\thasRunner: boolean\n\tdeadlineMs?: number\n\trichText?: RichTextBodyOption\n}\n\nconst canQueue = (payload: Payload): boolean => typeof payload.jobs?.queue === 'function'\n\nconst deadline = (ms: number): Promise<void> =>\n\tnew Promise((resolve) => {\n\t\tconst timer = setTimeout(resolve, ms)\n\t\ttimer.unref?.()\n\t})\n\n/**\n * Dispatch a submission's post-submit actions without throwing and without blocking the response on slow\n * action work. With no actions, returns immediately. When a job runner is present, enqueues the native\n * `form-builder-actions` task and returns (action work happens out of band). Otherwise runs the actions\n * inline but bounded by a deadline so a missing worker still delivers without hanging the request; any\n * error is swallowed (logged via `payload.logger`). Never rejects.\n */\nexport const dispatchActions = async (args: DispatchActionsArgs): Promise<void> => {\n\tconst { payload, registry, req, formId, submissionId } = args\n\tconst actions = args.actions ?? []\n\tif (actions.length === 0) {\n\t\treturn\n\t}\n\n\tif (args.hasRunner && canQueue(payload)) {\n\t\ttry {\n\t\t\tawait payload.jobs.queue({\n\t\t\t\ttask: ACTIONS_TASK_SLUG,\n\t\t\t\tinput: { formId: String(formId), submissionId: String(submissionId) },\n\t\t\t\treq,\n\t\t\t})\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: failed to enqueue actions for submission ${String(submissionId)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn\n\t}\n\n\tconst ms = args.deadlineMs ?? INLINE_DISPATCH_DEADLINE_MS\n\t// Guard the action arm itself (not just the race) so a rejection AFTER the deadline wins is never an unhandled rejection.\n\tconst work = runActionsForSubmission({\n\t\tinput: { formId, submissionId },\n\t\tregistry,\n\t\tpayload,\n\t\treq,\n\t\trichText: args.richText,\n\t}).catch((error) => {\n\t\tpayload.logger?.error(\n\t\t\t`@10x-media/form-builder: inline action dispatch for submission ${String(submissionId)} threw: ${\n\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t}`\n\t\t)\n\t})\n\tawait Promise.race([work, deadline(ms)])\n}\n"],"mappings":";AAsBA,MAAM,YAAY,YAA8B,OAAO,QAAQ,MAAM,UAAU;AAE/E,MAAM,YAAY,OACjB,IAAI,SAAS,YAAY;CAExB,WADyB,SAAS,EAC9B,EAAE,QAAQ;AACf,CAAC;;;;;;;;AASF,MAAa,kBAAkB,OAAO,SAA6C;CAClF,MAAM,EAAE,SAAS,UAAU,KAAK,QAAQ,iBAAiB;CAEzD,KADgB,KAAK,WAAW,CAAC,GACrB,WAAW,GACtB;CAGD,IAAI,KAAK,aAAa,SAAS,OAAO,GAAG;EACxC,IAAI;GACH,MAAM,QAAQ,KAAK,MAAM;IACxB,MAAM;IACN,OAAO;KAAE,QAAQ,OAAO,MAAM;KAAG,cAAc,OAAO,YAAY;IAAE;IACpE;GACD,CAAC;EACF,SAAS,OAAO;GACf,QAAQ,QAAQ,MACf,qEAAqE,OAAO,YAAY,EAAE,IACzF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;EACD;EACA;CACD;CAEA,MAAM,KAAK,KAAK,cAAA;CAEhB,MAAM,OAAO,wBAAwB;EACpC,OAAO;GAAE;GAAQ;EAAa;EAC9B;EACA;EACA;EACA,UAAU,KAAK;CAChB,CAAC,EAAE,OAAO,UAAU;EACnB,QAAQ,QAAQ,MACf,kEAAkE,OAAO,YAAY,EAAE,UACtF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;CACD,CAAC;CACD,MAAM,QAAQ,KAAK,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;AACxC"}
1
+ {"version":3,"file":"dispatch.js","names":[],"sources":["../../src/actions/dispatch.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport type { ActionRegistry } from './registry'\nimport type { ActionInstance } from './runActions'\nimport { ACTIONS_TASK_SLUG, runActionsForSubmission } from './task'\n\n/** Default cap on inline action work so a missing worker never hangs the submission response. */\nexport const INLINE_DISPATCH_DEADLINE_MS = 5_000\n\nexport type DispatchActionsArgs = {\n\tactions: ActionInstance[] | null | undefined\n\tformId: number | string\n\tsubmissionId: number | string\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\t/** Whether a job runner is likely present (queued path); otherwise the bounded-inline fallback runs. */\n\thasRunner: boolean\n\t/** The owning form's `persistSubmissions`; when explicitly `false`, run the completion even with no actions so the row is pruned. */\n\tpersistSubmissions?: boolean\n\tdeadlineMs?: number\n\trichText?: RichTextBodyOption\n}\n\nconst canQueue = (payload: Payload): boolean => typeof payload.jobs?.queue === 'function'\n\nconst deadline = (ms: number): Promise<void> =>\n\tnew Promise((resolve) => {\n\t\tconst timer = setTimeout(resolve, ms)\n\t\ttimer.unref?.()\n\t})\n\n/**\n * Dispatch a submission's post-submit actions without throwing and without blocking the response on slow\n * action work. With no actions, returns immediately. When a job runner is present, enqueues the native\n * `form-builder-actions` task and returns (action work happens out of band). Otherwise runs the actions\n * inline but bounded by a deadline so a missing worker still delivers without hanging the request; any\n * error is swallowed (logged via `payload.logger`). Never rejects.\n */\nexport const dispatchActions = async (args: DispatchActionsArgs): Promise<void> => {\n\tconst { payload, registry, req, formId, submissionId } = args\n\tconst actions = args.actions ?? []\n\t// Nothing to run and nothing to prune: skip. An action-less form that opted out of persistence still\n\t// runs the completion below, so `runActionsForSubmission` can delete the row out of band.\n\tif (actions.length === 0 && args.persistSubmissions !== false) {\n\t\treturn\n\t}\n\n\tif (args.hasRunner && canQueue(payload)) {\n\t\ttry {\n\t\t\tawait payload.jobs.queue({\n\t\t\t\ttask: ACTIONS_TASK_SLUG,\n\t\t\t\tinput: { formId: String(formId), submissionId: String(submissionId) },\n\t\t\t\treq,\n\t\t\t})\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: failed to enqueue actions for submission ${String(submissionId)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn\n\t}\n\n\tconst ms = args.deadlineMs ?? INLINE_DISPATCH_DEADLINE_MS\n\t// Guard the action arm itself (not just the race) so a rejection AFTER the deadline wins is never an unhandled rejection.\n\tconst work = runActionsForSubmission({\n\t\tinput: { formId, submissionId },\n\t\tregistry,\n\t\tpayload,\n\t\treq,\n\t\trichText: args.richText,\n\t}).catch((error) => {\n\t\tpayload.logger?.error(\n\t\t\t`@10x-media/form-builder: inline action dispatch for submission ${String(submissionId)} threw: ${\n\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t}`\n\t\t)\n\t})\n\tawait Promise.race([work, deadline(ms)])\n}\n"],"mappings":";AAwBA,MAAM,YAAY,YAA8B,OAAO,QAAQ,MAAM,UAAU;AAE/E,MAAM,YAAY,OACjB,IAAI,SAAS,YAAY;CAExB,WADyB,SAAS,EAC9B,EAAE,QAAQ;AACf,CAAC;;;;;;;;AASF,MAAa,kBAAkB,OAAO,SAA6C;CAClF,MAAM,EAAE,SAAS,UAAU,KAAK,QAAQ,iBAAiB;CAIzD,KAHgB,KAAK,WAAW,CAAC,GAGrB,WAAW,KAAK,KAAK,uBAAuB,OACvD;CAGD,IAAI,KAAK,aAAa,SAAS,OAAO,GAAG;EACxC,IAAI;GACH,MAAM,QAAQ,KAAK,MAAM;IACxB,MAAM;IACN,OAAO;KAAE,QAAQ,OAAO,MAAM;KAAG,cAAc,OAAO,YAAY;IAAE;IACpE;GACD,CAAC;EACF,SAAS,OAAO;GACf,QAAQ,QAAQ,MACf,qEAAqE,OAAO,YAAY,EAAE,IACzF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;EACD;EACA;CACD;CAEA,MAAM,KAAK,KAAK,cAAA;CAEhB,MAAM,OAAO,wBAAwB;EACpC,OAAO;GAAE;GAAQ;EAAa;EAC9B;EACA;EACA;EACA,UAAU,KAAK;CAChB,CAAC,EAAE,OAAO,UAAU;EACnB,QAAQ,QAAQ,MACf,kEAAkE,OAAO,YAAY,EAAE,UACtF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;CACD,CAAC;CACD,MAAM,QAAQ,KAAK,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;AACxC"}
@@ -21,11 +21,30 @@ const toList = (value) => Array.isArray(value) ? value.filter((entry) => typeof
21
21
  */
22
22
  const firstAddress = (value) => value.split(/[,;\n\r]+/).map((part) => part.trim()).find(isPlausibleEmail) ?? "";
23
23
  /**
24
- * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated
25
- * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the
26
- * submission, a plain email passes through), sanitized to a single address, empties dropped, joined.
24
+ * Async recipient resolution for the email actions, extending `resolveRecipients` with server-resolved
25
+ * sources. A registered source value calls its `resolve` (each returned address reduced to one by
26
+ * `firstAddress`, so a source cannot inject headers or extra recipients either); a `{{token}}`/plain
27
+ * email resolves exactly as before. Returns a clean address list (`[]` when nothing resolves) so the
28
+ * caller can skip an empty send. A thrown resolver propagates, failing the action loudly rather than
29
+ * sending to a shortened list.
27
30
  */
28
- const resolveRecipients = (value, resolve) => toList(value).map((entry) => firstAddress(interpolate(entry, resolve))).filter((entry) => entry.length > 0).join(", ");
31
+ const resolveRecipientEntries = async (value, opts) => {
32
+ const out = [];
33
+ for (const entry of toList(value)) {
34
+ const source = opts.sources?.get(entry);
35
+ if (source) {
36
+ const resolved = opts.sourceArgs ? await source.resolve(opts.sourceArgs) : [];
37
+ for (const address of resolved) {
38
+ const clean = firstAddress(address);
39
+ if (clean) out.push(clean);
40
+ }
41
+ continue;
42
+ }
43
+ const clean = firstAddress(interpolate(entry, opts.resolve));
44
+ if (clean) out.push(clean);
45
+ }
46
+ return out;
47
+ };
29
48
  /**
30
49
  * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email
31
50
  * or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is
@@ -49,6 +68,7 @@ const validateRecipients = (opts) => async (value, { data, req }) => {
49
68
  }
50
69
  else allowedValues = /* @__PURE__ */ new Set();
51
70
  for (const entry of list) {
71
+ if (opts.sourceValues?.has(entry)) continue;
52
72
  const token = parseFieldToken(entry);
53
73
  if (token) {
54
74
  if (fieldTokens === false) return asTranslate(req.t)(keys.validationRecipientNotAllowed);
@@ -84,6 +104,8 @@ const resolveDepartmentAllowed = (req, departments) => {
84
104
  const buildRecipientField = (name, labelKey, localize, opts = {}) => {
85
105
  const { departments } = opts;
86
106
  const resolveAllowed = departments ? (req) => resolveDepartmentAllowed(req, departments) : void 0;
107
+ const sourceList = Object.values(opts.sources ?? {});
108
+ const sourceValues = sourceList.length > 0 ? new Set(sourceList.map((s) => s.value)) : void 0;
87
109
  return {
88
110
  name,
89
111
  type: "text",
@@ -93,7 +115,8 @@ const buildRecipientField = (name, labelKey, localize, opts = {}) => {
93
115
  tokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ["email"],
94
116
  allowCustom: opts.recipients?.allowCustom,
95
117
  fieldTokens: opts.recipients?.fieldTokens,
96
- resolveAllowed
118
+ resolveAllowed,
119
+ sourceValues
97
120
  }),
98
121
  ...localizedIf(localize),
99
122
  admin: {
@@ -104,13 +127,17 @@ const buildRecipientField = (name, labelKey, localize, opts = {}) => {
104
127
  ...opts.endpoint ? { endpoint: opts.endpoint } : {},
105
128
  ...opts.recipients?.allowCustom === false ? { allowCustom: false } : {},
106
129
  ...opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {},
107
- ...opts.recipients?.tokenFieldTypes ? { tokenFieldTypes: opts.recipients.tokenFieldTypes } : {}
130
+ ...opts.recipients?.tokenFieldTypes ? { tokenFieldTypes: opts.recipients.tokenFieldTypes } : {},
131
+ ...sourceList.length > 0 ? { sources: sourceList.map((s) => ({
132
+ value: s.value,
133
+ label: s.label
134
+ })) } : {}
108
135
  }
109
136
  } }
110
137
  }
111
138
  };
112
139
  };
113
140
  //#endregion
114
- export { buildRecipientField, firstAddress, isPlausibleEmail, resolveRecipients };
141
+ export { buildRecipientField, firstAddress, isPlausibleEmail, resolveRecipientEntries };
115
142
 
116
143
  //# sourceMappingURL=emailRecipients.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"emailRecipients.js","names":[],"sources":["../../src/actions/emailRecipients.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../email/departments'\nimport { fieldNames, 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'\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst TOKEN_RE = /^\\{\\{\\s*([\\w.-]+)\\s*\\}\\}$/\n\n/** A plausible email address (a permissive shape check, not full RFC validation). */\nexport const isPlausibleEmail = (value: string): boolean => EMAIL_RE.test(value.trim())\n\n/** The field name inside a `{{name}}` recipient token, or undefined when the string is not a token. */\nexport const parseFieldToken = (value: string): string | undefined => {\n\tconst match = TOKEN_RE.exec(value.trim())\n\treturn match ? match[1] : undefined\n}\n\nexport const isFieldToken = (value: string): boolean => parseFieldToken(value) !== undefined\n\nconst toList = (value: unknown): string[] =>\n\tArray.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string')\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n/**\n * The first plausible email in a resolved value, or empty. A recipient entry names a single address,\n * so this drops anything past a separator or CR/LF that a `{{field}}` token might interpolate from\n * visitor input, closing SMTP header injection and extra-recipient injection.\n */\nexport const firstAddress = (value: string): string =>\n\tvalue\n\t\t.split(/[,;\\n\\r]+/)\n\t\t.map((part) => part.trim())\n\t\t.find(isPlausibleEmail) ?? ''\n\n/**\n * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated\n * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the\n * submission, a plain email passes through), sanitized to a single address, empties dropped, joined.\n */\nexport const resolveRecipients = (value: unknown, resolve: (name: string) => string): string =>\n\ttoList(value)\n\t\t.map((entry) => firstAddress(interpolate(entry, resolve)))\n\t\t.filter((entry) => entry.length > 0)\n\t\t.join(', ')\n\n/**\n * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email\n * or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is\n * false, a non-token entry must additionally be a member of the resolved options (`resolveAllowed`,\n * e.g. the department addresses), matching the client constraint on the server. Fails closed if the\n * options resolver throws (mirroring the `from` field). Reads the form's `fields` off `data`. Like\n * `from`, Payload runs this on every save, so the resolver is consulted each save under\n * `allowCustom: false`; `buildRecipientField` memoizes it per request so all recipient fields share one call.\n */\nexport const validateRecipients =\n\t(opts: {\n\t\ttokenFieldTypes?: string[]\n\t\tallowCustom?: boolean\n\t\tfieldTokens?: boolean\n\t\tresolveAllowed?: (req: PayloadRequest) => Set<string> | Promise<Set<string>>\n\t}) =>\n\tasync (\n\t\tvalue: unknown,\n\t\t{ data, req }: { data?: unknown; req: PayloadRequest }\n\t): Promise<string | true> => {\n\t\tconst list = toList(value)\n\t\tif (list.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\tconst fields =\n\t\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\t\tconst { tokenFieldTypes, allowCustom, fieldTokens, resolveAllowed } = opts\n\t\tconst allowedFields = new Set(\n\t\t\ttokenFieldTypes && tokenFieldTypes.length > 0\n\t\t\t\t? fieldNamesOfType(fields, tokenFieldTypes)\n\t\t\t\t: fieldNames(fields)\n\t\t)\n\t\t// Only resolve the membership set when it is actually enforced (allowCustom explicitly false),\n\t\t// so the default (free-typed) path pays no resolver cost. Compare case-insensitively, matching\n\t\t// the client's dedupe, since email delivery ignores case.\n\t\tlet allowedValues: Set<string> | undefined\n\t\tif (allowCustom === false) {\n\t\t\tif (resolveAllowed) {\n\t\t\t\ttry {\n\t\t\t\t\tallowedValues = await resolveAllowed(req)\n\t\t\t\t} catch {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientOptionsUnavailable)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallowedValues = new Set()\n\t\t\t}\n\t\t}\n\t\tfor (const entry of list) {\n\t\t\tconst token = parseFieldToken(entry)\n\t\t\tif (token) {\n\t\t\t\t// `fieldTokens: false` disables recipient tokens; enforce it on the server, not just the client.\n\t\t\t\tif (fieldTokens === false) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t\t}\n\t\t\t\tif (!allowedFields.has(token)) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientUnknownField)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!isPlausibleEmail(entry)) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientInvalid)\n\t\t\t}\n\t\t\tif (allowCustom === false && !allowedValues?.has(entry.trim().toLowerCase())) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n/** Host-configurable behavior for the recipient fields (plugin option `email.recipients`). */\nexport type RecipientsConfig = {\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n}\n\nconst RECIPIENTS_FIELD_REF = '@10x-media/form-builder/client#RecipientsSelect'\n\nconst DEPARTMENT_ALLOWED_CACHE = 'formBuilderDepartmentAllowedSet'\n\n/**\n * The department option values as a lowercased Set, memoized on `req.context` so every recipient\n * field validated in one save shares a single `departments` call. Payload validates the fields\n * concurrently, so the in-flight promise is cached synchronously (not the resolved value) to avoid a\n * resolve-per-field race. Lowercased because email delivery ignores case, matching the client dedupe.\n */\nconst resolveDepartmentAllowed = (\n\treq: PayloadRequest,\n\tdepartments: DepartmentEmailsResolver\n): Promise<Set<string>> => {\n\tconst cached = req.context?.[DEPARTMENT_ALLOWED_CACHE]\n\tif (cached instanceof Promise) {\n\t\treturn cached as Promise<Set<string>>\n\t}\n\tconst promise = Promise.resolve()\n\t\t.then(() => departments({ req }))\n\t\t.then((options) => new Set(options.map((option) => option.value.trim().toLowerCase())))\n\tif (req.context) {\n\t\treq.context[DEPARTMENT_ALLOWED_CACHE] = promise\n\t}\n\treturn promise\n}\n\n/**\n * A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`\n * (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's\n * behavior; `width` sets `admin.width` so a pair can share a row; `departments` (when set) backs\n * server-side `allowCustom: false` enforcement with the resolved option values.\n */\n// biome-ignore lint/complexity/useMaxParams: the field identity (name, label, localize) plus its grouped options is the minimal surface\nexport const buildRecipientField = (\n\tname: string,\n\tlabelKey: string,\n\tlocalize: boolean,\n\topts: {\n\t\tendpoint?: string\n\t\trecipients?: RecipientsConfig\n\t\twidth?: string\n\t\tdepartments?: DepartmentEmailsResolver\n\t} = {}\n): TextField => {\n\tconst { departments } = opts\n\tconst resolveAllowed = departments\n\t\t? (req: PayloadRequest) => resolveDepartmentAllowed(req, departments)\n\t\t: undefined\n\treturn {\n\t\tname,\n\t\ttype: 'text',\n\t\thasMany: true,\n\t\tlabel: labelFor(labelKey),\n\t\tvalidate: validateRecipients({\n\t\t\ttokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ['email'],\n\t\t\tallowCustom: opts.recipients?.allowCustom,\n\t\t\tfieldTokens: opts.recipients?.fieldTokens,\n\t\t\tresolveAllowed,\n\t\t}),\n\t\t...localizedIf(localize),\n\t\tadmin: {\n\t\t\t...(opts.width ? { width: opts.width } : {}),\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: RECIPIENTS_FIELD_REF,\n\t\t\t\t\tclientProps: {\n\t\t\t\t\t\t...(opts.endpoint ? { endpoint: opts.endpoint } : {}),\n\t\t\t\t\t\t...(opts.recipients?.allowCustom === false ? { allowCustom: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.tokenFieldTypes\n\t\t\t\t\t\t\t? { tokenFieldTypes: opts.recipients.tokenFieldTypes }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"emailRecipients.js","names":[],"sources":["../../src/actions/emailRecipients.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../email/departments'\nimport { fieldNames, 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 type {\n\tRecipientResolveArgs,\n\tRecipientSource,\n\tRecipientSourceRegistry,\n} from './recipientSources'\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst TOKEN_RE = /^\\{\\{\\s*([\\w.-]+)\\s*\\}\\}$/\n\n/** A plausible email address (a permissive shape check, not full RFC validation). */\nexport const isPlausibleEmail = (value: string): boolean => EMAIL_RE.test(value.trim())\n\n/** The field name inside a `{{name}}` recipient token, or undefined when the string is not a token. */\nexport const parseFieldToken = (value: string): string | undefined => {\n\tconst match = TOKEN_RE.exec(value.trim())\n\treturn match ? match[1] : undefined\n}\n\nexport const isFieldToken = (value: string): boolean => parseFieldToken(value) !== undefined\n\nconst toList = (value: unknown): string[] =>\n\tArray.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string')\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n/**\n * The first plausible email in a resolved value, or empty. A recipient entry names a single address,\n * so this drops anything past a separator or CR/LF that a `{{field}}` token might interpolate from\n * visitor input, closing SMTP header injection and extra-recipient injection.\n */\nexport const firstAddress = (value: string): string =>\n\tvalue\n\t\t.split(/[,;\\n\\r]+/)\n\t\t.map((part) => part.trim())\n\t\t.find(isPlausibleEmail) ?? ''\n\n/**\n * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated\n * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the\n * submission, a plain email passes through), sanitized to a single address, empties dropped, joined.\n */\nexport const resolveRecipients = (value: unknown, resolve: (name: string) => string): string =>\n\ttoList(value)\n\t\t.map((entry) => firstAddress(interpolate(entry, resolve)))\n\t\t.filter((entry) => entry.length > 0)\n\t\t.join(', ')\n\n/**\n * Async recipient resolution for the email actions, extending `resolveRecipients` with server-resolved\n * sources. A registered source value calls its `resolve` (each returned address reduced to one by\n * `firstAddress`, so a source cannot inject headers or extra recipients either); a `{{token}}`/plain\n * email resolves exactly as before. Returns a clean address list (`[]` when nothing resolves) so the\n * caller can skip an empty send. A thrown resolver propagates, failing the action loudly rather than\n * sending to a shortened list.\n */\nexport const resolveRecipientEntries = async (\n\tvalue: unknown,\n\topts: {\n\t\tresolve: (name: string) => string\n\t\tsources?: Map<string, RecipientSource>\n\t\tsourceArgs?: RecipientResolveArgs\n\t}\n): Promise<string[]> => {\n\tconst out: string[] = []\n\tfor (const entry of toList(value)) {\n\t\tconst source = opts.sources?.get(entry)\n\t\tif (source) {\n\t\t\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\t\t\tconst resolved = opts.sourceArgs ? await source.resolve(opts.sourceArgs) : []\n\t\t\tfor (const address of resolved) {\n\t\t\t\tconst clean = firstAddress(address)\n\t\t\t\tif (clean) {\n\t\t\t\t\tout.push(clean)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tconst clean = firstAddress(interpolate(entry, opts.resolve))\n\t\tif (clean) {\n\t\t\tout.push(clean)\n\t\t}\n\t}\n\treturn out\n}\n\n/**\n * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email\n * or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is\n * false, a non-token entry must additionally be a member of the resolved options (`resolveAllowed`,\n * e.g. the department addresses), matching the client constraint on the server. Fails closed if the\n * options resolver throws (mirroring the `from` field). Reads the form's `fields` off `data`. Like\n * `from`, Payload runs this on every save, so the resolver is consulted each save under\n * `allowCustom: false`; `buildRecipientField` memoizes it per request so all recipient fields share one call.\n */\nexport const validateRecipients =\n\t(opts: {\n\t\ttokenFieldTypes?: string[]\n\t\tallowCustom?: boolean\n\t\tfieldTokens?: boolean\n\t\tresolveAllowed?: (req: PayloadRequest) => Set<string> | Promise<Set<string>>\n\t\t/** Stored values of registered recipient sources; a member is a valid recipient by itself. */\n\t\tsourceValues?: Set<string>\n\t}) =>\n\tasync (\n\t\tvalue: unknown,\n\t\t{ data, req }: { data?: unknown; req: PayloadRequest }\n\t): Promise<string | true> => {\n\t\tconst list = toList(value)\n\t\tif (list.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\tconst fields =\n\t\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\t\tconst { tokenFieldTypes, allowCustom, fieldTokens, resolveAllowed } = opts\n\t\tconst allowedFields = new Set(\n\t\t\ttokenFieldTypes && tokenFieldTypes.length > 0\n\t\t\t\t? fieldNamesOfType(fields, tokenFieldTypes)\n\t\t\t\t: fieldNames(fields)\n\t\t)\n\t\t// Only resolve the membership set when it is actually enforced (allowCustom explicitly false),\n\t\t// so the default (free-typed) path pays no resolver cost. Compare case-insensitively, matching\n\t\t// the client's dedupe, since email delivery ignores case.\n\t\tlet allowedValues: Set<string> | undefined\n\t\tif (allowCustom === false) {\n\t\t\tif (resolveAllowed) {\n\t\t\t\ttry {\n\t\t\t\t\tallowedValues = await resolveAllowed(req)\n\t\t\t\t} catch {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientOptionsUnavailable)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallowedValues = new Set()\n\t\t\t}\n\t\t}\n\t\tfor (const entry of list) {\n\t\t\t// A registered source value is a valid recipient on its own: checked first, so a same-named form\n\t\t\t// field cannot shadow it and `allowCustom: false` cannot block it (it is not a custom address).\n\t\t\tif (opts.sourceValues?.has(entry)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst token = parseFieldToken(entry)\n\t\t\tif (token) {\n\t\t\t\t// `fieldTokens: false` disables recipient tokens; enforce it on the server, not just the client.\n\t\t\t\tif (fieldTokens === false) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t\t}\n\t\t\t\tif (!allowedFields.has(token)) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientUnknownField)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!isPlausibleEmail(entry)) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientInvalid)\n\t\t\t}\n\t\t\tif (allowCustom === false && !allowedValues?.has(entry.trim().toLowerCase())) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n/** Host-configurable behavior for the recipient fields (plugin option `email.recipients`). */\nexport type RecipientsConfig = {\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n}\n\nconst RECIPIENTS_FIELD_REF = '@10x-media/form-builder/client#RecipientsSelect'\n\nconst DEPARTMENT_ALLOWED_CACHE = 'formBuilderDepartmentAllowedSet'\n\n/**\n * The department option values as a lowercased Set, memoized on `req.context` so every recipient\n * field validated in one save shares a single `departments` call. Payload validates the fields\n * concurrently, so the in-flight promise is cached synchronously (not the resolved value) to avoid a\n * resolve-per-field race. Lowercased because email delivery ignores case, matching the client dedupe.\n */\nconst resolveDepartmentAllowed = (\n\treq: PayloadRequest,\n\tdepartments: DepartmentEmailsResolver\n): Promise<Set<string>> => {\n\tconst cached = req.context?.[DEPARTMENT_ALLOWED_CACHE]\n\tif (cached instanceof Promise) {\n\t\treturn cached as Promise<Set<string>>\n\t}\n\tconst promise = Promise.resolve()\n\t\t.then(() => departments({ req }))\n\t\t.then((options) => new Set(options.map((option) => option.value.trim().toLowerCase())))\n\tif (req.context) {\n\t\treq.context[DEPARTMENT_ALLOWED_CACHE] = promise\n\t}\n\treturn promise\n}\n\n/**\n * A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`\n * (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's\n * behavior; `width` sets `admin.width` so a pair can share a row; `departments` (when set) backs\n * server-side `allowCustom: false` enforcement with the resolved option values.\n */\n// biome-ignore lint/complexity/useMaxParams: the field identity (name, label, localize) plus its grouped options is the minimal surface\nexport const buildRecipientField = (\n\tname: string,\n\tlabelKey: string,\n\tlocalize: boolean,\n\topts: {\n\t\tendpoint?: string\n\t\trecipients?: RecipientsConfig\n\t\twidth?: string\n\t\tdepartments?: DepartmentEmailsResolver\n\t\tsources?: RecipientSourceRegistry\n\t} = {}\n): TextField => {\n\tconst { departments } = opts\n\tconst resolveAllowed = departments\n\t\t? (req: PayloadRequest) => resolveDepartmentAllowed(req, departments)\n\t\t: undefined\n\tconst sourceList = Object.values(opts.sources ?? {})\n\tconst sourceValues = sourceList.length > 0 ? new Set(sourceList.map((s) => s.value)) : undefined\n\treturn {\n\t\tname,\n\t\ttype: 'text',\n\t\thasMany: true,\n\t\tlabel: labelFor(labelKey),\n\t\tvalidate: validateRecipients({\n\t\t\ttokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ['email'],\n\t\t\tallowCustom: opts.recipients?.allowCustom,\n\t\t\tfieldTokens: opts.recipients?.fieldTokens,\n\t\t\tresolveAllowed,\n\t\t\tsourceValues,\n\t\t}),\n\t\t...localizedIf(localize),\n\t\tadmin: {\n\t\t\t...(opts.width ? { width: opts.width } : {}),\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: RECIPIENTS_FIELD_REF,\n\t\t\t\t\tclientProps: {\n\t\t\t\t\t\t...(opts.endpoint ? { endpoint: opts.endpoint } : {}),\n\t\t\t\t\t\t...(opts.recipients?.allowCustom === false ? { allowCustom: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.tokenFieldTypes\n\t\t\t\t\t\t\t? { tokenFieldTypes: opts.recipients.tokenFieldTypes }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t...(sourceList.length > 0\n\t\t\t\t\t\t\t? { sources: sourceList.map((s) => ({ value: s.value, label: s.label })) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import { SubmissionDescriptor, SubmissionValue } from "../submissions/types.js";
2
+ import { FormContextReference } from "../context/formContext.js";
3
+ import { Payload, PayloadRequest } from "payload";
4
+
5
+ //#region src/actions/recipientSources.d.ts
6
+ /** Arguments a recipient source's `resolve` receives when a submission's email actions run. */
7
+ type RecipientResolveArgs = {
8
+ /** The verified form-context reference, or null when the form was rendered without one. */context: FormContextReference | null;
9
+ values: SubmissionValue[];
10
+ descriptors: SubmissionDescriptor[];
11
+ form: {
12
+ id: number | string;
13
+ title?: string;
14
+ };
15
+ submissionId: number | string;
16
+ payload: Payload;
17
+ req?: PayloadRequest;
18
+ locale: string;
19
+ };
20
+ /**
21
+ * A recipient the plugin resolves server-side at send time (plugin option `email.recipientSources`).
22
+ * `value` is the namespaced string stored in the recipient list (e.g. `context:pageContact`), so it
23
+ * cannot collide with an address; `label` is what the editor sees in the recipient field. `resolve`
24
+ * returns zero or more addresses, `[]` meaning "nothing to send to from this source", which is normal.
25
+ */
26
+ type RecipientSource = {
27
+ value: string;
28
+ label: string | Record<string, string>;
29
+ resolve: (args: RecipientResolveArgs) => Promise<string[]> | string[];
30
+ };
31
+ type RecipientSourceRegistry = Record<string, RecipientSource>;
32
+ //#endregion
33
+ export { RecipientResolveArgs, RecipientSource, RecipientSourceRegistry };
34
+ //# sourceMappingURL=recipientSources.d.ts.map
@@ -0,0 +1,11 @@
1
+ //#region src/actions/recipientSources.ts
2
+ /** Index sources by their stored `value` for O(1) lookup during validation and resolution. */
3
+ const sourcesByValue = (registry) => {
4
+ const map = /* @__PURE__ */ new Map();
5
+ for (const source of Object.values(registry ?? {})) map.set(source.value, source);
6
+ return map;
7
+ };
8
+ //#endregion
9
+ export { sourcesByValue };
10
+
11
+ //# sourceMappingURL=recipientSources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipientSources.js","names":[],"sources":["../../src/actions/recipientSources.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport type { FormContextReference } from '../context/formContext'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\n\n/** Arguments a recipient source's `resolve` receives when a submission's email actions run. */\nexport type RecipientResolveArgs = {\n\t/** The verified form-context reference, or null when the form was rendered without one. */\n\tcontext: FormContextReference | null\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tform: { id: number | string; title?: string }\n\tsubmissionId: number | string\n\tpayload: Payload\n\treq?: PayloadRequest\n\tlocale: string\n}\n\n/**\n * A recipient the plugin resolves server-side at send time (plugin option `email.recipientSources`).\n * `value` is the namespaced string stored in the recipient list (e.g. `context:pageContact`), so it\n * cannot collide with an address; `label` is what the editor sees in the recipient field. `resolve`\n * returns zero or more addresses, `[]` meaning \"nothing to send to from this source\", which is normal.\n */\nexport type RecipientSource = {\n\tvalue: string\n\tlabel: string | Record<string, string>\n\tresolve: (args: RecipientResolveArgs) => Promise<string[]> | string[]\n}\n\nexport type RecipientSourceRegistry = Record<string, RecipientSource>\n\n/** Index sources by their stored `value` for O(1) lookup during validation and resolution. */\nexport const sourcesByValue = (\n\tregistry?: RecipientSourceRegistry\n): Map<string, RecipientSource> => {\n\tconst map = new Map<string, RecipientSource>()\n\tfor (const source of Object.values(registry ?? {})) {\n\t\tmap.set(source.value, source)\n\t}\n\treturn map\n}\n"],"mappings":";;AAgCA,MAAa,kBACZ,aACkC;CAClC,MAAM,sBAAM,IAAI,IAA6B;CAC7C,KAAK,MAAM,UAAU,OAAO,OAAO,YAAY,CAAC,CAAC,GAChD,IAAI,IAAI,OAAO,OAAO,MAAM;CAE7B,OAAO;AACR"}
@@ -7,6 +7,17 @@ const ACTIONS_TASK_SLUG = "form-builder-actions";
7
7
  const asActions = (value) => Array.isArray(value) ? value : [];
8
8
  const asValues = (value) => Array.isArray(value) ? value : [];
9
9
  const asDescriptors = (value) => Array.isArray(value) ? value : [];
10
+ /** The verified `{ relationTo, value }` stored on a submission, or null when it carried no context. */
11
+ const asContext = (value) => {
12
+ if (value && typeof value === "object") {
13
+ const { relationTo, value: reference } = value;
14
+ if (typeof relationTo === "string" && (typeof reference === "string" || typeof reference === "number")) return {
15
+ relationTo,
16
+ value: reference
17
+ };
18
+ }
19
+ return null;
20
+ };
10
21
  /**
11
22
  * Load the form and submission by id and run the form's actions through the shared, failure-isolating
12
23
  * `runActions`. Tolerates a missing form or submission (the row may have been deleted between enqueue and
@@ -44,12 +55,21 @@ const runActionsForSubmission = async (args) => {
44
55
  submissionId: submission.id,
45
56
  values: asValues(submission.values),
46
57
  descriptors: asDescriptors(submission.descriptors),
58
+ context: asContext(submission.context),
47
59
  payload,
48
60
  req,
49
61
  locale,
50
62
  t
51
63
  });
52
64
  for (const result of results) if (!result.ok) payload.logger?.error(`@10x-media/form-builder: action "${result.type}" failed for submission ${String(submission.id)}: ${result.error ?? "unknown error"}`);
65
+ if (form.persistSubmissions === false) await payload.delete({
66
+ collection: FORM_SUBMISSIONS_SLUG,
67
+ id: submission.id,
68
+ overrideAccess: true,
69
+ req
70
+ }).catch((error) => {
71
+ payload.logger?.error(`@10x-media/form-builder: failed to prune submission ${String(submission.id)}: ${error instanceof Error ? error.message : String(error)}`);
72
+ });
53
73
  return results;
54
74
  };
55
75
  /** Native Payload jobs task that runs a submission's post-submit actions out of band. */