@aglyn/plugins-email 1.0.0-beta.218 → 1.0.0-beta.220

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 (56) hide show
  1. package/package.json +18 -18
  2. package/src/lib/components/campaign-topic-options-widget.js +2 -2
  3. package/src/lib/components/campaign-topic-options-widget.js.map +1 -1
  4. package/src/lib/components/campaign-topic-select.js +3 -3
  5. package/src/lib/components/campaign-topic-select.js.map +1 -1
  6. package/src/lib/components/consent-group-review.js +2 -2
  7. package/src/lib/components/consent-group-review.js.map +1 -1
  8. package/src/lib/components/dynamic-list-rule-fields.d.ts +20 -4
  9. package/src/lib/components/dynamic-list-rule-fields.js +537 -439
  10. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -1
  11. package/src/lib/components/email-topic-detail.d.ts +1 -1
  12. package/src/lib/components/email-topic-detail.js +5 -5
  13. package/src/lib/components/email-topic-detail.js.map +1 -1
  14. package/src/lib/components/email-topics-card.d.ts +1 -1
  15. package/src/lib/components/email-topics-card.js +6 -5
  16. package/src/lib/components/email-topics-card.js.map +1 -1
  17. package/src/lib/components/emails-console-sections.js +3 -2
  18. package/src/lib/components/emails-console-sections.js.map +1 -1
  19. package/src/lib/components/list-import-drawer.js +2 -2
  20. package/src/lib/components/list-import-drawer.js.map +1 -1
  21. package/src/lib/components/use-org-email-topics.d.ts +4 -4
  22. package/src/lib/components/use-org-email-topics.js +6 -5
  23. package/src/lib/components/use-org-email-topics.js.map +1 -1
  24. package/src/lib/list-import.d.ts +149 -0
  25. package/src/lib/list-import.js +324 -0
  26. package/src/lib/list-import.js.map +1 -0
  27. package/src/lib/model/email-record-routes.d.ts +22 -0
  28. package/src/lib/model/email-record-routes.js +75 -0
  29. package/src/lib/model/email-record-routes.js.map +1 -0
  30. package/src/lib/model/email-topic-catalog.d.ts +36 -0
  31. package/src/lib/model/email-topic-catalog.js +36 -0
  32. package/src/lib/model/email-topic-catalog.js.map +1 -0
  33. package/src/lib/model/index.d.ts +0 -7
  34. package/src/lib/model/index.js +0 -6
  35. package/src/lib/model/index.js.map +1 -1
  36. package/src/lib/model/template-provenance.d.ts +1 -1
  37. package/src/lib/model/template-provenance.js +1 -1
  38. package/src/lib/model/template-provenance.js.map +1 -1
  39. package/src/lib/plugin.js +5 -1
  40. package/src/lib/plugin.js.map +1 -1
  41. package/src/lib/server-console.js +6 -2
  42. package/src/lib/server-console.js.map +1 -1
  43. package/src/lib/server-email-drafts.js +2 -2
  44. package/src/lib/server-email-drafts.js.map +1 -1
  45. package/src/lib/server-list-gate.js +35 -10
  46. package/src/lib/server-list-gate.js.map +1 -1
  47. package/src/lib/server-list-import.js +4 -3
  48. package/src/lib/server-list-import.js.map +1 -1
  49. package/src/lib/server-topic-index.d.ts +52 -0
  50. package/src/lib/server-topic-index.js +92 -0
  51. package/src/lib/server-topic-index.js.map +1 -0
  52. package/src/lib/server.js +11 -11
  53. package/src/lib/server.js.map +1 -1
  54. package/src/lib/unsubscribe-link.d.ts +1 -1
  55. package/src/lib/unsubscribe-link.js +2 -2
  56. package/src/lib/unsubscribe-link.js.map +1 -1
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
17
+ import { registerPluginRecordRoute } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
18
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
19
+ /**
20
+ * Where the Emails page's records are read, published for every other
21
+ * surface (AGL-3080).
22
+ *
23
+ * A contact's timeline links a campaign email to its report, and a composer
24
+ * refused for an unverified sender links to where a sender is set up. Both
25
+ * pages are this plugin's — the Emails page under a site, and the same page
26
+ * over every site at the organization level — so a surface elsewhere asks
27
+ * the record-route registry for the kind rather than spelling this plugin's
28
+ * nav slug, and gets `null` (text instead of a link) where it is not loaded.
29
+ *
30
+ * - `emailMessage`: one send, at `messages/{id}`; the list is the Messages
31
+ * section. What draws a message's report is a zone the plugin that owns
32
+ * campaigns fills, but the address is this page's.
33
+ * - `sendingIdentity`: who the mail comes from, set up in the Sending
34
+ * section. An identity has no page of its own, so its record answers the
35
+ * section it is kept in.
36
+ * - `emailTemplate`: a site's email design, at `templates/{id}` (the
37
+ * screen's id). A design is a site's, so the organization level has no
38
+ * address for one: a caller at the org names the site the send used.
39
+ */ /** The nav slug the shell resolves this plugin's page by. */ const EMAILS_SLUG = 'emails';
40
+ function hub({ orgSlug, host }) {
41
+ return host ? buildRoute(Route.HOST_PLUGIN, {
42
+ orgSlug,
43
+ host,
44
+ pluginSlug: EMAILS_SLUG
45
+ }) : buildRoute(Route.ORG_PLUGIN, {
46
+ orgSlug,
47
+ pluginSlug: EMAILS_SLUG
48
+ });
49
+ }
50
+ /**
51
+ * Called from the console registrar. The owner is named rather than read off
52
+ * the loader's marker, so the routes register the same way under a spec that
53
+ * calls the registrar directly.
54
+ */ export function registerEmailRecordRoutes() {
55
+ registerPluginRecordRoute('emailMessage', {
56
+ list: (context)=>`${hub(context)}/messages`,
57
+ record: (context, id)=>`${hub(context)}/messages/${encodeURIComponent(id)}`
58
+ }, {
59
+ pluginId: BUNDLE_ID
60
+ });
61
+ registerPluginRecordRoute('emailTemplate', {
62
+ list: (context)=>context.host ? `${hub(context)}/templates` : null,
63
+ record: (context, id)=>context.host ? `${hub(context)}/templates/${encodeURIComponent(id)}` : null
64
+ }, {
65
+ pluginId: BUNDLE_ID
66
+ });
67
+ registerPluginRecordRoute('sendingIdentity', {
68
+ list: (context)=>`${hub(context)}/sending`,
69
+ record: (context)=>`${hub(context)}/sending`
70
+ }, {
71
+ pluginId: BUNDLE_ID
72
+ });
73
+ }
74
+
75
+ //# sourceMappingURL=email-record-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/email-record-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport {\n registerPluginRecordRoute,\n type PluginRecordRouteContext,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * Where the Emails page's records are read, published for every other\n * surface (AGL-3080).\n *\n * A contact's timeline links a campaign email to its report, and a composer\n * refused for an unverified sender links to where a sender is set up. Both\n * pages are this plugin's — the Emails page under a site, and the same page\n * over every site at the organization level — so a surface elsewhere asks\n * the record-route registry for the kind rather than spelling this plugin's\n * nav slug, and gets `null` (text instead of a link) where it is not loaded.\n *\n * - `emailMessage`: one send, at `messages/{id}`; the list is the Messages\n * section. What draws a message's report is a zone the plugin that owns\n * campaigns fills, but the address is this page's.\n * - `sendingIdentity`: who the mail comes from, set up in the Sending\n * section. An identity has no page of its own, so its record answers the\n * section it is kept in.\n * - `emailTemplate`: a site's email design, at `templates/{id}` (the\n * screen's id). A design is a site's, so the organization level has no\n * address for one: a caller at the org names the site the send used.\n */\n\n/** The nav slug the shell resolves this plugin's page by. */\nconst EMAILS_SLUG = 'emails'\n\nfunction hub({ orgSlug, host }: PluginRecordRouteContext): string {\n return host\n ? buildRoute(Route.HOST_PLUGIN, { orgSlug, host, pluginSlug: EMAILS_SLUG })\n : buildRoute(Route.ORG_PLUGIN, { orgSlug, pluginSlug: EMAILS_SLUG })\n}\n\n/**\n * Called from the console registrar. The owner is named rather than read off\n * the loader's marker, so the routes register the same way under a spec that\n * calls the registrar directly.\n */\nexport function registerEmailRecordRoutes(): void {\n registerPluginRecordRoute(\n 'emailMessage',\n {\n list: (context) => `${hub(context)}/messages`,\n record: (context, id) => `${hub(context)}/messages/${encodeURIComponent(id)}`,\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginRecordRoute(\n 'emailTemplate',\n {\n list: (context) => (context.host ? `${hub(context)}/templates` : null),\n record: (context, id) =>\n context.host ? `${hub(context)}/templates/${encodeURIComponent(id)}` : null,\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginRecordRoute(\n 'sendingIdentity',\n {\n list: (context) => `${hub(context)}/sending`,\n record: (context) => `${hub(context)}/sending`,\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["buildRoute","Route","registerPluginRecordRoute","BUNDLE_ID","EMAILS_SLUG","hub","orgSlug","host","HOST_PLUGIN","pluginSlug","ORG_PLUGIN","registerEmailRecordRoutes","list","context","record","id","encodeURIComponent","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,EAAEC,KAAK,QAAQ,wCAAuC;AACzE,SACEC,yBAAyB,QAEpB,mDAAkD;AACzD,SAASC,SAAS,QAAQ,gCAA4B;AAEtD;;;;;;;;;;;;;;;;;;;;CAoBC,GAED,2DAA2D,GAC3D,MAAMC,cAAc;AAEpB,SAASC,IAAI,EAAEC,OAAO,EAAEC,IAAI,EAA4B;IACtD,OAAOA,OACHP,WAAWC,MAAMO,WAAW,EAAE;QAAEF;QAASC;QAAME,YAAYL;IAAY,KACvEJ,WAAWC,MAAMS,UAAU,EAAE;QAAEJ;QAASG,YAAYL;IAAY;AACtE;AAEA;;;;CAIC,GACD,OAAO,SAASO;IACdT,0BACE,gBACA;QACEU,MAAM,CAACC,UAAY,GAAGR,IAAIQ,SAAS,SAAS,CAAC;QAC7CC,QAAQ,CAACD,SAASE,KAAO,GAAGV,IAAIQ,SAAS,UAAU,EAAEG,mBAAmBD,KAAK;IAC/E,GACA;QAAEE,UAAUd;IAAU;IAExBD,0BACE,iBACA;QACEU,MAAM,CAACC,UAAaA,QAAQN,IAAI,GAAG,GAAGF,IAAIQ,SAAS,UAAU,CAAC,GAAG;QACjEC,QAAQ,CAACD,SAASE,KAChBF,QAAQN,IAAI,GAAG,GAAGF,IAAIQ,SAAS,WAAW,EAAEG,mBAAmBD,KAAK,GAAG;IAC3E,GACA;QAAEE,UAAUd;IAAU;IAExBD,0BACE,mBACA;QACEU,MAAM,CAACC,UAAY,GAAGR,IAAIQ,SAAS,QAAQ,CAAC;QAC5CC,QAAQ,CAACD,UAAY,GAAGR,IAAIQ,SAAS,QAAQ,CAAC;IAChD,GACA;QAAEI,UAAUd;IAAU;AAE1B"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type SubscriptionTopic } from '@aglyn/aglyn/app-utils/subscription-topics';
18
+ /**
19
+ * THE TOPIC CATALOG AN ORG AUTHORS — this plugin's storage.
20
+ *
21
+ * The streams themselves, what a recipient's opt-out means and how a send
22
+ * honors it are the mail rail's (`app-utils/subscription-topics.ts`), and the
23
+ * built-in streams are declared by the plugins that send under them. What an
24
+ * org writes — a built-in renamed, re-described, archived or confirmed, and
25
+ * the topics it adds of its own — is stored here, one document per topic, at
26
+ * `orgs/{orgId}/emailTopics/{topicId}`, org-shared like `lists`. Another
27
+ * plugin that needs an org's catalog asks the `subscriptionTopic` record
28
+ * index this plugin publishes, never the collection.
29
+ */
30
+ /** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */
31
+ export declare const EMAIL_TOPICS_COLLECTION = "emailTopics";
32
+ /** The stored documents as topics, skipping any that is not one. */
33
+ export declare function emailTopicsFromDocs(docs: ReadonlyArray<{
34
+ id: string;
35
+ data: () => Record<string, unknown> | undefined;
36
+ }>): SubscriptionTopic[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { normalizeSubscriptionTopic } from "@aglyn/aglyn/app-utils/subscription-topics";
17
+ /**
18
+ * THE TOPIC CATALOG AN ORG AUTHORS — this plugin's storage.
19
+ *
20
+ * The streams themselves, what a recipient's opt-out means and how a send
21
+ * honors it are the mail rail's (`app-utils/subscription-topics.ts`), and the
22
+ * built-in streams are declared by the plugins that send under them. What an
23
+ * org writes — a built-in renamed, re-described, archived or confirmed, and
24
+ * the topics it adds of its own — is stored here, one document per topic, at
25
+ * `orgs/{orgId}/emailTopics/{topicId}`, org-shared like `lists`. Another
26
+ * plugin that needs an org's catalog asks the `subscriptionTopic` record
27
+ * index this plugin publishes, never the collection.
28
+ */ /** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */ export const EMAIL_TOPICS_COLLECTION = 'emailTopics';
29
+ /** The stored documents as topics, skipping any that is not one. */ export function emailTopicsFromDocs(docs) {
30
+ return docs.map((doc)=>{
31
+ var _doc_data;
32
+ return normalizeSubscriptionTopic(doc.id, (_doc_data = doc.data()) != null ? _doc_data : null);
33
+ }).filter((topic)=>!!topic);
34
+ }
35
+
36
+ //# sourceMappingURL=email-topic-catalog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/email-topic-catalog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n normalizeSubscriptionTopic,\n type SubscriptionTopic,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\n\n/**\n * THE TOPIC CATALOG AN ORG AUTHORS — this plugin's storage.\n *\n * The streams themselves, what a recipient's opt-out means and how a send\n * honors it are the mail rail's (`app-utils/subscription-topics.ts`), and the\n * built-in streams are declared by the plugins that send under them. What an\n * org writes — a built-in renamed, re-described, archived or confirmed, and\n * the topics it adds of its own — is stored here, one document per topic, at\n * `orgs/{orgId}/emailTopics/{topicId}`, org-shared like `lists`. Another\n * plugin that needs an org's catalog asks the `subscriptionTopic` record\n * index this plugin publishes, never the collection.\n */\n\n/** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */\nexport const EMAIL_TOPICS_COLLECTION = 'emailTopics'\n\n/** The stored documents as topics, skipping any that is not one. */\nexport function emailTopicsFromDocs(\n docs: ReadonlyArray<{ id: string; data: () => Record<string, unknown> | undefined }>,\n): SubscriptionTopic[] {\n return docs\n .map((doc) => normalizeSubscriptionTopic(doc.id, doc.data() ?? null))\n .filter((topic): topic is SubscriptionTopic => !!topic)\n}\n"],"names":["normalizeSubscriptionTopic","EMAIL_TOPICS_COLLECTION","emailTopicsFromDocs","docs","map","doc","id","data","filter","topic"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,0BAA0B,QAErB,6CAA4C;AAEnD;;;;;;;;;;;CAWC,GAED,uEAAuE,GACvE,OAAO,MAAMC,0BAA0B,cAAa;AAEpD,kEAAkE,GAClE,OAAO,SAASC,oBACdC,IAAoF;IAEpF,OAAOA,KACJC,GAAG,CAAC,CAACC;YAA2CA;eAAnCL,2BAA2BK,IAAIC,EAAE,GAAED,YAAAA,IAAIE,IAAI,cAARF,YAAc;OAC9DG,MAAM,CAAC,CAACC,QAAsC,CAAC,CAACA;AACrD"}
@@ -26,13 +26,6 @@ export { renderEmailHtml, substituteMergeTokens, resolveMergeTags, type EmailRen
26
26
  * rather than chosen in JSX.
27
27
  */
28
28
  export * from '@aglyn/shared-ui-email-campaigns/model/campaign-report';
29
- /**
30
- * What a campaign EARNED: the last-click window, the gross/refunded pair and
31
- * the per-currency buckets that are never added together. Separate from the
32
- * rate math because it reads a different document and answers the merchant's
33
- * second question rather than their first.
34
- */
35
- export * from '@aglyn/shared-ui-email-campaigns/model/campaign-revenue';
36
29
  /**
37
30
  * The same math taken across every message sent from one TEMPLATE. Separate
38
31
  * from `campaign-report` because a sum has a membership: which messages went
@@ -38,12 +38,6 @@ export { renderEmailHtml, substituteMergeTokens, resolveMergeTags } from "@aglyn
38
38
  * the link rollup — pure, so every denominator is named once and provable
39
39
  * rather than chosen in JSX.
40
40
  */ export * from "@aglyn/shared-ui-email-campaigns/model/campaign-report";
41
- /**
42
- * What a campaign EARNED: the last-click window, the gross/refunded pair and
43
- * the per-currency buckets that are never added together. Separate from the
44
- * rate math because it reads a different document and answers the merchant's
45
- * second question rather than their first.
46
- */ export * from "@aglyn/shared-ui-email-campaigns/model/campaign-revenue";
47
41
  /**
48
42
  * The same math taken across every message sent from one TEMPLATE. Separate
49
43
  * from `campaign-report` because a sum has a membership: which messages went
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/index.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Email rendering model (AGL-412): besigner-built email HTML rendering + merge tags, relocated from core app-utils.\n * Context-free — importable by client components, /server handlers, and\n * other plugins/apps via `@aglyn/plugins-email/model`.\n */\n// The renderer and merge-tag helpers moved to `@aglyn/shared-util-email`\n// (AGL-750): they are pure functions over a node map with no imports at all,\n// and the console's own API routes need them to render system email\n// templates. Apps are forbidden to import feature plugins (the `scope:app`\n// → `aglyn:addons` boundary), so leaving them here made them unreachable\n// from exactly the code that has to send the mail.\n//\n// Re-exported so `@aglyn/plugins-email/model` keeps working unchanged.\nexport {\n renderEmailHtml,\n substituteMergeTokens,\n resolveMergeTags,\n type EmailRenderOptions,\n type EmailRenderProduct,\n type RenderedEmail,\n} from '@aglyn/shared-util-email'\n\n// The campaign document model and its arithmetic live in\n// `@aglyn/shared-ui-email-campaigns`. Marketing owns the campaign console\n// section and Email owns the message surfaces; both are independently\n// toggleable plugins, so a shape either one reads has to sit in a lib they\n// depend on as peers rather than inside one of them. Re-exported here so\n// `@aglyn/plugins-email/model` remains one barrel for this plugin's own\n// callers.\n\n/**\n * Campaign reporting: the rate math, the populations the send recorded, and\n * the link rollup — pure, so every denominator is named once and provable\n * rather than chosen in JSX.\n */\nexport * from '@aglyn/shared-ui-email-campaigns/model/campaign-report'\n\n/**\n * What a campaign EARNED: the last-click window, the gross/refunded pair and\n * the per-currency buckets that are never added together. Separate from the\n * rate math because it reads a different document and answers the merchant's\n * second question rather than their first.\n */\nexport * from '@aglyn/shared-ui-email-campaigns/model/campaign-revenue'\n\n/**\n * The same math taken across every message sent from one TEMPLATE. Separate\n * from `campaign-report` because a sum has a membership: which messages went\n * into it, and therefore which population the rate describes.\n */\nexport * from './template-report'\n\n/** Reading one message record — its state, and when it went out. */\nexport * from '@aglyn/shared-ui-email-campaigns/model/email-record'\n\n/**\n * Whose template this is: authored here, or installed from a marketplace\n * listing and versioned by somebody else.\n */\nexport * from './template-provenance'\n\n/**\n * One campaign message, rendered. Shared by the send loop, the composer's\n * pre-send preview and any other surface that has to show what will be\n * mailed — a preview that renders the message a second way previews\n * something else.\n */\n\n/**\n * The campaign CONTAINER — its window, its lists, and the arithmetic that\n * rolls its sends into one set of figures.\n */\nexport * from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\n\n/**\n * How each sending-domain state reads, said once for the list and the\n * domain's own page — including why `inconclusive` is neither of them.\n */\nexport * from './sending-domain-status'\n"],"names":["renderEmailHtml","substituteMergeTokens","resolveMergeTags"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,yEAAyE;AACzE,6EAA6E;AAC7E,oEAAoE;AACpE,2EAA2E;AAC3E,yEAAyE;AACzE,mDAAmD;AACnD,EAAE;AACF,uEAAuE;AACvE,SACEA,eAAe,EACfC,qBAAqB,EACrBC,gBAAgB,QAIX,2BAA0B;AAEjC,yDAAyD;AACzD,0EAA0E;AAC1E,sEAAsE;AACtE,2EAA2E;AAC3E,yEAAyE;AACzE,wEAAwE;AACxE,WAAW;AAEX;;;;CAIC,GACD,cAAc,yDAAwD;AAEtE;;;;;CAKC,GACD,cAAc,0DAAyD;AAEvE;;;;CAIC,GACD,cAAc,uBAAmB;AAEjC,kEAAkE,GAClE,cAAc,sDAAqD;AAEnE;;;CAGC,GACD,cAAc,2BAAuB;AAErC;;;;;CAKC,GAED;;;CAGC,GACD,cAAc,4DAA2D;AAEzE;;;CAGC,GACD,cAAc,6BAAyB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/index.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Email rendering model (AGL-412): besigner-built email HTML rendering + merge tags, relocated from core app-utils.\n * Context-free — importable by client components, /server handlers, and\n * other plugins/apps via `@aglyn/plugins-email/model`.\n */\n// The renderer and merge-tag helpers moved to `@aglyn/shared-util-email`\n// (AGL-750): they are pure functions over a node map with no imports at all,\n// and the console's own API routes need them to render system email\n// templates. Apps are forbidden to import feature plugins (the `scope:app`\n// → `aglyn:addons` boundary), so leaving them here made them unreachable\n// from exactly the code that has to send the mail.\n//\n// Re-exported so `@aglyn/plugins-email/model` keeps working unchanged.\nexport {\n renderEmailHtml,\n substituteMergeTokens,\n resolveMergeTags,\n type EmailRenderOptions,\n type EmailRenderProduct,\n type RenderedEmail,\n} from '@aglyn/shared-util-email'\n\n// The campaign document model and its arithmetic live in\n// `@aglyn/shared-ui-email-campaigns`. Marketing owns the campaign console\n// section and Email owns the message surfaces; both are independently\n// toggleable plugins, so a shape either one reads has to sit in a lib they\n// depend on as peers rather than inside one of them. Re-exported here so\n// `@aglyn/plugins-email/model` remains one barrel for this plugin's own\n// callers.\n\n/**\n * Campaign reporting: the rate math, the populations the send recorded, and\n * the link rollup — pure, so every denominator is named once and provable\n * rather than chosen in JSX.\n */\nexport * from '@aglyn/shared-ui-email-campaigns/model/campaign-report'\n\n/**\n * The same math taken across every message sent from one TEMPLATE. Separate\n * from `campaign-report` because a sum has a membership: which messages went\n * into it, and therefore which population the rate describes.\n */\nexport * from './template-report'\n\n/** Reading one message record — its state, and when it went out. */\nexport * from '@aglyn/shared-ui-email-campaigns/model/email-record'\n\n/**\n * Whose template this is: authored here, or installed from a marketplace\n * listing and versioned by somebody else.\n */\nexport * from './template-provenance'\n\n/**\n * One campaign message, rendered. Shared by the send loop, the composer's\n * pre-send preview and any other surface that has to show what will be\n * mailed — a preview that renders the message a second way previews\n * something else.\n */\n\n/**\n * The campaign CONTAINER — its window, its lists, and the arithmetic that\n * rolls its sends into one set of figures.\n */\nexport * from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\n\n/**\n * How each sending-domain state reads, said once for the list and the\n * domain's own page — including why `inconclusive` is neither of them.\n */\nexport * from './sending-domain-status'\n"],"names":["renderEmailHtml","substituteMergeTokens","resolveMergeTags"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,yEAAyE;AACzE,6EAA6E;AAC7E,oEAAoE;AACpE,2EAA2E;AAC3E,yEAAyE;AACzE,mDAAmD;AACnD,EAAE;AACF,uEAAuE;AACvE,SACEA,eAAe,EACfC,qBAAqB,EACrBC,gBAAgB,QAIX,2BAA0B;AAEjC,yDAAyD;AACzD,0EAA0E;AAC1E,sEAAsE;AACtE,2EAA2E;AAC3E,yEAAyE;AACzE,wEAAwE;AACxE,WAAW;AAEX;;;;CAIC,GACD,cAAc,yDAAwD;AAEtE;;;;CAIC,GACD,cAAc,uBAAmB;AAEjC,kEAAkE,GAClE,cAAc,sDAAqD;AAEnE;;;CAGC,GACD,cAAc,2BAAuB;AAErC;;;;;CAKC,GAED;;;CAGC,GACD,cAAc,4DAA2D;AAEzE;;;CAGC,GACD,cAAc,6BAAyB"}
@@ -58,7 +58,7 @@
58
58
  * computes standing it correctly declines to claim one. Do not go looking for
59
59
  * the writer — there is none yet.
60
60
  */
61
- import { type ResolvedProvenance } from '@aglyn/aglyn/app-utils/marketplace-provenance';
61
+ import { type ResolvedProvenance } from '@aglyn/aglyn/app-utils/artifact-provenance';
62
62
  /** Where a template's content comes from. */
63
63
  export type TemplateOrigin = 'local' | 'installed';
64
64
  /**
@@ -62,7 +62,7 @@
62
62
  * model is imported by `email-events.ts`, which runs in the plugin API route's
63
63
  * SERVER graph. Through the barrel that is a client-only module pulled into a
64
64
  * server bundle, which `app-router-graph.spec.ts` refuses.
65
- */ import { resolveProvenance } from "@aglyn/aglyn/app-utils/marketplace-provenance";
65
+ */ import { resolveProvenance } from "@aglyn/aglyn/app-utils/artifact-provenance";
66
66
  /** The stamped standing values this reads, mapped to the states above. */ const STAMPED_STANDING = {
67
67
  offered: 'offered',
68
68
  listed: 'offered',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/template-provenance.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHOSE TEMPLATE IS THIS, AND IS IT STILL ANY GOOD.\n *\n * An email template is a besigner document, and the org reading it is not\n * necessarily the org that wrote it: a template can be installed from a\n * marketplace listing, in which case its content is versioned by somebody\n * else and can be updated, rejected or withdrawn by a party outside this\n * workspace. A surface that assumes every template is locally authored and\n * permanently valid reads an installed one as if the org owned it.\n *\n * ## Provenance is not re-derived here\n *\n * {@link resolveProvenance} in core is the ONE reader of where an installed\n * artifact came from, and it already tolerates every shape the install routes\n * have written. This module asks it and adds nothing to it; a second reading\n * of `installedFrom` would be a second answer to the same question.\n *\n * ## Standing is a fact about the LISTING, not about the install\n *\n * Whether a publisher has withdrawn a template, or had it rejected on\n * re-review, is recorded on the marketplace listing. This page does not read\n * listings — that would be a marketplace read on every template opened, for a\n * fact that is usually \"fine\" — so an installed template's standing is\n * `unread` unless the install path stamped an answer onto the document.\n *\n * `unread` is deliberately not `ok`. The difference is the whole reason this\n * type has four states rather than a boolean: \"we checked and it is fine\" and\n * \"we did not check\" lead a reader to the same action only when they are\n * lucky, and a template that was killed upstream is exactly the unlucky case.\n *\n * ## Today every installed template reads `unread`, and that is not a bug\n *\n * Nothing writes `installedFrom.standing`. The install stamps\n * `installedFrom.assurance` — whether that VERSION carried a review verdict —\n * which is a different fact and is knowable at install time. Standing is not:\n * a publisher withdraws or is killed AFTER somebody installed, so recording it\n * needs a later read or a fan-out write that does not exist.\n *\n * What actually protects a tenant is the SEND: `loadEmailTemplate` consults\n * the marketplace revocation record and refuses to mail from a killed\n * template. This badge is the softer, earlier warning, and until something\n * computes standing it correctly declines to claim one. Do not go looking for\n * the writer — there is none yet.\n */\n\n/*\n * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,\n * which reaches `enabled-plugins-context` and therefore React — and this\n * model is imported by `email-events.ts`, which runs in the plugin API route's\n * SERVER graph. Through the barrel that is a client-only module pulled into a\n * server bundle, which `app-router-graph.spec.ts` refuses.\n */\nimport {\n resolveProvenance,\n type ResolvedProvenance,\n} from '@aglyn/aglyn/app-utils/marketplace-provenance'\n\n/** Where a template's content comes from. */\nexport type TemplateOrigin = 'local' | 'installed'\n\n/**\n * How this template stands with whoever publishes it.\n *\n * The three non-`local` values are read from what the install path stamped;\n * none of them is computed here, because computing any of them means reading\n * the listing.\n */\nexport type TemplateStanding =\n /** Authored in this org. There is no publisher to stand with. */\n | 'local'\n /** Installed, and the publisher's current standing has not been read. */\n | 'unread'\n /** Installed, and recorded as still offered by its publisher. */\n | 'offered'\n /** Installed, and recorded as withdrawn, killed or rejected upstream. */\n | 'withdrawn'\n\nexport interface TemplateProvenance {\n origin: TemplateOrigin\n standing: TemplateStanding\n /** The core resolver's answer, verbatim. Null for a local template. */\n installed: ResolvedProvenance | null\n /**\n * One line for the reader, or null when there is nothing worth saying.\n *\n * A local template gets `null`: \"you wrote this\" is the default assumption\n * and repeating it on every page is noise. Everything else says what is\n * known and, where it matters, what is not.\n */\n note: string | null\n /**\n * This template should not be sent without checking with its publisher\n * first. Only ever true for `withdrawn`, and the surface renders it as a\n * warning rather than hiding the template: a message already scheduled\n * against it is a fact the reader has to be able to see.\n */\n warn: boolean\n}\n\n/** The stamped standing values this reads, mapped to the states above. */\nconst STAMPED_STANDING: Record<string, TemplateStanding> = {\n offered: 'offered',\n listed: 'offered',\n verified: 'offered',\n withdrawn: 'withdrawn',\n rejected: 'withdrawn',\n killed: 'withdrawn',\n revoked: 'withdrawn',\n}\n\n/**\n * Reads a template screen document's provenance and standing.\n *\n * Takes the document rather than ids so it costs nothing: everything here is\n * already on the screen document the detail page reads to render anything at\n * all. A template with no install stamp is local, which is the correct\n * reading — every template predating the marketplace was authored here.\n */\nexport function templateProvenance(\n screen:\n | (Record<string, unknown> & {\n /*\n * The install stamp, as a bag of fields with ONE of them named.\n *\n * `Record<string, unknown>` on the inner shape as well as the outer,\n * and it is load-bearing rather than decorative: a type whose every\n * property is optional is a WEAK type, and TypeScript refuses an\n * argument that shares none of its properties. `installedFrom` as\n * written by the install path carries `listingId`, `version`,\n * `sha256`, `artifactType` and `publisherOrgId` and — since nothing\n * writes it yet — never `standing`, so a real stamp had no property\n * in common with `{ standing?: unknown }` and was rejected at the\n * call site while being exactly what this function reads.\n */\n installedFrom?: (Record<string, unknown> & { standing?: unknown }) | null\n })\n | null\n | undefined,\n): TemplateProvenance {\n const installed = resolveProvenance(screen as any, 'emailTemplate')\n if (installed.state === 'unknown' || !installed.listingId) {\n return {\n origin: 'local',\n standing: 'local',\n installed: null,\n note: null,\n warn: false,\n }\n }\n\n const stamped = String(screen?.installedFrom?.standing ?? '')\n const standing: TemplateStanding = STAMPED_STANDING[stamped] ?? 'unread'\n const version = installed.version ? ` version ${installed.version}` : ''\n return {\n origin: 'installed',\n standing,\n installed,\n note:\n standing === 'withdrawn'\n ? `Installed from a marketplace listing${version}, which its ` +\n 'publisher no longer offers. It still sends exactly as it is here, ' +\n 'but it will not receive updates and cannot be reinstalled.'\n : standing === 'offered'\n ? `Installed from a marketplace listing${version}. Its content is ` +\n 'versioned by its publisher, so an update can change what this ' +\n 'template looks like.'\n : `Installed from a marketplace listing${version}. Whether its ` +\n 'publisher still offers it has not been checked here — this page ' +\n 'reads the template, not the listing.',\n warn: standing === 'withdrawn',\n }\n}\n"],"names":["resolveProvenance","STAMPED_STANDING","offered","listed","verified","withdrawn","rejected","killed","revoked","templateProvenance","screen","installed","state","listingId","origin","standing","note","warn","stamped","String","installedFrom","version"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CC,GAED;;;;;;CAMC,GACD,SACEA,iBAAiB,QAEZ,gDAA+C;AA4CtD,wEAAwE,GACxE,MAAMC,mBAAqD;IACzDC,SAAS;IACTC,QAAQ;IACRC,UAAU;IACVC,WAAW;IACXC,UAAU;IACVC,QAAQ;IACRC,SAAS;AACX;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,mBACdC,MAkBa;cAcsBT;QADZS;IAXvB,MAAMC,YAAYX,kBAAkBU,QAAe;IACnD,IAAIC,UAAUC,KAAK,KAAK,aAAa,CAACD,UAAUE,SAAS,EAAE;QACzD,OAAO;YACLC,QAAQ;YACRC,UAAU;YACVJ,WAAW;YACXK,MAAM;YACNC,MAAM;QACR;IACF;IAEA,MAAMC,UAAUC,eAAOT,2BAAAA,wBAAAA,OAAQU,aAAa,qBAArBV,sBAAuBK,QAAQ,mBAAI;IAC1D,MAAMA,YAA6Bd,4BAAAA,gBAAgB,CAACiB,QAAQ,YAAzBjB,4BAA6B;IAChE,MAAMoB,UAAUV,UAAUU,OAAO,GAAG,CAAC,SAAS,EAAEV,UAAUU,OAAO,EAAE,GAAG;IACtE,OAAO;QACLP,QAAQ;QACRC;QACAJ;QACAK,MACED,aAAa,cACT,CAAC,oCAAoC,EAAEM,QAAQ,YAAY,CAAC,GAC5D,uEACA,+DACAN,aAAa,YACX,CAAC,oCAAoC,EAAEM,QAAQ,iBAAiB,CAAC,GACjE,mEACA,yBACA,CAAC,oCAAoC,EAAEA,QAAQ,cAAc,CAAC,GAC9D,qEACA;QACRJ,MAAMF,aAAa;IACrB;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/template-provenance.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHOSE TEMPLATE IS THIS, AND IS IT STILL ANY GOOD.\n *\n * An email template is a besigner document, and the org reading it is not\n * necessarily the org that wrote it: a template can be installed from a\n * marketplace listing, in which case its content is versioned by somebody\n * else and can be updated, rejected or withdrawn by a party outside this\n * workspace. A surface that assumes every template is locally authored and\n * permanently valid reads an installed one as if the org owned it.\n *\n * ## Provenance is not re-derived here\n *\n * {@link resolveProvenance} in core is the ONE reader of where an installed\n * artifact came from, and it already tolerates every shape the install routes\n * have written. This module asks it and adds nothing to it; a second reading\n * of `installedFrom` would be a second answer to the same question.\n *\n * ## Standing is a fact about the LISTING, not about the install\n *\n * Whether a publisher has withdrawn a template, or had it rejected on\n * re-review, is recorded on the marketplace listing. This page does not read\n * listings — that would be a marketplace read on every template opened, for a\n * fact that is usually \"fine\" — so an installed template's standing is\n * `unread` unless the install path stamped an answer onto the document.\n *\n * `unread` is deliberately not `ok`. The difference is the whole reason this\n * type has four states rather than a boolean: \"we checked and it is fine\" and\n * \"we did not check\" lead a reader to the same action only when they are\n * lucky, and a template that was killed upstream is exactly the unlucky case.\n *\n * ## Today every installed template reads `unread`, and that is not a bug\n *\n * Nothing writes `installedFrom.standing`. The install stamps\n * `installedFrom.assurance` — whether that VERSION carried a review verdict —\n * which is a different fact and is knowable at install time. Standing is not:\n * a publisher withdraws or is killed AFTER somebody installed, so recording it\n * needs a later read or a fan-out write that does not exist.\n *\n * What actually protects a tenant is the SEND: `loadEmailTemplate` consults\n * the marketplace revocation record and refuses to mail from a killed\n * template. This badge is the softer, earlier warning, and until something\n * computes standing it correctly declines to claim one. Do not go looking for\n * the writer — there is none yet.\n */\n\n/*\n * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,\n * which reaches `enabled-plugins-context` and therefore React — and this\n * model is imported by `email-events.ts`, which runs in the plugin API route's\n * SERVER graph. Through the barrel that is a client-only module pulled into a\n * server bundle, which `app-router-graph.spec.ts` refuses.\n */\nimport {\n resolveProvenance,\n type ResolvedProvenance,\n} from '@aglyn/aglyn/app-utils/artifact-provenance'\n\n/** Where a template's content comes from. */\nexport type TemplateOrigin = 'local' | 'installed'\n\n/**\n * How this template stands with whoever publishes it.\n *\n * The three non-`local` values are read from what the install path stamped;\n * none of them is computed here, because computing any of them means reading\n * the listing.\n */\nexport type TemplateStanding =\n /** Authored in this org. There is no publisher to stand with. */\n | 'local'\n /** Installed, and the publisher's current standing has not been read. */\n | 'unread'\n /** Installed, and recorded as still offered by its publisher. */\n | 'offered'\n /** Installed, and recorded as withdrawn, killed or rejected upstream. */\n | 'withdrawn'\n\nexport interface TemplateProvenance {\n origin: TemplateOrigin\n standing: TemplateStanding\n /** The core resolver's answer, verbatim. Null for a local template. */\n installed: ResolvedProvenance | null\n /**\n * One line for the reader, or null when there is nothing worth saying.\n *\n * A local template gets `null`: \"you wrote this\" is the default assumption\n * and repeating it on every page is noise. Everything else says what is\n * known and, where it matters, what is not.\n */\n note: string | null\n /**\n * This template should not be sent without checking with its publisher\n * first. Only ever true for `withdrawn`, and the surface renders it as a\n * warning rather than hiding the template: a message already scheduled\n * against it is a fact the reader has to be able to see.\n */\n warn: boolean\n}\n\n/** The stamped standing values this reads, mapped to the states above. */\nconst STAMPED_STANDING: Record<string, TemplateStanding> = {\n offered: 'offered',\n listed: 'offered',\n verified: 'offered',\n withdrawn: 'withdrawn',\n rejected: 'withdrawn',\n killed: 'withdrawn',\n revoked: 'withdrawn',\n}\n\n/**\n * Reads a template screen document's provenance and standing.\n *\n * Takes the document rather than ids so it costs nothing: everything here is\n * already on the screen document the detail page reads to render anything at\n * all. A template with no install stamp is local, which is the correct\n * reading — every template predating the marketplace was authored here.\n */\nexport function templateProvenance(\n screen:\n | (Record<string, unknown> & {\n /*\n * The install stamp, as a bag of fields with ONE of them named.\n *\n * `Record<string, unknown>` on the inner shape as well as the outer,\n * and it is load-bearing rather than decorative: a type whose every\n * property is optional is a WEAK type, and TypeScript refuses an\n * argument that shares none of its properties. `installedFrom` as\n * written by the install path carries `listingId`, `version`,\n * `sha256`, `artifactType` and `publisherOrgId` and — since nothing\n * writes it yet — never `standing`, so a real stamp had no property\n * in common with `{ standing?: unknown }` and was rejected at the\n * call site while being exactly what this function reads.\n */\n installedFrom?: (Record<string, unknown> & { standing?: unknown }) | null\n })\n | null\n | undefined,\n): TemplateProvenance {\n const installed = resolveProvenance(screen as any, 'emailTemplate')\n if (installed.state === 'unknown' || !installed.listingId) {\n return {\n origin: 'local',\n standing: 'local',\n installed: null,\n note: null,\n warn: false,\n }\n }\n\n const stamped = String(screen?.installedFrom?.standing ?? '')\n const standing: TemplateStanding = STAMPED_STANDING[stamped] ?? 'unread'\n const version = installed.version ? ` version ${installed.version}` : ''\n return {\n origin: 'installed',\n standing,\n installed,\n note:\n standing === 'withdrawn'\n ? `Installed from a marketplace listing${version}, which its ` +\n 'publisher no longer offers. It still sends exactly as it is here, ' +\n 'but it will not receive updates and cannot be reinstalled.'\n : standing === 'offered'\n ? `Installed from a marketplace listing${version}. Its content is ` +\n 'versioned by its publisher, so an update can change what this ' +\n 'template looks like.'\n : `Installed from a marketplace listing${version}. Whether its ` +\n 'publisher still offers it has not been checked here — this page ' +\n 'reads the template, not the listing.',\n warn: standing === 'withdrawn',\n }\n}\n"],"names":["resolveProvenance","STAMPED_STANDING","offered","listed","verified","withdrawn","rejected","killed","revoked","templateProvenance","screen","installed","state","listingId","origin","standing","note","warn","stamped","String","installedFrom","version"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CC,GAED;;;;;;CAMC,GACD,SACEA,iBAAiB,QAEZ,6CAA4C;AA4CnD,wEAAwE,GACxE,MAAMC,mBAAqD;IACzDC,SAAS;IACTC,QAAQ;IACRC,UAAU;IACVC,WAAW;IACXC,UAAU;IACVC,QAAQ;IACRC,SAAS;AACX;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,mBACdC,MAkBa;cAcsBT;QADZS;IAXvB,MAAMC,YAAYX,kBAAkBU,QAAe;IACnD,IAAIC,UAAUC,KAAK,KAAK,aAAa,CAACD,UAAUE,SAAS,EAAE;QACzD,OAAO;YACLC,QAAQ;YACRC,UAAU;YACVJ,WAAW;YACXK,MAAM;YACNC,MAAM;QACR;IACF;IAEA,MAAMC,UAAUC,eAAOT,2BAAAA,wBAAAA,OAAQU,aAAa,qBAArBV,sBAAuBK,QAAQ,mBAAI;IAC1D,MAAMA,YAA6Bd,4BAAAA,gBAAgB,CAACiB,QAAQ,YAAzBjB,4BAA6B;IAChE,MAAMoB,UAAUV,UAAUU,OAAO,GAAG,CAAC,SAAS,EAAEV,UAAUU,OAAO,EAAE,GAAG;IACtE,OAAO;QACLP,QAAQ;QACRC;QACAJ;QACAK,MACED,aAAa,cACT,CAAC,oCAAoC,EAAEM,QAAQ,YAAY,CAAC,GAC5D,uEACA,+DACAN,aAAa,YACX,CAAC,oCAAoC,EAAEM,QAAQ,iBAAiB,CAAC,GACjE,mEACA,yBACA,CAAC,oCAAoC,EAAEA,QAAQ,cAAc,CAAC,GAC9D,qEACA;QACRJ,MAAMF,aAAa;IACrB;AACF"}
package/src/lib/plugin.js CHANGED
@@ -20,6 +20,7 @@ import { lazy } from "react";
20
20
  import { EMAIL_MESSAGES_ZONE, EMAIL_TEMPLATE_RECIPIENTS_ZONE } from "./components/email-zones.js";
21
21
  import { EMAILS_CONSOLE_SECTIONS } from "./components/emails-console-sections.js";
22
22
  import { BUNDLE_ID } from "./constants/bundle-common.js";
23
+ import { registerEmailRecordRoutes } from "./model/email-record-routes.js";
23
24
  /** Code-split: the Emails console page only loads when opened. */ const EmailsConsolePage = lazy(()=>import("./components/emails-console-page.js"));
24
25
  /*
25
26
  * What this plugin draws inside a campaign's own pages, each loaded only where
@@ -38,6 +39,9 @@ const EmailDesignPreview = lazy(()=>import("./components/email-design-preview.js
38
39
  * identities and the suppression list) — with no edit to the console's own
39
40
  * nav or page files.
40
41
  */ export function registerEmailConsole() {
42
+ // Where a message and a sending identity are read (AGL-3080), for the
43
+ // surfaces that link to them without knowing this page's address.
44
+ registerEmailRecordRoutes();
41
45
  /*
42
46
  * The two places this plugin's pages hand over to whichever plugin owns
43
47
  * campaigns. A message is one send of a campaign and every action on it is
@@ -58,7 +62,7 @@ const EmailDesignPreview = lazy(()=>import("./components/email-design-preview.js
58
62
  zone: EMAIL_TEMPLATE_RECIPIENTS_ZONE,
59
63
  label: 'Who received a template’s emails',
60
64
  surface: 'console',
61
- description: 'On one template’s page, under its report. A widget here lists the recipients of every send built from that template; it is handed the site and the template’s screen id.'
65
+ description: 'On one template’s page, under its report. A widget here lists the recipients of every send built from that template; it is handed the site and the template’s id.'
62
66
  }, {
63
67
  pluginId: BUNDLE_ID
64
68
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/plugin.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { registerPluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport { mdiEmailOutline } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport {\n EMAIL_MESSAGES_ZONE,\n EMAIL_TEMPLATE_RECIPIENTS_ZONE,\n} from './components/email-zones'\nimport { EMAILS_CONSOLE_SECTIONS } from './components/emails-console-sections'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/** Code-split: the Emails console page only loads when opened. */\nconst EmailsConsolePage = lazy(() => import('./components/emails-console-page'))\n\n/*\n * What this plugin draws inside a campaign's own pages, each loaded only where\n * the campaign owner's zone is on screen.\n */\nconst CampaignTopicSelect = lazy(\n () => import('./components/campaign-topic-select'),\n)\nconst CampaignTopicOptionsWidget = lazy(\n () => import('./components/campaign-topic-options-widget'),\n)\nconst CampaignSenderEditorWidget = lazy(\n () => import('./components/campaign-sender-editor-widget'),\n)\nconst CampaignDesignCreateWidget = lazy(\n () => import('./components/campaign-design-create-widget'),\n)\nconst EmailDesignPreview = lazy(\n () => import('./components/email-design-preview'),\n)\n\n/**\n * Console half (AGL-395): registers the Emails nav item + page in the\n * ConsoleExtension registry. Safe to call at console app load — the page is\n * lazy, so no besigner/canvas code loads. The shell renders the Emails nav\n * item and, via its generic plugin route, the page (the messages and their\n * composer, the templates, the audience lists, the topic catalog, the sending\n * identities and the suppression list) — with no edit to the console's own\n * nav or page files.\n */\nexport function registerEmailConsole(): void {\n /*\n * The two places this plugin's pages hand over to whichever plugin owns\n * campaigns. A message is one send of a campaign and every action on it is\n * that plugin's route, so the Messages section is a zone this page hosts\n * rather than pages this plugin imports; the same holds for the recipients\n * table under a template's report. Named, because a spec calls this\n * registrar without the loader.\n */\n registerPluginZone(\n {\n zone: EMAIL_MESSAGES_ZONE,\n label: 'The Emails page’s Messages section',\n surface: 'console',\n layout: 'bare',\n description:\n 'The whole body of `/emails/messages` and the routes under it, under a site and on the organization’s Emails page. A widget here is handed the site (or `null` and the org mount at the organization level), the Emails page’s base path and the segments under `messages`, and draws the list, one message’s report or its composer.',\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginZone(\n {\n zone: EMAIL_TEMPLATE_RECIPIENTS_ZONE,\n label: 'Who received a template’s emails',\n surface: 'console',\n description:\n 'On one template’s page, under its report. A widget here lists the recipients of every send built from that template; it is handed the site and the template’s screen id.',\n },\n { pluginId: BUNDLE_ID },\n )\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Email',\n /*\n * WHO may open the email console, declared so the shell enforces it.\n *\n * The audiences section reads `orgs/{orgId}/lists/{listId}/members`, and\n * those members are enrolled CONTACTS — an address, a name, and the\n * consent basis recording why the person may be mailed. That is the same\n * org-shared people data the CRM holds, reached from a different page.\n *\n * The rules gate those reads on `isOrgWideMember()` ALONE, with no role\n * condition, so org-wide membership of any role is enough to list every\n * audience the organization has and everybody on it. An org VIEWER — the\n * role that exists to read and change nothing — therefore reads the whole\n * marketing audience today. `data.manage` is what closes that: it\n * defaults to owner, admin and editor, so the population it admits is\n * exactly the one `server-list-gate.ts` accepts a list write from, and\n * the viewer it excludes is the reader the rules never excluded.\n *\n * `data.manage` rather than a key of this plugin's own for the reason the\n * catalog gives for refusing a `marketing.manage`: campaigns are written\n * client-direct against rules that gate on the HOST role, so a new\n * org-level key would name an action with no org-level boundary under it.\n * `data.manage` is not in that position — it already governs the\n * org-shared data this surface exposes, and the list gate already reads\n * the roles it defaults to.\n *\n * A SITE COLLABORATOR holding it opens the page, and the answer is NOT\n * the one Contacts reached. There the listener is scoped and the rules\n * prove the same predicate per document; here the audiences read demands\n * `isOrgWideMember()`, which a collaborator is not, so the org-shared\n * half is already refused beneath the console and the half that remains —\n * this site's own messages, templates and sending identities — is theirs.\n * Refusing the surface outright would take that away to close nothing.\n */\n permission: 'data.manage',\n /*\n * The mail a campaign rides on is this plugin's — the topic catalog, the\n * sending identities, the design document and its renderer — so each is\n * drawn here, in a zone the campaign owner's composer and message page\n * host. Every one reports through a callback; none writes a campaign.\n */\n widgets: [\n {\n slot: 'campaignTopicSelect',\n widgetId: 'email-campaign-topic-select',\n title: 'Topic',\n Component: CampaignTopicSelect,\n },\n {\n slot: 'campaignTopicOptions',\n widgetId: 'email-campaign-topic-options',\n title: 'Topics',\n Component: CampaignTopicOptionsWidget,\n },\n {\n slot: 'campaignSenderEditor',\n widgetId: 'email-campaign-sender-editor',\n title: 'Add a sender',\n Component: CampaignSenderEditorWidget,\n },\n {\n slot: 'campaignDesignCreate',\n widgetId: 'email-campaign-design-create',\n title: 'Design this email',\n Component: CampaignDesignCreateWidget,\n },\n {\n slot: 'campaignDesignPreview',\n widgetId: 'email-campaign-design-preview',\n title: 'Preview',\n Component: EmailDesignPreview,\n },\n ],\n navItems: [\n {\n label: 'Emails',\n href: '/emails',\n icon: { path: mdiEmailOutline.path },\n // Sections as ROUTES (AGL-2501): `/emails/messages` and friends are\n // real URLs the shell resolves and gates, so the page mounts the one\n // being read instead of subscribing all six.\n sections: EMAILS_CONSOLE_SECTIONS,\n header: {\n title: 'Emails',\n icon: { path: mdiEmailOutline.path },\n docsTopic: 'emailCampaigns',\n },\n Component: EmailsConsolePage,\n },\n ],\n /*\n * The ORGANIZATION's Emails page, at `/[orgSlug]/emails`: the same six\n * sections over every site. The audiences and the topics are the org's\n * already; the messages, the templates, the sending identities and the\n * suppression lists are read site by site and link into the site that\n * holds them. The page is the same component — handed no site, it renders\n * the org sections.\n *\n * No `navTabId`, the same as the site tab: this plugin's release flag\n * gates the plugin itself, so one flag already holds both levels.\n */\n orgNavItems: [\n {\n label: 'Emails',\n href: '/emails',\n icon: { path: mdiEmailOutline.path },\n sections: EMAILS_CONSOLE_SECTIONS,\n header: {\n title: 'Emails',\n icon: { path: mdiEmailOutline.path },\n docsTopic: 'emailCampaigns',\n },\n Component: EmailsConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","registerPluginZone","mdiEmailOutline","lazy","EMAIL_MESSAGES_ZONE","EMAIL_TEMPLATE_RECIPIENTS_ZONE","EMAILS_CONSOLE_SECTIONS","BUNDLE_ID","EmailsConsolePage","CampaignTopicSelect","CampaignTopicOptionsWidget","CampaignSenderEditorWidget","CampaignDesignCreateWidget","EmailDesignPreview","registerEmailConsole","zone","label","surface","layout","description","pluginId","registerConsoleExtension","displayName","permission","widgets","slot","widgetId","title","Component","navItems","href","icon","path","sections","header","docsTopic","orgNavItems"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,eAAe,QAAQ,yBAAwB;AACxD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SACEC,mBAAmB,EACnBC,8BAA8B,QACzB,8BAA0B;AACjC,SAASC,uBAAuB,QAAQ,0CAAsC;AAC9E,SAASC,SAAS,QAAQ,+BAA2B;AAErD,gEAAgE,GAChE,MAAMC,oBAAoBL,KAAK,IAAM,MAAM,CAAC;AAE5C;;;CAGC,GACD,MAAMM,sBAAsBN,KAC1B,IAAM,MAAM,CAAC;AAEf,MAAMO,6BAA6BP,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMQ,6BAA6BR,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMS,6BAA6BT,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMU,qBAAqBV,KACzB,IAAM,MAAM,CAAC;AAGf;;;;;;;;CAQC,GACD,OAAO,SAASW;IACd;;;;;;;GAOC,GACDb,mBACE;QACEc,MAAMX;QACNY,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACA;QAAEC,UAAUb;IAAU;IAExBN,mBACE;QACEc,MAAMV;QACNW,OAAO;QACPC,SAAS;QACTE,aACE;IACJ,GACA;QAAEC,UAAUb;IAAU;IAExBP,MAAMqB,wBAAwB,CAAC;QAC7BD,UAAUb;QACVe,aAAa;QACb;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAgCC,GACDC,YAAY;QACZ;;;;;KAKC,GACDC,SAAS;YACP;gBACEC,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWnB;YACb;YACA;gBACEgB,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWlB;YACb;YACA;gBACEe,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWjB;YACb;YACA;gBACEc,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWhB;YACb;YACA;gBACEa,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWf;YACb;SACD;QACDgB,UAAU;YACR;gBACEb,OAAO;gBACPc,MAAM;gBACNC,MAAM;oBAAEC,MAAM9B,gBAAgB8B,IAAI;gBAAC;gBACnC,oEAAoE;gBACpE,qEAAqE;gBACrE,6CAA6C;gBAC7CC,UAAU3B;gBACV4B,QAAQ;oBACNP,OAAO;oBACPI,MAAM;wBAAEC,MAAM9B,gBAAgB8B,IAAI;oBAAC;oBACnCG,WAAW;gBACb;gBACAP,WAAWpB;YACb;SACD;QACD;;;;;;;;;;KAUC,GACD4B,aAAa;YACX;gBACEpB,OAAO;gBACPc,MAAM;gBACNC,MAAM;oBAAEC,MAAM9B,gBAAgB8B,IAAI;gBAAC;gBACnCC,UAAU3B;gBACV4B,QAAQ;oBACNP,OAAO;oBACPI,MAAM;wBAAEC,MAAM9B,gBAAgB8B,IAAI;oBAAC;oBACnCG,WAAW;gBACb;gBACAP,WAAWpB;YACb;SACD;IACH;AACF;AAEA,cAAc,YAAQ"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/plugin.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { registerPluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport { mdiEmailOutline } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport {\n EMAIL_MESSAGES_ZONE,\n EMAIL_TEMPLATE_RECIPIENTS_ZONE,\n} from './components/email-zones'\nimport { EMAILS_CONSOLE_SECTIONS } from './components/emails-console-sections'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerEmailRecordRoutes } from './model/email-record-routes'\n\n/** Code-split: the Emails console page only loads when opened. */\nconst EmailsConsolePage = lazy(() => import('./components/emails-console-page'))\n\n/*\n * What this plugin draws inside a campaign's own pages, each loaded only where\n * the campaign owner's zone is on screen.\n */\nconst CampaignTopicSelect = lazy(\n () => import('./components/campaign-topic-select'),\n)\nconst CampaignTopicOptionsWidget = lazy(\n () => import('./components/campaign-topic-options-widget'),\n)\nconst CampaignSenderEditorWidget = lazy(\n () => import('./components/campaign-sender-editor-widget'),\n)\nconst CampaignDesignCreateWidget = lazy(\n () => import('./components/campaign-design-create-widget'),\n)\nconst EmailDesignPreview = lazy(\n () => import('./components/email-design-preview'),\n)\n\n/**\n * Console half (AGL-395): registers the Emails nav item + page in the\n * ConsoleExtension registry. Safe to call at console app load — the page is\n * lazy, so no besigner/canvas code loads. The shell renders the Emails nav\n * item and, via its generic plugin route, the page (the messages and their\n * composer, the templates, the audience lists, the topic catalog, the sending\n * identities and the suppression list) — with no edit to the console's own\n * nav or page files.\n */\nexport function registerEmailConsole(): void {\n // Where a message and a sending identity are read (AGL-3080), for the\n // surfaces that link to them without knowing this page's address.\n registerEmailRecordRoutes()\n /*\n * The two places this plugin's pages hand over to whichever plugin owns\n * campaigns. A message is one send of a campaign and every action on it is\n * that plugin's route, so the Messages section is a zone this page hosts\n * rather than pages this plugin imports; the same holds for the recipients\n * table under a template's report. Named, because a spec calls this\n * registrar without the loader.\n */\n registerPluginZone(\n {\n zone: EMAIL_MESSAGES_ZONE,\n label: 'The Emails page’s Messages section',\n surface: 'console',\n layout: 'bare',\n description:\n 'The whole body of `/emails/messages` and the routes under it, under a site and on the organization’s Emails page. A widget here is handed the site (or `null` and the org mount at the organization level), the Emails page’s base path and the segments under `messages`, and draws the list, one message’s report or its composer.',\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginZone(\n {\n zone: EMAIL_TEMPLATE_RECIPIENTS_ZONE,\n label: 'Who received a template’s emails',\n surface: 'console',\n description:\n 'On one template’s page, under its report. A widget here lists the recipients of every send built from that template; it is handed the site and the template’s id.',\n },\n { pluginId: BUNDLE_ID },\n )\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Email',\n /*\n * WHO may open the email console, declared so the shell enforces it.\n *\n * The audiences section reads `orgs/{orgId}/lists/{listId}/members`, and\n * those members are enrolled CONTACTS — an address, a name, and the\n * consent basis recording why the person may be mailed. That is the same\n * org-shared people data the CRM holds, reached from a different page.\n *\n * The rules gate those reads on `isOrgWideMember()` ALONE, with no role\n * condition, so org-wide membership of any role is enough to list every\n * audience the organization has and everybody on it. An org VIEWER — the\n * role that exists to read and change nothing — therefore reads the whole\n * marketing audience today. `data.manage` is what closes that: it\n * defaults to owner, admin and editor, so the population it admits is\n * exactly the one `server-list-gate.ts` accepts a list write from, and\n * the viewer it excludes is the reader the rules never excluded.\n *\n * `data.manage` rather than a key of this plugin's own for the reason the\n * catalog gives for refusing a `marketing.manage`: campaigns are written\n * client-direct against rules that gate on the HOST role, so a new\n * org-level key would name an action with no org-level boundary under it.\n * `data.manage` is not in that position — it already governs the\n * org-shared data this surface exposes, and the list gate already reads\n * the roles it defaults to.\n *\n * A SITE COLLABORATOR holding it opens the page, and the answer is NOT\n * the one Contacts reached. There the listener is scoped and the rules\n * prove the same predicate per document; here the audiences read demands\n * `isOrgWideMember()`, which a collaborator is not, so the org-shared\n * half is already refused beneath the console and the half that remains —\n * this site's own messages, templates and sending identities — is theirs.\n * Refusing the surface outright would take that away to close nothing.\n */\n permission: 'data.manage',\n /*\n * The mail a campaign rides on is this plugin's — the topic catalog, the\n * sending identities, the design document and its renderer — so each is\n * drawn here, in a zone the campaign owner's composer and message page\n * host. Every one reports through a callback; none writes a campaign.\n */\n widgets: [\n {\n slot: 'campaignTopicSelect',\n widgetId: 'email-campaign-topic-select',\n title: 'Topic',\n Component: CampaignTopicSelect,\n },\n {\n slot: 'campaignTopicOptions',\n widgetId: 'email-campaign-topic-options',\n title: 'Topics',\n Component: CampaignTopicOptionsWidget,\n },\n {\n slot: 'campaignSenderEditor',\n widgetId: 'email-campaign-sender-editor',\n title: 'Add a sender',\n Component: CampaignSenderEditorWidget,\n },\n {\n slot: 'campaignDesignCreate',\n widgetId: 'email-campaign-design-create',\n title: 'Design this email',\n Component: CampaignDesignCreateWidget,\n },\n {\n slot: 'campaignDesignPreview',\n widgetId: 'email-campaign-design-preview',\n title: 'Preview',\n Component: EmailDesignPreview,\n },\n ],\n navItems: [\n {\n label: 'Emails',\n href: '/emails',\n icon: { path: mdiEmailOutline.path },\n // Sections as ROUTES (AGL-2501): `/emails/messages` and friends are\n // real URLs the shell resolves and gates, so the page mounts the one\n // being read instead of subscribing all six.\n sections: EMAILS_CONSOLE_SECTIONS,\n header: {\n title: 'Emails',\n icon: { path: mdiEmailOutline.path },\n docsTopic: 'emailCampaigns',\n },\n Component: EmailsConsolePage,\n },\n ],\n /*\n * The ORGANIZATION's Emails page, at `/[orgSlug]/emails`: the same six\n * sections over every site. The audiences and the topics are the org's\n * already; the messages, the templates, the sending identities and the\n * suppression lists are read site by site and link into the site that\n * holds them. The page is the same component — handed no site, it renders\n * the org sections.\n *\n * No `navTabId`, the same as the site tab: this plugin's release flag\n * gates the plugin itself, so one flag already holds both levels.\n */\n orgNavItems: [\n {\n label: 'Emails',\n href: '/emails',\n icon: { path: mdiEmailOutline.path },\n sections: EMAILS_CONSOLE_SECTIONS,\n header: {\n title: 'Emails',\n icon: { path: mdiEmailOutline.path },\n docsTopic: 'emailCampaigns',\n },\n Component: EmailsConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","registerPluginZone","mdiEmailOutline","lazy","EMAIL_MESSAGES_ZONE","EMAIL_TEMPLATE_RECIPIENTS_ZONE","EMAILS_CONSOLE_SECTIONS","BUNDLE_ID","registerEmailRecordRoutes","EmailsConsolePage","CampaignTopicSelect","CampaignTopicOptionsWidget","CampaignSenderEditorWidget","CampaignDesignCreateWidget","EmailDesignPreview","registerEmailConsole","zone","label","surface","layout","description","pluginId","registerConsoleExtension","displayName","permission","widgets","slot","widgetId","title","Component","navItems","href","icon","path","sections","header","docsTopic","orgNavItems"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,eAAe,QAAQ,yBAAwB;AACxD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SACEC,mBAAmB,EACnBC,8BAA8B,QACzB,8BAA0B;AACjC,SAASC,uBAAuB,QAAQ,0CAAsC;AAC9E,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,yBAAyB,QAAQ,iCAA6B;AAEvE,gEAAgE,GAChE,MAAMC,oBAAoBN,KAAK,IAAM,MAAM,CAAC;AAE5C;;;CAGC,GACD,MAAMO,sBAAsBP,KAC1B,IAAM,MAAM,CAAC;AAEf,MAAMQ,6BAA6BR,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMS,6BAA6BT,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMU,6BAA6BV,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMW,qBAAqBX,KACzB,IAAM,MAAM,CAAC;AAGf;;;;;;;;CAQC,GACD,OAAO,SAASY;IACd,sEAAsE;IACtE,kEAAkE;IAClEP;IACA;;;;;;;GAOC,GACDP,mBACE;QACEe,MAAMZ;QACNa,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACA;QAAEC,UAAUd;IAAU;IAExBN,mBACE;QACEe,MAAMX;QACNY,OAAO;QACPC,SAAS;QACTE,aACE;IACJ,GACA;QAAEC,UAAUd;IAAU;IAExBP,MAAMsB,wBAAwB,CAAC;QAC7BD,UAAUd;QACVgB,aAAa;QACb;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAgCC,GACDC,YAAY;QACZ;;;;;KAKC,GACDC,SAAS;YACP;gBACEC,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWnB;YACb;YACA;gBACEgB,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWlB;YACb;YACA;gBACEe,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWjB;YACb;YACA;gBACEc,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWhB;YACb;YACA;gBACEa,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWf;YACb;SACD;QACDgB,UAAU;YACR;gBACEb,OAAO;gBACPc,MAAM;gBACNC,MAAM;oBAAEC,MAAM/B,gBAAgB+B,IAAI;gBAAC;gBACnC,oEAAoE;gBACpE,qEAAqE;gBACrE,6CAA6C;gBAC7CC,UAAU5B;gBACV6B,QAAQ;oBACNP,OAAO;oBACPI,MAAM;wBAAEC,MAAM/B,gBAAgB+B,IAAI;oBAAC;oBACnCG,WAAW;gBACb;gBACAP,WAAWpB;YACb;SACD;QACD;;;;;;;;;;KAUC,GACD4B,aAAa;YACX;gBACEpB,OAAO;gBACPc,MAAM;gBACNC,MAAM;oBAAEC,MAAM/B,gBAAgB+B,IAAI;gBAAC;gBACnCC,UAAU5B;gBACV6B,QAAQ;oBACNP,OAAO;oBACPI,MAAM;wBAAEC,MAAM/B,gBAAgB+B,IAAI;oBAAC;oBACnCG,WAAW;gBACb;gBACAP,WAAWpB;YACb;SACD;IACH;AACF;AAEA,cAAc,YAAQ"}
@@ -33,7 +33,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
33
33
  * never checked, which is a consent record minted by pressing a button. The
34
34
  * rules now deny client create and update on that collection, and this route
35
35
  * is the writer — through `enrollListMember`, which owns the document id, and
36
- * through the shared `list-assignment-policy`, which owns the basis.
36
+ * through the shared `enrollment-basis`, which owns the basis.
37
37
  *
38
38
  * ## The same policy the Inbox uses, not a second one
39
39
  *
@@ -74,7 +74,8 @@ import { registerEmailSuppressionsApi } from "./server-suppressions.js";
74
74
  import { registerEmailListImportApi } from "./server-list-import.js";
75
75
  import { LIST_MEMBER_BATCH_MAX, readAddresses, resolveAddresses, resolveListContext } from "./server-list-gate.js";
76
76
  import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, dynamicListRuleIsEmpty, normalizeDynamicListRule, readMarketingBasis, registerPluginApiRoute } from "@aglyn/aglyn/server";
77
- import { collectDynamicListCandidates, enrollListMember } from "@aglyn/tenant-data-admin";
77
+ import { collectDynamicListCandidates, enrollListMember, firebaseAdmin } from "@aglyn/tenant-data-admin";
78
+ import { registerSubscriptionTopicIndex } from "./server-topic-index.js";
78
79
  /** `source` stamped on every membership these routes write. */ export const CONSOLE_ADD_SOURCE = 'console:list-add';
79
80
  /*
80
81
  * Re-exported, not redefined. `@aglyn/plugins-email/server` has published
@@ -406,6 +407,9 @@ import { collectDynamicListCandidates, enrollListMember } from "@aglyn/tenant-da
406
407
  // write through it run only on the console, inline and on the beat
407
408
  // (AGL-3026).
408
409
  registerEmailDesignDraftWriter();
410
+ // The org's topic catalog, as the tenant surface publishes it: the console
411
+ // runs sends under a stream too.
412
+ registerSubscriptionTopicIndex(()=>firebaseAdmin.app().firestore());
409
413
  registerPluginApiRoute('email/list-rule-preview', emailListRulePreviewHandler);
410
414
  registerPluginApiRoute('email/list-members-preview', emailListMembersPreviewHandler);
411
415
  registerPluginApiRoute('email/list-members-add', emailListMembersAddHandler);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-console.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Putting somebody on an email list from the Emails console.\n *\n * The audience card could create a list and delete a list and nothing else:\n * no way to see who was on one, no way to add anybody, no way to take anybody\n * off. Every member document in production arrived from a capture surface —\n * the newsletter handler, the workflow `enrollList` step, the dynamic-list\n * materializer, the Inbox assignment — so the one act a merchant most expects\n * of a list, \"add this person\", was the one act the product refused.\n *\n * ## Why this is a route and not a client write\n *\n * Rules put `orgs/{orgId}/lists/{listId}/members` behind org-wide membership,\n * and until now that block allowed a client CREATE. Nothing used it, and it\n * was the whole feature waiting to be built wrong: a browser that can write a\n * member document can write `marketingConsent: true` beside an address it has\n * never checked, which is a consent record minted by pressing a button. The\n * rules now deny client create and update on that collection, and this route\n * is the writer — through `enrollListMember`, which owns the document id, and\n * through the shared `list-assignment-policy`, which owns the basis.\n *\n * ## The same policy the Inbox uses, not a second one\n *\n * `assignmentBasis` and `assignmentReadout` are imported from the framework,\n * where they moved when this became their second caller. There is no third\n * consent basis and no console-only override: a stored `declined` refuses\n * here exactly as it refuses there, an attestation is recorded with the\n * account that made it, and a stored opt-in is carried across with the date\n * the PERSON set rather than the date somebody pressed Add.\n *\n * ## Reads only, until the operator has seen the count\n *\n * `email/list-members-preview` writes nothing. It answers, per address, what\n * would happen and why, so the attestation the operator gives on the second\n * call is given with the numbers in front of them. Both routes run the SAME\n * resolution over the SAME inputs — `resolveAddresses` — so the summary they\n * were shown is the summary that acts.\n *\n * ## Finding people is a read, and it uses the same gate\n *\n * `email/list-rule-preview` answers \"who do these filters select\" without\n * writing anything, so a fixed list can be filled from a search rather than\n * from somebody typing addresses one at a time. It runs the addresses it finds\n * through `resolveAddresses` as well — a bulk path that reached the membership\n * without the suppression check and the attestation would be a way to enroll\n * exactly the people the single-address path refuses.\n *\n * ## The gate itself is `server-list-gate.ts`\n *\n * Who may change a list's membership, and what is true about each address, are\n * asked by three route modules now: these two, and the file importer in\n * `server-list-import.ts`. They live in a module of their own so the importer\n * shares this file's answer rather than importing this file — which would be a\n * cycle, and a cycle whose module-level constants would evaluate in whichever\n * order the loader happened to reach them.\n */\n\nimport { registerEmailDesignDraftWriter } from './server-email-drafts'\nimport { registerEmailSuppressionsApi } from './server-suppressions'\nimport { registerEmailListImportApi } from './server-list-import'\nimport {\n LIST_MEMBER_BATCH_MAX,\n readAddresses,\n resolveAddresses,\n resolveListContext,\n} from './server-list-gate'\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n dynamicListRuleIsEmpty,\n normalizeDynamicListRule,\n readMarketingBasis,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n collectDynamicListCandidates,\n enrollListMember,\n} from '@aglyn/tenant-data-admin'\n\n/** `source` stamped on every membership these routes write. */\nexport const CONSOLE_ADD_SOURCE = 'console:list-add'\n\n/*\n * Re-exported, not redefined. `@aglyn/plugins-email/server` has published\n * these names since the add path shipped, and the console panel and its specs\n * import them from there; moving the gate must not move the plugin's public\n * surface with it.\n */\nexport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type AddressResolution,\n type AddressVerdict,\n type ListContext,\n type ResolvedBatch,\n} from './server-list-gate'\n\n\n/**\n * `POST email/list-members-preview` — what adding these addresses would do.\n *\n * Reads only, and reached by an explicit act — typing an address or pasting a\n * column — never on mount. It exists because the answer needs three things\n * the browser cannot have: the org's contacts (rules put them behind org-wide\n * membership, which the acting console session may not hold) and both\n * suppression lists. Computing any of it client-side would be a second copy\n * of the rule, on the surface whose whole job is to tell the operator the\n * truth about what is about to happen.\n *\n * It takes no attestation and has nowhere to put one. The preview answers\n * what is TRUE about each person, which is the input to the operator's\n * decision rather than a function of it — a preview whose numbers moved as\n * the box was ticked would not be a count anybody could stand behind.\n */\nexport const emailListMembersPreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const { verdicts, optedIn, needAttestation, refused } =\n await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n /*\n * Named fields rather than a spread of the resolution.\n *\n * The resolution carries the consent RECORDS it was computed from, for the\n * write path's use — raw stored basis, provenance, the account behind an\n * operator assertion. None of that is the browser's, and a spread would\n * put all of it on the wire the moment a field was added to the internal\n * shape. What the surface needs is the verdicts and the counts.\n */\n return res.status(200).json({\n listName: context.listName,\n verdicts,\n optedIn,\n needAttestation,\n refused,\n })\n } catch (error) {\n console.error('[email] list member preview failed', error)\n return res.status(500).json({ error: 'The addresses could not be checked.' })\n }\n}\n\n/**\n * `POST email/list-members-add` — put these addresses on the list.\n *\n * Body: `{ hostId, listId, email | emails[], name?, attestConsent? }`.\n * `attestConsent` is the operator STATING that they have these people's\n * permission; it is not a way to name a basis, because the pass-through basis\n * is derived server-side from each person's own record.\n *\n * ## One attestation, for the count the operator was shown\n *\n * The batch carries a single assertion because it is a single act: an\n * operator pasting a column is making one claim about where that column came\n * from. What makes that safe is that the claim is applied per address by the\n * same function the preview ran, so it reaches only the addresses that\n * actually need it — an address with a stored opt-in keeps its own basis and\n * its own date, and an address nothing can enroll is refused with the\n * attestation on the table.\n *\n * ## Partial success is the honest answer\n *\n * A batch where one address is suppressed and forty are fine is not a failed\n * request. Every address comes back with what happened to it, so 200 here\n * means \"the request was processed\", never \"everybody was added\" — the caller\n * reads the per-address verdicts, which is why they are returned rather than\n * a count.\n */\nexport const emailListMembersAddHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n const attested = req.body?.attestConsent === true\n const name = String(req.body?.name ?? '').trim()\n\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const nowMs = Date.now()\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n\n const results = []\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: verdict.refusal,\n error: verdict.summary,\n })\n continue\n }\n /*\n * The ONE place a basis is decided, and the only place the attestation\n * is consulted.\n *\n * `resolveAddresses` has already answered the questions that are facts\n * about the person; this answers the question that is about the\n * operator, per address, from the consent record that resolution read.\n * `no-basis` — an address with nothing on record and nobody asserting\n * anything — is refused HERE and only here.\n */\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: decision.refusal,\n error: ASSIGNMENT_REFUSAL_MESSAGES[decision.refusal],\n })\n continue\n }\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(name && requested.emails.length === 1 ? { name } : {}),\n source: CONSOLE_ADD_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when a person stops matching, and a decision somebody\n // made by hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in'\n ? { grantEntries: resolution.grants.get(verdict.email) ?? {} }\n : {}),\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds\n * the row, so it is the backstop for every enrollment route; reaching\n * it here means the two records disagree, and the refusal wins.\n */\n const reason: AssignmentRefusal =\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address'\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n continue\n }\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: true,\n memberId: enrollment.memberId,\n created: enrollment.created,\n basis: decision.basis,\n })\n }\n\n return res.status(200).json({\n listName: context.listName,\n added: results.filter((result) => result.enrolled).length,\n results,\n })\n } catch (error) {\n console.error('[email] list member add failed', error)\n return res.status(500).json({ error: 'The addresses could not be added.' })\n }\n}\n\n/**\n * WHO A SET OF FILTERS FINDS, and what would happen if you added them.\n *\n * ## The gap it fills\n *\n * The filters behind a list could only ever be MATERIALIZED — the sweep wrote\n * the matching people straight into the membership. A merchant could not ask\n * \"who is this\" without committing to it, and a fixed list could not use the\n * filters at all: its only way to gain a member was somebody typing or pasting\n * an address. This answers the question without writing anything.\n *\n * ## It is the same consent gate, not a second one\n *\n * The addresses the scan finds are put through {@link resolveAddresses} — the\n * exact function `email/list-members-preview` uses — so a suppressed address\n * is reported as refused here, and somebody with no opt-in on record is\n * reported as needing an attestation here, before any of them is offered for\n * adding. That is deliberate and load-bearing: a bulk path that skipped the\n * check would be a way to fill a list with people the one-at-a-time path\n * refuses, which is the defect class this product already has a register entry\n * for. The ADD still goes through `email/list-members-add`, which re-runs\n * every check server-side, so this preview is an honest readout rather than a\n * permission.\n *\n * ## What it will not do\n *\n * It writes nothing, it enrolls nobody, and it compares no count against any\n * limit. The batch cap below bounds how many addresses it hands back for a\n * single add — the scan itself still reports how many people it MATCHED, so a\n * merchant is told the audience is larger than one batch rather than shown a\n * truncated number as if it were the whole.\n */\nexport const emailListRulePreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const rule = normalizeDynamicListRule(req.body?.rule)\n if (dynamicListRuleIsEmpty(rule)) {\n /*\n * Not an error, and not an empty result either. A rule with no source\n * matches nobody by construction, and reporting that as \"0 people\" is\n * indistinguishable from a rule that ran and found none — which is the\n * confusion the `empty` flag exists to prevent everywhere else.\n */\n return res.status(200).json({\n matched: 0,\n truncated: false,\n complete: true,\n empty: true,\n emails: [],\n verdicts: [],\n optedIn: 0,\n needAttestation: 0,\n refused: 0,\n })\n }\n const scan = await collectDynamicListCandidates({\n hostId: context.hostId,\n rule,\n })\n /*\n * The addresses, in the scan's own order, capped at what one add can\n * carry. `matched` is reported separately and is NOT this length: a\n * merchant looking at a 400-person audience must be told it is 400 even\n * when the button in front of them adds 100.\n */\n const emails = scan.candidates\n .map((candidate) => candidate.email)\n .filter(Boolean)\n const batch = emails.slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: batch,\n })\n return res.status(200).json({\n listName: context.listName,\n matched: emails.length,\n /*\n * Two different reasons a readout can be short of the truth, reported\n * apart: `truncated` is this batch being smaller than the match, and\n * `complete: false` is the SCAN having run out of budget, so `matched`\n * is itself a floor.\n */\n truncated: emails.length > batch.length,\n complete: scan.complete,\n empty: false,\n emails: batch,\n verdicts: resolution.verdicts,\n optedIn: resolution.optedIn,\n needAttestation: resolution.needAttestation,\n refused: resolution.refused,\n })\n } catch (error) {\n console.error('[email] list rule preview failed', error)\n return res\n .status(500)\n .json({ error: 'The audience could not be worked out.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`, so each is counted against the console\n * dispatcher's per-subject budget — the ceiling that bounds a surface which\n * puts a person into a marketing audience, and which scans a whole contact\n * silo to work out who that would be.\n *\n * The limiter these are counted by is the CONSOLE one,\n * `consoleApiRateLimitRefusal`. The visitor limiter is a different limiter on\n * a different dispatcher: `visitorWriteRateLimitRefusal` is installed only in\n * `apps/tenant`, and no request to these routes has ever passed through it.\n * The two share a counter and an exemption list and nothing else — the console\n * key is the authenticated subject, because on an authenticated surface a\n * per-(site, IP) bucket both isolates nothing the identity has not already\n * isolated and hands one operator a fresh budget per site.\n */\nexport function registerEmailConsoleApi(): void {\n // The email design writer another plugin reaches through the core's\n // resource-drafts seam (AGL-2912). Registered here alone: the AI jobs that\n // write through it run only on the console, inline and on the beat\n // (AGL-3026).\n registerEmailDesignDraftWriter()\n registerPluginApiRoute('email/list-rule-preview', emailListRulePreviewHandler)\n registerPluginApiRoute(\n 'email/list-members-preview',\n emailListMembersPreviewHandler,\n )\n registerPluginApiRoute('email/list-members-add', emailListMembersAddHandler)\n // The importer is four routes over one staged job, so it has its own\n // module — but not its own gate: it reaches this file's `resolveListContext`\n // and `resolveAddresses` rather than a bulk-shaped copy of them, which is\n // the whole reason an import cannot enroll somebody the add path refuses.\n registerEmailListImportApi()\n // Suppressions live in their own module: they are a per-SITE list gated on\n // the site role, where list membership is an ORG audience gated on org-wide\n // access, and one file holding both gates is one file for the wrong one to\n // be copied out of.\n registerEmailSuppressionsApi()\n}\n"],"names":["registerEmailDesignDraftWriter","registerEmailSuppressionsApi","registerEmailListImportApi","LIST_MEMBER_BATCH_MAX","readAddresses","resolveAddresses","resolveListContext","ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","dynamicListRuleIsEmpty","normalizeDynamicListRule","readMarketingBasis","registerPluginApiRoute","collectDynamicListCandidates","enrollListMember","CONSOLE_ADD_SOURCE","emailListMembersPreviewHandler","req","res","method","status","json","error","requested","context","ok","body","verdicts","optedIn","needAttestation","refused","hostId","inputs","emails","listName","console","emailListMembersAddHandler","attested","attestConsent","name","String","trim","nowMs","Date","now","resolution","results","verdict","refusal","email","push","input","enrolled","reason","summary","decision","stored","get","group","actingUid","uid","enrollment","listRef","length","source","via","consent","basis","grantEntries","grants","memberId","created","added","filter","result","emailListRulePreviewHandler","rule","matched","truncated","complete","empty","scan","candidates","map","candidate","Boolean","batch","slice","registerEmailConsoleApi"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-console.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Putting somebody on an email list from the Emails console.\n *\n * The audience card could create a list and delete a list and nothing else:\n * no way to see who was on one, no way to add anybody, no way to take anybody\n * off. Every member document in production arrived from a capture surface —\n * the newsletter handler, the workflow `enrollList` step, the dynamic-list\n * materializer, the Inbox assignment — so the one act a merchant most expects\n * of a list, \"add this person\", was the one act the product refused.\n *\n * ## Why this is a route and not a client write\n *\n * Rules put `orgs/{orgId}/lists/{listId}/members` behind org-wide membership,\n * and until now that block allowed a client CREATE. Nothing used it, and it\n * was the whole feature waiting to be built wrong: a browser that can write a\n * member document can write `marketingConsent: true` beside an address it has\n * never checked, which is a consent record minted by pressing a button. The\n * rules now deny client create and update on that collection, and this route\n * is the writer — through `enrollListMember`, which owns the document id, and\n * through the shared `enrollment-basis`, which owns the basis.\n *\n * ## The same policy the Inbox uses, not a second one\n *\n * `assignmentBasis` and `assignmentReadout` are imported from the framework,\n * where they moved when this became their second caller. There is no third\n * consent basis and no console-only override: a stored `declined` refuses\n * here exactly as it refuses there, an attestation is recorded with the\n * account that made it, and a stored opt-in is carried across with the date\n * the PERSON set rather than the date somebody pressed Add.\n *\n * ## Reads only, until the operator has seen the count\n *\n * `email/list-members-preview` writes nothing. It answers, per address, what\n * would happen and why, so the attestation the operator gives on the second\n * call is given with the numbers in front of them. Both routes run the SAME\n * resolution over the SAME inputs — `resolveAddresses` — so the summary they\n * were shown is the summary that acts.\n *\n * ## Finding people is a read, and it uses the same gate\n *\n * `email/list-rule-preview` answers \"who do these filters select\" without\n * writing anything, so a fixed list can be filled from a search rather than\n * from somebody typing addresses one at a time. It runs the addresses it finds\n * through `resolveAddresses` as well — a bulk path that reached the membership\n * without the suppression check and the attestation would be a way to enroll\n * exactly the people the single-address path refuses.\n *\n * ## The gate itself is `server-list-gate.ts`\n *\n * Who may change a list's membership, and what is true about each address, are\n * asked by three route modules now: these two, and the file importer in\n * `server-list-import.ts`. They live in a module of their own so the importer\n * shares this file's answer rather than importing this file — which would be a\n * cycle, and a cycle whose module-level constants would evaluate in whichever\n * order the loader happened to reach them.\n */\n\nimport { registerEmailDesignDraftWriter } from './server-email-drafts'\nimport { registerEmailSuppressionsApi } from './server-suppressions'\nimport { registerEmailListImportApi } from './server-list-import'\nimport {\n LIST_MEMBER_BATCH_MAX,\n readAddresses,\n resolveAddresses,\n resolveListContext,\n} from './server-list-gate'\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n dynamicListRuleIsEmpty,\n normalizeDynamicListRule,\n readMarketingBasis,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n collectDynamicListCandidates,\n enrollListMember,\n firebaseAdmin,\n} from '@aglyn/tenant-data-admin'\nimport { registerSubscriptionTopicIndex } from './server-topic-index'\n\n/** `source` stamped on every membership these routes write. */\nexport const CONSOLE_ADD_SOURCE = 'console:list-add'\n\n/*\n * Re-exported, not redefined. `@aglyn/plugins-email/server` has published\n * these names since the add path shipped, and the console panel and its specs\n * import them from there; moving the gate must not move the plugin's public\n * surface with it.\n */\nexport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type AddressResolution,\n type AddressVerdict,\n type ListContext,\n type ResolvedBatch,\n} from './server-list-gate'\n\n\n/**\n * `POST email/list-members-preview` — what adding these addresses would do.\n *\n * Reads only, and reached by an explicit act — typing an address or pasting a\n * column — never on mount. It exists because the answer needs three things\n * the browser cannot have: the org's contacts (rules put them behind org-wide\n * membership, which the acting console session may not hold) and both\n * suppression lists. Computing any of it client-side would be a second copy\n * of the rule, on the surface whose whole job is to tell the operator the\n * truth about what is about to happen.\n *\n * It takes no attestation and has nowhere to put one. The preview answers\n * what is TRUE about each person, which is the input to the operator's\n * decision rather than a function of it — a preview whose numbers moved as\n * the box was ticked would not be a count anybody could stand behind.\n */\nexport const emailListMembersPreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const { verdicts, optedIn, needAttestation, refused } =\n await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n /*\n * Named fields rather than a spread of the resolution.\n *\n * The resolution carries the consent RECORDS it was computed from, for the\n * write path's use — raw stored basis, provenance, the account behind an\n * operator assertion. None of that is the browser's, and a spread would\n * put all of it on the wire the moment a field was added to the internal\n * shape. What the surface needs is the verdicts and the counts.\n */\n return res.status(200).json({\n listName: context.listName,\n verdicts,\n optedIn,\n needAttestation,\n refused,\n })\n } catch (error) {\n console.error('[email] list member preview failed', error)\n return res.status(500).json({ error: 'The addresses could not be checked.' })\n }\n}\n\n/**\n * `POST email/list-members-add` — put these addresses on the list.\n *\n * Body: `{ hostId, listId, email | emails[], name?, attestConsent? }`.\n * `attestConsent` is the operator STATING that they have these people's\n * permission; it is not a way to name a basis, because the pass-through basis\n * is derived server-side from each person's own record.\n *\n * ## One attestation, for the count the operator was shown\n *\n * The batch carries a single assertion because it is a single act: an\n * operator pasting a column is making one claim about where that column came\n * from. What makes that safe is that the claim is applied per address by the\n * same function the preview ran, so it reaches only the addresses that\n * actually need it — an address with a stored opt-in keeps its own basis and\n * its own date, and an address nothing can enroll is refused with the\n * attestation on the table.\n *\n * ## Partial success is the honest answer\n *\n * A batch where one address is suppressed and forty are fine is not a failed\n * request. Every address comes back with what happened to it, so 200 here\n * means \"the request was processed\", never \"everybody was added\" — the caller\n * reads the per-address verdicts, which is why they are returned rather than\n * a count.\n */\nexport const emailListMembersAddHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n const attested = req.body?.attestConsent === true\n const name = String(req.body?.name ?? '').trim()\n\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const nowMs = Date.now()\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n\n const results = []\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: verdict.refusal,\n error: verdict.summary,\n })\n continue\n }\n /*\n * The ONE place a basis is decided, and the only place the attestation\n * is consulted.\n *\n * `resolveAddresses` has already answered the questions that are facts\n * about the person; this answers the question that is about the\n * operator, per address, from the consent record that resolution read.\n * `no-basis` — an address with nothing on record and nobody asserting\n * anything — is refused HERE and only here.\n */\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: decision.refusal,\n error: ASSIGNMENT_REFUSAL_MESSAGES[decision.refusal],\n })\n continue\n }\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(name && requested.emails.length === 1 ? { name } : {}),\n source: CONSOLE_ADD_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when a person stops matching, and a decision somebody\n // made by hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in'\n ? { grantEntries: resolution.grants.get(verdict.email) ?? {} }\n : {}),\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds\n * the row, so it is the backstop for every enrollment route; reaching\n * it here means the two records disagree, and the refusal wins.\n */\n const reason: AssignmentRefusal =\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address'\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n continue\n }\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: true,\n memberId: enrollment.memberId,\n created: enrollment.created,\n basis: decision.basis,\n })\n }\n\n return res.status(200).json({\n listName: context.listName,\n added: results.filter((result) => result.enrolled).length,\n results,\n })\n } catch (error) {\n console.error('[email] list member add failed', error)\n return res.status(500).json({ error: 'The addresses could not be added.' })\n }\n}\n\n/**\n * WHO A SET OF FILTERS FINDS, and what would happen if you added them.\n *\n * ## The gap it fills\n *\n * The filters behind a list could only ever be MATERIALIZED — the sweep wrote\n * the matching people straight into the membership. A merchant could not ask\n * \"who is this\" without committing to it, and a fixed list could not use the\n * filters at all: its only way to gain a member was somebody typing or pasting\n * an address. This answers the question without writing anything.\n *\n * ## It is the same consent gate, not a second one\n *\n * The addresses the scan finds are put through {@link resolveAddresses} — the\n * exact function `email/list-members-preview` uses — so a suppressed address\n * is reported as refused here, and somebody with no opt-in on record is\n * reported as needing an attestation here, before any of them is offered for\n * adding. That is deliberate and load-bearing: a bulk path that skipped the\n * check would be a way to fill a list with people the one-at-a-time path\n * refuses, which is the defect class this product already has a register entry\n * for. The ADD still goes through `email/list-members-add`, which re-runs\n * every check server-side, so this preview is an honest readout rather than a\n * permission.\n *\n * ## What it will not do\n *\n * It writes nothing, it enrolls nobody, and it compares no count against any\n * limit. The batch cap below bounds how many addresses it hands back for a\n * single add — the scan itself still reports how many people it MATCHED, so a\n * merchant is told the audience is larger than one batch rather than shown a\n * truncated number as if it were the whole.\n */\nexport const emailListRulePreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const rule = normalizeDynamicListRule(req.body?.rule)\n if (dynamicListRuleIsEmpty(rule)) {\n /*\n * Not an error, and not an empty result either. A rule with no source\n * matches nobody by construction, and reporting that as \"0 people\" is\n * indistinguishable from a rule that ran and found none — which is the\n * confusion the `empty` flag exists to prevent everywhere else.\n */\n return res.status(200).json({\n matched: 0,\n truncated: false,\n complete: true,\n empty: true,\n emails: [],\n verdicts: [],\n optedIn: 0,\n needAttestation: 0,\n refused: 0,\n })\n }\n const scan = await collectDynamicListCandidates({\n hostId: context.hostId,\n rule,\n })\n /*\n * The addresses, in the scan's own order, capped at what one add can\n * carry. `matched` is reported separately and is NOT this length: a\n * merchant looking at a 400-person audience must be told it is 400 even\n * when the button in front of them adds 100.\n */\n const emails = scan.candidates\n .map((candidate) => candidate.email)\n .filter(Boolean)\n const batch = emails.slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: batch,\n })\n return res.status(200).json({\n listName: context.listName,\n matched: emails.length,\n /*\n * Two different reasons a readout can be short of the truth, reported\n * apart: `truncated` is this batch being smaller than the match, and\n * `complete: false` is the SCAN having run out of budget, so `matched`\n * is itself a floor.\n */\n truncated: emails.length > batch.length,\n complete: scan.complete,\n empty: false,\n emails: batch,\n verdicts: resolution.verdicts,\n optedIn: resolution.optedIn,\n needAttestation: resolution.needAttestation,\n refused: resolution.refused,\n })\n } catch (error) {\n console.error('[email] list rule preview failed', error)\n return res\n .status(500)\n .json({ error: 'The audience could not be worked out.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`, so each is counted against the console\n * dispatcher's per-subject budget — the ceiling that bounds a surface which\n * puts a person into a marketing audience, and which scans a whole contact\n * silo to work out who that would be.\n *\n * The limiter these are counted by is the CONSOLE one,\n * `consoleApiRateLimitRefusal`. The visitor limiter is a different limiter on\n * a different dispatcher: `visitorWriteRateLimitRefusal` is installed only in\n * `apps/tenant`, and no request to these routes has ever passed through it.\n * The two share a counter and an exemption list and nothing else — the console\n * key is the authenticated subject, because on an authenticated surface a\n * per-(site, IP) bucket both isolates nothing the identity has not already\n * isolated and hands one operator a fresh budget per site.\n */\nexport function registerEmailConsoleApi(): void {\n // The email design writer another plugin reaches through the core's\n // resource-drafts seam (AGL-2912). Registered here alone: the AI jobs that\n // write through it run only on the console, inline and on the beat\n // (AGL-3026).\n registerEmailDesignDraftWriter()\n // The org's topic catalog, as the tenant surface publishes it: the console\n // runs sends under a stream too.\n registerSubscriptionTopicIndex(() => firebaseAdmin.app().firestore())\n registerPluginApiRoute('email/list-rule-preview', emailListRulePreviewHandler)\n registerPluginApiRoute(\n 'email/list-members-preview',\n emailListMembersPreviewHandler,\n )\n registerPluginApiRoute('email/list-members-add', emailListMembersAddHandler)\n // The importer is four routes over one staged job, so it has its own\n // module — but not its own gate: it reaches this file's `resolveListContext`\n // and `resolveAddresses` rather than a bulk-shaped copy of them, which is\n // the whole reason an import cannot enroll somebody the add path refuses.\n registerEmailListImportApi()\n // Suppressions live in their own module: they are a per-SITE list gated on\n // the site role, where list membership is an ORG audience gated on org-wide\n // access, and one file holding both gates is one file for the wrong one to\n // be copied out of.\n registerEmailSuppressionsApi()\n}\n"],"names":["registerEmailDesignDraftWriter","registerEmailSuppressionsApi","registerEmailListImportApi","LIST_MEMBER_BATCH_MAX","readAddresses","resolveAddresses","resolveListContext","ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","dynamicListRuleIsEmpty","normalizeDynamicListRule","readMarketingBasis","registerPluginApiRoute","collectDynamicListCandidates","enrollListMember","firebaseAdmin","registerSubscriptionTopicIndex","CONSOLE_ADD_SOURCE","emailListMembersPreviewHandler","req","res","method","status","json","error","requested","context","ok","body","verdicts","optedIn","needAttestation","refused","hostId","inputs","emails","listName","console","emailListMembersAddHandler","attested","attestConsent","name","String","trim","nowMs","Date","now","resolution","results","verdict","refusal","email","push","input","enrolled","reason","summary","decision","stored","get","group","actingUid","uid","enrollment","listRef","length","source","via","consent","basis","grantEntries","grants","memberId","created","added","filter","result","emailListRulePreviewHandler","rule","matched","truncated","complete","empty","scan","candidates","map","candidate","Boolean","batch","slice","registerEmailConsoleApi","app","firestore"],"mappings":"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"}
