@aglyn/plugins-email 1.0.0-beta.219 → 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 (37) 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/dynamic-list-rule-fields.d.ts +20 -4
  7. package/src/lib/components/dynamic-list-rule-fields.js +462 -411
  8. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -1
  9. package/src/lib/components/email-topic-detail.d.ts +1 -1
  10. package/src/lib/components/email-topic-detail.js +5 -5
  11. package/src/lib/components/email-topic-detail.js.map +1 -1
  12. package/src/lib/components/email-topics-card.d.ts +1 -1
  13. package/src/lib/components/email-topics-card.js +6 -5
  14. package/src/lib/components/email-topics-card.js.map +1 -1
  15. package/src/lib/components/use-org-email-topics.d.ts +4 -4
  16. package/src/lib/components/use-org-email-topics.js +6 -5
  17. package/src/lib/components/use-org-email-topics.js.map +1 -1
  18. package/src/lib/list-import.js +2 -2
  19. package/src/lib/list-import.js.map +1 -1
  20. package/src/lib/model/email-topic-catalog.d.ts +36 -0
  21. package/src/lib/model/email-topic-catalog.js +36 -0
  22. package/src/lib/model/email-topic-catalog.js.map +1 -0
  23. package/src/lib/model/index.d.ts +0 -7
  24. package/src/lib/model/index.js +0 -6
  25. package/src/lib/model/index.js.map +1 -1
  26. package/src/lib/server-console.js +6 -2
  27. package/src/lib/server-console.js.map +1 -1
  28. package/src/lib/server-list-import.js +1 -1
  29. package/src/lib/server-list-import.js.map +1 -1
  30. package/src/lib/server-topic-index.d.ts +52 -0
  31. package/src/lib/server-topic-index.js +92 -0
  32. package/src/lib/server-topic-index.js.map +1 -0
  33. package/src/lib/server.js +11 -11
  34. package/src/lib/server.js.map +1 -1
  35. package/src/lib/unsubscribe-link.d.ts +1 -1
  36. package/src/lib/unsubscribe-link.js +1 -1
  37. package/src/lib/unsubscribe-link.js.map +1 -1
