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

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 (63) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/actions/body/serializeBody.d.ts +10 -14
  3. package/dist/actions/body/serializeBody.js +5 -10
  4. package/dist/actions/body/serializeBody.js.map +1 -1
  5. package/dist/actions/builtin/emailAction.d.ts +7 -0
  6. package/dist/actions/builtin/emailAction.js +14 -7
  7. package/dist/actions/builtin/emailAction.js.map +1 -1
  8. package/dist/actions/defineAction.d.ts +2 -4
  9. package/dist/actions/defineAction.js.map +1 -1
  10. package/dist/actions/emailRender.d.ts +5 -5
  11. package/dist/actions/fromAddresses.d.ts +3 -1
  12. package/dist/actions/fromAddresses.js.map +1 -1
  13. package/dist/actions/recipientSources.d.ts +6 -17
  14. package/dist/actions/recipientSources.js.map +1 -1
  15. package/dist/actions/submissionContext.d.ts +34 -0
  16. package/dist/actions/task.js +10 -13
  17. package/dist/actions/task.js.map +1 -1
  18. package/dist/aggregation/aggregateResponses.js +1 -1
  19. package/dist/aggregation/resolveResultsRequest.js +38 -24
  20. package/dist/aggregation/resolveResultsRequest.js.map +1 -1
  21. package/dist/client/EndpointOptionsSelect.d.ts +1 -0
  22. package/dist/client/EndpointOptionsSelect.js +1 -0
  23. package/dist/client/EndpointOptionsSelect.js.map +1 -1
  24. package/dist/client/FieldNameSelect.d.ts +1 -0
  25. package/dist/client/FieldNameSelect.js +1 -0
  26. package/dist/client/FieldNameSelect.js.map +1 -1
  27. package/dist/client/RecipientsSelect.d.ts +1 -0
  28. package/dist/client/RecipientsSelect.js +1 -0
  29. package/dist/client/RecipientsSelect.js.map +1 -1
  30. package/dist/collections/formSubmissions.js +3 -3
  31. package/dist/collections/forms.js +2 -2
  32. package/dist/exports/rsc.d.ts +1 -1
  33. package/dist/exports/rsc.js +3 -3
  34. package/dist/form/findFormAtLocale.d.ts +30 -0
  35. package/dist/form/findFormAtLocale.js +88 -0
  36. package/dist/form/findFormAtLocale.js.map +1 -0
  37. package/dist/index.d.ts +4 -2
  38. package/dist/index.js +15 -12
  39. package/dist/index.js.map +1 -1
  40. package/dist/options.d.ts +24 -1
  41. package/dist/plugin/registerCollections.js +2 -2
  42. package/dist/poll/resolvePollOutcome.js +1 -1
  43. package/dist/poll/votes/recountPollVotes.js +2 -2
  44. package/dist/poll/votes/voteTallyHook.js +2 -2
  45. package/dist/react/Form.d.ts +9 -5
  46. package/dist/react/Form.js +8 -9
  47. package/dist/react/Form.js.map +1 -1
  48. package/dist/react/Poll.js +4 -2
  49. package/dist/react/Poll.js.map +1 -1
  50. package/dist/react/fetchResults.d.ts +2 -1
  51. package/dist/react/fetchResults.js +5 -2
  52. package/dist/react/fetchResults.js.map +1 -1
  53. package/dist/react/submitForm.d.ts +4 -4
  54. package/dist/react/submitForm.js.map +1 -1
  55. package/dist/submissions/createSubmission.d.ts +6 -0
  56. package/dist/submissions/createSubmission.js +7 -2
  57. package/dist/submissions/createSubmission.js.map +1 -1
  58. package/dist/submissions/submissionLocale.js +27 -6
  59. package/dist/submissions/submissionLocale.js.map +1 -1
  60. package/dist/submissions/validateSubmission.js +108 -101
  61. package/dist/submissions/validateSubmission.js.map +1 -1
  62. package/dist/submissions/voteChangeEndpoint.js +1 -1
  63. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @10x-media/form-builder
2
2
 
3
+ ## 0.1.0-beta.26
4
+
5
+ ### Minor Changes
6
+
7
+ - A per-form fallback locale, shared recipient lists by default, and no more blank emails.
8
+
9
+ - **Breaking: the email recipient lists are no longer localized by default.** `to`, `cc`, `bcc`, and `replyTo` on `emailTeam` and `confirmation` used to hold a separate list per locale, so a locale the editor never filled in had no recipient and failed `emailTeam` outright. They are now shared across locales. To keep per-locale routing, set `email.localizeRecipients: true` and nothing changes. To adopt the shared default, migrate the stored lists to a single value (typically the default locale's) the way you would for any Payload field that stops being localized.
10
+ - **`fallbackLocale`** chooses the fallback locale per form for every server-side read of it (validating a submission, running its actions, serving poll results), e.g. a tenant's own default locale instead of the config-wide one, or a forced fallback on a host with `localization.fallback: false`. Without it those reads fall back exactly like any Payload read, as your config says. It receives the form as already read, so a non-localized owner such as `form.tenant` needs no read of your own, and the form is read again only when the result differs from the fallback already applied.
11
+ - An email whose subject and body are both empty now fails its action with `empty subject and body` instead of being sent blank.
12
+ - The recipient fields, and the plugin's other custom selects, show Payload's localized badge when they are localized.
13
+
14
+ - Send-time hooks receive the whole form document. Recipient sources, from sources, `richText.serialize`, `email.render`, and a custom action's `run` used to get `form` as `{ id, title }` only, so a host needing any other field (a multi-tenant host's `tenant`) read the same form again in every hook. `form` is now the document the plugin already loaded for the run, at depth 0 (relationships are ids) and in the submission's locale, typed as the exported `SubmissionForm` (`{ id, title? } & Record<string, unknown>`). `id` and `title` are unchanged, so existing hooks keep working; drop the re-reads and read the field off `form` instead.
15
+
16
+ - The submission locale gets its own `submissionLocale` prop.
17
+
18
+ - **Breaking (behavioral): `<Form>`'s `locale` prop is no longer sent with the submission.** The previous beta sent it as `?locale=`, so every host passing `locale` (as the i18n docs teach) had its submissions and emails switch to the visitor's language on upgrade, and a formatting tag like `en-US` silently fell back to the default locale because it is not a content locale code. `locale` is back to formatting and renderer strings only. To store the submission in the visitor's content locale and render its emails in it, pass `submissionLocale` (one of your `localization` codes); a custom `onSubmit` receives it as `locale`. `<Poll>` also sends it with the results request, so option labels in the results match the form.
19
+ - **`createSubmission` takes `locale`**, the server-side counterpart of `submissionLocale`, clamped the same way.
20
+ - **`fetchFormResults` takes `locale`**, and the results endpoint serves option labels in the clamped `?locale=`.
21
+ - Fixed: without localization, a visitor-supplied `?locale=zh_Hant` was stored as is, and `Intl` throws on the underscore, so a field type's `format` could break the submission's admin view. The locale is now stored as a canonical tag (`zh-Hant`), and anything that is not a valid tag becomes `en`.
22
+ - Fixed: creating a submission with your own `req` (`payload.create` or `createSubmission`) no longer rewrites that request's `locale` or `fallbackLocale`. Without localization it stays unset rather than becoming `en`.
23
+ - `email.render`'s `EmailRenderArgs` and a recipient source's `RecipientResolveArgs` now both extend a shared, exported `SubmissionContextArgs`, so a field added for one hook no longer joins the other's API.
24
+
3
25
  ## 0.1.0-beta.25
4
26
 
5
27
  ### Minor Changes
@@ -1,13 +1,9 @@
1
1
  import { SubmissionDescriptor, SubmissionValue } from "../../submissions/types.js";
2
2
  import { BodyConverter } from "./converters.js";
3
+ import { SubmissionForm } from "../submissionContext.js";
3
4
  import { PayloadRequest, RichTextField } from "payload";
4
5
 
5
6
  //#region src/actions/body/serializeBody.d.ts
6
- /** Minimal form identity threaded alongside a rendered body (e.g. per-tenant template lookups). */
7
- type SerializeBodyForm = {
8
- id: number | string;
9
- title?: string;
10
- };
11
7
  /** Submission data plus optional converter overrides available while serializing a body. */