@@ -32,7 +32,7 @@ import { emailDesignDocuments } from "./model/email-design-document.js";
32
32
  /** A design's name when the caller asks for none, as the composer's create names one. */ export const EMAIL_DESIGN_DEFAULT_NAME = 'Untitled email';
33
33
  /** The longest subject or preheader stored, the send route's header cap. */ export const EMAIL_DESIGN_HEADER_MAX_CHARS = 200;
34
34
  /** The most alternative subject lines or preheaders a design keeps. */ export const EMAIL_DESIGN_MAX_VARIANTS = 10;
35
- /** The resources route's refusal at the non-page ceiling. */ export const EMAIL_DESIGN_LIMIT_REFUSAL = 'This site is at its limit of ' + `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` + 'delete some to make room';
35
+ /** The resources route's refusal at the non-page ceiling. */ export const EMAIL_DESIGN_LIMIT_REFUSAL = 'This site is at its limit of ' + `${NON_PAGE_SCREEN_MAX_PER_HOST} email designs and template pages — ` + 'delete some to make room';
36
36
  /** The resources route's refusal for a member who may not write the site. */ export const EMAIL_DESIGN_ROLE_REFUSAL = 'Editing requires the editor role';
37
37
  /*
38
38
  * What the check renders against. No mail is sent and nothing is fetched: the
@@ -343,7 +343,7 @@ export function createEmailDesignDraftWriter(deps = {}) {
343
343
  }, recordOf(existing)) : {
344
344
  ok: false,
345
345
  status: 409,
346
- error: 'That id already names a screen that is not an email design'
346
+ error: 'That id is already used by something other than an email design'
347
347
  };
348
348
  }
349
349
  const refusal = (_roleRefusal = roleRefusal(host, request.uid)) != null ? _roleRefusal : roomRefusal(rows, host);