@10x-media/form-builder 0.1.0-beta.17 → 0.1.0-beta.19
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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -0
- package/dist/actions/builtin/emailAction.d.ts +4 -3
- package/dist/actions/builtin/emailAction.js +11 -5
- package/dist/actions/builtin/emailAction.js.map +1 -1
- package/dist/actions/emailRecipients.js +4 -1
- package/dist/actions/emailRecipients.js.map +1 -1
- package/dist/actions/fromAddresses.d.ts +17 -1
- package/dist/actions/fromAddresses.js +66 -9
- package/dist/actions/fromAddresses.js.map +1 -1
- package/dist/actions/recipientSources.js +1 -1
- package/dist/actions/recipientSources.js.map +1 -1
- package/dist/client/EndpointOptionsSelect.d.ts +16 -7
- package/dist/client/EndpointOptionsSelect.js +16 -10
- package/dist/client/EndpointOptionsSelect.js.map +1 -1
- package/dist/client/RecipientsSelect.d.ts +3 -1
- package/dist/client/RecipientsSelect.js +7 -3
- package/dist/client/RecipientsSelect.js.map +1 -1
- package/dist/client/endpointOptions.d.ts +11 -0
- package/dist/client/endpointOptions.js +17 -8
- package/dist/client/endpointOptions.js.map +1 -1
- package/dist/collections/forms.js +2 -1
- package/dist/collections/forms.js.map +1 -1
- package/dist/collections/formsEndpoints.js +28 -25
- package/dist/collections/formsEndpoints.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +12 -2
- package/dist/plugin/registerCollections.js +2 -1
- package/dist/plugin/registerCollections.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @10x-media/form-builder
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.19
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- The from-address and department selects now load their options while a form is still being created, instead of sitting empty until the first save. Both option sets are request-scoped (they depend on who is asking, not on the form document), so their endpoints now live at id-less paths (`GET /api/forms/from-addresses`, `GET /api/forms/departments`); the old `/:id/`-prefixed routes still answer on the same handlers for anything that hardcoded them. `EndpointOptionsSelect` and `RecipientsSelect` gain a `scope: 'document' | 'request'` clientProp (default `'document'`, unchanged behaviour) so a host field backed by its own request-scoped endpoint can opt into the same create-mode loading. Document-scoped selects (poll options, consent sources) are unaffected.
|
|
8
|
+
|
|
9
|
+
## 0.1.0-beta.18
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- Add `email.fromSources`: senders resolved at send time, for hosts where the from-address is tenant identity rather than per-form configuration. A source stores a stable namespaced value (e.g. `tenant:default`) on the action and re-resolves the actual address on every send with the same run-time arguments a recipient source gets, so a tenant that changes its from-address changes it for every saved form at once. Sources appear in the existing From select ahead of the `fromAddresses` literals; literals keep today's behaviour exactly, including never touching a source at send.
|
|
14
|
+
|
|
3
15
|
## 0.1.0-beta.17
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# @10x-media/form-builder
|
|
2
4
|
|
|
3
5
|
An end-to-end forms platform for Payload v3: author forms in the admin, validate server-side, render headless on your frontend, collect typed submissions, aggregate results, and act on them. Simple by default for editors, with a definition seam (`defineFormField`, `defineValidationRule`, `defineAction`, and friends) wherever developers need depth, and 100% native to Payload.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { FromAddressesResolver } from "../fromAddresses.js";
|
|
2
|
-
import { DepartmentEmailsResolver } from "../../email/departments.js";
|
|
3
1
|
import { RecipientSourceRegistry } from "../recipientSources.js";
|
|
2
|
+
import { FromAddressSourceRegistry, FromAddressesResolver } from "../fromAddresses.js";
|
|
3
|
+
import { DepartmentEmailsResolver } from "../../email/departments.js";
|
|
4
4
|
import { RecipientsConfig } from "../emailRecipients.js";
|
|
5
5
|
import { Field, RichTextField } from "payload";
|
|
6
6
|
|
|
@@ -9,7 +9,8 @@ import { Field, RichTextField } from "payload";
|
|
|
9
9
|
type EmailActionOptions = {
|
|
10
10
|
localize: boolean;
|
|
11
11
|
editor?: RichTextField['editor'];
|
|
12
|
-
fromAddresses?: FromAddressesResolver;
|
|
12
|
+
fromAddresses?: FromAddressesResolver; /** Send-time-resolved senders offered in the from select (plugin option `email.fromSources`). */
|
|
13
|
+
fromSources?: FromAddressSourceRegistry;
|
|
13
14
|
departments?: DepartmentEmailsResolver;
|
|
14
15
|
recipients?: RecipientsConfig; /** Server-resolved recipient sources offered in every recipient list (plugin option `email.recipientSources`). */
|
|
15
16
|
recipientSources?: RecipientSourceRegistry;
|
|
@@ -5,7 +5,7 @@ import { interpolate } from "../../recall/interpolate.js";
|
|
|
5
5
|
import { buildRecipientField, resolveRecipientEntries } from "../emailRecipients.js";
|
|
6
6
|
import { resolverFor } from "../body/serializeBody.js";
|
|
7
7
|
import { defineAction } from "../defineAction.js";
|
|
8
|
-
import { buildFromField } from "../fromAddresses.js";
|
|
8
|
+
import { buildFromField, resolveSendFrom } from "../fromAddresses.js";
|
|
9
9
|
import { sourcesByValue } from "../recipientSources.js";
|
|
10
10
|
//#region src/actions/builtin/emailAction.ts
|
|
11
11
|
/**
|
|
@@ -17,7 +17,8 @@ import { sourcesByValue } from "../recipientSources.js";
|
|
|
17
17
|
* primary `to` target and its missing-value behavior differ, threaded through `spec`.
|
|
18
18
|
*/
|
|
19
19
|
const buildEmailAction = (options, spec) => {
|
|
20
|
-
const { localize, editor, fromAddresses, departments, recipients, recipientSources } = options;
|
|
20
|
+
const { localize, editor, fromAddresses, fromSources, departments, recipients, recipientSources } = options;
|
|
21
|
+
const fromSourcesByValue = sourcesByValue(fromSources);
|
|
21
22
|
const endpoint = departments ? "departments" : void 0;
|
|
22
23
|
const recip = (name, labelKey) => buildRecipientField(name, labelKey, localize, {
|
|
23
24
|
endpoint,
|
|
@@ -34,7 +35,7 @@ const buildEmailAction = (options, spec) => {
|
|
|
34
35
|
type: "row",
|
|
35
36
|
fields: [spec.target(recip), recip("replyTo", keys.actionConfigReplyTo)]
|
|
36
37
|
},
|
|
37
|
-
...fromAddresses ? [buildFromField(fromAddresses)] : [],
|
|
38
|
+
...fromAddresses || fromSources ? [buildFromField(fromAddresses, fromSources)] : [],
|
|
38
39
|
{
|
|
39
40
|
type: "row",
|
|
40
41
|
fields: [recip("cc", keys.actionConfigCc), recip("bcc", keys.actionConfigBcc)]
|
|
@@ -57,7 +58,7 @@ const buildEmailAction = (options, spec) => {
|
|
|
57
58
|
run: async (args) => {
|
|
58
59
|
const { config, values } = args;
|
|
59
60
|
const resolve = resolverFor(values);
|
|
60
|
-
const sources = sourcesByValue(
|
|
61
|
+
const sources = sourcesByValue(recipientSources);
|
|
61
62
|
const sourceArgs = {
|
|
62
63
|
context: args.context,
|
|
63
64
|
values,
|
|
@@ -96,11 +97,16 @@ const buildEmailAction = (options, spec) => {
|
|
|
96
97
|
sources,
|
|
97
98
|
sourceArgs
|
|
98
99
|
})).join(", ");
|
|
100
|
+
const from = await resolveSendFrom({
|
|
101
|
+
configured: config.from,
|
|
102
|
+
sources: fromSourcesByValue,
|
|
103
|
+
sourceArgs
|
|
104
|
+
});
|
|
99
105
|
await args.payload.sendEmail({
|
|
100
106
|
to,
|
|
101
107
|
subject,
|
|
102
108
|
html,
|
|
103
|
-
...
|
|
109
|
+
...from ? { from } : {},
|
|
104
110
|
...cc ? { cc } : {},
|
|
105
111
|
...bcc ? { bcc } : {},
|
|
106
112
|
...replyTo ? { replyTo } : {}
|
|
@@ -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 {
|
|
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 {\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}\n\n/** The config fields shared by every built-in email action (each action adds its own `to` target). */\nexport type EmailActionConfig = {\n\tfrom?: string\n\tcc?: string[]\n\tbcc?: string[]\n\treplyTo?: string[]\n\tsubject?: string\n\tbody?: unknown\n}\n\n/** Builds a recipient-list field (`to`/`cc`/`bcc`/`replyTo`) with the shared width, endpoint, and options. */\ntype RecipientFieldBuilder = (name: string, labelKey: string) => Field\n\ntype Resolver = ReturnType<typeof resolverFor>\n\n/** What `resolveTo` needs to compute the primary target, including server-resolved sources. */\ntype ResolveToArgs<TConfig extends EmailActionConfig> = {\n\tconfig: TConfig\n\tresolve: Resolver\n\tsources: Map<string, RecipientSource>\n\tsourceArgs: RecipientResolveArgs\n}\n\n/** What distinguishes one email action from another: identity, its primary target, and how it resolves/guards that target. */\ntype EmailActionSpec<TConfig extends EmailActionConfig> = {\n\ttype: string\n\tlabel: string\n\t/** The first cell of the opening row (paired with `replyTo`): a recipient list, or an email-field select. */\n\ttarget: (recip: RecipientFieldBuilder) => Field\n\t/** Resolve the primary `to` to a comma-joined address string, or `''` when nothing resolves. */\n\tresolveTo: (args: ResolveToArgs<TConfig>) => Promise<string> | string\n\t/**\n\t * Whether the author configured any target at all. A configured target that resolves empty (e.g. a\n\t * source returned `[]`) is a normal skip; only a target the author never set is a misconfiguration.\n\t */\n\thasTarget: (config: TConfig) => boolean\n\t/** With no target authored, `emailTeam` treats it as a misconfiguration (`throw`), `confirmation` as a silent skip. */\n\tonMissingTo: 'throw' | 'skip'\n}\n\n/**\n * The shared skeleton of the built-in email actions (`emailTeam`, `confirmation`): an identical\n * config (a first row pairing the action's target with `replyTo`, an optional `from` select, a\n * cc/bcc row, a subject, and a rich text body, content and recipient fields carrying `localized`\n * when `localize`) and an identical send (interpolate the subject, render the body, resolve\n * cc/bcc/replyTo, and hand a single comma-joined string per list to `payload.sendEmail`). Only the\n * primary `to` target and its missing-value behavior differ, threaded through `spec`.\n */\nexport const buildEmailAction = <TConfig extends EmailActionConfig>(\n\toptions: EmailActionOptions,\n\tspec: EmailActionSpec<TConfig>\n): ActionDefinition<TConfig> => {\n\tconst {\n\t\tlocalize,\n\t\teditor,\n\t\tfromAddresses,\n\t\tfromSources,\n\t\tdepartments,\n\t\trecipients,\n\t\trecipientSources,\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 html = await args.renderBody(config.body)\n\t\t\tconst cc = (await resolveRecipientEntries(config.cc, { resolve, sources, sourceArgs })).join(\n\t\t\t\t', '\n\t\t\t)\n\t\t\tconst bcc = (\n\t\t\t\tawait resolveRecipientEntries(config.bcc, { resolve, sources, sourceArgs })\n\t\t\t).join(', ')\n\t\t\tconst replyTo = (\n\t\t\t\tawait resolveRecipientEntries(config.replyTo, { resolve, sources, sourceArgs })\n\t\t\t).join(', ')\n\n\t\t\t// 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":";;;;;;;;;;;;;;;;;;AAuFA,MAAa,oBACZ,SACA,SAC+B;CAC/B,MAAM,EACL,UACA,QACA,eACA,aACA,aACA,YACA,qBACG;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,OAAO,MAAM,KAAK,WAAW,OAAO,IAAI;GAC9C,MAAM,MAAM,MAAM,wBAAwB,OAAO,IAAI;IAAE;IAAS;IAAS;GAAW,CAAC,GAAG,KACvF,IACD;GACA,MAAM,OACL,MAAM,wBAAwB,OAAO,KAAK;IAAE;IAAS;IAAS;GAAW,CAAC,GACzE,KAAK,IAAI;GACX,MAAM,WACL,MAAM,wBAAwB,OAAO,SAAS;IAAE;IAAS;IAAS;GAAW,CAAC,GAC7E,KAAK,IAAI;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"}
|
|
@@ -124,7 +124,10 @@ const buildRecipientField = (name, labelKey, localize, opts = {}) => {
|
|
|
124
124
|
components: { Field: {
|
|
125
125
|
path: RECIPIENTS_FIELD_REF,
|
|
126
126
|
clientProps: {
|
|
127
|
-
...opts.endpoint ? {
|
|
127
|
+
...opts.endpoint ? {
|
|
128
|
+
endpoint: opts.endpoint,
|
|
129
|
+
scope: "request"
|
|
130
|
+
} : {},
|
|
128
131
|
...opts.recipients?.allowCustom === false ? { allowCustom: false } : {},
|
|
129
132
|
...opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {},
|
|
130
133
|
...opts.recipients?.tokenFieldTypes ? { tokenFieldTypes: opts.recipients.tokenFieldTypes } : {},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"emailRecipients.js","names":[],"sources":["../../src/actions/emailRecipients.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../email/departments'\nimport { fieldNames, fieldNamesOfType } from '../fields/fieldNamesOfType'\nimport { localizedIf } from '../fields/localizedIf'\nimport { interpolate } from '../recall/interpolate'\nimport { keys } from '../translations/keys'\nimport { asTranslate, labelFor } from '../translations/server'\nimport type {\n\tRecipientResolveArgs,\n\tRecipientSource,\n\tRecipientSourceRegistry,\n} from './recipientSources'\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst TOKEN_RE = /^\\{\\{\\s*([\\w.-]+)\\s*\\}\\}$/\n\n/** A plausible email address (a permissive shape check, not full RFC validation). */\nexport const isPlausibleEmail = (value: string): boolean => EMAIL_RE.test(value.trim())\n\n/** The field name inside a `{{name}}` recipient token, or undefined when the string is not a token. */\nexport const parseFieldToken = (value: string): string | undefined => {\n\tconst match = TOKEN_RE.exec(value.trim())\n\treturn match ? match[1] : undefined\n}\n\nexport const isFieldToken = (value: string): boolean => parseFieldToken(value) !== undefined\n\nconst toList = (value: unknown): string[] =>\n\tArray.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string')\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n/**\n * The first plausible email in a resolved value, or empty. A recipient entry names a single address,\n * so this drops anything past a separator or CR/LF that a `{{field}}` token might interpolate from\n * visitor input, closing SMTP header injection and extra-recipient injection.\n */\nexport const firstAddress = (value: string): string =>\n\tvalue\n\t\t.split(/[,;\\n\\r]+/)\n\t\t.map((part) => part.trim())\n\t\t.find(isPlausibleEmail) ?? ''\n\n/**\n * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated\n * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the\n * submission, a plain email passes through), sanitized to a single address, empties dropped, joined.\n */\nexport const resolveRecipients = (value: unknown, resolve: (name: string) => string): string =>\n\ttoList(value)\n\t\t.map((entry) => firstAddress(interpolate(entry, resolve)))\n\t\t.filter((entry) => entry.length > 0)\n\t\t.join(', ')\n\n/**\n * Async recipient resolution for the email actions, extending `resolveRecipients` with server-resolved\n * sources. A registered source value calls its `resolve` (each returned address reduced to one by\n * `firstAddress`, so a source cannot inject headers or extra recipients either); a `{{token}}`/plain\n * email resolves exactly as before. Returns a clean address list (`[]` when nothing resolves) so the\n * caller can skip an empty send. A thrown resolver propagates, failing the action loudly rather than\n * sending to a shortened list.\n */\nexport const resolveRecipientEntries = async (\n\tvalue: unknown,\n\topts: {\n\t\tresolve: (name: string) => string\n\t\tsources?: Map<string, RecipientSource>\n\t\tsourceArgs?: RecipientResolveArgs\n\t}\n): Promise<string[]> => {\n\tconst out: string[] = []\n\tfor (const entry of toList(value)) {\n\t\tconst source = opts.sources?.get(entry)\n\t\tif (source) {\n\t\t\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\t\t\tconst resolved = opts.sourceArgs ? await source.resolve(opts.sourceArgs) : []\n\t\t\tfor (const address of resolved) {\n\t\t\t\tconst clean = firstAddress(address)\n\t\t\t\tif (clean) {\n\t\t\t\t\tout.push(clean)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tconst clean = firstAddress(interpolate(entry, opts.resolve))\n\t\tif (clean) {\n\t\t\tout.push(clean)\n\t\t}\n\t}\n\treturn out\n}\n\n/**\n * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email\n * or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is\n * false, a non-token entry must additionally be a member of the resolved options (`resolveAllowed`,\n * e.g. the department addresses), matching the client constraint on the server. Fails closed if the\n * options resolver throws (mirroring the `from` field). Reads the form's `fields` off `data`. Like\n * `from`, Payload runs this on every save, so the resolver is consulted each save under\n * `allowCustom: false`; `buildRecipientField` memoizes it per request so all recipient fields share one call.\n */\nexport const validateRecipients =\n\t(opts: {\n\t\ttokenFieldTypes?: string[]\n\t\tallowCustom?: boolean\n\t\tfieldTokens?: boolean\n\t\tresolveAllowed?: (req: PayloadRequest) => Set<string> | Promise<Set<string>>\n\t\t/** Stored values of registered recipient sources; a member is a valid recipient by itself. */\n\t\tsourceValues?: Set<string>\n\t}) =>\n\tasync (\n\t\tvalue: unknown,\n\t\t{ data, req }: { data?: unknown; req: PayloadRequest }\n\t): Promise<string | true> => {\n\t\tconst list = toList(value)\n\t\tif (list.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\tconst fields =\n\t\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\t\tconst { tokenFieldTypes, allowCustom, fieldTokens, resolveAllowed } = opts\n\t\tconst allowedFields = new Set(\n\t\t\ttokenFieldTypes && tokenFieldTypes.length > 0\n\t\t\t\t? fieldNamesOfType(fields, tokenFieldTypes)\n\t\t\t\t: fieldNames(fields)\n\t\t)\n\t\t// Only resolve the membership set when it is actually enforced (allowCustom explicitly false),\n\t\t// so the default (free-typed) path pays no resolver cost. Compare case-insensitively, matching\n\t\t// the client's dedupe, since email delivery ignores case.\n\t\tlet allowedValues: Set<string> | undefined\n\t\tif (allowCustom === false) {\n\t\t\tif (resolveAllowed) {\n\t\t\t\ttry {\n\t\t\t\t\tallowedValues = await resolveAllowed(req)\n\t\t\t\t} catch {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientOptionsUnavailable)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallowedValues = new Set()\n\t\t\t}\n\t\t}\n\t\tfor (const entry of list) {\n\t\t\t// A registered source value is a valid recipient on its own: checked first, so a same-named form\n\t\t\t// field cannot shadow it and `allowCustom: false` cannot block it (it is not a custom address).\n\t\t\tif (opts.sourceValues?.has(entry)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst token = parseFieldToken(entry)\n\t\t\tif (token) {\n\t\t\t\t// `fieldTokens: false` disables recipient tokens; enforce it on the server, not just the client.\n\t\t\t\tif (fieldTokens === false) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t\t}\n\t\t\t\tif (!allowedFields.has(token)) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientUnknownField)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!isPlausibleEmail(entry)) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientInvalid)\n\t\t\t}\n\t\t\tif (allowCustom === false && !allowedValues?.has(entry.trim().toLowerCase())) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n/** Host-configurable behavior for the recipient fields (plugin option `email.recipients`). */\nexport type RecipientsConfig = {\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n}\n\nconst RECIPIENTS_FIELD_REF = '@10x-media/form-builder/client#RecipientsSelect'\n\nconst DEPARTMENT_ALLOWED_CACHE = 'formBuilderDepartmentAllowedSet'\n\n/**\n * The department option values as a lowercased Set, memoized on `req.context` so every recipient\n * field validated in one save shares a single `departments` call. Payload validates the fields\n * concurrently, so the in-flight promise is cached synchronously (not the resolved value) to avoid a\n * resolve-per-field race. Lowercased because email delivery ignores case, matching the client dedupe.\n */\nconst resolveDepartmentAllowed = (\n\treq: PayloadRequest,\n\tdepartments: DepartmentEmailsResolver\n): Promise<Set<string>> => {\n\tconst cached = req.context?.[DEPARTMENT_ALLOWED_CACHE]\n\tif (cached instanceof Promise) {\n\t\treturn cached as Promise<Set<string>>\n\t}\n\tconst promise = Promise.resolve()\n\t\t.then(() => departments({ req }))\n\t\t.then((options) => new Set(options.map((option) => option.value.trim().toLowerCase())))\n\tif (req.context) {\n\t\treq.context[DEPARTMENT_ALLOWED_CACHE] = promise\n\t}\n\treturn promise\n}\n\n/**\n * A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`\n * (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's\n * behavior; `width` sets `admin.width` so a pair can share a row; `departments` (when set) backs\n * server-side `allowCustom: false` enforcement with the resolved option values.\n */\n// biome-ignore lint/complexity/useMaxParams: the field identity (name, label, localize) plus its grouped options is the minimal surface\nexport const buildRecipientField = (\n\tname: string,\n\tlabelKey: string,\n\tlocalize: boolean,\n\topts: {\n\t\tendpoint?: string\n\t\trecipients?: RecipientsConfig\n\t\twidth?: string\n\t\tdepartments?: DepartmentEmailsResolver\n\t\tsources?: RecipientSourceRegistry\n\t} = {}\n): TextField => {\n\tconst { departments } = opts\n\tconst resolveAllowed = departments\n\t\t? (req: PayloadRequest) => resolveDepartmentAllowed(req, departments)\n\t\t: undefined\n\tconst sourceList = Object.values(opts.sources ?? {})\n\tconst sourceValues = sourceList.length > 0 ? new Set(sourceList.map((s) => s.value)) : undefined\n\treturn {\n\t\tname,\n\t\ttype: 'text',\n\t\thasMany: true,\n\t\tlabel: labelFor(labelKey),\n\t\tvalidate: validateRecipients({\n\t\t\ttokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ['email'],\n\t\t\tallowCustom: opts.recipients?.allowCustom,\n\t\t\tfieldTokens: opts.recipients?.fieldTokens,\n\t\t\tresolveAllowed,\n\t\t\tsourceValues,\n\t\t}),\n\t\t...localizedIf(localize),\n\t\tadmin: {\n\t\t\t...(opts.width ? { width: opts.width } : {}),\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: RECIPIENTS_FIELD_REF,\n\t\t\t\t\tclientProps: {\n\t\t\t\t\t\t...(opts.endpoint ? { endpoint: opts.endpoint } : {}),\n\t\t\t\t\t\t...(opts.recipients?.allowCustom === false ? { allowCustom: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.tokenFieldTypes\n\t\t\t\t\t\t\t? { tokenFieldTypes: opts.recipients.tokenFieldTypes }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t...(sourceList.length > 0\n\t\t\t\t\t\t\t? { sources: sourceList.map((s) => ({ value: s.value, label: s.label })) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"],"mappings":";;;;;;AAaA,MAAM,WAAW;AACjB,MAAM,WAAW;;AAGjB,MAAa,oBAAoB,UAA2B,SAAS,KAAK,MAAM,KAAK,CAAC;;AAGtF,MAAa,mBAAmB,UAAsC;CACrE,MAAM,QAAQ,SAAS,KAAK,MAAM,KAAK,CAAC;CACxC,OAAO,QAAQ,MAAM,KAAK,KAAA;AAC3B;AAIA,MAAM,UAAU,UACf,MAAM,QAAQ,KAAK,IAChB,MAAM,QAAQ,UAA2B,OAAO,UAAU,QAAQ,IAClE,OAAO,UAAU,YAAY,MAAM,SAAS,IAC3C,CAAC,KAAK,IACN,CAAC;;;;;;AAON,MAAa,gBAAgB,UAC5B,MACE,MAAM,WAAW,EACjB,KAAK,SAAS,KAAK,KAAK,CAAC,EACzB,KAAK,gBAAgB,KAAK;;;;;;;;;AAqB7B,MAAa,0BAA0B,OACtC,OACA,SAKuB;CACvB,MAAM,MAAgB,CAAC;CACvB,KAAK,MAAM,SAAS,OAAO,KAAK,GAAG;EAClC,MAAM,SAAS,KAAK,SAAS,IAAI,KAAK;EACtC,IAAI,QAAQ;GAEX,MAAM,WAAW,KAAK,aAAa,MAAM,OAAO,QAAQ,KAAK,UAAU,IAAI,CAAC;GAC5E,KAAK,MAAM,WAAW,UAAU;IAC/B,MAAM,QAAQ,aAAa,OAAO;IAClC,IAAI,OACH,IAAI,KAAK,KAAK;GAEhB;GACA;EACD;EACA,MAAM,QAAQ,aAAa,YAAY,OAAO,KAAK,OAAO,CAAC;EAC3D,IAAI,OACH,IAAI,KAAK,KAAK;CAEhB;CACA,OAAO;AACR;;;;;;;;;;AAWA,MAAa,sBACX,SAQD,OACC,OACA,EAAE,MAAM,UACoB;CAC5B,MAAM,OAAO,OAAO,KAAK;CACzB,IAAI,KAAK,WAAW,GACnB,OAAO;CAER,MAAM,SACL,QAAQ,OAAO,SAAS,WAAY,KAAiC,SAAS,KAAA;CAC/E,MAAM,EAAE,iBAAiB,aAAa,aAAa,mBAAmB;CACtE,MAAM,gBAAgB,IAAI,IACzB,mBAAmB,gBAAgB,SAAS,IACzC,iBAAiB,QAAQ,eAAe,IACxC,WAAW,MAAM,CACrB;CAIA,IAAI;CACJ,IAAI,gBAAgB,OACnB,IAAI,gBACH,IAAI;EACH,gBAAgB,MAAM,eAAe,GAAG;CACzC,QAAQ;EACP,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,qCAAqC;CACrE;MAEA,gCAAgB,IAAI,IAAI;CAG1B,KAAK,MAAM,SAAS,MAAM;EAGzB,IAAI,KAAK,cAAc,IAAI,KAAK,GAC/B;EAED,MAAM,QAAQ,gBAAgB,KAAK;EACnC,IAAI,OAAO;GAEV,IAAI,gBAAgB,OACnB,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,6BAA6B;GAE7D,IAAI,CAAC,cAAc,IAAI,KAAK,GAC3B,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,+BAA+B;GAE/D;EACD;EACA,IAAI,CAAC,iBAAiB,KAAK,GAC1B,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,0BAA0B;EAE1D,IAAI,gBAAgB,SAAS,CAAC,eAAe,IAAI,MAAM,KAAK,EAAE,YAAY,CAAC,GAC1E,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,6BAA6B;CAE9D;CACA,OAAO;AACR;AAYD,MAAM,uBAAuB;AAE7B,MAAM,2BAA2B;;;;;;;AAQjC,MAAM,4BACL,KACA,gBAC0B;CAC1B,MAAM,SAAS,IAAI,UAAU;CAC7B,IAAI,kBAAkB,SACrB,OAAO;CAER,MAAM,UAAU,QAAQ,QAAQ,EAC9B,WAAW,YAAY,EAAE,IAAI,CAAC,CAAC,EAC/B,MAAM,YAAY,IAAI,IAAI,QAAQ,KAAK,WAAW,OAAO,MAAM,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC;CACvF,IAAI,IAAI,SACP,IAAI,QAAQ,4BAA4B;CAEzC,OAAO;AACR;;;;;;;AASA,MAAa,uBACZ,MACA,UACA,UACA,OAMI,CAAC,MACU;CACf,MAAM,EAAE,gBAAgB;CACxB,MAAM,iBAAiB,eACnB,QAAwB,yBAAyB,KAAK,WAAW,IAClE,KAAA;CACH,MAAM,aAAa,OAAO,OAAO,KAAK,WAAW,CAAC,CAAC;CACnD,MAAM,eAAe,WAAW,SAAS,IAAI,IAAI,IAAI,WAAW,KAAK,MAAM,EAAE,KAAK,CAAC,IAAI,KAAA;CACvF,OAAO;EACN;EACA,MAAM;EACN,SAAS;EACT,OAAO,SAAS,QAAQ;EACxB,UAAU,mBAAmB;GAC5B,iBAAiB,KAAK,YAAY,mBAAmB,CAAC,OAAO;GAC7D,aAAa,KAAK,YAAY;GAC9B,aAAa,KAAK,YAAY;GAC9B;GACA;EACD,CAAC;EACD,GAAG,YAAY,QAAQ;EACvB,OAAO;GACN,GAAI,KAAK,QAAQ,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;GAC1C,YAAY,EACX,OAAO;IACN,MAAM;IACN,aAAa;KACZ,GAAI,KAAK,WAAW,EAAE,UAAU,KAAK,SAAS,IAAI,CAAC;KACnD,GAAI,KAAK,YAAY,gBAAgB,QAAQ,EAAE,aAAa,MAAM,IAAI,CAAC;KACvE,GAAI,KAAK,YAAY,gBAAgB,QAAQ,EAAE,aAAa,MAAM,IAAI,CAAC;KACvE,GAAI,KAAK,YAAY,kBAClB,EAAE,iBAAiB,KAAK,WAAW,gBAAgB,IACnD,CAAC;KACJ,GAAI,WAAW,SAAS,IACrB,EAAE,SAAS,WAAW,KAAK,OAAO;MAAE,OAAO,EAAE;MAAO,OAAO,EAAE;KAAM,EAAE,EAAE,IACvE,CAAC;IACL;GACD,EACD;EACD;CACD;AACD"}
|
|
1
|
+
{"version":3,"file":"emailRecipients.js","names":[],"sources":["../../src/actions/emailRecipients.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../email/departments'\nimport { fieldNames, fieldNamesOfType } from '../fields/fieldNamesOfType'\nimport { localizedIf } from '../fields/localizedIf'\nimport { interpolate } from '../recall/interpolate'\nimport { keys } from '../translations/keys'\nimport { asTranslate, labelFor } from '../translations/server'\nimport type {\n\tRecipientResolveArgs,\n\tRecipientSource,\n\tRecipientSourceRegistry,\n} from './recipientSources'\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst TOKEN_RE = /^\\{\\{\\s*([\\w.-]+)\\s*\\}\\}$/\n\n/** A plausible email address (a permissive shape check, not full RFC validation). */\nexport const isPlausibleEmail = (value: string): boolean => EMAIL_RE.test(value.trim())\n\n/** The field name inside a `{{name}}` recipient token, or undefined when the string is not a token. */\nexport const parseFieldToken = (value: string): string | undefined => {\n\tconst match = TOKEN_RE.exec(value.trim())\n\treturn match ? match[1] : undefined\n}\n\nexport const isFieldToken = (value: string): boolean => parseFieldToken(value) !== undefined\n\nconst toList = (value: unknown): string[] =>\n\tArray.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string')\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n/**\n * The first plausible email in a resolved value, or empty. A recipient entry names a single address,\n * so this drops anything past a separator or CR/LF that a `{{field}}` token might interpolate from\n * visitor input, closing SMTP header injection and extra-recipient injection.\n */\nexport const firstAddress = (value: string): string =>\n\tvalue\n\t\t.split(/[,;\\n\\r]+/)\n\t\t.map((part) => part.trim())\n\t\t.find(isPlausibleEmail) ?? ''\n\n/**\n * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated\n * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the\n * submission, a plain email passes through), sanitized to a single address, empties dropped, joined.\n */\nexport const resolveRecipients = (value: unknown, resolve: (name: string) => string): string =>\n\ttoList(value)\n\t\t.map((entry) => firstAddress(interpolate(entry, resolve)))\n\t\t.filter((entry) => entry.length > 0)\n\t\t.join(', ')\n\n/**\n * Async recipient resolution for the email actions, extending `resolveRecipients` with server-resolved\n * sources. A registered source value calls its `resolve` (each returned address reduced to one by\n * `firstAddress`, so a source cannot inject headers or extra recipients either); a `{{token}}`/plain\n * email resolves exactly as before. Returns a clean address list (`[]` when nothing resolves) so the\n * caller can skip an empty send. A thrown resolver propagates, failing the action loudly rather than\n * sending to a shortened list.\n */\nexport const resolveRecipientEntries = async (\n\tvalue: unknown,\n\topts: {\n\t\tresolve: (name: string) => string\n\t\tsources?: Map<string, RecipientSource>\n\t\tsourceArgs?: RecipientResolveArgs\n\t}\n): Promise<string[]> => {\n\tconst out: string[] = []\n\tfor (const entry of toList(value)) {\n\t\tconst source = opts.sources?.get(entry)\n\t\tif (source) {\n\t\t\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\t\t\tconst resolved = opts.sourceArgs ? await source.resolve(opts.sourceArgs) : []\n\t\t\tfor (const address of resolved) {\n\t\t\t\tconst clean = firstAddress(address)\n\t\t\t\tif (clean) {\n\t\t\t\t\tout.push(clean)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tconst clean = firstAddress(interpolate(entry, opts.resolve))\n\t\tif (clean) {\n\t\t\tout.push(clean)\n\t\t}\n\t}\n\treturn out\n}\n\n/**\n * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email\n * or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is\n * false, a non-token entry must additionally be a member of the resolved options (`resolveAllowed`,\n * e.g. the department addresses), matching the client constraint on the server. Fails closed if the\n * options resolver throws (mirroring the `from` field). Reads the form's `fields` off `data`. Like\n * `from`, Payload runs this on every save, so the resolver is consulted each save under\n * `allowCustom: false`; `buildRecipientField` memoizes it per request so all recipient fields share one call.\n */\nexport const validateRecipients =\n\t(opts: {\n\t\ttokenFieldTypes?: string[]\n\t\tallowCustom?: boolean\n\t\tfieldTokens?: boolean\n\t\tresolveAllowed?: (req: PayloadRequest) => Set<string> | Promise<Set<string>>\n\t\t/** Stored values of registered recipient sources; a member is a valid recipient by itself. */\n\t\tsourceValues?: Set<string>\n\t}) =>\n\tasync (\n\t\tvalue: unknown,\n\t\t{ data, req }: { data?: unknown; req: PayloadRequest }\n\t): Promise<string | true> => {\n\t\tconst list = toList(value)\n\t\tif (list.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\tconst fields =\n\t\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\t\tconst { tokenFieldTypes, allowCustom, fieldTokens, resolveAllowed } = opts\n\t\tconst allowedFields = new Set(\n\t\t\ttokenFieldTypes && tokenFieldTypes.length > 0\n\t\t\t\t? fieldNamesOfType(fields, tokenFieldTypes)\n\t\t\t\t: fieldNames(fields)\n\t\t)\n\t\t// Only resolve the membership set when it is actually enforced (allowCustom explicitly false),\n\t\t// so the default (free-typed) path pays no resolver cost. Compare case-insensitively, matching\n\t\t// the client's dedupe, since email delivery ignores case.\n\t\tlet allowedValues: Set<string> | undefined\n\t\tif (allowCustom === false) {\n\t\t\tif (resolveAllowed) {\n\t\t\t\ttry {\n\t\t\t\t\tallowedValues = await resolveAllowed(req)\n\t\t\t\t} catch {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientOptionsUnavailable)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallowedValues = new Set()\n\t\t\t}\n\t\t}\n\t\tfor (const entry of list) {\n\t\t\t// A registered source value is a valid recipient on its own: checked first, so a same-named form\n\t\t\t// field cannot shadow it and `allowCustom: false` cannot block it (it is not a custom address).\n\t\t\tif (opts.sourceValues?.has(entry)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst token = parseFieldToken(entry)\n\t\t\tif (token) {\n\t\t\t\t// `fieldTokens: false` disables recipient tokens; enforce it on the server, not just the client.\n\t\t\t\tif (fieldTokens === false) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t\t}\n\t\t\t\tif (!allowedFields.has(token)) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientUnknownField)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!isPlausibleEmail(entry)) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientInvalid)\n\t\t\t}\n\t\t\tif (allowCustom === false && !allowedValues?.has(entry.trim().toLowerCase())) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n/** Host-configurable behavior for the recipient fields (plugin option `email.recipients`). */\nexport type RecipientsConfig = {\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n}\n\nconst RECIPIENTS_FIELD_REF = '@10x-media/form-builder/client#RecipientsSelect'\n\nconst DEPARTMENT_ALLOWED_CACHE = 'formBuilderDepartmentAllowedSet'\n\n/**\n * The department option values as a lowercased Set, memoized on `req.context` so every recipient\n * field validated in one save shares a single `departments` call. Payload validates the fields\n * concurrently, so the in-flight promise is cached synchronously (not the resolved value) to avoid a\n * resolve-per-field race. Lowercased because email delivery ignores case, matching the client dedupe.\n */\nconst resolveDepartmentAllowed = (\n\treq: PayloadRequest,\n\tdepartments: DepartmentEmailsResolver\n): Promise<Set<string>> => {\n\tconst cached = req.context?.[DEPARTMENT_ALLOWED_CACHE]\n\tif (cached instanceof Promise) {\n\t\treturn cached as Promise<Set<string>>\n\t}\n\tconst promise = Promise.resolve()\n\t\t.then(() => departments({ req }))\n\t\t.then((options) => new Set(options.map((option) => option.value.trim().toLowerCase())))\n\tif (req.context) {\n\t\treq.context[DEPARTMENT_ALLOWED_CACHE] = promise\n\t}\n\treturn promise\n}\n\n/**\n * A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`\n * (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's\n * behavior; `width` sets `admin.width` so a pair can share a row; `departments` (when set) backs\n * server-side `allowCustom: false` enforcement with the resolved option values.\n */\n// biome-ignore lint/complexity/useMaxParams: the field identity (name, label, localize) plus its grouped options is the minimal surface\nexport const buildRecipientField = (\n\tname: string,\n\tlabelKey: string,\n\tlocalize: boolean,\n\topts: {\n\t\tendpoint?: string\n\t\trecipients?: RecipientsConfig\n\t\twidth?: string\n\t\tdepartments?: DepartmentEmailsResolver\n\t\tsources?: RecipientSourceRegistry\n\t} = {}\n): TextField => {\n\tconst { departments } = opts\n\tconst resolveAllowed = departments\n\t\t? (req: PayloadRequest) => resolveDepartmentAllowed(req, departments)\n\t\t: undefined\n\tconst sourceList = Object.values(opts.sources ?? {})\n\tconst sourceValues = sourceList.length > 0 ? new Set(sourceList.map((s) => s.value)) : undefined\n\treturn {\n\t\tname,\n\t\ttype: 'text',\n\t\thasMany: true,\n\t\tlabel: labelFor(labelKey),\n\t\tvalidate: validateRecipients({\n\t\t\ttokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ['email'],\n\t\t\tallowCustom: opts.recipients?.allowCustom,\n\t\t\tfieldTokens: opts.recipients?.fieldTokens,\n\t\t\tresolveAllowed,\n\t\t\tsourceValues,\n\t\t}),\n\t\t...localizedIf(localize),\n\t\tadmin: {\n\t\t\t...(opts.width ? { width: opts.width } : {}),\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: RECIPIENTS_FIELD_REF,\n\t\t\t\t\tclientProps: {\n\t\t\t\t\t\t// The departments option set is request-scoped, so it loads before the first save.\n\t\t\t\t\t\t...(opts.endpoint ? { endpoint: opts.endpoint, scope: 'request' } : {}),\n\t\t\t\t\t\t...(opts.recipients?.allowCustom === false ? { allowCustom: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.tokenFieldTypes\n\t\t\t\t\t\t\t? { tokenFieldTypes: opts.recipients.tokenFieldTypes }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t...(sourceList.length > 0\n\t\t\t\t\t\t\t? { sources: sourceList.map((s) => ({ value: s.value, label: s.label })) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RecipientResolveArgs } from "./recipientSources.js";
|
|
1
2
|
import { PayloadRequest } from "payload";
|
|
2
3
|
|
|
3
4
|
//#region src/actions/fromAddresses.d.ts
|
|
@@ -6,6 +7,21 @@ type FromAddressOption = {
|
|
|
6
7
|
label: string;
|
|
7
8
|
value: string;
|
|
8
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* A sender the plugin resolves server-side at send time (plugin option `email.fromSources`), the
|
|
12
|
+
* from-side counterpart of a `RecipientSource`. `value` is the namespaced string stored on the
|
|
13
|
+
* action (e.g. `tenant:default`), so it cannot collide with a literal address and stays
|
|
14
|
+
* audit-stable while the address it resolves to follows the host; `label` is what the editor sees
|
|
15
|
+
* in the from select. `resolve` returns the address to send from right now (reduced to a single
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
type FromAddressSource = {
|
|
20
|
+
value: string;
|
|
21
|
+
label: string | Record<string, string>;
|
|
22
|
+
resolve: (args: RecipientResolveArgs) => Promise<string | null> | string | null;
|
|
23
|
+
};
|
|
24
|
+
type FromAddressSourceRegistry = Record<string, FromAddressSource>;
|
|
9
25
|
/**
|
|
10
26
|
* Host seam resolving the selectable `from` addresses for `emailTeam`/`confirmation`
|
|
11
27
|
* (plugin option `email.fromAddresses`). Multi-tenant hosts derive tenant scoping from `req`
|
|
@@ -17,5 +33,5 @@ type FromAddressesResolver = (args: {
|
|
|
17
33
|
req: PayloadRequest;
|
|
18
34
|
}) => Promise<FromAddressOption[]> | FromAddressOption[];
|
|
19
35
|
//#endregion
|
|
20
|
-
export { FromAddressOption, FromAddressesResolver };
|
|
36
|
+
export { FromAddressOption, FromAddressSource, FromAddressSourceRegistry, FromAddressesResolver };
|
|
21
37
|
//# sourceMappingURL=fromAddresses.d.ts.map
|
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
import { keys } from "../translations/keys.js";
|
|
2
2
|
import { asTranslate, labelFor } from "../translations/server.js";
|
|
3
|
+
import { isPlausibleEmail } from "./emailRecipients.js";
|
|
3
4
|
//#region src/actions/fromAddresses.ts
|
|
5
|
+
/**
|
|
6
|
+
* The `from` handed to `payload.sendEmail`: a stored source value re-resolves through its source
|
|
7
|
+
* with the run-time args; anything else (a literal picked from `fromAddresses`, which never
|
|
8
|
+
* touches a source at send) is forwarded verbatim, and no configured value means no `from` at
|
|
9
|
+
* all. Mirrors `resolveRecipientEntries`: a throwing source propagates.
|
|
10
|
+
*/
|
|
11
|
+
const resolveSendFrom = async (opts) => {
|
|
12
|
+
const { configured, sources, sourceArgs } = opts;
|
|
13
|
+
if (!configured) return;
|
|
14
|
+
const source = sources?.get(configured);
|
|
15
|
+
if (!source) return configured;
|
|
16
|
+
const resolved = sourceArgs ? await source.resolve(sourceArgs) : null;
|
|
17
|
+
if (!resolved) return;
|
|
18
|
+
return firstSender(resolved) || void 0;
|
|
19
|
+
};
|
|
20
|
+
/** A bare plausible address, or a `Name <addr>` display form wrapping one (quotes and commas in the name included). */
|
|
21
|
+
const isPlausibleSender = (value) => {
|
|
22
|
+
if (isPlausibleEmail(value)) return true;
|
|
23
|
+
const bracketed = /^[^<>]*<([^<>\s]+)>$/.exec(value);
|
|
24
|
+
return Boolean(bracketed?.[1] && isPlausibleEmail(bracketed[1]));
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The sender-side counterpart of `firstAddress`: one sender only, but `Name <addr>` display form
|
|
28
|
+
* survives because that is the documented shape of a `from`. Order matters: cut at line breaks
|
|
29
|
+
* first (the header-injection vector), accept the whole remaining line so a quoted display name
|
|
30
|
+
* may contain commas, and only then comma-split to clamp a multi-address result to its first
|
|
31
|
+
* entry. An implausible result becomes empty (send with the adapter default) rather than a
|
|
32
|
+
* broken header.
|
|
33
|
+
*/
|
|
34
|
+
const firstSender = (value) => {
|
|
35
|
+
const line = (value.split(/[\n\r]+/)[0] ?? "").trim();
|
|
36
|
+
if (isPlausibleSender(line)) return line;
|
|
37
|
+
const [first] = line.split(/[,;]+/);
|
|
38
|
+
const cleaned = (first ?? "").trim();
|
|
39
|
+
return isPlausibleSender(cleaned) ? cleaned : "";
|
|
40
|
+
};
|
|
4
41
|
const FROM_FIELD_REF = "@10x-media/form-builder/client#EndpointOptionsSelect";
|
|
5
42
|
/**
|
|
6
43
|
* Validate for the `from` field, closed over the host resolver (mirrors the confirmation action's
|
|
@@ -15,8 +52,10 @@ const FROM_FIELD_REF = "@10x-media/form-builder/client#EndpointOptionsSelect";
|
|
|
15
52
|
* unlike `toField` and `resultsField` this seam depends on host infrastructure that can be down.
|
|
16
53
|
* A resolver reaching a flaky upstream should cache or fall back internally rather than throw.
|
|
17
54
|
*/
|
|
18
|
-
const validateFromField = (resolver) => async (value, { req }) => {
|
|
55
|
+
const validateFromField = (resolver, sourceValues) => async (value, { req }) => {
|
|
19
56
|
if (typeof value !== "string" || value.length === 0) return true;
|
|
57
|
+
if (sourceValues?.has(value)) return true;
|
|
58
|
+
if (!resolver) return asTranslate(req.t)(keys.validationFromUnknown);
|
|
20
59
|
let options;
|
|
21
60
|
try {
|
|
22
61
|
options = await resolver({ req });
|
|
@@ -27,39 +66,57 @@ const validateFromField = (resolver) => async (value, { req }) => {
|
|
|
27
66
|
};
|
|
28
67
|
/**
|
|
29
68
|
* The `from` select shared by `emailTeam` and `confirmation`: an `EndpointOptionsSelect` backed by
|
|
30
|
-
* the forms collection's
|
|
31
|
-
* is set).
|
|
32
|
-
*
|
|
69
|
+
* the forms collection's `/from-addresses` endpoint (registered when `email.fromAddresses` or
|
|
70
|
+
* `email.fromSources` is set). The option set is request-scoped, not per-form, so the select is
|
|
71
|
+
* marked `scope: 'request'` and its options load while the form is still being created.
|
|
33
72
|
*/
|
|
34
|
-
const buildFromField = (resolver) => ({
|
|
73
|
+
const buildFromField = (resolver, sources) => ({
|
|
35
74
|
name: "from",
|
|
36
75
|
type: "text",
|
|
37
76
|
label: labelFor(keys.actionConfigFrom),
|
|
38
|
-
validate: validateFromField(resolver),
|
|
77
|
+
validate: validateFromField(resolver, sources ? new Set(Object.values(sources).map((source) => source.value)) : void 0),
|
|
39
78
|
admin: { components: { Field: {
|
|
40
79
|
path: FROM_FIELD_REF,
|
|
41
80
|
clientProps: {
|
|
42
81
|
endpoint: "from-addresses",
|
|
82
|
+
scope: "request",
|
|
43
83
|
descriptionKey: keys.actionConfigFromDescription
|
|
44
84
|
}
|
|
45
85
|
} } }
|
|
46
86
|
});
|
|
47
87
|
/**
|
|
88
|
+
* A source entry as the from select shows it. A string label is display text served raw (matching
|
|
89
|
+
* how `RecipientsSelect` receives source labels); a per-locale record picks the request's admin
|
|
90
|
+
* language, then English, then any value.
|
|
91
|
+
*/
|
|
92
|
+
const sourceOption = (source, req) => {
|
|
93
|
+
if (typeof source.label === "string") return {
|
|
94
|
+
label: source.label,
|
|
95
|
+
value: source.value
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
label: source.label[req.i18n.language] ?? source.label.en ?? Object.values(source.label)[0] ?? source.value,
|
|
99
|
+
value: source.value
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
48
103
|
* Authorize and resolve the `GET /:id/from-addresses` request backing the `from` selects:
|
|
49
104
|
* authenticated callers get the host resolver's current options for this request; anonymous
|
|
50
105
|
* callers are always refused. The route id is unused (see `buildFromField`). Statuses mirror
|
|
51
106
|
* the poll-options endpoint: 403 unauthenticated, 503 when the resolver throws (fail closed).
|
|
52
107
|
*/
|
|
53
108
|
const resolveFromAddressesRequest = async (args) => {
|
|
54
|
-
const { isAuthed, req, resolver } = args;
|
|
109
|
+
const { isAuthed, req, resolver, sources } = args;
|
|
55
110
|
if (!isAuthed) return {
|
|
56
111
|
status: 403,
|
|
57
112
|
body: { errors: [{ message: "Forbidden" }] }
|
|
58
113
|
};
|
|
59
114
|
try {
|
|
115
|
+
const sourceOptions = Object.values(sources ?? {}).map((source) => sourceOption(source, req));
|
|
116
|
+
const resolved = resolver ? await resolver({ req }) : [];
|
|
60
117
|
return {
|
|
61
118
|
status: 200,
|
|
62
|
-
body: { options:
|
|
119
|
+
body: { options: [...sourceOptions, ...resolved] }
|
|
63
120
|
};
|
|
64
121
|
} catch {
|
|
65
122
|
return {
|
|
@@ -69,6 +126,6 @@ const resolveFromAddressesRequest = async (args) => {
|
|
|
69
126
|
}
|
|
70
127
|
};
|
|
71
128
|
//#endregion
|
|
72
|
-
export { buildFromField, resolveFromAddressesRequest };
|
|
129
|
+
export { buildFromField, resolveFromAddressesRequest, resolveSendFrom };
|
|
73
130
|
|
|
74
131
|
//# sourceMappingURL=fromAddresses.js.map
|
|
@@ -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'\n\n/** One selectable \"from\" address for the built-in email actions. */\nexport type FromAddressOption = { label: string; value: string }\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) =>\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\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 `/:id/from-addresses` endpoint (registered only when `email.fromAddresses`\n * is set). That route's document id goes unused server-side: the option set is request-scoped, not\n * per-form, so this reuses the existing doc-scoped component as-is instead of adding an id-less mode.\n */\nexport const buildFromField = (resolver: FromAddressesResolver): TextField => ({\n\tname: 'from',\n\ttype: 'text',\n\tlabel: labelFor(keys.actionConfigFrom),\n\tvalidate: validateFromField(resolver),\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\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}\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 } = args\n\tif (!isAuthed) {\n\t\treturn { status: 403, body: { errors: [{ message: 'Forbidden' }] } }\n\t}\n\ttry {\n\t\tconst options = await resolver({ req })\n\t\treturn { status: 200, body: { options } }\n\t} catch {\n\t\treturn { status: 503, body: { errors: [{ message: 'From addresses unavailable' }] } }\n\t}\n}\n"],"mappings":";;;AAkBA,MAAM,iBAAiB;;;;;;;;;;;;;;AAevB,MAAa,qBACX,aACD,OAAO,OAAgB,EAAE,UAA2D;CACnF,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GACjD,OAAO;CAER,IAAI;CACJ,IAAI;EACH,UAAU,MAAM,SAAS,EAAE,IAAI,CAAC;CACjC,QAAQ;EACP,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,yBAAyB;CACzD;CACA,OAAO,QAAQ,MAAM,WAAW,OAAO,UAAU,KAAK,IACnD,OACA,YAAY,IAAI,CAAC,EAAE,KAAK,qBAAqB;AACjD;;;;;;;AAQD,MAAa,kBAAkB,cAAgD;CAC9E,MAAM;CACN,MAAM;CACN,OAAO,SAAS,KAAK,gBAAgB;CACrC,UAAU,kBAAkB,QAAQ;CACpC,OAAO,EACN,YAAY,EACX,OAAO;EACN,MAAM;EACN,aAAa;GACZ,UAAU;GACV,gBAAgB,KAAK;EACtB;CACD,EACD,EACD;AACD;;;;;;;AAoBA,MAAa,8BAA8B,OAC1C,SACgD;CAChD,MAAM,EAAE,UAAU,KAAK,aAAa;CACpC,IAAI,CAAC,UACJ,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;CAAE;CAEpE,IAAI;EAEH,OAAO;GAAE,QAAQ;GAAK,MAAM,EAAE,SAAA,MADR,SAAS,EAAE,IAAI,CAAC,EACA;EAAE;CACzC,QAAQ;EACP,OAAO;GAAE,QAAQ;GAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,6BAA6B,CAAC,EAAE;EAAE;CACrF;AACD"}
|
|
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,5 +1,5 @@
|
|
|
1
1
|
//#region src/actions/recipientSources.ts
|
|
2
|
-
/** Index
|
|
2
|
+
/** Index a source registry (recipient or from) by stored `value` for O(1) lookup during validation and resolution. */
|
|
3
3
|
const sourcesByValue = (registry) => {
|
|
4
4
|
const map = /* @__PURE__ */ new Map();
|
|
5
5
|
for (const source of Object.values(registry ?? {})) map.set(source.value, source);
|
|
@@ -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
|
|
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,4 +1,5 @@
|
|
|
1
1
|
import { TranslationKey } from "../translations/keys.js";
|
|
2
|
+
import { EndpointOptionsScope } from "./endpointOptions.js";
|
|
2
3
|
|
|
3
4
|
//#region src/client/EndpointOptionsSelect.d.ts
|
|
4
5
|
/** Standard text-field client props plus this component's `clientProps`. */
|
|
@@ -18,6 +19,12 @@ type EndpointOptionsSelectProps = {
|
|
|
18
19
|
* `{ options: { label, value }[] }`.
|
|
19
20
|
*/
|
|
20
21
|
endpoint: string;
|
|
22
|
+
/**
|
|
23
|
+
* What the options depend on; see {@link EndpointOptionsScope}. Default `'document'`. Pass
|
|
24
|
+
* `'request'` for an endpoint registered at the id-less path so options load while the document
|
|
25
|
+
* is still being created.
|
|
26
|
+
*/
|
|
27
|
+
scope?: EndpointOptionsScope;
|
|
21
28
|
/**
|
|
22
29
|
* Field description as a translation key, resolved client-side. Payload drops `admin.description`
|
|
23
30
|
* functions from client fields, so a translated description must travel as a key; a static
|
|
@@ -32,13 +39,15 @@ type EndpointOptionsSelectProps = {
|
|
|
32
39
|
isMulti?: boolean;
|
|
33
40
|
};
|
|
34
41
|
/**
|
|
35
|
-
* A select whose options load from a
|
|
36
|
-
*
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* the
|
|
41
|
-
*
|
|
42
|
+
* A select whose options load from a plugin endpoint, for stored values whose valid choices only
|
|
43
|
+
* the server knows (e.g. a poll's source-resolved options). The pattern: pass `endpoint` via
|
|
44
|
+
* `clientProps`, the component reads the document id from `useDocumentInfo` and the API route
|
|
45
|
+
* from `useConfig`, fetches once with the admin cookie, and renders translated loading/error
|
|
46
|
+
* states. The stored value stays selectable even when it is missing from the fetched options (or
|
|
47
|
+
* the fetch failed), so opening an old document never silently drops data. Document scope (the
|
|
48
|
+
* default) skips the fetch until the document is saved, since the server cannot resolve options
|
|
49
|
+
* for it yet; request scope fetches immediately, create mode included, and ignores the id
|
|
50
|
+
* entirely so the first save never re-fetches.
|
|
42
51
|
*/
|
|
43
52
|
declare const EndpointOptionsSelect: (props: EndpointOptionsSelectProps) => import("react/jsx-runtime").JSX.Element;
|
|
44
53
|
//#endregion
|
|
@@ -8,13 +8,15 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
import { useEffect, useState } from "react";
|
|
9
9
|
//#region src/client/EndpointOptionsSelect.tsx
|
|
10
10
|
/**
|
|
11
|
-
* A select whose options load from a
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
11
|
+
* A select whose options load from a plugin endpoint, for stored values whose valid choices only
|
|
12
|
+
* the server knows (e.g. a poll's source-resolved options). The pattern: pass `endpoint` via
|
|
13
|
+
* `clientProps`, the component reads the document id from `useDocumentInfo` and the API route
|
|
14
|
+
* from `useConfig`, fetches once with the admin cookie, and renders translated loading/error
|
|
15
|
+
* states. The stored value stays selectable even when it is missing from the fetched options (or
|
|
16
|
+
* the fetch failed), so opening an old document never silently drops data. Document scope (the
|
|
17
|
+
* default) skips the fetch until the document is saved, since the server cannot resolve options
|
|
18
|
+
* for it yet; request scope fetches immediately, create mode included, and ignores the id
|
|
19
|
+
* entirely so the first save never re-fetches.
|
|
18
20
|
*/
|
|
19
21
|
const EndpointOptionsSelect = (props) => {
|
|
20
22
|
const isMulti = props.isMulti === true;
|
|
@@ -25,14 +27,17 @@ const EndpointOptionsSelect = (props) => {
|
|
|
25
27
|
const { id, collectionSlug } = useDocumentInfo();
|
|
26
28
|
const { config } = useConfig();
|
|
27
29
|
const apiRoute = config.routes.api;
|
|
30
|
+
const scope = props.scope ?? "document";
|
|
31
|
+
const docId = scope === "request" ? void 0 : id;
|
|
28
32
|
const [state, setState] = useState({ status: "idle" });
|
|
29
33
|
useEffect(() => {
|
|
30
|
-
if (
|
|
34
|
+
if (!collectionSlug || scope === "document" && docId == null) return;
|
|
31
35
|
const controller = new AbortController();
|
|
32
36
|
const url = buildEndpointOptionsUrl({
|
|
33
37
|
apiRoute,
|
|
34
38
|
collectionSlug,
|
|
35
|
-
id,
|
|
39
|
+
id: docId,
|
|
40
|
+
scope,
|
|
36
41
|
endpoint: props.endpoint
|
|
37
42
|
});
|
|
38
43
|
setState({ status: "loading" });
|
|
@@ -53,7 +58,8 @@ const EndpointOptionsSelect = (props) => {
|
|
|
53
58
|
}, [
|
|
54
59
|
apiRoute,
|
|
55
60
|
collectionSlug,
|
|
56
|
-
|
|
61
|
+
docId,
|
|
62
|
+
scope,
|
|
57
63
|
props.endpoint
|
|
58
64
|
]);
|
|
59
65
|
const selectedValues = isMulti ? Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.length > 0) : [] : typeof value === "string" && value.length > 0 ? [value] : [];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EndpointOptionsSelect.js","names":["useTranslation"],"sources":["../../src/client/EndpointOptionsSelect.tsx"],"sourcesContent":["'use client'\n\nimport {\n\tFieldDescription,\n\tFieldLabel,\n\tReactSelect,\n\ttype ReactSelectOption,\n\tuseConfig,\n\tuseDocumentInfo,\n\tuseField,\n} from '@payloadcms/ui'\nimport { type CSSProperties, useEffect, useState } from 'react'\nimport { keys, type TranslationKey } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\nimport {\n\tbuildEndpointOptionsUrl,\n\ttype EndpointOption,\n\tparseEndpointOptions,\n} from './endpointOptions'\nimport { toStaticLabel } from './toStaticLabel'\n\n/** Standard text-field client props plus this component's `clientProps`. */\nexport type EndpointOptionsSelectProps = {\n\t/** The field path within the document (Payload-injected, not the endpoint path). */\n\tpath?: string\n\tfield?: { label?: unknown; admin?: { description?: unknown; width?: string } }\n\tlabel?: unknown\n\t/**\n\t * Endpoint subpath under the current document's collection API route; `'poll-options'` fetches\n\t * `GET {routes.api}/{collectionSlug}/{id}/poll-options`. The endpoint must return\n\t * `{ options: { label, value }[] }`.\n\t */\n\tendpoint: string\n\t/**\n\t * Field description as a translation key, resolved client-side. Payload drops `admin.description`\n\t * functions from client fields, so a translated description must travel as a key; a static\n\t * `admin.description` string still renders when this is unset.\n\t */\n\tdescriptionKey?: TranslationKey\n\t/** Mirrors ReactSelect's `isClearable`; defaults to true. */\n\tisClearable?: boolean\n\t/**\n\t * Render a multi-value select bound to a `string[]`, for a `hasMany` field (e.g. a poll outcome\n\t * with tied winners). Default false: a single-value select bound to a `string`.\n\t */\n\tisMulti?: boolean\n}\n\ntype FetchState =\n\t| { status: 'error' | 'idle' | 'loading'; options?: never }\n\t| { status: 'loaded'; options: EndpointOption[] }\n\n/**\n * A select whose options load from a
|
|
1
|
+
{"version":3,"file":"EndpointOptionsSelect.js","names":["useTranslation"],"sources":["../../src/client/EndpointOptionsSelect.tsx"],"sourcesContent":["'use client'\n\nimport {\n\tFieldDescription,\n\tFieldLabel,\n\tReactSelect,\n\ttype ReactSelectOption,\n\tuseConfig,\n\tuseDocumentInfo,\n\tuseField,\n} from '@payloadcms/ui'\nimport { type CSSProperties, useEffect, useState } from 'react'\nimport { keys, type TranslationKey } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\nimport {\n\tbuildEndpointOptionsUrl,\n\ttype EndpointOption,\n\ttype EndpointOptionsScope,\n\tparseEndpointOptions,\n} from './endpointOptions'\nimport { toStaticLabel } from './toStaticLabel'\n\n/** Standard text-field client props plus this component's `clientProps`. */\nexport type EndpointOptionsSelectProps = {\n\t/** The field path within the document (Payload-injected, not the endpoint path). */\n\tpath?: string\n\tfield?: { label?: unknown; admin?: { description?: unknown; width?: string } }\n\tlabel?: unknown\n\t/**\n\t * Endpoint subpath under the current document's collection API route; `'poll-options'` fetches\n\t * `GET {routes.api}/{collectionSlug}/{id}/poll-options`. The endpoint must return\n\t * `{ options: { label, value }[] }`.\n\t */\n\tendpoint: string\n\t/**\n\t * What the options depend on; see {@link EndpointOptionsScope}. Default `'document'`. Pass\n\t * `'request'` for an endpoint registered at the id-less path so options load while the document\n\t * is still being created.\n\t */\n\tscope?: EndpointOptionsScope\n\t/**\n\t * Field description as a translation key, resolved client-side. Payload drops `admin.description`\n\t * functions from client fields, so a translated description must travel as a key; a static\n\t * `admin.description` string still renders when this is unset.\n\t */\n\tdescriptionKey?: TranslationKey\n\t/** Mirrors ReactSelect's `isClearable`; defaults to true. */\n\tisClearable?: boolean\n\t/**\n\t * Render a multi-value select bound to a `string[]`, for a `hasMany` field (e.g. a poll outcome\n\t * with tied winners). Default false: a single-value select bound to a `string`.\n\t */\n\tisMulti?: boolean\n}\n\ntype FetchState =\n\t| { status: 'error' | 'idle' | 'loading'; options?: never }\n\t| { status: 'loaded'; options: EndpointOption[] }\n\n/**\n * A select whose options load from a plugin endpoint, for stored values whose valid choices only\n * the server knows (e.g. a poll's source-resolved options). The pattern: pass `endpoint` via\n * `clientProps`, the component reads the document id from `useDocumentInfo` and the API route\n * from `useConfig`, fetches once with the admin cookie, and renders translated loading/error\n * states. The stored value stays selectable even when it is missing from the fetched options (or\n * the fetch failed), so opening an old document never silently drops data. Document scope (the\n * default) skips the fetch until the document is saved, since the server cannot resolve options\n * for it yet; request scope fetches immediately, create mode included, and ignores the id\n * entirely so the first save never re-fetches.\n */\nexport const EndpointOptionsSelect = (props: EndpointOptionsSelectProps) => {\n\tconst isMulti = props.isMulti === true\n\tconst { path, setValue, value } = useField<string | string[]>({ path: props.path })\n\tconst label = toStaticLabel(props.field?.label ?? props.label)\n\tconst { t } = useTranslation()\n\tconst description = props.descriptionKey\n\t\t? t(props.descriptionKey)\n\t\t: toStaticLabel(props.field?.admin?.description)\n\tconst { id, collectionSlug } = useDocumentInfo()\n\tconst { config } = useConfig()\n\tconst apiRoute = config.routes.api\n\tconst scope = props.scope ?? 'document'\n\t// Request scope never puts the id in the URL; deriving undefined here keeps the effect from\n\t// re-fetching when the first save assigns one.\n\tconst docId = scope === 'request' ? undefined : id\n\tconst [state, setState] = useState<FetchState>({ status: 'idle' })\n\n\tuseEffect(() => {\n\t\tif (!collectionSlug || (scope === 'document' && docId == null)) {\n\t\t\treturn\n\t\t}\n\t\tconst controller = new AbortController()\n\t\tconst url = buildEndpointOptionsUrl({\n\t\t\tapiRoute,\n\t\t\tcollectionSlug,\n\t\t\tid: docId,\n\t\t\tscope,\n\t\t\tendpoint: props.endpoint,\n\t\t})\n\t\tsetState({ status: 'loading' })\n\t\tfetch(url, {\n\t\t\tcredentials: 'include',\n\t\t\theaders: { Accept: 'application/json' },\n\t\t\tsignal: controller.signal,\n\t\t})\n\t\t\t.then(async (response) => {\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tthrow new Error(`Options request failed with ${response.status}`)\n\t\t\t\t}\n\t\t\t\tsetState({ status: 'loaded', options: parseEndpointOptions(await response.json()) })\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tif (!controller.signal.aborted) {\n\t\t\t\t\tsetState({ status: 'error' })\n\t\t\t\t}\n\t\t\t})\n\t\treturn () => controller.abort()\n\t}, [apiRoute, collectionSlug, docId, scope, props.endpoint])\n\n\t// A missing fetched option (or a failed fetch) must never silently drop a stored value, so every\n\t// selected value the options don't cover is kept selectable by its own label.\n\tconst selectedValues: string[] = isMulti\n\t\t? Array.isArray(value)\n\t\t\t? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n\t\t\t: []\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\tconst options: ReactSelectOption[] = state.status === 'loaded' ? state.options : []\n\tconst missing = selectedValues\n\t\t.filter((selected) => !options.some((option) => option.value === selected))\n\t\t.map((selected) => ({ label: selected, value: selected }))\n\tconst allOptions = missing.length > 0 ? [...options, ...missing] : options\n\tconst selectedOptions = selectedValues\n\t\t.map((selected) => allOptions.find((option) => option.value === selected))\n\t\t.filter((option): option is ReactSelectOption => option !== undefined)\n\n\tconst handleChange = (selected: ReactSelectOption | ReactSelectOption[] | null) => {\n\t\tif (isMulti) {\n\t\t\tconst chosen = Array.isArray(selected) ? selected : selected ? [selected] : []\n\t\t\tsetValue(chosen.map((option) => option.value as string))\n\t\t\treturn\n\t\t}\n\t\tconst chosen = Array.isArray(selected) ? selected[0] : selected\n\t\tsetValue(chosen ? (chosen.value as string) : '')\n\t}\n\n\tconst fieldStyle = props.field?.admin?.width\n\t\t? ({ '--field-width': props.field.admin.width } as CSSProperties)\n\t\t: undefined\n\n\treturn (\n\t\t<div className=\"field-type\" style={{ marginBlockEnd: '1rem', ...fieldStyle }}>\n\t\t\t<FieldLabel label={label} path={path} />\n\t\t\t<ReactSelect\n\t\t\t\toptions={allOptions}\n\t\t\t\tvalue={isMulti ? selectedOptions : (selectedOptions[0] ?? undefined)}\n\t\t\t\tisMulti={isMulti}\n\t\t\t\tisClearable={props.isClearable !== false}\n\t\t\t\tisLoading={state.status === 'loading'}\n\t\t\t\tplaceholder={state.status === 'loading' ? t(keys.endpointOptionsLoading) : undefined}\n\t\t\t\tshowError={state.status === 'error'}\n\t\t\t\tonChange={handleChange}\n\t\t\t/>\n\t\t\t<FieldDescription\n\t\t\t\tdescription={state.status === 'error' ? t(keys.endpointOptionsError) : description}\n\t\t\t\tpath={path}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"],"mappings":"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"}
|