12
8
  type BodyContext = {
13
9
  values: SubmissionValue[];
@@ -16,14 +12,14 @@ type BodyContext = {
16
12
  };
17
13
  /**
18
14
  * Args a custom `richText.serialize` replacement receives per rendered body. Always populated by
19
- * `makeRenderBody` (the action-body pipeline): `form` and `req` enable per-tenant template
20
- * lookups or handing the body off to a renderer like react-email.
15
+ * `makeRenderBody` (the action-body pipeline): `form` (the whole document) and `req` enable
16
+ * per-tenant template lookups or handing the body off to a renderer like react-email.
21
17
  */
22
18
  type SerializeBodyArgs = {
23
19
  body: unknown;
24
20
  values: SubmissionValue[];
25
- descriptors: SubmissionDescriptor[];
26
- form: SerializeBodyForm;
21
+ descriptors: SubmissionDescriptor[]; /** The whole form document at depth 0, in the submission's locale; see `SubmissionForm`. */
22
+ form: SubmissionForm;
27
23
  req?: PayloadRequest;
28
24
  /**
29
25
  * The submission's own stored locale, the one the form (and so `body`) was loaded at. Use it for
@@ -36,11 +32,11 @@ type SerializeBodyArgs = {
36
32
  * Customizes how the plugin's rich text is authored and rendered. `converters` spread over the
37
33
  * default Lexical node converters; `serialize` replaces the whole action-body pipeline (for
38
34
  * non-HTML channels like chat or plain text, or to hand the body plus the submitted `form`/`req`
39
- * off to a renderer like react-email). Wrapping emails in a layout is `email.render`'s job. `editor` is the default Lexical/richText editor for every
40
- * plugin-authored richText field: message content, consent statement, the response message, and
41
- * the action body fields. `bodyEditor` overrides the action body fields specifically (emailTeam
42
- * and confirmation), and `responseEditor` overrides the success `response` message field; both fall
43
- * back to `editor` when absent.
35
+ * off to a renderer like react-email). Wrapping emails in a layout is `email.render`'s job.
36
+ * `editor` is the default Lexical/richText editor for every plugin-authored richText field: message
37
+ * content, consent statement, the response message, and the action body fields. `bodyEditor`
38
+ * overrides the action body fields specifically (emailTeam and confirmation), and `responseEditor`
39
+ * overrides the success `response` message field; both fall back to `editor` when absent.
44
40
  */
45
41
  type RichTextBodyOption = {
46
42
  converters?: Record<string, BodyConverter>;
@@ -58,20 +58,15 @@ const serializeBody = (body, ctx) => {
58
58
  return "";
59
59
  };
60
60
  /** Build the `renderBody` passed to actions, honoring a plugin-level `richText` customization. */
61
- const makeRenderBody = (args) => async (body) => {
62
- if (args.richText?.serialize) return await args.richText.serialize({
63
- body,
64
- values: args.values,
65
- descriptors: args.descriptors,
66
- form: args.form,
67
- req: args.req,
68
- locale: args.locale,
69
- actionType: args.actionType
61
+ const makeRenderBody = ({ richText, ...args }) => async (body) => {
62
+ if (richText?.serialize) return await richText.serialize({
63
+ ...args,
64
+ body
70
65
  });
71
66
  return serializeBody(body, {
72
67
  values: args.values,
73
68
  descriptors: args.descriptors,
74
- converters: args.richText?.converters
69
+ converters: richText?.converters
75
70
  });
76
71
  };
77
72
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"serializeBody.js","names":[],"sources":["../../../src/actions/body/serializeBody.ts"],"sourcesContent":["import type { PayloadRequest, RichTextField } from 'payload'\nimport { interpolate } from '../../recall/interpolate'\nimport type { SubmissionDescriptor, SubmissionValue } from '../../submissions/types'\nimport type { BodyConverter, BodyRender } from './converters'\nimport { defaultBodyConverters } from './converters'\nimport { escapeHtml } from './escapeHtml'\nimport { serializeSlate } from './serializeSlate'\nimport { renderAllValues, renderAllValuesTable } from './wildcards'\n\n/** Minimal form identity threaded alongside a rendered body (e.g. per-tenant template lookups). */\ntype SerializeBodyForm = { id: number | string; title?: string }\n\n/** Submission data plus optional converter overrides available while serializing a body. */\nexport type BodyContext = {\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tconverters?: Record<string, BodyConverter>\n}\n\n/**\n * Args a custom `richText.serialize` replacement receives per rendered body. Always populated by\n * `makeRenderBody` (the action-body pipeline): `form` and `req` enable per-tenant template\n * lookups or handing the body off to a renderer like react-email.\n */\nexport type SerializeBodyArgs = {\n\tbody: unknown\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tform: SerializeBodyForm\n\treq?: PayloadRequest\n\t/**\n\t * The submission's own stored locale, the one the form (and so `body`) was loaded at. Use it for\n\t * a wrapper's own strings rather than `req.locale`, which on the queued path is the job runner's.\n\t */\n\tlocale: string\n\t/** The `blockType` of the action rendering this body (e.g. `emailTeam`, `confirmation`). */\n\tactionType: string\n}\n\n/**\n * Customizes how the plugin's rich text is authored and rendered. `converters` spread over the\n * default Lexical node converters; `serialize` replaces the whole action-body pipeline (for\n * non-HTML channels like chat or plain text, or to hand the body plus the submitted `form`/`req`\n * off to a renderer like react-email). Wrapping emails in a layout is `email.render`'s job. `editor` is the default Lexical/richText editor for every\n * plugin-authored richText field: message content, consent statement, the response message, and\n * the action body fields. `bodyEditor` overrides the action body fields specifically (emailTeam\n * and confirmation), and `responseEditor` overrides the success `response` message field; both fall\n * back to `editor` when absent.\n */\nexport type RichTextBodyOption = {\n\tconverters?: Record<string, BodyConverter>\n\tserialize?: (args: SerializeBodyArgs) => Promise<string> | string\n\teditor?: RichTextField['editor']\n\tbodyEditor?: RichTextField['editor']\n\tresponseEditor?: RichTextField['editor']\n}\n\n/** Recall resolver over submission values: field name to stringified value, `''` when absent. */\nexport const resolverFor =\n\t(values: SubmissionValue[]) =>\n\t(name: string): string => {\n\t\tconst entry = values.find((value) => value.field === name)\n\t\treturn entry == null ? '' : String(entry.value ?? '')\n\t}\n\nconst renderFor = (ctx: BodyContext): BodyRender => {\n\tconst resolve = resolverFor(ctx.values)\n\tconst htmlResolve = (name: string): string => {\n\t\tif (name === '*') {\n\t\t\treturn renderAllValues(ctx.values, ctx.descriptors)\n\t\t}\n\t\tif (name === '*:table') {\n\t\t\treturn renderAllValuesTable(ctx.values, ctx.descriptors)\n\t\t}\n\t\treturn escapeHtml(resolve(name))\n\t}\n\treturn {\n\t\ttext: (raw) => interpolate(escapeHtml(raw), htmlResolve),\n\t\tinterpolate: (raw) => interpolate(raw, resolve),\n\t}\n}\n\nconst serializeNodes = (\n\tnodes: unknown[],\n\tconverters: Record<string, BodyConverter>,\n\trender: BodyRender\n): string =>\n\tnodes\n\t\t.map((node) => {\n\t\t\tif (node == null || typeof node !== 'object') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst lexicalNode = node as Record<string, unknown>\n\t\t\tconst children = Array.isArray(lexicalNode.children)\n\t\t\t\t? serializeNodes(lexicalNode.children, converters, render)\n\t\t\t\t: ''\n\t\t\tconst converter =\n\t\t\t\ttypeof lexicalNode.type === 'string' ? converters[lexicalNode.type] : undefined\n\t\t\treturn converter ? converter({ node: lexicalNode, children, render }) : children\n\t\t})\n\t\t.join('')\n\nconst lexicalRootOf = (body: unknown): Record<string, unknown> | null => {\n\tif (body == null || typeof body !== 'object' || Array.isArray(body)) {\n\t\treturn null\n\t}\n\tconst root = (body as { root?: unknown }).root\n\treturn root != null && typeof root === 'object' ? (root as Record<string, unknown>) : null\n}\n\n/**\n * Serialize an action's `body` config into HTML. A legacy string body is interpolated as-is\n * (pre-richText behavior, no escaping); a Lexical state walks the converter registry; a Slate\n * array uses the minimal legacy serializer; anything else yields `''`. Rendered text is\n * HTML-escaped and supports `{{ name|fallback }}`, `{{*}}`, and `{{*:table}}` tokens.\n */\nexport const serializeBody = (body: unknown, ctx: BodyContext): string => {\n\tif (typeof body === 'string') {\n\t\treturn interpolate(body, resolverFor(ctx.values))\n\t}\n\tconst render = renderFor(ctx)\n\tif (Array.isArray(body)) {\n\t\treturn serializeSlate(body, render)\n\t}\n\tconst root = lexicalRootOf(body)\n\tif (root) {\n\t\tconst converters = { ...defaultBodyConverters, ...(ctx.converters ?? {}) }\n\t\treturn serializeNodes(Array.isArray(root.children) ? root.children : [], converters, render)\n\t}\n\treturn ''\n}\n\n/** Build the `renderBody` passed to actions, honoring a plugin-level `richText` customization. */\nexport const makeRenderBody =\n\t(args: {\n\t\tvalues: SubmissionValue[]\n\t\tdescriptors: SubmissionDescriptor[]\n\t\tform: SerializeBodyForm\n\t\treq?: PayloadRequest\n\t\tlocale: string\n\t\tactionType: string\n\t\trichText?: RichTextBodyOption\n\t}) =>\n\tasync (body: unknown): Promise<string> => {\n\t\tif (args.richText?.serialize) {\n\t\t\treturn await args.richText.serialize({\n\t\t\t\tbody,\n\t\t\t\tvalues: args.values,\n\t\t\t\tdescriptors: args.descriptors,\n\t\t\t\tform: args.form,\n\t\t\t\treq: args.req,\n\t\t\t\tlocale: args.locale,\n\t\t\t\tactionType: args.actionType,\n\t\t\t})\n\t\t}\n\t\treturn serializeBody(body, {\n\t\t\tvalues: args.values,\n\t\t\tdescriptors: args.descriptors,\n\t\t\tconverters: args.richText?.converters,\n\t\t})\n\t}\n"],"mappings":";;;;;;;AA0DA,MAAa,eACX,YACA,SAAyB;CACzB,MAAM,QAAQ,OAAO,MAAM,UAAU,MAAM,UAAU,IAAI;CACzD,OAAO,SAAS,OAAO,KAAK,OAAO,MAAM,SAAS,EAAE;AACrD;AAED,MAAM,aAAa,QAAiC;CACnD,MAAM,UAAU,YAAY,IAAI,MAAM;CACtC,MAAM,eAAe,SAAyB;EAC7C,IAAI,SAAS,KACZ,OAAO,gBAAgB,IAAI,QAAQ,IAAI,WAAW;EAEnD,IAAI,SAAS,WACZ,OAAO,qBAAqB,IAAI,QAAQ,IAAI,WAAW;EAExD,OAAO,WAAW,QAAQ,IAAI,CAAC;CAChC;CACA,OAAO;EACN,OAAO,QAAQ,YAAY,WAAW,GAAG,GAAG,WAAW;EACvD,cAAc,QAAQ,YAAY,KAAK,OAAO;CAC/C;AACD;AAEA,MAAM,kBACL,OACA,YACA,WAEA,MACE,KAAK,SAAS;CACd,IAAI,QAAQ,QAAQ,OAAO,SAAS,UACnC,OAAO;CAER,MAAM,cAAc;CACpB,MAAM,WAAW,MAAM,QAAQ,YAAY,QAAQ,IAChD,eAAe,YAAY,UAAU,YAAY,MAAM,IACvD;CACH,MAAM,YACL,OAAO,YAAY,SAAS,WAAW,WAAW,YAAY,QAAQ,KAAA;CACvE,OAAO,YAAY,UAAU;EAAE,MAAM;EAAa;EAAU;CAAO,CAAC,IAAI;AACzE,CAAC,EACA,KAAK,EAAE;AAEV,MAAM,iBAAiB,SAAkD;CACxE,IAAI,QAAQ,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,GACjE,OAAO;CAER,MAAM,OAAQ,KAA4B;CAC1C,OAAO,QAAQ,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACvF;;;;;;;AAQA,MAAa,iBAAiB,MAAe,QAA6B;CACzE,IAAI,OAAO,SAAS,UACnB,OAAO,YAAY,MAAM,YAAY,IAAI,MAAM,CAAC;CAEjD,MAAM,SAAS,UAAU,GAAG;CAC5B,IAAI,MAAM,QAAQ,IAAI,GACrB,OAAO,eAAe,MAAM,MAAM;CAEnC,MAAM,OAAO,cAAc,IAAI;CAC/B,IAAI,MAAM;EACT,MAAM,aAAa;GAAE,GAAG;GAAuB,GAAI,IAAI,cAAc,CAAC;EAAG;EACzE,OAAO,eAAe,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,WAAW,CAAC,GAAG,YAAY,MAAM;CAC5F;CACA,OAAO;AACR;;AAGA,MAAa,kBACX,SASD,OAAO,SAAmC;CACzC,IAAI,KAAK,UAAU,WAClB,OAAO,MAAM,KAAK,SAAS,UAAU;EACpC;EACA,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,MAAM,KAAK;EACX,KAAK,KAAK;EACV,QAAQ,KAAK;EACb,YAAY,KAAK;CAClB,CAAC;CAEF,OAAO,cAAc,MAAM;EAC1B,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,YAAY,KAAK,UAAU;CAC5B,CAAC;AACF"}
1
+ {"version":3,"file":"serializeBody.js","names":[],"sources":["../../../src/actions/body/serializeBody.ts"],"sourcesContent":["import type { PayloadRequest, RichTextField } from 'payload'\nimport { interpolate } from '../../recall/interpolate'\nimport type { SubmissionDescriptor, SubmissionValue } from '../../submissions/types'\nimport type { SubmissionForm } from '../submissionContext'\nimport type { BodyConverter, BodyRender } from './converters'\nimport { defaultBodyConverters } from './converters'\nimport { escapeHtml } from './escapeHtml'\nimport { serializeSlate } from './serializeSlate'\nimport { renderAllValues, renderAllValuesTable } from './wildcards'\n\n/** Submission data plus optional converter overrides available while serializing a body. */\nexport type BodyContext = {\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tconverters?: Record<string, BodyConverter>\n}\n\n/**\n * Args a custom `richText.serialize` replacement receives per rendered body. Always populated by\n * `makeRenderBody` (the action-body pipeline): `form` (the whole document) and `req` enable\n * per-tenant template lookups or handing the body off to a renderer like react-email.\n */\nexport type SerializeBodyArgs = {\n\tbody: unknown\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\t/** The whole form document at depth 0, in the submission's locale; see `SubmissionForm`. */\n\tform: SubmissionForm\n\treq?: PayloadRequest\n\t/**\n\t * The submission's own stored locale, the one the form (and so `body`) was loaded at. Use it for\n\t * a wrapper's own strings rather than `req.locale`, which on the queued path is the job runner's.\n\t */\n\tlocale: string\n\t/** The `blockType` of the action rendering this body (e.g. `emailTeam`, `confirmation`). */\n\tactionType: string\n}\n\n/**\n * Customizes how the plugin's rich text is authored and rendered. `converters` spread over the\n * default Lexical node converters; `serialize` replaces the whole action-body pipeline (for\n * non-HTML channels like chat or plain text, or to hand the body plus the submitted `form`/`req`\n * off to a renderer like react-email). Wrapping emails in a layout is `email.render`'s job.\n * `editor` is the default Lexical/richText editor for every plugin-authored richText field: message\n * content, consent statement, the response message, and the action body fields. `bodyEditor`\n * overrides the action body fields specifically (emailTeam and confirmation), and `responseEditor`\n * overrides the success `response` message field; both fall back to `editor` when absent.\n */\nexport type RichTextBodyOption = {\n\tconverters?: Record<string, BodyConverter>\n\tserialize?: (args: SerializeBodyArgs) => Promise<string> | string\n\teditor?: RichTextField['editor']\n\tbodyEditor?: RichTextField['editor']\n\tresponseEditor?: RichTextField['editor']\n}\n\n/** Recall resolver over submission values: field name to stringified value, `''` when absent. */\nexport const resolverFor =\n\t(values: SubmissionValue[]) =>\n\t(name: string): string => {\n\t\tconst entry = values.find((value) => value.field === name)\n\t\treturn entry == null ? '' : String(entry.value ?? '')\n\t}\n\nconst renderFor = (ctx: BodyContext): BodyRender => {\n\tconst resolve = resolverFor(ctx.values)\n\tconst htmlResolve = (name: string): string => {\n\t\tif (name === '*') {\n\t\t\treturn renderAllValues(ctx.values, ctx.descriptors)\n\t\t}\n\t\tif (name === '*:table') {\n\t\t\treturn renderAllValuesTable(ctx.values, ctx.descriptors)\n\t\t}\n\t\treturn escapeHtml(resolve(name))\n\t}\n\treturn {\n\t\ttext: (raw) => interpolate(escapeHtml(raw), htmlResolve),\n\t\tinterpolate: (raw) => interpolate(raw, resolve),\n\t}\n}\n\nconst serializeNodes = (\n\tnodes: unknown[],\n\tconverters: Record<string, BodyConverter>,\n\trender: BodyRender\n): string =>\n\tnodes\n\t\t.map((node) => {\n\t\t\tif (node == null || typeof node !== 'object') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst lexicalNode = node as Record<string, unknown>\n\t\t\tconst children = Array.isArray(lexicalNode.children)\n\t\t\t\t? serializeNodes(lexicalNode.children, converters, render)\n\t\t\t\t: ''\n\t\t\tconst converter =\n\t\t\t\ttypeof lexicalNode.type === 'string' ? converters[lexicalNode.type] : undefined\n\t\t\treturn converter ? converter({ node: lexicalNode, children, render }) : children\n\t\t})\n\t\t.join('')\n\nconst lexicalRootOf = (body: unknown): Record<string, unknown> | null => {\n\tif (body == null || typeof body !== 'object' || Array.isArray(body)) {\n\t\treturn null\n\t}\n\tconst root = (body as { root?: unknown }).root\n\treturn root != null && typeof root === 'object' ? (root as Record<string, unknown>) : null\n}\n\n/**\n * Serialize an action's `body` config into HTML. A legacy string body is interpolated as-is\n * (pre-richText behavior, no escaping); a Lexical state walks the converter registry; a Slate\n * array uses the minimal legacy serializer; anything else yields `''`. Rendered text is\n * HTML-escaped and supports `{{ name|fallback }}`, `{{*}}`, and `{{*:table}}` tokens.\n */\nexport const serializeBody = (body: unknown, ctx: BodyContext): string => {\n\tif (typeof body === 'string') {\n\t\treturn interpolate(body, resolverFor(ctx.values))\n\t}\n\tconst render = renderFor(ctx)\n\tif (Array.isArray(body)) {\n\t\treturn serializeSlate(body, render)\n\t}\n\tconst root = lexicalRootOf(body)\n\tif (root) {\n\t\tconst converters = { ...defaultBodyConverters, ...(ctx.converters ?? {}) }\n\t\treturn serializeNodes(Array.isArray(root.children) ? root.children : [], converters, render)\n\t}\n\treturn ''\n}\n\n/** Build the `renderBody` passed to actions, honoring a plugin-level `richText` customization. */\nexport const makeRenderBody =\n\t({ richText, ...args }: Omit<SerializeBodyArgs, 'body'> & { richText?: RichTextBodyOption }) =>\n\tasync (body: unknown): Promise<string> => {\n\t\tif (richText?.serialize) {\n\t\t\treturn await richText.serialize({ ...args, body })\n\t\t}\n\t\treturn serializeBody(body, {\n\t\t\tvalues: args.values,\n\t\t\tdescriptors: args.descriptors,\n\t\t\tconverters: richText?.converters,\n\t\t})\n\t}\n"],"mappings":";;;;;;;AAyDA,MAAa,eACX,YACA,SAAyB;CACzB,MAAM,QAAQ,OAAO,MAAM,UAAU,MAAM,UAAU,IAAI;CACzD,OAAO,SAAS,OAAO,KAAK,OAAO,MAAM,SAAS,EAAE;AACrD;AAED,MAAM,aAAa,QAAiC;CACnD,MAAM,UAAU,YAAY,IAAI,MAAM;CACtC,MAAM,eAAe,SAAyB;EAC7C,IAAI,SAAS,KACZ,OAAO,gBAAgB,IAAI,QAAQ,IAAI,WAAW;EAEnD,IAAI,SAAS,WACZ,OAAO,qBAAqB,IAAI,QAAQ,IAAI,WAAW;EAExD,OAAO,WAAW,QAAQ,IAAI,CAAC;CAChC;CACA,OAAO;EACN,OAAO,QAAQ,YAAY,WAAW,GAAG,GAAG,WAAW;EACvD,cAAc,QAAQ,YAAY,KAAK,OAAO;CAC/C;AACD;AAEA,MAAM,kBACL,OACA,YACA,WAEA,MACE,KAAK,SAAS;CACd,IAAI,QAAQ,QAAQ,OAAO,SAAS,UACnC,OAAO;CAER,MAAM,cAAc;CACpB,MAAM,WAAW,MAAM,QAAQ,YAAY,QAAQ,IAChD,eAAe,YAAY,UAAU,YAAY,MAAM,IACvD;CACH,MAAM,YACL,OAAO,YAAY,SAAS,WAAW,WAAW,YAAY,QAAQ,KAAA;CACvE,OAAO,YAAY,UAAU;EAAE,MAAM;EAAa;EAAU;CAAO,CAAC,IAAI;AACzE,CAAC,EACA,KAAK,EAAE;AAEV,MAAM,iBAAiB,SAAkD;CACxE,IAAI,QAAQ,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,GACjE,OAAO;CAER,MAAM,OAAQ,KAA4B;CAC1C,OAAO,QAAQ,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACvF;;;;;;;AAQA,MAAa,iBAAiB,MAAe,QAA6B;CACzE,IAAI,OAAO,SAAS,UACnB,OAAO,YAAY,MAAM,YAAY,IAAI,MAAM,CAAC;CAEjD,MAAM,SAAS,UAAU,GAAG;CAC5B,IAAI,MAAM,QAAQ,IAAI,GACrB,OAAO,eAAe,MAAM,MAAM;CAEnC,MAAM,OAAO,cAAc,IAAI;CAC/B,IAAI,MAAM;EACT,MAAM,aAAa;GAAE,GAAG;GAAuB,GAAI,IAAI,cAAc,CAAC;EAAG;EACzE,OAAO,eAAe,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,WAAW,CAAC,GAAG,YAAY,MAAM;CAC5F;CACA,OAAO;AACR;;AAGA,MAAa,kBACX,EAAE,UAAU,GAAG,WAChB,OAAO,SAAmC;CACzC,IAAI,UAAU,WACb,OAAO,MAAM,SAAS,UAAU;EAAE,GAAG;EAAM;CAAK,CAAC;CAElD,OAAO,cAAc,MAAM;EAC1B,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,YAAY,UAAU;CACvB,CAAC;AACF"}
@@ -9,6 +9,13 @@ import { Field, RichTextField } from "payload";
9
9
  /** The plugin-derived options every built-in email action is built from (was five positional args). */
10
10
  type EmailActionOptions = {
11
11
  localize: boolean;
12
+ /**
13
+ * Localize the recipient lists (`to`, `cc`, `bcc`, `replyTo`) too, so each locale routes to its
14
+ * own addresses (plugin option `email.localizeRecipients`). Off by default: routing is usually
15
+ * the same in every locale, and a list left empty in a locale that does not fall back fails
16
+ * `emailTeam` there. Only applies while `localize` is on.
17
+ */
18
+ localizeRecipients?: boolean;
12
19
  editor?: RichTextField['editor'];
13
20
  fromAddresses?: FromAddressesResolver; /** Send-time-resolved senders offered in the from select (plugin option `email.fromSources`). */
14
21
  fromSources?: FromAddressSourceRegistry;
@@ -9,19 +9,25 @@ import { buildFromField, resolveSendFrom } from "../fromAddresses.js";
9
9
  import { sourcesByValue } from "../recipientSources.js";
10
10
  //#region src/actions/builtin/emailAction.ts
11
11
  /**
12
+ * Whether serialized body html carries anything beyond markup and whitespace: an empty editor
13
+ * serializes to bare tags, while a lone image or rule is content even without text.
14
+ */
15
+ const hasVisibleContent = (html) => /<(img|hr|table|iframe|video|svg)\b/i.test(html) || html.replace(/<[^>]*>|&nbsp;/g, "").trim() !== "";
16
+ /**
12
17
  * The shared skeleton of the built-in email actions (`emailTeam`, `confirmation`): an identical
13
18
  * config (a first row pairing the action's target with `replyTo`, an optional `from` select, a
14
- * cc/bcc row, a subject, and a rich text body, content and recipient fields carrying `localized`
15
- * when `localize`) and an identical send (interpolate the subject, render the body, resolve
16
- * cc/bcc/replyTo, pass the html through `options.render` when set, and hand a single comma-joined
17
- * string per list to `payload.sendEmail`). Only the
18
- * primary `to` target and its missing-value behavior differ, threaded through `spec`.
19
+ * cc/bcc row, a subject, and a rich text body, content fields carrying `localized` when `localize`
20
+ * and recipient fields only when `localizeRecipients` too) and an identical send (interpolate the
21
+ * subject, render the body, fail on an email with neither, resolve cc/bcc/replyTo, pass the html
22
+ * through `options.render` when set, and hand a single comma-joined string per list to
23
+ * `payload.sendEmail`). Only the primary `to` target and its missing-value behavior differ,
24
+ * threaded through `spec`.
19
25
  */
20
26
  const buildEmailAction = (options, spec) => {
21
- const { localize, editor, fromAddresses, fromSources, departments, recipients, recipientSources, render } = options;
27
+ const { localize, localizeRecipients, editor, fromAddresses, fromSources, departments, recipients, recipientSources, render } = options;
22
28
  const fromSourcesByValue = sourcesByValue(fromSources);
23
29
  const endpoint = departments ? "departments" : void 0;
24
- const recip = (name, labelKey) => buildRecipientField(name, labelKey, localize, {
30
+ const recip = (name, labelKey) => buildRecipientField(name, labelKey, localize && localizeRecipients === true, {
25
31
  endpoint,
26
32
  recipients,
27
33
  width: "50%",
@@ -83,6 +89,7 @@ const buildEmailAction = (options, spec) => {
83
89
  if (typeof args.payload.sendEmail !== "function") throw new Error(`${spec.type}: no email adapter configured`);
84
90
  const subject = interpolate(config.subject ?? "", resolve);
85
91
  const serialized = await args.renderBody(config.body);
92
+ if (!subject.trim() && !hasVisibleContent(serialized)) throw new Error(`${spec.type}: empty subject and body`);
86
93
  const html = render ? await render({
87
94
  ...sourceArgs,
88
95
  html: serialized,
@@ -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 {\n\tbuildRecipientField,\n\ttype RecipientsConfig,\n\tresolveRecipientEntries,\n} from '../emailRecipients'\nimport type { EmailActionType, EmailRender } from '../emailRender'\nimport {\n\tbuildFromField,\n\ttype FromAddressesResolver,\n\ttype FromAddressSourceRegistry,\n\tresolveSendFrom,\n} 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\t/** Send-time-resolved senders offered in the from select (plugin option `email.fromSources`). */\n\tfromSources?: FromAddressSourceRegistry\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\t/** Produces the final html from the serialized body (plugin option `email.render`). */\n\trender?: EmailRender\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: EmailActionType\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, pass the html through `options.render` when set, and hand a single comma-joined\n * 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 {\n\t\tlocalize,\n\t\teditor,\n\t\tfromAddresses,\n\t\tfromSources,\n\t\tdepartments,\n\t\trecipients,\n\t\trecipientSources,\n\t\trender,\n\t} = options\n\tconst fromSourcesByValue = sourcesByValue(fromSources)\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 || fromSources ? [buildFromField(fromAddresses, fromSources)] : []),\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(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 serialized = await args.renderBody(config.body)\n\t\t\tconst html = render\n\t\t\t\t? await render({\n\t\t\t\t\t\t...sourceArgs,\n\t\t\t\t\t\thtml: serialized,\n\t\t\t\t\t\tbody: config.body,\n\t\t\t\t\t\tsubject,\n\t\t\t\t\t\tactionType: spec.type,\n\t\t\t\t\t})\n\t\t\t\t: serialized\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// A literal `from` was validated at save time against `fromAddresses(req)`; not re-checked\n\t\t\t// here (the job's `req` may differ from the authoring admin's, and the config is\n\t\t\t// admin-authored, not visitor-controlled), so it is forwarded verbatim. A stored source\n\t\t\t// value instead resolves freshly on every send, so the sender follows the host (e.g. a\n\t\t\t// tenant that changed its address) rather than freezing at authoring time.\n\t\t\tconst from = await resolveSendFrom({\n\t\t\t\tconfigured: config.from,\n\t\t\t\tsources: fromSourcesByValue,\n\t\t\t\tsourceArgs,\n\t\t\t})\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...(from ? { 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":";;;;;;;;;;;;;;;;;;;AA2FA,MAAa,oBACZ,SACA,SAC+B;CAC/B,MAAM,EACL,UACA,QACA,eACA,aACA,aACA,YACA,kBACA,WACG;CACJ,MAAM,qBAAqB,eAAe,WAAW;CACrD,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,iBAAiB,cAAc,CAAC,eAAe,eAAe,WAAW,CAAC,IAAI,CAAC;GACnF;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,gBAAgB;GAC/C,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,aAAa,MAAM,KAAK,WAAW,OAAO,IAAI;GACpD,MAAM,OAAO,SACV,MAAM,OAAO;IACb,GAAG;IACH,MAAM;IACN,MAAM,OAAO;IACb;IACA,YAAY,KAAK;GAClB,CAAC,IACA;GACH,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;GAOX,MAAM,OAAO,MAAM,gBAAgB;IAClC,YAAY,OAAO;IACnB,SAAS;IACT;GACD,CAAC;GACD,MAAM,KAAK,QAAQ,UAAU;IAC5B;IACA;IACA;IACA,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;IACvB,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 type { EmailActionType, EmailRender } from '../emailRender'\nimport {\n\tbuildFromField,\n\ttype FromAddressesResolver,\n\ttype FromAddressSourceRegistry,\n\tresolveSendFrom,\n} 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\t/**\n\t * Localize the recipient lists (`to`, `cc`, `bcc`, `replyTo`) too, so each locale routes to its\n\t * own addresses (plugin option `email.localizeRecipients`). Off by default: routing is usually\n\t * the same in every locale, and a list left empty in a locale that does not fall back fails\n\t * `emailTeam` there. Only applies while `localize` is on.\n\t */\n\tlocalizeRecipients?: boolean\n\teditor?: RichTextField['editor']\n\tfromAddresses?: FromAddressesResolver\n\t/** Send-time-resolved senders offered in the from select (plugin option `email.fromSources`). */\n\tfromSources?: FromAddressSourceRegistry\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\t/** Produces the final html from the serialized body (plugin option `email.render`). */\n\trender?: EmailRender\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/**\n * Whether serialized body html carries anything beyond markup and whitespace: an empty editor\n * serializes to bare tags, while a lone image or rule is content even without text.\n */\nconst hasVisibleContent = (html: string): boolean =>\n\t/<(img|hr|table|iframe|video|svg)\\b/i.test(html) ||\n\thtml.replace(/<[^>]*>|&nbsp;/g, '').trim() !== ''\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: EmailActionType\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 fields carrying `localized` when `localize`\n * and recipient fields only when `localizeRecipients` too) and an identical send (interpolate the\n * subject, render the body, fail on an email with neither, resolve cc/bcc/replyTo, pass the html\n * through `options.render` when set, and hand a single comma-joined string per list to\n * `payload.sendEmail`). Only the primary `to` target and its missing-value behavior differ,\n * threaded through `spec`.\n */\nexport const buildEmailAction = <TConfig extends EmailActionConfig>(\n\toptions: EmailActionOptions,\n\tspec: EmailActionSpec<TConfig>\n): ActionDefinition<TConfig> => {\n\tconst {\n\t\tlocalize,\n\t\tlocalizeRecipients,\n\t\teditor,\n\t\tfromAddresses,\n\t\tfromSources,\n\t\tdepartments,\n\t\trecipients,\n\t\trecipientSources,\n\t\trender,\n\t} = options\n\tconst fromSourcesByValue = sourcesByValue(fromSources)\n\tconst endpoint = departments ? 'departments' : undefined\n\tconst recip: RecipientFieldBuilder = (name, labelKey) =>\n\t\tbuildRecipientField(name, labelKey, localize && localizeRecipients === true, {\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 || fromSources ? [buildFromField(fromAddresses, fromSources)] : []),\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(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 serialized = await args.renderBody(config.body)\n\t\t\t// Checked before `render`, which may wrap an empty body in a frame that has content of its own.\n\t\t\tif (!subject.trim() && !hasVisibleContent(serialized)) {\n\t\t\t\tthrow new Error(`${spec.type}: empty subject and body`)\n\t\t\t}\n\t\t\tconst html = render\n\t\t\t\t? await render({\n\t\t\t\t\t\t...sourceArgs,\n\t\t\t\t\t\thtml: serialized,\n\t\t\t\t\t\tbody: config.body,\n\t\t\t\t\t\tsubject,\n\t\t\t\t\t\tactionType: spec.type,\n\t\t\t\t\t})\n\t\t\t\t: serialized\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// A literal `from` was validated at save time against `fromAddresses(req)`; not re-checked\n\t\t\t// here (the job's `req` may differ from the authoring admin's, and the config is\n\t\t\t// admin-authored, not visitor-controlled), so it is forwarded verbatim. A stored source\n\t\t\t// value instead resolves freshly on every send, so the sender follows the host (e.g. a\n\t\t\t// tenant that changed its address) rather than freezing at authoring time.\n\t\t\tconst from = await resolveSendFrom({\n\t\t\t\tconfigured: config.from,\n\t\t\t\tsources: fromSourcesByValue,\n\t\t\t\tsourceArgs,\n\t\t\t})\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...(from ? { 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":"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"}
@@ -1,15 +1,13 @@
1
1
  import { SubmissionDescriptor, SubmissionValue } from "../submissions/types.js";
2
2
  import { Translate } from "../fields/types.js";
3
3
  import { FormContextReference } from "../context/formContext.js";
4
+ import { SubmissionForm } from "./submissionContext.js";
4
5
  import { Field, Payload, PayloadRequest } from "payload";
5
6
 
6
7
  //#region src/actions/defineAction.d.ts
7
8
  /** Context passed to an action's `run` when a submission completes. */
8
9
  type ActionRunArgs<TConfig extends Record<string, unknown> = Record<string, unknown>> = {
9
- form: {
10
- id: number | string;
11
- title?: string;
12
- };
10
+ /** The whole form document at depth 0, in the submission's locale; see `SubmissionForm`. */form: SubmissionForm;
13
11
  submissionId: number | string;
14
12
  values: SubmissionValue[];
15
13
  descriptors: SubmissionDescriptor[]; /** The verified form-context reference the submission was made through, or null when it had none. */
@@ -1 +1 @@
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/** Context passed to an action's `validateConfig` when a form carrying it is saved. */\nexport type ActionValidateArgs = {\n\t/** The whole form data under validation, merged over the stored doc on partial updates. */\n\tdata: Record<string, unknown>\n\treq: PayloadRequest\n}\n\n/**\n * Throw from an action's `run` to attach structured context (a status code, a provider response)\n * to the failed `ActionResult` without concatenating it into the message. The plugin logs `detail`\n * alongside the failure; any thrown error carrying a `detail` property is treated the same.\n */\nexport class ActionError extends Error {\n\tdetail?: unknown\n\n\tconstructor(message: string, detail?: unknown) {\n\t\tsuper(message)\n\t\tthis.name = 'ActionError'\n\t\tthis.detail = detail\n\t}\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\t/**\n\t * This action's failure is the submission's failure: it runs inline before the response (never\n\t * queued, bounded by the dispatch deadline), a throw or timeout turns the submit into an error\n\t * the visitor sees, the remaining actions are skipped, and the submission is kept even on a\n\t * `persistSubmissions: false` form so what the visitor sent is never lost. For an action that\n\t * IS the point of the submission (a signup provider that is the system of record); leave unset\n\t * for notifications and other fire-and-forget work.\n\t */\n\tessential?: boolean\n\t/**\n\t * Cross-field check over one stored instance of this action, run alongside field validation on\n\t * every form save. Return `true` to accept or an error message to refuse; the message is\n\t * attached to the action block (`actions.<index>`) rather than to any one config field, which\n\t * per-field `validate` cannot express (an optional array's `validate` never runs on the empty\n\t * array, the case a \"template token has no mapping\" check cares about most).\n\t */\n\tvalidateConfig?: (\n\t\tconfig: TConfig,\n\t\tctx: ActionValidateArgs\n\t) => string | true | Promise<string | true>\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,IAAa,cAAb,cAAiC,MAAM;CACtC;CAEA,YAAY,SAAiB,QAAkB;EAC9C,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,SAAS;CACf;AACD;AAqCA,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'\nimport type { SubmissionForm } from './submissionContext'\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\t/** The whole form document at depth 0, in the submission's locale; see `SubmissionForm`. */\n\tform: SubmissionForm\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/** Context passed to an action's `validateConfig` when a form carrying it is saved. */\nexport type ActionValidateArgs = {\n\t/** The whole form data under validation, merged over the stored doc on partial updates. */\n\tdata: Record<string, unknown>\n\treq: PayloadRequest\n}\n\n/**\n * Throw from an action's `run` to attach structured context (a status code, a provider response)\n * to the failed `ActionResult` without concatenating it into the message. The plugin logs `detail`\n * alongside the failure; any thrown error carrying a `detail` property is treated the same.\n */\nexport class ActionError extends Error {\n\tdetail?: unknown\n\n\tconstructor(message: string, detail?: unknown) {\n\t\tsuper(message)\n\t\tthis.name = 'ActionError'\n\t\tthis.detail = detail\n\t}\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\t/**\n\t * This action's failure is the submission's failure: it runs inline before the response (never\n\t * queued, bounded by the dispatch deadline), a throw or timeout turns the submit into an error\n\t * the visitor sees, the remaining actions are skipped, and the submission is kept even on a\n\t * `persistSubmissions: false` form so what the visitor sent is never lost. For an action that\n\t * IS the point of the submission (a signup provider that is the system of record); leave unset\n\t * for notifications and other fire-and-forget work.\n\t */\n\tessential?: boolean\n\t/**\n\t * Cross-field check over one stored instance of this action, run alongside field validation on\n\t * every form save. Return `true` to accept or an error message to refuse; the message is\n\t * attached to the action block (`actions.<index>`) rather than to any one config field, which\n\t * per-field `validate` cannot express (an optional array's `validate` never runs on the empty\n\t * array, the case a \"template token has no mapping\" check cares about most).\n\t */\n\tvalidateConfig?: (\n\t\tconfig: TConfig,\n\t\tctx: ActionValidateArgs\n\t) => string | true | Promise<string | true>\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":";;;;;;AAoCA,IAAa,cAAb,cAAiC,MAAM;CACtC;CAEA,YAAY,SAAiB,QAAkB;EAC9C,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,SAAS;CACf;AACD;AAqCA,MAAa,gBACZ,eAC+B"}
@@ -1,4 +1,4 @@
1
- import { RecipientResolveArgs } from "./recipientSources.js";
1
+ import { SubmissionContextArgs } from "./submissionContext.js";
2
2
 
3
3
  //#region src/actions/emailRender.d.ts
4
4
  /** The built-in email actions `email.render` runs for. */
@@ -6,11 +6,11 @@ type EmailActionType = 'emailTeam' | 'confirmation';
6
6
  /**
7
7
  * What `email.render` receives per outgoing email: the finished body `html` (the default pipeline's
8
8
  * output, or `richText.serialize`'s when set), the raw `body` it was rendered from, the interpolated
9
- * `subject`, the `actionType` sending it, and the same run-time args a recipient source gets:
10
- * `locale` (the submission's own), `form`, `submissionId`, `values`, `descriptors`, `context`,
11
- * `payload`, and `req`.
9
+ * `subject`, the `actionType` sending it, and the submission context: `locale` (the submission's
10
+ * own), `form` (the whole document, see `SubmissionForm`), `submissionId`, `values`,
11
+ * `descriptors`, `context`, `payload`, and `req`.
12
12
  */
13
- type EmailRenderArgs = RecipientResolveArgs & {
13
+ type EmailRenderArgs = SubmissionContextArgs & {
14
14
  html: string; /** The action's stored rich text (or legacy string) body config, before serialization. */
15
15
  body: unknown;
16
16
  subject: string;
@@ -14,7 +14,9 @@ type FromAddressOption = {
14
14
  * audit-stable while the address it resolves to follows the host; `label` is what the editor sees
15
15
  * in the from select. `resolve` returns the address to send from right now (reduced to a single
16
16
  * address), or null/empty to send with the email adapter's default sender. A throw fails the
17
- * action loudly (and retries on the queued path) rather than sending as the wrong identity.
17
+ * action loudly (and retries on the queued path) rather than sending as the wrong identity. Its
18
+ * `form` is the whole form document (see `SubmissionForm`), so a per-tenant sender reads the tenant
19
+ * off it rather than reading the form back.
18
20
  */
19
21
  type FromAddressSource = {
20
22
  value: string;
@@ -1 +1 @@
1
- {"version":3,"file":"fromAddresses.js","names":[],"sources":["../../src/actions/fromAddresses.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport { keys } from '../translations/keys'\nimport { asTranslate, labelFor } from '../translations/server'\nimport { isPlausibleEmail } from './emailRecipients'\nimport type { RecipientResolveArgs } from './recipientSources'\n\n/** One selectable \"from\" address for the built-in email actions. */\nexport type FromAddressOption = { label: string; value: string }\n\n/**\n * A sender the plugin resolves server-side at send time (plugin option `email.fromSources`), the\n * from-side counterpart of a `RecipientSource`. `value` is the namespaced string stored on the\n * action (e.g. `tenant:default`), so it cannot collide with a literal address and stays\n * audit-stable while the address it resolves to follows the host; `label` is what the editor sees\n * in the from select. `resolve` returns the address to send from right now (reduced to a single\n * address), or null/empty to send with the email adapter's default sender. A throw fails the\n * action loudly (and retries on the queued path) rather than sending as the wrong identity.\n */\nexport type FromAddressSource = {\n\tvalue: string\n\tlabel: string | Record<string, string>\n\tresolve: (args: RecipientResolveArgs) => Promise<string | null> | string | null\n}\n\nexport type FromAddressSourceRegistry = Record<string, FromAddressSource>\n\n/**\n * The `from` handed to `payload.sendEmail`: a stored source value re-resolves through its source\n * with the run-time args; anything else (a literal picked from `fromAddresses`, which never\n * touches a source at send) is forwarded verbatim, and no configured value means no `from` at\n * all. Mirrors `resolveRecipientEntries`: a throwing source propagates.\n */\nexport const resolveSendFrom = async (opts: {\n\tconfigured: string | undefined\n\tsources?: Map<string, FromAddressSource>\n\tsourceArgs?: RecipientResolveArgs\n}): Promise<string | undefined> => {\n\tconst { configured, sources, sourceArgs } = opts\n\tif (!configured) {\n\t\treturn undefined\n\t}\n\tconst source = sources?.get(configured)\n\tif (!source) {\n\t\treturn configured\n\t}\n\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\tconst resolved = sourceArgs ? await source.resolve(sourceArgs) : null\n\tif (!resolved) {\n\t\treturn undefined\n\t}\n\treturn firstSender(resolved) || undefined\n}\n\n/** A bare plausible address, or a `Name <addr>` display form wrapping one (quotes and commas in the name included). */\nconst isPlausibleSender = (value: string): boolean => {\n\tif (isPlausibleEmail(value)) {\n\t\treturn true\n\t}\n\tconst bracketed = /^[^<>]*<([^<>\\s]+)>$/.exec(value)\n\treturn Boolean(bracketed?.[1] && isPlausibleEmail(bracketed[1]))\n}\n\n/**\n * The sender-side counterpart of `firstAddress`: one sender only, but `Name <addr>` display form\n * survives because that is the documented shape of a `from`. Order matters: cut at line breaks\n * first (the header-injection vector), accept the whole remaining line so a quoted display name\n * may contain commas, and only then comma-split to clamp a multi-address result to its first\n * entry. An implausible result becomes empty (send with the adapter default) rather than a\n * broken header.\n */\nconst firstSender = (value: string): string => {\n\tconst line = (value.split(/[\\n\\r]+/)[0] ?? '').trim()\n\tif (isPlausibleSender(line)) {\n\t\treturn line\n\t}\n\tconst [first] = line.split(/[,;]+/)\n\tconst cleaned = (first ?? '').trim()\n\treturn isPlausibleSender(cleaned) ? cleaned : ''\n}\n\n/**\n * Host seam resolving the selectable `from` addresses for `emailTeam`/`confirmation`\n * (plugin option `email.fromAddresses`). Multi-tenant hosts derive tenant scoping from `req`\n * (host header, cookie, or auth context) and return only that tenant's allowed senders. `value`\n * is the literal string handed to `payload.sendEmail`'s `from` (e.g. `'Name <addr@x.com>'` or a\n * plain address). Absent keeps the email adapter's default sender and adds no `from` field at all.\n */\nexport type FromAddressesResolver = (args: {\n\treq: PayloadRequest\n}) => Promise<FromAddressOption[]> | FromAddressOption[]\n\nconst FROM_FIELD_REF = '@10x-media/form-builder/client#EndpointOptionsSelect'\n\n/**\n * Validate for the `from` field, closed over the host resolver (mirrors the confirmation action's\n * `toField` and poll's `resultsField`): unset is fine, otherwise the value must be one of the\n * resolver's options for this request. A throwing resolver fails closed with a translated message\n * rather than surfacing a raw error on save.\n *\n * Failing closed has an operational cost worth knowing: Payload runs this on every save, not only\n * when `from` changed, so for as long as the resolver is down no form carrying an email action with\n * a `from` set can be saved at all, including edits that never touch the address. That is the\n * deliberate trade: failing open would persist a sender the host can no longer vouch for, and\n * unlike `toField` and `resultsField` this seam depends on host infrastructure that can be down.\n * A resolver reaching a flaky upstream should cache or fall back internally rather than throw.\n */\nexport const validateFromField =\n\t(resolver: FromAddressesResolver | undefined, sourceValues?: Set<string>) =>\n\tasync (value: unknown, { req }: { req: PayloadRequest }): Promise<string | true> => {\n\t\tif (typeof value !== 'string' || value.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\t// A registered source value validates by membership alone, no resolver round trip.\n\t\tif (sourceValues?.has(value)) {\n\t\t\treturn true\n\t\t}\n\t\tif (!resolver) {\n\t\t\treturn asTranslate(req.t)(keys.validationFromUnknown)\n\t\t}\n\t\tlet options: FromAddressOption[]\n\t\ttry {\n\t\t\toptions = await resolver({ req })\n\t\t} catch {\n\t\t\treturn asTranslate(req.t)(keys.validationFromUnavailable)\n\t\t}\n\t\treturn options.some((option) => option.value === value)\n\t\t\t? true\n\t\t\t: asTranslate(req.t)(keys.validationFromUnknown)\n\t}\n\n/**\n * The `from` select shared by `emailTeam` and `confirmation`: an `EndpointOptionsSelect` backed by\n * the forms collection's `/from-addresses` endpoint (registered when `email.fromAddresses` or\n * `email.fromSources` is set). The option set is request-scoped, not per-form, so the select is\n * marked `scope: 'request'` and its options load while the form is still being created.\n */\nexport const buildFromField = (\n\tresolver: FromAddressesResolver | undefined,\n\tsources?: FromAddressSourceRegistry\n): TextField => ({\n\tname: 'from',\n\ttype: 'text',\n\tlabel: labelFor(keys.actionConfigFrom),\n\tvalidate: validateFromField(\n\t\tresolver,\n\t\tsources ? new Set(Object.values(sources).map((source) => source.value)) : undefined\n\t),\n\tadmin: {\n\t\tcomponents: {\n\t\t\tField: {\n\t\t\t\tpath: FROM_FIELD_REF,\n\t\t\t\tclientProps: {\n\t\t\t\t\tendpoint: 'from-addresses',\n\t\t\t\t\tscope: 'request',\n\t\t\t\t\tdescriptionKey: keys.actionConfigFromDescription,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n})\n\nexport type ResolveFromAddressesRequestArgs = {\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq: PayloadRequest\n\tresolver?: FromAddressesResolver\n\tsources?: FromAddressSourceRegistry\n}\n\n/**\n * A source entry as the from select shows it. A string label is display text served raw (matching\n * how `RecipientsSelect` receives source labels); a per-locale record picks the request's admin\n * language, then English, then any value.\n */\nconst sourceOption = (source: FromAddressSource, req: PayloadRequest): FromAddressOption => {\n\tif (typeof source.label === 'string') {\n\t\treturn { label: source.label, value: source.value }\n\t}\n\tconst label =\n\t\tsource.label[req.i18n.language] ??\n\t\tsource.label.en ??\n\t\tObject.values(source.label)[0] ??\n\t\tsource.value\n\treturn { label, value: source.value }\n}\n\nexport type ResolveFromAddressesRequestResult = {\n\tstatus: number\n\tbody: { options: FromAddressOption[] } | { errors: { message: string }[] }\n}\n\n/**\n * Authorize and resolve the `GET /:id/from-addresses` request backing the `from` selects:\n * authenticated callers get the host resolver's current options for this request; anonymous\n * callers are always refused. The route id is unused (see `buildFromField`). Statuses mirror\n * the poll-options endpoint: 403 unauthenticated, 503 when the resolver throws (fail closed).\n */\nexport const resolveFromAddressesRequest = async (\n\targs: ResolveFromAddressesRequestArgs\n): Promise<ResolveFromAddressesRequestResult> => {\n\tconst { isAuthed, req, resolver, sources } = args\n\tif (!isAuthed) {\n\t\treturn { status: 403, body: { errors: [{ message: 'Forbidden' }] } }\n\t}\n\ttry {\n\t\t// Sources lead: the send-time-resolved sender is the tenant identity, static literals are\n\t\t// the exceptions an editor picks deliberately.\n\t\tconst sourceOptions = Object.values(sources ?? {}).map((source) => sourceOption(source, req))\n\t\tconst resolved = resolver ? await resolver({ req }) : []\n\t\treturn { status: 200, body: { options: [...sourceOptions, ...resolved] } }\n\t} catch {\n\t\treturn { status: 503, body: { errors: [{ message: 'From addresses unavailable' }] } }\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"fromAddresses.js","names":[],"sources":["../../src/actions/fromAddresses.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport { keys } from '../translations/keys'\nimport { asTranslate, labelFor } from '../translations/server'\nimport { isPlausibleEmail } from './emailRecipients'\nimport type { RecipientResolveArgs } from './recipientSources'\n\n/** One selectable \"from\" address for the built-in email actions. */\nexport type FromAddressOption = { label: string; value: string }\n\n/**\n * A sender the plugin resolves server-side at send time (plugin option `email.fromSources`), the\n * from-side counterpart of a `RecipientSource`. `value` is the namespaced string stored on the\n * action (e.g. `tenant:default`), so it cannot collide with a literal address and stays\n * audit-stable while the address it resolves to follows the host; `label` is what the editor sees\n * in the from select. `resolve` returns the address to send from right now (reduced to a single\n * address), or null/empty to send with the email adapter's default sender. A throw fails the\n * action loudly (and retries on the queued path) rather than sending as the wrong identity. Its\n * `form` is the whole form document (see `SubmissionForm`), so a per-tenant sender reads the tenant\n * off it rather than reading the form back.\n */\nexport type FromAddressSource = {\n\tvalue: string\n\tlabel: string | Record<string, string>\n\tresolve: (args: RecipientResolveArgs) => Promise<string | null> | string | null\n}\n\nexport type FromAddressSourceRegistry = Record<string, FromAddressSource>\n\n/**\n * The `from` handed to `payload.sendEmail`: a stored source value re-resolves through its source\n * with the run-time args; anything else (a literal picked from `fromAddresses`, which never\n * touches a source at send) is forwarded verbatim, and no configured value means no `from` at\n * all. Mirrors `resolveRecipientEntries`: a throwing source propagates.\n */\nexport const resolveSendFrom = async (opts: {\n\tconfigured: string | undefined\n\tsources?: Map<string, FromAddressSource>\n\tsourceArgs?: RecipientResolveArgs\n}): Promise<string | undefined> => {\n\tconst { configured, sources, sourceArgs } = opts\n\tif (!configured) {\n\t\treturn undefined\n\t}\n\tconst source = sources?.get(configured)\n\tif (!source) {\n\t\treturn configured\n\t}\n\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\tconst resolved = sourceArgs ? await source.resolve(sourceArgs) : null\n\tif (!resolved) {\n\t\treturn undefined\n\t}\n\treturn firstSender(resolved) || undefined\n}\n\n/** A bare plausible address, or a `Name <addr>` display form wrapping one (quotes and commas in the name included). */\nconst isPlausibleSender = (value: string): boolean => {\n\tif (isPlausibleEmail(value)) {\n\t\treturn true\n\t}\n\tconst bracketed = /^[^<>]*<([^<>\\s]+)>$/.exec(value)\n\treturn Boolean(bracketed?.[1] && isPlausibleEmail(bracketed[1]))\n}\n\n/**\n * The sender-side counterpart of `firstAddress`: one sender only, but `Name <addr>` display form\n * survives because that is the documented shape of a `from`. Order matters: cut at line breaks\n * first (the header-injection vector), accept the whole remaining line so a quoted display name\n * may contain commas, and only then comma-split to clamp a multi-address result to its first\n * entry. An implausible result becomes empty (send with the adapter default) rather than a\n * broken header.\n */\nconst firstSender = (value: string): string => {\n\tconst line = (value.split(/[\\n\\r]+/)[0] ?? '').trim()\n\tif (isPlausibleSender(line)) {\n\t\treturn line\n\t}\n\tconst [first] = line.split(/[,;]+/)\n\tconst cleaned = (first ?? '').trim()\n\treturn isPlausibleSender(cleaned) ? cleaned : ''\n}\n\n/**\n * Host seam resolving the selectable `from` addresses for `emailTeam`/`confirmation`\n * (plugin option `email.fromAddresses`). Multi-tenant hosts derive tenant scoping from `req`\n * (host header, cookie, or auth context) and return only that tenant's allowed senders. `value`\n * is the literal string handed to `payload.sendEmail`'s `from` (e.g. `'Name <addr@x.com>'` or a\n * plain address). Absent keeps the email adapter's default sender and adds no `from` field at all.\n */\nexport type FromAddressesResolver = (args: {\n\treq: PayloadRequest\n}) => Promise<FromAddressOption[]> | FromAddressOption[]\n\nconst FROM_FIELD_REF = '@10x-media/form-builder/client#EndpointOptionsSelect'\n\n/**\n * Validate for the `from` field, closed over the host resolver (mirrors the confirmation action's\n * `toField` and poll's `resultsField`): unset is fine, otherwise the value must be one of the\n * resolver's options for this request. A throwing resolver fails closed with a translated message\n * rather than surfacing a raw error on save.\n *\n * Failing closed has an operational cost worth knowing: Payload runs this on every save, not only\n * when `from` changed, so for as long as the resolver is down no form carrying an email action with\n * a `from` set can be saved at all, including edits that never touch the address. That is the\n * deliberate trade: failing open would persist a sender the host can no longer vouch for, and\n * unlike `toField` and `resultsField` this seam depends on host infrastructure that can be down.\n * A resolver reaching a flaky upstream should cache or fall back internally rather than throw.\n */\nexport const validateFromField =\n\t(resolver: FromAddressesResolver | undefined, sourceValues?: Set<string>) =>\n\tasync (value: unknown, { req }: { req: PayloadRequest }): Promise<string | true> => {\n\t\tif (typeof value !== 'string' || value.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\t// A registered source value validates by membership alone, no resolver round trip.\n\t\tif (sourceValues?.has(value)) {\n\t\t\treturn true\n\t\t}\n\t\tif (!resolver) {\n\t\t\treturn asTranslate(req.t)(keys.validationFromUnknown)\n\t\t}\n\t\tlet options: FromAddressOption[]\n\t\ttry {\n\t\t\toptions = await resolver({ req })\n\t\t} catch {\n\t\t\treturn asTranslate(req.t)(keys.validationFromUnavailable)\n\t\t}\n\t\treturn options.some((option) => option.value === value)\n\t\t\t? true\n\t\t\t: asTranslate(req.t)(keys.validationFromUnknown)\n\t}\n\n/**\n * The `from` select shared by `emailTeam` and `confirmation`: an `EndpointOptionsSelect` backed by\n * the forms collection's `/from-addresses` endpoint (registered when `email.fromAddresses` or\n * `email.fromSources` is set). The option set is request-scoped, not per-form, so the select is\n * marked `scope: 'request'` and its options load while the form is still being created.\n */\nexport const buildFromField = (\n\tresolver: FromAddressesResolver | undefined,\n\tsources?: FromAddressSourceRegistry\n): TextField => ({\n\tname: 'from',\n\ttype: 'text',\n\tlabel: labelFor(keys.actionConfigFrom),\n\tvalidate: validateFromField(\n\t\tresolver,\n\t\tsources ? new Set(Object.values(sources).map((source) => source.value)) : undefined\n\t),\n\tadmin: {\n\t\tcomponents: {\n\t\t\tField: {\n\t\t\t\tpath: FROM_FIELD_REF,\n\t\t\t\tclientProps: {\n\t\t\t\t\tendpoint: 'from-addresses',\n\t\t\t\t\tscope: 'request',\n\t\t\t\t\tdescriptionKey: keys.actionConfigFromDescription,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n})\n\nexport type ResolveFromAddressesRequestArgs = {\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq: PayloadRequest\n\tresolver?: FromAddressesResolver\n\tsources?: FromAddressSourceRegistry\n}\n\n/**\n * A source entry as the from select shows it. A string label is display text served raw (matching\n * how `RecipientsSelect` receives source labels); a per-locale record picks the request's admin\n * language, then English, then any value.\n */\nconst sourceOption = (source: FromAddressSource, req: PayloadRequest): FromAddressOption => {\n\tif (typeof source.label === 'string') {\n\t\treturn { label: source.label, value: source.value }\n\t}\n\tconst label =\n\t\tsource.label[req.i18n.language] ??\n\t\tsource.label.en ??\n\t\tObject.values(source.label)[0] ??\n\t\tsource.value\n\treturn { label, value: source.value }\n}\n\nexport type ResolveFromAddressesRequestResult = {\n\tstatus: number\n\tbody: { options: FromAddressOption[] } | { errors: { message: string }[] }\n}\n\n/**\n * Authorize and resolve the `GET /:id/from-addresses` request backing the `from` selects:\n * authenticated callers get the host resolver's current options for this request; anonymous\n * callers are always refused. The route id is unused (see `buildFromField`). Statuses mirror\n * the poll-options endpoint: 403 unauthenticated, 503 when the resolver throws (fail closed).\n */\nexport const resolveFromAddressesRequest = async (\n\targs: ResolveFromAddressesRequestArgs\n): Promise<ResolveFromAddressesRequestResult> => {\n\tconst { isAuthed, req, resolver, sources } = args\n\tif (!isAuthed) {\n\t\treturn { status: 403, body: { errors: [{ message: 'Forbidden' }] } }\n\t}\n\ttry {\n\t\t// Sources lead: the send-time-resolved sender is the tenant identity, static literals are\n\t\t// the exceptions an editor picks deliberately.\n\t\tconst sourceOptions = Object.values(sources ?? {}).map((source) => sourceOption(source, req))\n\t\tconst resolved = resolver ? await resolver({ req }) : []\n\t\treturn { status: 200, body: { options: [...sourceOptions, ...resolved] } }\n\t} catch {\n\t\treturn { status: 503, body: { errors: [{ message: 'From addresses unavailable' }] } }\n\t}\n}\n"],"mappings":"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"}
@@ -1,22 +1,11 @@
1
- import { SubmissionDescriptor, SubmissionValue } from "../submissions/types.js";
2
- import { FormContextReference } from "../context/formContext.js";
3
- import { Payload, PayloadRequest } from "payload";
1
+ import { SubmissionContextArgs } from "./submissionContext.js";
4
2
 
5
3
  //#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
- };
4
+ /**
5
+ * Arguments a recipient source's `resolve` receives when a submission's email actions run. `form` is
6
+ * the whole form document (see `SubmissionForm`).
7
+ */
8
+ type RecipientResolveArgs = SubmissionContextArgs;
20
9
  /**
21
10
  * A recipient the plugin resolves server-side at send time (plugin option `email.recipientSources`).
22
11
  * `value` is the namespaced string stored in the recipient list (e.g. `context:pageContact`), so it
@@ -1 +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 a source registry (recipient or from) by stored `value` for O(1) lookup during validation and resolution. */\nexport const sourcesByValue = <T extends { value: string }>(\n\tregistry?: Record<string, T>\n): Map<string, T> => {\n\tconst map = new Map<string, T>()\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,aACoB;CACpB,MAAM,sBAAM,IAAI,IAAe;CAC/B,KAAK,MAAM,UAAU,OAAO,OAAO,YAAY,CAAC,CAAC,GAChD,IAAI,IAAI,OAAO,OAAO,MAAM;CAE7B,OAAO;AACR"}
1
+ {"version":3,"file":"recipientSources.js","names":[],"sources":["../../src/actions/recipientSources.ts"],"sourcesContent":["import type { SubmissionContextArgs } from './submissionContext'\n\n/**\n * Arguments a recipient source's `resolve` receives when a submission's email actions run. `form` is\n * the whole form document (see `SubmissionForm`).\n */\nexport type RecipientResolveArgs = SubmissionContextArgs\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 a source registry (recipient or from) by stored `value` for O(1) lookup during validation and resolution. */\nexport const sourcesByValue = <T extends { value: string }>(\n\tregistry?: Record<string, T>\n): Map<string, T> => {\n\tconst map = new Map<string, T>()\n\tfor (const source of Object.values(registry ?? {})) {\n\t\tmap.set(source.value, source)\n\t}\n\treturn map\n}\n"],"mappings":";;AAuBA,MAAa,kBACZ,aACoB;CACpB,MAAM,sBAAM,IAAI,IAAe;CAC/B,KAAK,MAAM,UAAU,OAAO,OAAO,YAAY,CAAC,CAAC,GAChD,IAAI,IAAI,OAAO,OAAO,MAAM;CAE7B,OAAO;AACR"}
@@ -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/submissionContext.d.ts
6
+ /**
7
+ * The form a submission's post-submit hooks run for: the whole document as the plugin loaded it for
8
+ * the run, at depth 0 (relationships are ids, e.g. a multi-tenant host's `form.tenant`) with its
9
+ * localized fields in the submission's locale. Read a field off it instead of reading the form back,
10
+ * casting for your own fields (`form.tenant as string`). Every hook of the run shares this one object,
11
+ * so treat it as read-only.
12
+ */
13
+ type SubmissionForm = {
14
+ id: number | string;
15
+ title?: string;
16
+ } & Record<string, unknown>;
17
+ /**
18
+ * The submission a post-submit hook runs for, shared by the public hook contracts that receive it
19
+ * (a recipient source's `resolve`, `email.render`). Each extends it with its own fields rather than
20
+ * with another's, so a field added for one hook never silently joins another's API.
21
+ */
22
+ type SubmissionContextArgs = {
23
+ /** The verified form-context reference, or null when the form was rendered without one. */context: FormContextReference | null;
24
+ values: SubmissionValue[];
25
+ descriptors: SubmissionDescriptor[];
26
+ form: SubmissionForm;
27
+ submissionId: number | string;
28
+ payload: Payload;
29
+ req?: PayloadRequest; /** The submission's own stored locale, the one the form (and so its action config) was loaded at. */
30
+ locale: string;
31
+ };
32
+ //#endregion
33
+ export { SubmissionContextArgs, SubmissionForm };
34
+ //# sourceMappingURL=submissionContext.d.ts.map
@@ -1,8 +1,9 @@
1
1
  import { asFieldTranslate } from "../translations/server.js";
2
2
  import { isEssentialAction } from "./registry.js";
3
- import { FORMS_SLUG } from "../collections/forms.js";
4
- import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
3
+ import { resolveSubmissionLocale } from "../submissions/submissionLocale.js";
5
4
  import { runActions } from "./runActions.js";
5
+ import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
6
+ import { findFormAtLocale, missingFormOnReadError } from "../form/findFormAtLocale.js";
6
7
  //#region src/actions/task.ts
7
8
  const ACTIONS_TASK_SLUG = "form-builder-actions";
8
9
  const asActions = (value) => Array.isArray(value) ? value : [];
@@ -34,15 +35,14 @@ const runActionsForSubmission = async (args) => {
34
35
  req
35
36
  }).catch(() => null);
36
37
  if (!submission) return [];
37
- const locale = typeof submission.locale === "string" ? submission.locale : req?.locale ?? "en";
38
- const form = await payload.findByID({
39
- collection: FORMS_SLUG,
38
+ const locale = resolveSubmissionLocale(typeof submission.locale === "string" ? submission.locale : req?.locale, payload.config.localization);
39
+ const form = await findFormAtLocale({
40
+ payload,
40
41
  id: input.formId,
41
- depth: 0,
42
- overrideAccess: true,
43
42
  locale,
44
- req
45
- }).catch(() => null);
43
+ req,
44
+ overrideAccess: true
45
+ }).catch(missingFormOnReadError);
46
46
  if (!form) return [];
47
47
  const t = asFieldTranslate(req?.i18n?.t ?? ((key) => key));
48
48
  const subset = input.subset ?? "all";
@@ -54,10 +54,7 @@ const runActionsForSubmission = async (args) => {
54
54
  }),
55
55
  registry,
56
56
  richText,
57
- form: {
58
- id: form.id,
59
- title: typeof form.title === "string" ? form.title : void 0
60
- },
57
+ form,
61
58
  submissionId: submission.id,
62
59
  values: asValues(submission.values),
63
60
  descriptors: asDescriptors(submission.descriptors),
@@ -1 +1 @@
1
- {"version":3,"file":"task.js","names":[],"sources":["../../src/actions/task.ts"],"sourcesContent":["import type { Config, Payload, PayloadRequest, TaskConfig, TypedLocale } from 'payload'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { FormContextReference } from '../context/formContext'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\nimport { asFieldTranslate } from '../translations/server'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport { type ActionRegistry, isEssentialAction } from './registry'\nimport type { ActionInstance, ActionResult } from './runActions'\nimport { runActions } from './runActions'\n\nexport const ACTIONS_TASK_SLUG = 'form-builder-actions'\n\n/** Input the dispatch path enqueues; the handler re-loads everything else from the DB. */\n/** `subset` filters by the action definitions' `essential` flag; absent runs everything ('all'). */\nexport type ActionsTaskInput = {\n\tformId: number | string\n\tsubmissionId: number | string\n\tsubset?: 'essential' | 'rest'\n}\n\nconst asActions = (value: unknown): ActionInstance[] =>\n\tArray.isArray(value) ? (value as ActionInstance[]) : []\n\nconst asValues = (value: unknown): SubmissionValue[] =>\n\tArray.isArray(value) ? (value as SubmissionValue[]) : []\n\nconst asDescriptors = (value: unknown): SubmissionDescriptor[] =>\n\tArray.isArray(value) ? (value as SubmissionDescriptor[]) : []\n\n/** The verified `{ relationTo, value }` stored on a submission, or null when it carried no context. */\nconst asContext = (value: unknown): FormContextReference | null => {\n\tif (value && typeof value === 'object') {\n\t\tconst { relationTo, value: reference } = value as Record<string, unknown>\n\t\tif (\n\t\t\ttypeof relationTo === 'string' &&\n\t\t\t(typeof reference === 'string' || typeof reference === 'number')\n\t\t) {\n\t\t\treturn { relationTo, value: reference }\n\t\t}\n\t}\n\treturn null\n}\n\n/**\n * Load the form and submission by id and run the form's actions through the shared, failure-isolating\n * `runActions`. Tolerates a missing form or submission (the row may have been deleted between enqueue and\n * run) by returning early. Used by both the queued task handler and the inline fallback.\n */\nexport const runActionsForSubmission = async (args: {\n\tinput: ActionsTaskInput\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\trichText?: RichTextBodyOption\n}): Promise<ActionResult[]> => {\n\tconst { input, registry, payload, req, richText } = args\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: input.submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (!submission) {\n\t\treturn []\n\t}\n\n\t// The submission's own stored locale (set from req.locale at submit) is authoritative, so the form\n\t// is loaded at it. A localized action config, notably the emailTeam `to`, then resolves to the\n\t// submission's locale even on the queued path, where the job runner's req may carry a different\n\t// (or no) locale than the visitor who submitted.\n\tconst locale = typeof submission.locale === 'string' ? submission.locale : (req?.locale ?? 'en')\n\n\tconst form = await payload\n\t\t.findByID({\n\t\t\tcollection: FORMS_SLUG,\n\t\t\tid: input.formId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\t// Cast: the stored locale is a plain string; a host's concrete locale union is unknowable from\n\t\t\t// the plugin, and an unrecognized code just falls back on read, so this narrows (zero runtime\n\t\t\t// delta) to satisfy a host whose `findByID` locale is a real union.\n\t\t\tlocale: locale as TypedLocale,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn []\n\t}\n\n\tconst t: Translate = asFieldTranslate(req?.i18n?.t ?? ((key: string) => key))\n\n\tconst subset = input.subset ?? 'all'\n\tconst selected = asActions(form.actions).filter((instance) => {\n\t\tif (subset === 'all') {\n\t\t\treturn true\n\t\t}\n\t\tconst isEssential = isEssentialAction(registry, instance)\n\t\treturn subset === 'essential' ? isEssential : !isEssential\n\t})\n\n\tconst results = await runActions({\n\t\tactions: selected,\n\t\tregistry,\n\t\trichText,\n\t\tform: { id: form.id, title: typeof form.title === 'string' ? form.title : undefined },\n\t\tsubmissionId: submission.id,\n\t\tvalues: asValues(submission.values),\n\t\tdescriptors: asDescriptors(submission.descriptors),\n\t\tcontext: asContext(submission.context),\n\t\tpayload,\n\t\treq,\n\t\tlocale,\n\t\tt,\n\t})\n\t// A failed action (SMTP down, webhook non-2xx, missing adapter) is isolated per action; surface it\n\t// so a silently undelivered email/webhook is visible instead of the submission looking successful.\n\tfor (const result of results) {\n\t\tif (!result.ok) {\n\t\t\tconst message = `@10x-media/form-builder: action \"${result.type}\" failed for submission ${String(submission.id)}: ${result.error ?? 'unknown error'}`\n\t\t\t// Pino's (mergeObject, message) form: an ActionError's structured detail lands as a\n\t\t\t// queryable log field instead of being concatenated into the message.\n\t\t\tif (result.detail !== undefined) {\n\t\t\t\tpayload.logger?.error({ detail: result.detail }, message)\n\t\t\t} else {\n\t\t\t\tpayload.logger?.error(message)\n\t\t\t}\n\t\t}\n\t}\n\t// A form can opt out of storing submissions (a pure signup that only POSTs to a provider): prune the\n\t// row after the whole action pass, regardless of individual action success (every action already got\n\t// the values). Best-effort: a delete failure is logged, never thrown. Uploads referenced in the values\n\t// are host-owned and not cascaded (documented).\n\t// Never on the essential pass: essential actions run first and their failure keeps the row (the\n\t// dispatcher then skips this completion entirely), so pruning belongs to the closing pass alone.\n\tif (subset !== 'essential' && form.persistSubmissions === false) {\n\t\tawait payload\n\t\t\t.delete({ collection: FORM_SUBMISSIONS_SLUG, id: submission.id, overrideAccess: true, req })\n\t\t\t.catch((error) => {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: failed to prune submission ${String(submission.id)}: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t})\n\t}\n\treturn results\n}\n\n/** Native Payload jobs task that runs a submission's post-submit actions out of band. */\nexport const buildActionsTask = (\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): TaskConfig =>\n\t({\n\t\tslug: ACTIONS_TASK_SLUG,\n\t\tinputSchema: [\n\t\t\t{ name: 'formId', type: 'text', required: true },\n\t\t\t{ name: 'submissionId', type: 'text', required: true },\n\t\t],\n\t\thandler: async ({ input, req }) => {\n\t\t\tawait runActionsForSubmission({\n\t\t\t\tinput: input as ActionsTaskInput,\n\t\t\t\tregistry,\n\t\t\t\tpayload: req.payload,\n\t\t\t\treq,\n\t\t\t\trichText,\n\t\t\t})\n\t\t\treturn { output: {} }\n\t\t},\n\t}) as TaskConfig\n\n/** Register the actions task on `config.jobs.tasks`, creating the jobs config if absent. */\nexport const registerActionsTask = (\n\tconfig: Config,\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): void => {\n\tconfig.jobs ??= {}\n\tconfig.jobs.tasks ??= []\n\tconfig.jobs.tasks.push(buildActionsTask(registry, richText))\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"task.js","names":[],"sources":["../../src/actions/task.ts"],"sourcesContent":["import type { Config, Payload, PayloadRequest, TaskConfig } from 'payload'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport type { FormContextReference } from '../context/formContext'\nimport type { Translate } from '../fields/types'\nimport { findFormAtLocale, missingFormOnReadError } from '../form/findFormAtLocale'\nimport { resolveSubmissionLocale } from '../submissions/submissionLocale'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\nimport { asFieldTranslate } from '../translations/server'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport { type ActionRegistry, isEssentialAction } from './registry'\nimport type { ActionInstance, ActionResult } from './runActions'\nimport { runActions } from './runActions'\nimport type { SubmissionForm } from './submissionContext'\n\nexport const ACTIONS_TASK_SLUG = 'form-builder-actions'\n\n/** Input the dispatch path enqueues; the handler re-loads everything else from the DB. */\n/** `subset` filters by the action definitions' `essential` flag; absent runs everything ('all'). */\nexport type ActionsTaskInput = {\n\tformId: number | string\n\tsubmissionId: number | string\n\tsubset?: 'essential' | 'rest'\n}\n\nconst asActions = (value: unknown): ActionInstance[] =>\n\tArray.isArray(value) ? (value as ActionInstance[]) : []\n\nconst asValues = (value: unknown): SubmissionValue[] =>\n\tArray.isArray(value) ? (value as SubmissionValue[]) : []\n\nconst asDescriptors = (value: unknown): SubmissionDescriptor[] =>\n\tArray.isArray(value) ? (value as SubmissionDescriptor[]) : []\n\n/** The verified `{ relationTo, value }` stored on a submission, or null when it carried no context. */\nconst asContext = (value: unknown): FormContextReference | null => {\n\tif (value && typeof value === 'object') {\n\t\tconst { relationTo, value: reference } = value as Record<string, unknown>\n\t\tif (\n\t\t\ttypeof relationTo === 'string' &&\n\t\t\t(typeof reference === 'string' || typeof reference === 'number')\n\t\t) {\n\t\t\treturn { relationTo, value: reference }\n\t\t}\n\t}\n\treturn null\n}\n\n/**\n * Load the form and submission by id and run the form's actions through the shared, failure-isolating\n * `runActions`. Tolerates a missing form or submission (the row may have been deleted between enqueue and\n * run) by returning early. Used by both the queued task handler and the inline fallback.\n */\nexport const runActionsForSubmission = async (args: {\n\tinput: ActionsTaskInput\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\trichText?: RichTextBodyOption\n}): Promise<ActionResult[]> => {\n\tconst { input, registry, payload, req, richText } = args\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: input.submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (!submission) {\n\t\treturn []\n\t}\n\n\t// The submission's own stored locale (set from req.locale at submit) is authoritative, so the form\n\t// is loaded at it. A localized action config, notably the emailTeam `to`, then resolves to the\n\t// submission's locale even on the queued path, where the job runner's req may carry a different\n\t// (or no) locale than the visitor who submitted. Re-clamped, since a host may have dropped that\n\t// locale since the submission was stored.\n\tconst locale = resolveSubmissionLocale(\n\t\ttypeof submission.locale === 'string' ? submission.locale : req?.locale,\n\t\tpayload.config.localization\n\t)\n\n\tconst form = await findFormAtLocale({\n\t\tpayload,\n\t\tid: input.formId,\n\t\tlocale,\n\t\treq,\n\t\toverrideAccess: true,\n\t}).catch(missingFormOnReadError)\n\tif (!form) {\n\t\treturn []\n\t}\n\n\tconst t: Translate = asFieldTranslate(req?.i18n?.t ?? ((key: string) => key))\n\n\tconst subset = input.subset ?? 'all'\n\tconst selected = asActions(form.actions).filter((instance) => {\n\t\tif (subset === 'all') {\n\t\t\treturn true\n\t\t}\n\t\tconst isEssential = isEssentialAction(registry, instance)\n\t\treturn subset === 'essential' ? isEssential : !isEssential\n\t})\n\n\tconst results = await runActions({\n\t\tactions: selected,\n\t\tregistry,\n\t\trichText,\n\t\t// The whole document, not just its identity, so a send-time hook reads a field off it (a\n\t\t// multi-tenant host's `tenant`) instead of loading the same form again. Double cast: a host's\n\t\t// generated Form interface has no index signature.\n\t\tform: form as unknown as SubmissionForm,\n\t\tsubmissionId: submission.id,\n\t\tvalues: asValues(submission.values),\n\t\tdescriptors: asDescriptors(submission.descriptors),\n\t\tcontext: asContext(submission.context),\n\t\tpayload,\n\t\treq,\n\t\tlocale,\n\t\tt,\n\t})\n\t// A failed action (SMTP down, webhook non-2xx, missing adapter) is isolated per action; surface it\n\t// so a silently undelivered email/webhook is visible instead of the submission looking successful.\n\tfor (const result of results) {\n\t\tif (!result.ok) {\n\t\t\tconst message = `@10x-media/form-builder: action \"${result.type}\" failed for submission ${String(submission.id)}: ${result.error ?? 'unknown error'}`\n\t\t\t// Pino's (mergeObject, message) form: an ActionError's structured detail lands as a\n\t\t\t// queryable log field instead of being concatenated into the message.\n\t\t\tif (result.detail !== undefined) {\n\t\t\t\tpayload.logger?.error({ detail: result.detail }, message)\n\t\t\t} else {\n\t\t\t\tpayload.logger?.error(message)\n\t\t\t}\n\t\t}\n\t}\n\t// A form can opt out of storing submissions (a pure signup that only POSTs to a provider): prune the\n\t// row after the whole action pass, regardless of individual action success (every action already got\n\t// the values). Best-effort: a delete failure is logged, never thrown. Uploads referenced in the values\n\t// are host-owned and not cascaded (documented).\n\t// Never on the essential pass: essential actions run first and their failure keeps the row (the\n\t// dispatcher then skips this completion entirely), so pruning belongs to the closing pass alone.\n\tif (subset !== 'essential' && form.persistSubmissions === false) {\n\t\tawait payload\n\t\t\t.delete({ collection: FORM_SUBMISSIONS_SLUG, id: submission.id, overrideAccess: true, req })\n\t\t\t.catch((error) => {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: failed to prune submission ${String(submission.id)}: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t})\n\t}\n\treturn results\n}\n\n/** Native Payload jobs task that runs a submission's post-submit actions out of band. */\nexport const buildActionsTask = (\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): TaskConfig =>\n\t({\n\t\tslug: ACTIONS_TASK_SLUG,\n\t\tinputSchema: [\n\t\t\t{ name: 'formId', type: 'text', required: true },\n\t\t\t{ name: 'submissionId', type: 'text', required: true },\n\t\t],\n\t\thandler: async ({ input, req }) => {\n\t\t\tawait runActionsForSubmission({\n\t\t\t\tinput: input as ActionsTaskInput,\n\t\t\t\tregistry,\n\t\t\t\tpayload: req.payload,\n\t\t\t\treq,\n\t\t\t\trichText,\n\t\t\t})\n\t\t\treturn { output: {} }\n\t\t},\n\t}) as TaskConfig\n\n/** Register the actions task on `config.jobs.tasks`, creating the jobs config if absent. */\nexport const registerActionsTask = (\n\tconfig: Config,\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): void => {\n\tconfig.jobs ??= {}\n\tconfig.jobs.tasks ??= []\n\tconfig.jobs.tasks.push(buildActionsTask(registry, richText))\n}\n"],"mappings":"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"}
@@ -1,8 +1,8 @@
1
1
  import { isNamedField } from "../fields/fieldKey.js";
2
2
  import { instanceOptionsOf } from "../fields/instanceOptions.js";
3
+ import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
3
4
  import { aggregateRowsForFields } from "./aggregateRows.js";
4
5
  import { FORMS_SLUG } from "../collections/forms.js";
5
- import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
6
6
  //#region src/aggregation/aggregateResponses.ts
7
7
  /** True when a field declares non-empty options (a choice field safe to aggregate publicly). */
8
8
  const fieldHasOptions = (field) => instanceOptionsOf(field) !== void 0;