@@ -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"}
@@ -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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"}
@@ -298,7 +298,7 @@ import { importedBasisReason, LIST_IMPORT_MAX_ADDRESSES, parseListImport, screen
298
298
  screening: screeningReport(parsed),
299
299
  /*
300
300
  * WHO attested, stored beside WHETHER. A flag on its own is an
301
- * unattributed claim, which is the one thing `list-assignment-policy`
301
+ * unattributed claim, which is the one thing `enrollment-basis`
302
302
  * refuses to let an attestation be — and every run reads the account
303
303
  * from here rather than from the session that triggered it.
304
304
  */ attested,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-import.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 * BRINGING AN EXISTING LIST IN — the four routes an import is made of.\n *\n * `docs/specs/email-competitive-gaps.md` G5: export works, import does not\n * exist, and every customer arriving from another product has a list and no\n * way to bring it. P4 is the condition attached to closing that: a bulk\n * import is the fastest way to destroy a shared sending domain, so it ships\n * WITH its controls.\n *\n * ## Every imported address goes through the checks a typed one does\n *\n * Not a similar set — the same functions. {@link resolveAddresses} is the\n * resolution `email/list-members-preview` runs, `assignmentBasis` is the\n * policy `email/list-members-add` applies, and `enrollListMember` is the one\n * writer of the membership collection. A second bulk path with its own idea of\n * suppression and its own idea of consent is exactly the defect class this\n * register has a P1 entry for, and that entry is closed.\n *\n * What this module adds is everything ABOVE that gate: reading a file,\n * screening it, holding the operator's attestation, and metering the work out\n * over as many requests as it takes.\n *\n * ## Four routes, because an import is four separate acts\n *\n * - `email/list-import-preview` — reads the file and says what is in it.\n * Writes nothing, enrolls nobody, and resolves a BOUNDED SAMPLE through the\n * consent gate so the numbers the operator attests against are real numbers\n * from real records rather than a promise.\n * - `email/list-import-start` — records the attestation and stages the\n * addresses. Still enrolls nobody: the act of saying \"I have permission for\n * these people\" is separated from the act of adding them so that the\n * attestation has a moment of its own.\n * - `email/list-import-run` — enrolls up to {@link LIST_IMPORT_RUN_BUDGET}\n * addresses from the cursor and moves it. Called until it answers\n * `complete`.\n * - `email/list-import-status` — the unfinished import on a list, if there is\n * one, so a merchant who closed the tab is not left with a half-added\n * audience and no way to see it.\n *\n * ## A budget and a cursor, not one request and not one transaction\n *\n * The shape `dynamic-list-materialize.ts` already uses: a per-run bound on\n * work, a cursor recording where the run stopped, and a next run that resumes\n * rather than restarts. A 50,000-address file is not a request that times out\n * halfway with no record of what it did; it is 500 bounded requests over one\n * durable job, and stopping in the middle of it leaves the addresses already\n * enrolled enrolled and the rest staged.\n *\n * The run budget is deliberately {@link LIST_MEMBER_BATCH_MAX} — the same\n * number of addresses one hand-typed add already resolves in one request — so\n * an import run costs exactly what an add costs and no new cost profile is\n * introduced to discover in production.\n *\n * ## ⛔ Nothing here is a capacity limit\n *\n * {@link LIST_IMPORT_MAX_ADDRESSES} refuses a FILE before anything is written.\n * It never trims a staged import, never drops an address to fit, and never\n * removes anybody already on the list. A ceiling in this product is enforced\n * at the reduction, and the reduction here is refusing the upload — which the\n * operator sees, can argue with, and can act on by splitting the file.\n *\n * ## Who the attester is, and why a resumer does not become one\n *\n * The attestation is one person's claim about where a file came from. It is\n * recorded on the job with the account that made it, and every run reads the\n * basis from THAT account, not from whoever pressed Resume. A colleague who\n * finishes somebody else's import has asserted nothing, and the consent\n * records the run writes must not say they did.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n createResourceUid,\n CSV_UPLOAD_MAX_CHARACTERS,\n readMarketingBasis,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { enrollListMember } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n importedBasisReason,\n LIST_IMPORT_MAX_ADDRESSES,\n parseListImport,\n screenListImport,\n type ListImportRow,\n} from './list-import'\n/*\n * The gate module directly, never `server-console.ts`'s re-export of it.\n *\n * That file imports THIS one to register these routes, so reaching its\n * re-export would close a cycle — and the constant below is evaluated at\n * module load, which is precisely where a cycle stops being harmless: the\n * binding is still in its temporal dead zone when the loader arrives.\n */\nimport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type ListContext,\n} from './server-list-gate'\n\n/** `source` stamped on every membership an import writes. */\nexport const CONSOLE_IMPORT_SOURCE = 'console:list-import'\n\n/** Where a list's import jobs live: `orgs/{orgId}/lists/{listId}/imports`. */\nexport const LIST_IMPORTS_SUBCOLLECTION = 'imports'\n\n/**\n * Addresses one run enrolls before answering and handing back the cursor.\n *\n * The same number as {@link LIST_MEMBER_BATCH_MAX} on purpose — see the module\n * note. It is a bound on WORK: it can never refuse a person, and the addresses\n * it does not reach in this run are reached by the next one.\n */\nexport const LIST_IMPORT_RUN_BUDGET = LIST_MEMBER_BATCH_MAX\n\n/**\n * Staged addresses per chunk document.\n *\n * Comfortably inside Firestore's one-megabyte document limit at the widths a\n * contact file actually carries, and a multiple of the run budget so a run\n * reads exactly one chunk. Reading two would be the common case at any size\n * that is not a multiple, which is a round trip paid on every run to save\n * nothing.\n */\nexport const LIST_IMPORT_CHUNK_SIZE = 500\n\n/** Unusable lines kept verbatim on the job, so the result names some of them. */\nconst UNUSABLE_SAMPLE_MAX = 25\n\n/** Role accounts kept verbatim on the job, for the same reason. */\nconst ROLE_ACCOUNT_SAMPLE_MAX = 25\n\n/** One staged row, in the short field names a 50,000-row staging area wants. */\ninterface StagedRow {\n /** The normalized address. */\n e: string\n /** A display name from the file, when it carried one. */\n n?: string\n /** The opt-in source the file declared. */\n s?: string\n /** The opt-in date the file declared, as written. */\n d?: string\n}\n\n/** What the merchant is told about the file, before they attest to it. */\ninterface ImportScreeningReport {\n /** How many addresses are at a shared or unattended mailbox. */\n roleAccounts: number\n /** A bounded sample of them, so the warning names names. */\n roleAccountSamples: string[]\n /** Column names that read as purchase or append tells. */\n purchaseTellColumns: string[]\n /** Whether the file declares an opt-in source or date per row. */\n declaresBasis: boolean\n}\n\n/** Reads the request's file text, or the refusal to send back. */\nfunction readImportText(\n req: Parameters<PluginApiHandler>[0],\n): { text: string } | { error: string } {\n const text = String(req.body?.text ?? '')\n if (!text.trim()) return { error: 'The file is empty.' }\n if (text.length > CSV_UPLOAD_MAX_CHARACTERS) {\n return {\n error:\n 'That file is too large to read in one go. Split it and import the ' +\n 'pieces — nothing is added until you do, and nothing already on the ' +\n 'list is affected.',\n }\n }\n return { text }\n}\n\n/**\n * The screening report for a parsed file.\n *\n * Counts plus a bounded sample rather than every offending address. The point\n * of the warning is that the operator SEES the shape of what they are about to\n * attest to; a list of four thousand role accounts is a scroll, not a warning,\n * and it would put four thousand addresses into a document whose reason for\n * existing is bookkeeping.\n */\nfunction screeningReport(parsed: {\n columns: string[]\n rows: ListImportRow[]\n}): ImportScreeningReport {\n const screening = screenListImport(parsed)\n return {\n roleAccounts: screening.roleAccounts.length,\n roleAccountSamples: screening.roleAccounts.slice(0, ROLE_ACCOUNT_SAMPLE_MAX),\n purchaseTellColumns: screening.purchaseTellColumns,\n declaresBasis: screening.declaresBasis,\n }\n}\n\n/**\n * `POST email/list-import-preview` — what is in this file.\n *\n * Reads only. It answers three separate questions and keeps them separate,\n * because collapsing them is how an import gets attested to on a number that\n * is not the number:\n *\n * - what the FILE contains: usable addresses, unusable lines, duplicates\n * collapsed, and the columns it carries;\n * - what the SCREENING found, which decides nothing and is shown anyway;\n * - what the CONSENT GATE says about a bounded sample of the addresses.\n *\n * The sample is the honest shape rather than a shortcut. Resolving fifty\n * thousand addresses against the contacts collection and both suppression\n * lists is the same scan the import itself performs, so a preview that did it\n * would be the import minus the writes — twice the cost, and a request that\n * times out on exactly the files this feature exists for. So the sample size\n * is reported beside the total and the run reports the real figures as they\n * become true, which is the same distinction `email/list-rule-preview` draws\n * between `matched` and the batch it hands back.\n */\nexport const emailListImportPreviewHandler: 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 file = readImportText(req)\n if ('error' in file) return res.status(400).json({ error: file.error })\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 parsed = parseListImport(file.text)\n const sample = parsed.rows\n .map((row) => row.email)\n .filter((email): email is string => !!email)\n .slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: sample,\n })\n return res.status(200).json({\n listName: context.listName,\n columns: parsed.columns,\n usable: parsed.usable,\n unusable: parsed.unusable,\n duplicates: parsed.duplicates,\n overCeiling: parsed.overCeiling,\n ceiling: LIST_IMPORT_MAX_ADDRESSES,\n unusableSamples: parsed.rows\n .filter((row) => !row.email)\n .slice(0, UNUSABLE_SAMPLE_MAX)\n .map((row) => row.input),\n screening: screeningReport(parsed),\n /*\n * The sample's verdicts, in the shape the panel's consent readout\n * already draws, and its SIZE beside them. A count with no denominator\n * next to it is the thing an operator misreads as the whole file.\n */\n sampleSize: sample.length,\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 import preview failed', error)\n return res.status(500).json({ error: 'The file could not be read.' })\n }\n}\n\n/**\n * `POST email/list-import-start` — record the attestation, stage the file.\n *\n * Body: `{ hostId, listId, text, attestConsent }`. Enrolls nobody. It writes\n * the job document that every subsequent run reads, and the chunks holding\n * the addresses, and then stops — so the moment the operator makes their\n * claim is a moment of its own, with a record of who made it and when, rather\n * than a flag riding along on the request that also did the work.\n *\n * `attestConsent` is the operator STATING they have these people's\n * permission. It is not a way to name a basis: the basis is derived per\n * address at run time from that person's own record, exactly as the\n * one-address add path derives it, and this flag can only ever produce the\n * attributable kind.\n */\nexport const emailListImportStartHandler: 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 file = readImportText(req)\n if ('error' in file) return res.status(400).json({ error: file.error })\n const attested = req.body?.attestConsent === true\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 parsed = parseListImport(file.text)\n const staged: StagedRow[] = parsed.rows\n .filter((row) => !!row.email)\n .map((row) => ({\n e: row.email as string,\n ...(row.name ? { n: row.name } : {}),\n ...(row.declaredSource ? { s: row.declaredSource } : {}),\n ...(row.declaredAt ? { d: row.declaredAt } : {}),\n }))\n if (!staged.length) {\n return res.status(400).json({\n error:\n 'No usable email addresses were found in that file. Check that it ' +\n 'has an address column, or paste one address per line.',\n })\n }\n\n const importId = createResourceUid()\n const importRef = context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .doc(importId)\n const chunks = importRef.collection('chunks')\n /*\n * The staging area first, the job document last.\n *\n * A job whose chunks are not all written yet is a job a run would read\n * past the end of, and the run is driven by a client that starts\n * immediately. Writing the job last means the only state anybody can\n * observe is a complete one.\n *\n * One document per chunk and not one batch over all of them: a batch is\n * capped at 500 writes and, more to the point, is a transaction — the\n * whole reason this is a staged job rather than one request is that a\n * fifty-thousand-address import must not be a single atomic thing that\n * either lands or does not.\n */\n for (let at = 0; at < staged.length; at += LIST_IMPORT_CHUNK_SIZE) {\n await chunks.doc(String(at / LIST_IMPORT_CHUNK_SIZE)).set({\n rows: staged.slice(at, at + LIST_IMPORT_CHUNK_SIZE),\n })\n }\n\n await importRef.set({\n listName: context.listName,\n status: 'running',\n total: staged.length,\n cursor: 0,\n enrolled: 0,\n refused: 0,\n refusals: {},\n unusable: parsed.unusable,\n duplicates: parsed.duplicates,\n overCeiling: parsed.overCeiling,\n unusableSamples: parsed.rows\n .filter((row) => !row.email)\n .slice(0, UNUSABLE_SAMPLE_MAX)\n .map((row) => row.input),\n columns: parsed.columns,\n screening: screeningReport(parsed),\n /*\n * WHO attested, stored beside WHETHER. A flag on its own is an\n * unattributed claim, which is the one thing `list-assignment-policy`\n * refuses to let an attestation be — and every run reads the account\n * from here rather than from the session that triggered it.\n */\n attested,\n attestedByUid: attested ? context.uid : null,\n attestedAtMs: attested ? Date.now() : null,\n startedByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n\n return res.status(200).json({\n importId,\n listName: context.listName,\n total: staged.length,\n attested,\n runBudget: LIST_IMPORT_RUN_BUDGET,\n })\n } catch (error) {\n console.error('[email] list import start failed', error)\n return res.status(500).json({ error: 'The import could not be started.' })\n }\n}\n\n/** The staged addresses a run will work on, from the cursor. */\nasync function readStaged(\n importRef: FirebaseFirestore.DocumentReference,\n cursor: number,\n total: number,\n): Promise<StagedRow[]> {\n const take = Math.min(LIST_IMPORT_RUN_BUDGET, Math.max(total - cursor, 0))\n if (take <= 0) return []\n const rows: StagedRow[] = []\n let at = cursor\n /*\n * A loop rather than one read.\n *\n * `LIST_IMPORT_CHUNK_SIZE` is a multiple of `LIST_IMPORT_RUN_BUDGET`, so as\n * those two constants stand a run reads exactly one chunk and this turns\n * once. That relationship is a PERFORMANCE choice — one round trip per run\n * — and the loop is what keeps it from also being a correctness\n * requirement: change either number to something that does not divide, or\n * resume a job whose cursor came from an older budget, and a run's batch\n * straddles a boundary. Reading one chunk and truncating would silently\n * import a short batch and advance the cursor past the rest.\n */\n while (rows.length < take) {\n const index = Math.floor(at / LIST_IMPORT_CHUNK_SIZE)\n const snapshot = await importRef\n .collection('chunks')\n .doc(String(index))\n .get()\n const stored = (snapshot.exists ? snapshot.get('rows') : null) as\n | StagedRow[]\n | null\n if (!Array.isArray(stored) || !stored.length) break\n const offset = at - index * LIST_IMPORT_CHUNK_SIZE\n const slice = stored.slice(offset, offset + (take - rows.length))\n if (!slice.length) break\n rows.push(...slice)\n at += slice.length\n }\n return rows\n}\n\n/**\n * `POST email/list-import-run` — enroll the next batch.\n *\n * Body: `{ hostId, listId, importId }`. Answers `complete` when the cursor\n * has reached the total, so the caller's loop is \"call until complete\" and\n * nothing has to guess how many runs a file needs.\n *\n * ## The cursor moves for every address, enrolled or refused\n *\n * A refusal is a finished address. Advancing only on success would put a\n * suppressed address at the head of the queue forever and turn the import\n * into a loop that never terminates on exactly the files that most need to\n * terminate.\n *\n * ## The counters are incremented, not recomputed\n *\n * `FieldValue.increment` rather than a read-modify-write, so two runs racing\n * on one job — a merchant with the drawer open in two tabs — cannot lose a\n * batch's worth of tally. The cursor is written as an absolute value because\n * it is the position the NEXT run reads from, and two racing runs that both\n * incremented it would skip a batch rather than repeat one; repeating is safe\n * (`enrollListMember` is keyed by the person), skipping is not.\n */\nexport const emailListImportRunHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const importId = String(req.body?.importId ?? '').trim()\n if (!importId) return res.status(400).json({ error: 'Missing importId' })\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 importRef = context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .doc(importId)\n const job = await importRef.get()\n if (!job.exists) {\n return res.status(404).json({ error: 'Unknown import' })\n }\n const total = Number(job.get('total') ?? 0)\n const cursor = Number(job.get('cursor') ?? 0)\n if (cursor >= total) {\n return res.status(200).json(finishedPayload(job, context))\n }\n\n const staged = await readStaged(importRef, cursor, total)\n if (!staged.length) {\n /*\n * The staging area is short of what the job claims. Recorded as\n * complete rather than retried forever: the addresses that were\n * enrolled stay enrolled, and a job that cannot be finished is more\n * useful marked finished with its real numbers than left as a\n * permanently unfinished import a merchant is told to resume.\n */\n await importRef.set(\n { status: 'complete', cursor: total, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return res.status(200).json({\n ...finishedPayload(job, context),\n complete: true,\n cursor: total,\n })\n }\n\n const attested = job.get('attested') === true\n /*\n * The ATTESTER's account, not the caller's. See the module note: a\n * colleague who resumes somebody else's import has asserted nothing, and\n * a consent record naming them would be a claim nobody made.\n */\n const attestingUid = String(job.get('attestedByUid') ?? '')\n const nowMs = Date.now()\n\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: staged.map((row) => row.e),\n })\n const byEmail = new Map(staged.map((row) => [row.e, row]))\n\n let enrolled = 0\n const refusals: Record<string, number> = {}\n const results: Array<{\n email: string | null\n enrolled: boolean\n reason?: AssignmentRefusal\n error?: string\n }> = []\n const refuse = (email: string | null, reason: AssignmentRefusal) => {\n refusals[reason] = (refusals[reason] ?? 0) + 1\n results.push({\n email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n }\n\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n refuse(verdict.email, verdict.refusal ?? 'unroutable-address')\n continue\n }\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: attestingUid,\n nowMs,\n })\n if ('refusal' in decision) {\n refuse(verdict.email, decision.refusal)\n continue\n }\n const row = byEmail.get(verdict.email)\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(row?.n ? { name: row.n } : {}),\n source: CONSOLE_IMPORT_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when somebody stops matching, and a file a merchant\n // uploaded is not a rule match that can lapse.\n via: 'manual',\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 consent: {\n ...decision,\n /*\n * The file's own declaration, carried onto the row it was made\n * about — but only for the basis it is evidence FOR. A\n * pass-through carries the person's own opt-in, and attaching a\n * spreadsheet column's claim to that would be dressing the\n * person's act in the merchant's words.\n */\n ...(decision.basis === 'operator-attested' && row\n ? {\n reason: importedBasisReason({\n declaredSource: row.s ?? '',\n declaredAt: row.d ?? '',\n }),\n }\n : {}),\n },\n })\n if (enrollment.enrolled === false) {\n refuse(\n verdict.email,\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address',\n )\n continue\n }\n enrolled += 1\n results.push({ email: verdict.email, enrolled: true })\n }\n\n const nextCursor = cursor + staged.length\n const complete = nextCursor >= total\n await importRef.set(\n {\n cursor: nextCursor,\n status: complete ? 'complete' : 'running',\n enrolled: FieldValue.increment(enrolled),\n refused: FieldValue.increment(staged.length - enrolled),\n /*\n * A NESTED map, not dotted keys. `set({merge:true})` reads its keys\n * as literal field names — only `update()` expands a dot into a field\n * path — so `refusals.declined` here would create a top-level field\n * with a dot in its name and leave the map it was meant to update\n * empty. A deep merge over a nested map does what is wanted and\n * honors the increments inside it.\n */\n refusals: Object.fromEntries(\n Object.entries(refusals).map(([reason, count]) => [\n reason,\n FieldValue.increment(count),\n ]),\n ),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n return res.status(200).json({\n importId,\n listName: context.listName,\n complete,\n total,\n cursor: nextCursor,\n /*\n * This RUN's numbers, named as this run's. The job's running totals are\n * read back by `email/list-import-status`; reporting an increment as a\n * total is how a progress readout comes to disagree with the record.\n */\n ranEnrolled: enrolled,\n ranRefused: staged.length - enrolled,\n refusals,\n results,\n })\n } catch (error) {\n console.error('[email] list import run failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/** The payload for a job that has nothing left to do. */\nfunction finishedPayload(\n job: FirebaseFirestore.DocumentSnapshot,\n context: Extract<ListContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n importId: job.id,\n listName: context.listName,\n complete: true,\n total: Number(job.get('total') ?? 0),\n cursor: Number(job.get('cursor') ?? 0),\n ranEnrolled: 0,\n ranRefused: 0,\n refusals: {},\n results: [],\n }\n}\n\n/**\n * `POST email/list-import-status` — the import on this list, if there is one.\n *\n * Reached when the import drawer opens, and at no other time. It exists\n * because a browser is not a durable thing: a merchant who closed the tab\n * during a large import has an audience that is part-way filled and, without\n * this, no way to see that or to finish it. What they must never be offered\n * instead is a fresh import of the same file, which would re-run the whole\n * gate over addresses already enrolled.\n *\n * Ordered on `createdAt`, which every job document written by\n * `email/list-import-start` carries — a `limit()` with no `orderBy` answers in\n * document-id order, and the ids come from `createResourceUid()`, so the\n * \"latest\" import would be an arbitrary one.\n */\nexport const emailListImportStatusHandler: 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 snapshot = await context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .orderBy('createdAt', 'desc')\n .limit(1)\n .get()\n const job = snapshot.docs[0]\n if (!job) return res.status(200).json({ listName: context.listName, job: null })\n return res.status(200).json({\n listName: context.listName,\n job: {\n importId: job.id,\n status: String(job.get('status') ?? 'running'),\n total: Number(job.get('total') ?? 0),\n cursor: Number(job.get('cursor') ?? 0),\n enrolled: Number(job.get('enrolled') ?? 0),\n refused: Number(job.get('refused') ?? 0),\n refusals: (job.get('refusals') ?? {}) as Record<string, number>,\n attested: job.get('attested') === true,\n unusable: Number(job.get('unusable') ?? 0),\n duplicates: Number(job.get('duplicates') ?? 0),\n unusableSamples: (job.get('unusableSamples') ?? []) as string[],\n screening: (job.get('screening') ?? null) as ImportScreeningReport | null,\n },\n })\n } catch (error) {\n console.error('[email] list import status failed', error)\n return res\n .status(500)\n .json({ error: 'The import could not be looked up.' })\n }\n}\n\n/**\n * Import route registration.\n *\n * Reached by a person pressing a button in a browser, like the rest of the\n * console half, so none of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts` — with one consequence worth stating: the RUN\n * route is called repeatedly by design, once per {@link\n * LIST_IMPORT_RUN_BUDGET} addresses, so the visitor limiter's per-(site, IP)\n * budget is the ceiling on how fast a large import can proceed. That is the\n * correct ceiling for a path that enrolls people into a marketing audience,\n * and it degrades into a slower import rather than a failed one.\n */\nexport function registerEmailListImportApi(): void {\n registerPluginApiRoute(\n 'email/list-import-preview',\n emailListImportPreviewHandler,\n )\n registerPluginApiRoute('email/list-import-start', emailListImportStartHandler)\n registerPluginApiRoute('email/list-import-run', emailListImportRunHandler)\n registerPluginApiRoute(\n 'email/list-import-status',\n emailListImportStatusHandler,\n )\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","createResourceUid","CSV_UPLOAD_MAX_CHARACTERS","readMarketingBasis","registerPluginApiRoute","enrollListMember","FieldValue","importedBasisReason","LIST_IMPORT_MAX_ADDRESSES","parseListImport","screenListImport","LIST_MEMBER_BATCH_MAX","resolveAddresses","resolveListContext","CONSOLE_IMPORT_SOURCE","LIST_IMPORTS_SUBCOLLECTION","LIST_IMPORT_RUN_BUDGET","LIST_IMPORT_CHUNK_SIZE","UNUSABLE_SAMPLE_MAX","ROLE_ACCOUNT_SAMPLE_MAX","readImportText","req","text","String","body","trim","error","length","screeningReport","parsed","screening","roleAccounts","roleAccountSamples","slice","purchaseTellColumns","declaresBasis","emailListImportPreviewHandler","res","method","status","json","file","context","ok","sample","rows","map","row","email","filter","resolution","hostId","inputs","listName","columns","usable","unusable","duplicates","overCeiling","ceiling","unusableSamples","input","sampleSize","verdicts","optedIn","needAttestation","refused","console","emailListImportStartHandler","attested","attestConsent","staged","e","name","n","declaredSource","s","declaredAt","d","importId","importRef","listRef","collection","doc","chunks","at","set","total","cursor","enrolled","refusals","attestedByUid","uid","attestedAtMs","Date","now","startedByUid","createdAt","serverTimestamp","updatedAt","runBudget","readStaged","take","Math","min","max","index","floor","snapshot","get","stored","exists","Array","isArray","offset","push","emailListImportRunHandler","job","Number","finishedPayload","merge","complete","attestingUid","nowMs","byEmail","Map","results","refuse","reason","verdict","refusal","decision","group","actingUid","enrollment","source","via","basis","grantEntries","grants","consent","nextCursor","increment","Object","fromEntries","entries","count","ranEnrolled","ranRefused","id","emailListImportStatusHandler","orderBy","limit","docs","registerEmailListImportApi"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-import.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 * BRINGING AN EXISTING LIST IN — the four routes an import is made of.\n *\n * `docs/specs/email-competitive-gaps.md` G5: export works, import does not\n * exist, and every customer arriving from another product has a list and no\n * way to bring it. P4 is the condition attached to closing that: a bulk\n * import is the fastest way to destroy a shared sending domain, so it ships\n * WITH its controls.\n *\n * ## Every imported address goes through the checks a typed one does\n *\n * Not a similar set — the same functions. {@link resolveAddresses} is the\n * resolution `email/list-members-preview` runs, `assignmentBasis` is the\n * policy `email/list-members-add` applies, and `enrollListMember` is the one\n * writer of the membership collection. A second bulk path with its own idea of\n * suppression and its own idea of consent is exactly the defect class this\n * register has a P1 entry for, and that entry is closed.\n *\n * What this module adds is everything ABOVE that gate: reading a file,\n * screening it, holding the operator's attestation, and metering the work out\n * over as many requests as it takes.\n *\n * ## Four routes, because an import is four separate acts\n *\n * - `email/list-import-preview` — reads the file and says what is in it.\n * Writes nothing, enrolls nobody, and resolves a BOUNDED SAMPLE through the\n * consent gate so the numbers the operator attests against are real numbers\n * from real records rather than a promise.\n * - `email/list-import-start` — records the attestation and stages the\n * addresses. Still enrolls nobody: the act of saying \"I have permission for\n * these people\" is separated from the act of adding them so that the\n * attestation has a moment of its own.\n * - `email/list-import-run` — enrolls up to {@link LIST_IMPORT_RUN_BUDGET}\n * addresses from the cursor and moves it. Called until it answers\n * `complete`.\n * - `email/list-import-status` — the unfinished import on a list, if there is\n * one, so a merchant who closed the tab is not left with a half-added\n * audience and no way to see it.\n *\n * ## A budget and a cursor, not one request and not one transaction\n *\n * The shape `dynamic-list-materialize.ts` already uses: a per-run bound on\n * work, a cursor recording where the run stopped, and a next run that resumes\n * rather than restarts. A 50,000-address file is not a request that times out\n * halfway with no record of what it did; it is 500 bounded requests over one\n * durable job, and stopping in the middle of it leaves the addresses already\n * enrolled enrolled and the rest staged.\n *\n * The run budget is deliberately {@link LIST_MEMBER_BATCH_MAX} — the same\n * number of addresses one hand-typed add already resolves in one request — so\n * an import run costs exactly what an add costs and no new cost profile is\n * introduced to discover in production.\n *\n * ## ⛔ Nothing here is a capacity limit\n *\n * {@link LIST_IMPORT_MAX_ADDRESSES} refuses a FILE before anything is written.\n * It never trims a staged import, never drops an address to fit, and never\n * removes anybody already on the list. A ceiling in this product is enforced\n * at the reduction, and the reduction here is refusing the upload — which the\n * operator sees, can argue with, and can act on by splitting the file.\n *\n * ## Who the attester is, and why a resumer does not become one\n *\n * The attestation is one person's claim about where a file came from. It is\n * recorded on the job with the account that made it, and every run reads the\n * basis from THAT account, not from whoever pressed Resume. A colleague who\n * finishes somebody else's import has asserted nothing, and the consent\n * records the run writes must not say they did.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n createResourceUid,\n CSV_UPLOAD_MAX_CHARACTERS,\n readMarketingBasis,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { enrollListMember } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n importedBasisReason,\n LIST_IMPORT_MAX_ADDRESSES,\n parseListImport,\n screenListImport,\n type ListImportRow,\n} from './list-import'\n/*\n * The gate module directly, never `server-console.ts`'s re-export of it.\n *\n * That file imports THIS one to register these routes, so reaching its\n * re-export would close a cycle — and the constant below is evaluated at\n * module load, which is precisely where a cycle stops being harmless: the\n * binding is still in its temporal dead zone when the loader arrives.\n */\nimport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type ListContext,\n} from './server-list-gate'\n\n/** `source` stamped on every membership an import writes. */\nexport const CONSOLE_IMPORT_SOURCE = 'console:list-import'\n\n/** Where a list's import jobs live: `orgs/{orgId}/lists/{listId}/imports`. */\nexport const LIST_IMPORTS_SUBCOLLECTION = 'imports'\n\n/**\n * Addresses one run enrolls before answering and handing back the cursor.\n *\n * The same number as {@link LIST_MEMBER_BATCH_MAX} on purpose — see the module\n * note. It is a bound on WORK: it can never refuse a person, and the addresses\n * it does not reach in this run are reached by the next one.\n */\nexport const LIST_IMPORT_RUN_BUDGET = LIST_MEMBER_BATCH_MAX\n\n/**\n * Staged addresses per chunk document.\n *\n * Comfortably inside Firestore's one-megabyte document limit at the widths a\n * contact file actually carries, and a multiple of the run budget so a run\n * reads exactly one chunk. Reading two would be the common case at any size\n * that is not a multiple, which is a round trip paid on every run to save\n * nothing.\n */\nexport const LIST_IMPORT_CHUNK_SIZE = 500\n\n/** Unusable lines kept verbatim on the job, so the result names some of them. */\nconst UNUSABLE_SAMPLE_MAX = 25\n\n/** Role accounts kept verbatim on the job, for the same reason. */\nconst ROLE_ACCOUNT_SAMPLE_MAX = 25\n\n/** One staged row, in the short field names a 50,000-row staging area wants. */\ninterface StagedRow {\n /** The normalized address. */\n e: string\n /** A display name from the file, when it carried one. */\n n?: string\n /** The opt-in source the file declared. */\n s?: string\n /** The opt-in date the file declared, as written. */\n d?: string\n}\n\n/** What the merchant is told about the file, before they attest to it. */\ninterface ImportScreeningReport {\n /** How many addresses are at a shared or unattended mailbox. */\n roleAccounts: number\n /** A bounded sample of them, so the warning names names. */\n roleAccountSamples: string[]\n /** Column names that read as purchase or append tells. */\n purchaseTellColumns: string[]\n /** Whether the file declares an opt-in source or date per row. */\n declaresBasis: boolean\n}\n\n/** Reads the request's file text, or the refusal to send back. */\nfunction readImportText(\n req: Parameters<PluginApiHandler>[0],\n): { text: string } | { error: string } {\n const text = String(req.body?.text ?? '')\n if (!text.trim()) return { error: 'The file is empty.' }\n if (text.length > CSV_UPLOAD_MAX_CHARACTERS) {\n return {\n error:\n 'That file is too large to read in one go. Split it and import the ' +\n 'pieces — nothing is added until you do, and nothing already on the ' +\n 'list is affected.',\n }\n }\n return { text }\n}\n\n/**\n * The screening report for a parsed file.\n *\n * Counts plus a bounded sample rather than every offending address. The point\n * of the warning is that the operator SEES the shape of what they are about to\n * attest to; a list of four thousand role accounts is a scroll, not a warning,\n * and it would put four thousand addresses into a document whose reason for\n * existing is bookkeeping.\n */\nfunction screeningReport(parsed: {\n columns: string[]\n rows: ListImportRow[]\n}): ImportScreeningReport {\n const screening = screenListImport(parsed)\n return {\n roleAccounts: screening.roleAccounts.length,\n roleAccountSamples: screening.roleAccounts.slice(0, ROLE_ACCOUNT_SAMPLE_MAX),\n purchaseTellColumns: screening.purchaseTellColumns,\n declaresBasis: screening.declaresBasis,\n }\n}\n\n/**\n * `POST email/list-import-preview` — what is in this file.\n *\n * Reads only. It answers three separate questions and keeps them separate,\n * because collapsing them is how an import gets attested to on a number that\n * is not the number:\n *\n * - what the FILE contains: usable addresses, unusable lines, duplicates\n * collapsed, and the columns it carries;\n * - what the SCREENING found, which decides nothing and is shown anyway;\n * - what the CONSENT GATE says about a bounded sample of the addresses.\n *\n * The sample is the honest shape rather than a shortcut. Resolving fifty\n * thousand addresses against the contacts collection and both suppression\n * lists is the same scan the import itself performs, so a preview that did it\n * would be the import minus the writes — twice the cost, and a request that\n * times out on exactly the files this feature exists for. So the sample size\n * is reported beside the total and the run reports the real figures as they\n * become true, which is the same distinction `email/list-rule-preview` draws\n * between `matched` and the batch it hands back.\n */\nexport const emailListImportPreviewHandler: 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 file = readImportText(req)\n if ('error' in file) return res.status(400).json({ error: file.error })\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 parsed = parseListImport(file.text)\n const sample = parsed.rows\n .map((row) => row.email)\n .filter((email): email is string => !!email)\n .slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: sample,\n })\n return res.status(200).json({\n listName: context.listName,\n columns: parsed.columns,\n usable: parsed.usable,\n unusable: parsed.unusable,\n duplicates: parsed.duplicates,\n overCeiling: parsed.overCeiling,\n ceiling: LIST_IMPORT_MAX_ADDRESSES,\n unusableSamples: parsed.rows\n .filter((row) => !row.email)\n .slice(0, UNUSABLE_SAMPLE_MAX)\n .map((row) => row.input),\n screening: screeningReport(parsed),\n /*\n * The sample's verdicts, in the shape the panel's consent readout\n * already draws, and its SIZE beside them. A count with no denominator\n * next to it is the thing an operator misreads as the whole file.\n */\n sampleSize: sample.length,\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 import preview failed', error)\n return res.status(500).json({ error: 'The file could not be read.' })\n }\n}\n\n/**\n * `POST email/list-import-start` — record the attestation, stage the file.\n *\n * Body: `{ hostId, listId, text, attestConsent }`. Enrolls nobody. It writes\n * the job document that every subsequent run reads, and the chunks holding\n * the addresses, and then stops — so the moment the operator makes their\n * claim is a moment of its own, with a record of who made it and when, rather\n * than a flag riding along on the request that also did the work.\n *\n * `attestConsent` is the operator STATING they have these people's\n * permission. It is not a way to name a basis: the basis is derived per\n * address at run time from that person's own record, exactly as the\n * one-address add path derives it, and this flag can only ever produce the\n * attributable kind.\n */\nexport const emailListImportStartHandler: 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 file = readImportText(req)\n if ('error' in file) return res.status(400).json({ error: file.error })\n const attested = req.body?.attestConsent === true\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 parsed = parseListImport(file.text)\n const staged: StagedRow[] = parsed.rows\n .filter((row) => !!row.email)\n .map((row) => ({\n e: row.email as string,\n ...(row.name ? { n: row.name } : {}),\n ...(row.declaredSource ? { s: row.declaredSource } : {}),\n ...(row.declaredAt ? { d: row.declaredAt } : {}),\n }))\n if (!staged.length) {\n return res.status(400).json({\n error:\n 'No usable email addresses were found in that file. Check that it ' +\n 'has an address column, or paste one address per line.',\n })\n }\n\n const importId = createResourceUid()\n const importRef = context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .doc(importId)\n const chunks = importRef.collection('chunks')\n /*\n * The staging area first, the job document last.\n *\n * A job whose chunks are not all written yet is a job a run would read\n * past the end of, and the run is driven by a client that starts\n * immediately. Writing the job last means the only state anybody can\n * observe is a complete one.\n *\n * One document per chunk and not one batch over all of them: a batch is\n * capped at 500 writes and, more to the point, is a transaction — the\n * whole reason this is a staged job rather than one request is that a\n * fifty-thousand-address import must not be a single atomic thing that\n * either lands or does not.\n */\n for (let at = 0; at < staged.length; at += LIST_IMPORT_CHUNK_SIZE) {\n await chunks.doc(String(at / LIST_IMPORT_CHUNK_SIZE)).set({\n rows: staged.slice(at, at + LIST_IMPORT_CHUNK_SIZE),\n })\n }\n\n await importRef.set({\n listName: context.listName,\n status: 'running',\n total: staged.length,\n cursor: 0,\n enrolled: 0,\n refused: 0,\n refusals: {},\n unusable: parsed.unusable,\n duplicates: parsed.duplicates,\n overCeiling: parsed.overCeiling,\n unusableSamples: parsed.rows\n .filter((row) => !row.email)\n .slice(0, UNUSABLE_SAMPLE_MAX)\n .map((row) => row.input),\n columns: parsed.columns,\n screening: screeningReport(parsed),\n /*\n * WHO attested, stored beside WHETHER. A flag on its own is an\n * unattributed claim, which is the one thing `enrollment-basis`\n * refuses to let an attestation be — and every run reads the account\n * from here rather than from the session that triggered it.\n */\n attested,\n attestedByUid: attested ? context.uid : null,\n attestedAtMs: attested ? Date.now() : null,\n startedByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n\n return res.status(200).json({\n importId,\n listName: context.listName,\n total: staged.length,\n attested,\n runBudget: LIST_IMPORT_RUN_BUDGET,\n })\n } catch (error) {\n console.error('[email] list import start failed', error)\n return res.status(500).json({ error: 'The import could not be started.' })\n }\n}\n\n/** The staged addresses a run will work on, from the cursor. */\nasync function readStaged(\n importRef: FirebaseFirestore.DocumentReference,\n cursor: number,\n total: number,\n): Promise<StagedRow[]> {\n const take = Math.min(LIST_IMPORT_RUN_BUDGET, Math.max(total - cursor, 0))\n if (take <= 0) return []\n const rows: StagedRow[] = []\n let at = cursor\n /*\n * A loop rather than one read.\n *\n * `LIST_IMPORT_CHUNK_SIZE` is a multiple of `LIST_IMPORT_RUN_BUDGET`, so as\n * those two constants stand a run reads exactly one chunk and this turns\n * once. That relationship is a PERFORMANCE choice — one round trip per run\n * — and the loop is what keeps it from also being a correctness\n * requirement: change either number to something that does not divide, or\n * resume a job whose cursor came from an older budget, and a run's batch\n * straddles a boundary. Reading one chunk and truncating would silently\n * import a short batch and advance the cursor past the rest.\n */\n while (rows.length < take) {\n const index = Math.floor(at / LIST_IMPORT_CHUNK_SIZE)\n const snapshot = await importRef\n .collection('chunks')\n .doc(String(index))\n .get()\n const stored = (snapshot.exists ? snapshot.get('rows') : null) as\n | StagedRow[]\n | null\n if (!Array.isArray(stored) || !stored.length) break\n const offset = at - index * LIST_IMPORT_CHUNK_SIZE\n const slice = stored.slice(offset, offset + (take - rows.length))\n if (!slice.length) break\n rows.push(...slice)\n at += slice.length\n }\n return rows\n}\n\n/**\n * `POST email/list-import-run` — enroll the next batch.\n *\n * Body: `{ hostId, listId, importId }`. Answers `complete` when the cursor\n * has reached the total, so the caller's loop is \"call until complete\" and\n * nothing has to guess how many runs a file needs.\n *\n * ## The cursor moves for every address, enrolled or refused\n *\n * A refusal is a finished address. Advancing only on success would put a\n * suppressed address at the head of the queue forever and turn the import\n * into a loop that never terminates on exactly the files that most need to\n * terminate.\n *\n * ## The counters are incremented, not recomputed\n *\n * `FieldValue.increment` rather than a read-modify-write, so two runs racing\n * on one job — a merchant with the drawer open in two tabs — cannot lose a\n * batch's worth of tally. The cursor is written as an absolute value because\n * it is the position the NEXT run reads from, and two racing runs that both\n * incremented it would skip a batch rather than repeat one; repeating is safe\n * (`enrollListMember` is keyed by the person), skipping is not.\n */\nexport const emailListImportRunHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const importId = String(req.body?.importId ?? '').trim()\n if (!importId) return res.status(400).json({ error: 'Missing importId' })\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 importRef = context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .doc(importId)\n const job = await importRef.get()\n if (!job.exists) {\n return res.status(404).json({ error: 'Unknown import' })\n }\n const total = Number(job.get('total') ?? 0)\n const cursor = Number(job.get('cursor') ?? 0)\n if (cursor >= total) {\n return res.status(200).json(finishedPayload(job, context))\n }\n\n const staged = await readStaged(importRef, cursor, total)\n if (!staged.length) {\n /*\n * The staging area is short of what the job claims. Recorded as\n * complete rather than retried forever: the addresses that were\n * enrolled stay enrolled, and a job that cannot be finished is more\n * useful marked finished with its real numbers than left as a\n * permanently unfinished import a merchant is told to resume.\n */\n await importRef.set(\n { status: 'complete', cursor: total, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return res.status(200).json({\n ...finishedPayload(job, context),\n complete: true,\n cursor: total,\n })\n }\n\n const attested = job.get('attested') === true\n /*\n * The ATTESTER's account, not the caller's. See the module note: a\n * colleague who resumes somebody else's import has asserted nothing, and\n * a consent record naming them would be a claim nobody made.\n */\n const attestingUid = String(job.get('attestedByUid') ?? '')\n const nowMs = Date.now()\n\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: staged.map((row) => row.e),\n })\n const byEmail = new Map(staged.map((row) => [row.e, row]))\n\n let enrolled = 0\n const refusals: Record<string, number> = {}\n const results: Array<{\n email: string | null\n enrolled: boolean\n reason?: AssignmentRefusal\n error?: string\n }> = []\n const refuse = (email: string | null, reason: AssignmentRefusal) => {\n refusals[reason] = (refusals[reason] ?? 0) + 1\n results.push({\n email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n }\n\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n refuse(verdict.email, verdict.refusal ?? 'unroutable-address')\n continue\n }\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: attestingUid,\n nowMs,\n })\n if ('refusal' in decision) {\n refuse(verdict.email, decision.refusal)\n continue\n }\n const row = byEmail.get(verdict.email)\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(row?.n ? { name: row.n } : {}),\n source: CONSOLE_IMPORT_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when somebody stops matching, and a file a merchant\n // uploaded is not a rule match that can lapse.\n via: 'manual',\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 consent: {\n ...decision,\n /*\n * The file's own declaration, carried onto the row it was made\n * about — but only for the basis it is evidence FOR. A\n * pass-through carries the person's own opt-in, and attaching a\n * spreadsheet column's claim to that would be dressing the\n * person's act in the merchant's words.\n */\n ...(decision.basis === 'operator-attested' && row\n ? {\n reason: importedBasisReason({\n declaredSource: row.s ?? '',\n declaredAt: row.d ?? '',\n }),\n }\n : {}),\n },\n })\n if (enrollment.enrolled === false) {\n refuse(\n verdict.email,\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address',\n )\n continue\n }\n enrolled += 1\n results.push({ email: verdict.email, enrolled: true })\n }\n\n const nextCursor = cursor + staged.length\n const complete = nextCursor >= total\n await importRef.set(\n {\n cursor: nextCursor,\n status: complete ? 'complete' : 'running',\n enrolled: FieldValue.increment(enrolled),\n refused: FieldValue.increment(staged.length - enrolled),\n /*\n * A NESTED map, not dotted keys. `set({merge:true})` reads its keys\n * as literal field names — only `update()` expands a dot into a field\n * path — so `refusals.declined` here would create a top-level field\n * with a dot in its name and leave the map it was meant to update\n * empty. A deep merge over a nested map does what is wanted and\n * honors the increments inside it.\n */\n refusals: Object.fromEntries(\n Object.entries(refusals).map(([reason, count]) => [\n reason,\n FieldValue.increment(count),\n ]),\n ),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n return res.status(200).json({\n importId,\n listName: context.listName,\n complete,\n total,\n cursor: nextCursor,\n /*\n * This RUN's numbers, named as this run's. The job's running totals are\n * read back by `email/list-import-status`; reporting an increment as a\n * total is how a progress readout comes to disagree with the record.\n */\n ranEnrolled: enrolled,\n ranRefused: staged.length - enrolled,\n refusals,\n results,\n })\n } catch (error) {\n console.error('[email] list import run failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/** The payload for a job that has nothing left to do. */\nfunction finishedPayload(\n job: FirebaseFirestore.DocumentSnapshot,\n context: Extract<ListContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n importId: job.id,\n listName: context.listName,\n complete: true,\n total: Number(job.get('total') ?? 0),\n cursor: Number(job.get('cursor') ?? 0),\n ranEnrolled: 0,\n ranRefused: 0,\n refusals: {},\n results: [],\n }\n}\n\n/**\n * `POST email/list-import-status` — the import on this list, if there is one.\n *\n * Reached when the import drawer opens, and at no other time. It exists\n * because a browser is not a durable thing: a merchant who closed the tab\n * during a large import has an audience that is part-way filled and, without\n * this, no way to see that or to finish it. What they must never be offered\n * instead is a fresh import of the same file, which would re-run the whole\n * gate over addresses already enrolled.\n *\n * Ordered on `createdAt`, which every job document written by\n * `email/list-import-start` carries — a `limit()` with no `orderBy` answers in\n * document-id order, and the ids come from `createResourceUid()`, so the\n * \"latest\" import would be an arbitrary one.\n */\nexport const emailListImportStatusHandler: 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 snapshot = await context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .orderBy('createdAt', 'desc')\n .limit(1)\n .get()\n const job = snapshot.docs[0]\n if (!job) return res.status(200).json({ listName: context.listName, job: null })\n return res.status(200).json({\n listName: context.listName,\n job: {\n importId: job.id,\n status: String(job.get('status') ?? 'running'),\n total: Number(job.get('total') ?? 0),\n cursor: Number(job.get('cursor') ?? 0),\n enrolled: Number(job.get('enrolled') ?? 0),\n refused: Number(job.get('refused') ?? 0),\n refusals: (job.get('refusals') ?? {}) as Record<string, number>,\n attested: job.get('attested') === true,\n unusable: Number(job.get('unusable') ?? 0),\n duplicates: Number(job.get('duplicates') ?? 0),\n unusableSamples: (job.get('unusableSamples') ?? []) as string[],\n screening: (job.get('screening') ?? null) as ImportScreeningReport | null,\n },\n })\n } catch (error) {\n console.error('[email] list import status failed', error)\n return res\n .status(500)\n .json({ error: 'The import could not be looked up.' })\n }\n}\n\n/**\n * Import route registration.\n *\n * Reached by a person pressing a button in a browser, like the rest of the\n * console half, so none of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts` — with one consequence worth stating: the RUN\n * route is called repeatedly by design, once per {@link\n * LIST_IMPORT_RUN_BUDGET} addresses, so the visitor limiter's per-(site, IP)\n * budget is the ceiling on how fast a large import can proceed. That is the\n * correct ceiling for a path that enrolls people into a marketing audience,\n * and it degrades into a slower import rather than a failed one.\n */\nexport function registerEmailListImportApi(): void {\n registerPluginApiRoute(\n 'email/list-import-preview',\n emailListImportPreviewHandler,\n )\n registerPluginApiRoute('email/list-import-start', emailListImportStartHandler)\n registerPluginApiRoute('email/list-import-run', emailListImportRunHandler)\n registerPluginApiRoute(\n 'email/list-import-status',\n emailListImportStatusHandler,\n )\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","createResourceUid","CSV_UPLOAD_MAX_CHARACTERS","readMarketingBasis","registerPluginApiRoute","enrollListMember","FieldValue","importedBasisReason","LIST_IMPORT_MAX_ADDRESSES","parseListImport","screenListImport","LIST_MEMBER_BATCH_MAX","resolveAddresses","resolveListContext","CONSOLE_IMPORT_SOURCE","LIST_IMPORTS_SUBCOLLECTION","LIST_IMPORT_RUN_BUDGET","LIST_IMPORT_CHUNK_SIZE","UNUSABLE_SAMPLE_MAX","ROLE_ACCOUNT_SAMPLE_MAX","readImportText","req","text","String","body","trim","error","length","screeningReport","parsed","screening","roleAccounts","roleAccountSamples","slice","purchaseTellColumns","declaresBasis","emailListImportPreviewHandler","res","method","status","json","file","context","ok","sample","rows","map","row","email","filter","resolution","hostId","inputs","listName","columns","usable","unusable","duplicates","overCeiling","ceiling","unusableSamples","input","sampleSize","verdicts","optedIn","needAttestation","refused","console","emailListImportStartHandler","attested","attestConsent","staged","e","name","n","declaredSource","s","declaredAt","d","importId","importRef","listRef","collection","doc","chunks","at","set","total","cursor","enrolled","refusals","attestedByUid","uid","attestedAtMs","Date","now","startedByUid","createdAt","serverTimestamp","updatedAt","runBudget","readStaged","take","Math","min","max","index","floor","snapshot","get","stored","exists","Array","isArray","offset","push","emailListImportRunHandler","job","Number","finishedPayload","merge","complete","attestingUid","nowMs","byEmail","Map","results","refuse","reason","verdict","refusal","decision","group","actingUid","enrollment","source","via","basis","grantEntries","grants","consent","nextCursor","increment","Object","fromEntries","entries","count","ranEnrolled","ranRefused","id","emailListImportStatusHandler","orderBy","limit","docs","registerEmailListImportApi"],"mappings":"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"}
@@ -0,0 +1,52 @@
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
+ import { type PluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index';
19
+ type Firestore = FirebaseFirestore.Firestore;
20
+ /**
21
+ * AN ORG'S TOPIC CATALOG, for a plugin that does not keep it.
22
+ *
23
+ * A plugin that sends under a stream sometimes has to ask what the org made of
24
+ * it — whether the org's newsletter asks for a confirmation click, what it is
25
+ * called now — and the catalog is this plugin's storage. So it is published as
26
+ * the `subscriptionTopic` record index and read through the core's seam, never
27
+ * through the collection.
28
+ *
29
+ * The records are the catalog a reader sees: the streams the plugins declare,
30
+ * overlaid by what the org stored, plus the org's own topics — archived ones
31
+ * included, because a message already sent under a retired stream must go on
32
+ * resolving to it. Each record's facts:
33
+ *
34
+ * - `description`: the sentence the preference page shows;
35
+ * - `archived`: retired from pickers and the preference page;
36
+ * - `doubleOptIn`: present only when the org decided it for this stream —
37
+ * absent means "whatever the site says" (`topicRequiresDoubleOptIn`).
38
+ *
39
+ * Scoped by the organization, or by a site, which resolves to its org.
40
+ */
41
+ /** The record kind another plugin asks for. */
42
+ export declare const SUBSCRIPTION_TOPIC_RECORD_KIND = "subscriptionTopic";
43
+ /**
44
+ * An org's catalog: the declared floor overlaid by what the org stored. An
45
+ * empty org id answers the floor alone. A failed read throws; the caller
46
+ * decides whether the floor is a safe answer.
47
+ */
48
+ export declare function readOrgTopicCatalog(firestore: Firestore, orgId: string): Promise<SubscriptionTopic[]>;
49
+ export declare function createSubscriptionTopicIndex(firestore: () => Firestore): PluginRecordIndex;
50
+ /** Publishes the index; the plugin's API registrars call it. */
51
+ export declare function registerSubscriptionTopicIndex(firestore: () => Firestore): void;
52
+ export {};
@@ -0,0 +1,92 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { mergeSubscriptionTopics } from "@aglyn/aglyn/app-utils/subscription-topics";
18
+ import { registerPluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
19
+ import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
20
+ import { BUNDLE_ID } from "./constants/bundle-common.js";
21
+ import { EMAIL_TOPICS_COLLECTION, emailTopicsFromDocs } from "./model/email-topic-catalog.js";
22
+ /**
23
+ * AN ORG'S TOPIC CATALOG, for a plugin that does not keep it.
24
+ *
25
+ * A plugin that sends under a stream sometimes has to ask what the org made of
26
+ * it — whether the org's newsletter asks for a confirmation click, what it is
27
+ * called now — and the catalog is this plugin's storage. So it is published as
28
+ * the `subscriptionTopic` record index and read through the core's seam, never
29
+ * through the collection.
30
+ *
31
+ * The records are the catalog a reader sees: the streams the plugins declare,
32
+ * overlaid by what the org stored, plus the org's own topics — archived ones
33
+ * included, because a message already sent under a retired stream must go on
34
+ * resolving to it. Each record's facts:
35
+ *
36
+ * - `description`: the sentence the preference page shows;
37
+ * - `archived`: retired from pickers and the preference page;
38
+ * - `doubleOptIn`: present only when the org decided it for this stream —
39
+ * absent means "whatever the site says" (`topicRequiresDoubleOptIn`).
40
+ *
41
+ * Scoped by the organization, or by a site, which resolves to its org.
42
+ */ /** The record kind another plugin asks for. */ export const SUBSCRIPTION_TOPIC_RECORD_KIND = 'subscriptionTopic';
43
+ /**
44
+ * An org's catalog: the declared floor overlaid by what the org stored. An
45
+ * empty org id answers the floor alone. A failed read throws; the caller
46
+ * decides whether the floor is a safe answer.
47
+ */ export async function readOrgTopicCatalog(firestore, orgId) {
48
+ var _ref;
49
+ if (!orgId) return mergeSubscriptionTopics(null);
50
+ const snapshot = await firestore.collection('orgs').doc(orgId).collection(EMAIL_TOPICS_COLLECTION).get();
51
+ return mergeSubscriptionTopics(emailTopicsFromDocs((_ref = snapshot == null ? void 0 : snapshot.docs) != null ? _ref : []));
52
+ }
53
+ function recordOf(topic) {
54
+ return {
55
+ id: topic.id,
56
+ name: topic.name,
57
+ facts: _extends({
58
+ description: topic.description,
59
+ archived: topic.archived === true
60
+ }, typeof topic.doubleOptIn === 'boolean' ? {
61
+ doubleOptIn: topic.doubleOptIn
62
+ } : {})
63
+ };
64
+ }
65
+ export function createSubscriptionTopicIndex(firestore) {
66
+ const catalog = async (scope)=>{
67
+ var _scope_orgId, _ref;
68
+ const orgId = String((_scope_orgId = scope.orgId) != null ? _scope_orgId : '').trim() || (scope.hostId ? (_ref = await resolveOrgIdForHost(scope.hostId)) != null ? _ref : '' : '');
69
+ return readOrgTopicCatalog(firestore(), orgId);
70
+ };
71
+ return {
72
+ async list (request) {
73
+ const topics = await catalog(request);
74
+ const limit = Math.max(0, Math.floor(request.limit));
75
+ return {
76
+ records: topics.slice(0, limit).map(recordOf),
77
+ truncated: topics.length > limit
78
+ };
79
+ },
80
+ async get (request) {
81
+ const topic = (await catalog(request)).find((one)=>one.id === request.id);
82
+ return topic ? recordOf(topic) : null;
83
+ }
84
+ };
85
+ }
86
+ /** Publishes the index; the plugin's API registrars call it. */ export function registerSubscriptionTopicIndex(firestore) {
87
+ registerPluginRecordIndex(SUBSCRIPTION_TOPIC_RECORD_KIND, createSubscriptionTopicIndex(firestore), {
88
+ pluginId: BUNDLE_ID
89
+ });
90
+ }
91
+
92
+ //# sourceMappingURL=server-topic-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-topic-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\nimport {\n mergeSubscriptionTopics,\n type SubscriptionTopic,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport {\n registerPluginRecordIndex,\n type PluginIndexedRecord,\n type PluginRecordIndex,\n type PluginRecordIndexScope,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { EMAIL_TOPICS_COLLECTION, emailTopicsFromDocs } from './model/email-topic-catalog'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/**\n * AN ORG'S TOPIC CATALOG, for a plugin that does not keep it.\n *\n * A plugin that sends under a stream sometimes has to ask what the org made of\n * it — whether the org's newsletter asks for a confirmation click, what it is\n * called now — and the catalog is this plugin's storage. So it is published as\n * the `subscriptionTopic` record index and read through the core's seam, never\n * through the collection.\n *\n * The records are the catalog a reader sees: the streams the plugins declare,\n * overlaid by what the org stored, plus the org's own topics — archived ones\n * included, because a message already sent under a retired stream must go on\n * resolving to it. Each record's facts:\n *\n * - `description`: the sentence the preference page shows;\n * - `archived`: retired from pickers and the preference page;\n * - `doubleOptIn`: present only when the org decided it for this stream —\n * absent means \"whatever the site says\" (`topicRequiresDoubleOptIn`).\n *\n * Scoped by the organization, or by a site, which resolves to its org.\n */\n\n/** The record kind another plugin asks for. */\nexport const SUBSCRIPTION_TOPIC_RECORD_KIND = 'subscriptionTopic'\n\n/**\n * An org's catalog: the declared floor overlaid by what the org stored. An\n * empty org id answers the floor alone. A failed read throws; the caller\n * decides whether the floor is a safe answer.\n */\nexport async function readOrgTopicCatalog(\n firestore: Firestore,\n orgId: string,\n): Promise<SubscriptionTopic[]> {\n if (!orgId) return mergeSubscriptionTopics(null)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n return mergeSubscriptionTopics(emailTopicsFromDocs(snapshot?.docs ?? []))\n}\n\nfunction recordOf(topic: SubscriptionTopic): PluginIndexedRecord {\n return {\n id: topic.id,\n name: topic.name,\n facts: {\n description: topic.description,\n archived: topic.archived === true,\n ...(typeof topic.doubleOptIn === 'boolean'\n ? { doubleOptIn: topic.doubleOptIn }\n : {}),\n },\n }\n}\n\nexport function createSubscriptionTopicIndex(\n firestore: () => Firestore,\n): PluginRecordIndex {\n const catalog = async (scope: PluginRecordIndexScope) => {\n const orgId =\n String(scope.orgId ?? '').trim() ||\n (scope.hostId ? ((await resolveOrgIdForHost(scope.hostId)) ?? '') : '')\n return readOrgTopicCatalog(firestore(), orgId)\n }\n return {\n async list(request) {\n const topics = await catalog(request)\n const limit = Math.max(0, Math.floor(request.limit))\n return {\n records: topics.slice(0, limit).map(recordOf),\n truncated: topics.length > limit,\n }\n },\n async get(request) {\n const topic = (await catalog(request)).find((one) => one.id === request.id)\n return topic ? recordOf(topic) : null\n },\n }\n}\n\n/** Publishes the index; the plugin's API registrars call it. */\nexport function registerSubscriptionTopicIndex(firestore: () => Firestore): void {\n registerPluginRecordIndex(\n SUBSCRIPTION_TOPIC_RECORD_KIND,\n createSubscriptionTopicIndex(firestore),\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["mergeSubscriptionTopics","registerPluginRecordIndex","resolveOrgIdForHost","BUNDLE_ID","EMAIL_TOPICS_COLLECTION","emailTopicsFromDocs","SUBSCRIPTION_TOPIC_RECORD_KIND","readOrgTopicCatalog","firestore","orgId","snapshot","collection","doc","get","docs","recordOf","topic","id","name","facts","description","archived","doubleOptIn","createSubscriptionTopicIndex","catalog","scope","String","trim","hostId","list","request","topics","limit","Math","max","floor","records","slice","map","truncated","length","find","one","registerSubscriptionTopicIndex","pluginId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,uBAAuB,QAElB,6CAA4C;AACnD,SACEC,yBAAyB,QAIpB,kDAAiD;AACxD,SAASC,mBAAmB,QAAQ,2BAA0B;AAC9D,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,uBAAuB,EAAEC,mBAAmB,QAAQ,iCAA6B;AAI1F;;;;;;;;;;;;;;;;;;;;CAoBC,GAED,6CAA6C,GAC7C,OAAO,MAAMC,iCAAiC,oBAAmB;AAEjE;;;;CAIC,GACD,OAAO,eAAeC,oBACpBC,SAAoB,EACpBC,KAAa;;IAEb,IAAI,CAACA,OAAO,OAAOT,wBAAwB;IAC3C,MAAMU,WAAW,MAAMF,UACpBG,UAAU,CAAC,QACXC,GAAG,CAACH,OACJE,UAAU,CAACP,yBACXS,GAAG;IACN,OAAOb,wBAAwBK,4BAAoBK,4BAAAA,SAAUI,IAAI,mBAAI,EAAE;AACzE;AAEA,SAASC,SAASC,KAAwB;IACxC,OAAO;QACLC,IAAID,MAAMC,EAAE;QACZC,MAAMF,MAAME,IAAI;QAChBC,OAAO;YACLC,aAAaJ,MAAMI,WAAW;YAC9BC,UAAUL,MAAMK,QAAQ,KAAK;WACzB,OAAOL,MAAMM,WAAW,KAAK,YAC7B;YAAEA,aAAaN,MAAMM,WAAW;QAAC,IACjC,CAAC;IAET;AACF;AAEA,OAAO,SAASC,6BACdf,SAA0B;IAE1B,MAAMgB,UAAU,OAAOC;YAEZA,cACW;QAFpB,MAAMhB,QACJiB,QAAOD,eAAAA,MAAMhB,KAAK,YAAXgB,eAAe,IAAIE,IAAI,MAC7BF,CAAAA,MAAMG,MAAM,IAAK,OAAA,MAAM1B,oBAAoBuB,MAAMG,MAAM,aAAtC,OAA4C,KAAM,EAAC;QACvE,OAAOrB,oBAAoBC,aAAaC;IAC1C;IACA,OAAO;QACL,MAAMoB,MAAKC,OAAO;YAChB,MAAMC,SAAS,MAAMP,QAAQM;YAC7B,MAAME,QAAQC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACL,QAAQE,KAAK;YAClD,OAAO;gBACLI,SAASL,OAAOM,KAAK,CAAC,GAAGL,OAAOM,GAAG,CAACvB;gBACpCwB,WAAWR,OAAOS,MAAM,GAAGR;YAC7B;QACF;QACA,MAAMnB,KAAIiB,OAAO;YACf,MAAMd,QAAQ,AAAC,CAAA,MAAMQ,QAAQM,QAAO,EAAGW,IAAI,CAAC,CAACC,MAAQA,IAAIzB,EAAE,KAAKa,QAAQb,EAAE;YAC1E,OAAOD,QAAQD,SAASC,SAAS;QACnC;IACF;AACF;AAEA,8DAA8D,GAC9D,OAAO,SAAS2B,+BAA+BnC,SAA0B;IACvEP,0BACEK,gCACAiB,6BAA6Bf,YAC7B;QAAEoC,UAAUzC;IAAU;AAE1B"}