@aglyn/plugins-email 1.0.0-beta.143

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 (144) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +58 -0
  4. package/src/index.d.ts +35 -0
  5. package/src/index.js +35 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-design-create-widget.d.ts +21 -0
  8. package/src/lib/components/campaign-design-create-widget.js +61 -0
  9. package/src/lib/components/campaign-design-create-widget.js.map +1 -0
  10. package/src/lib/components/campaign-sender-editor-widget.d.ts +18 -0
  11. package/src/lib/components/campaign-sender-editor-widget.js +39 -0
  12. package/src/lib/components/campaign-sender-editor-widget.js.map +1 -0
  13. package/src/lib/components/campaign-topic-options-widget.d.ts +19 -0
  14. package/src/lib/components/campaign-topic-options-widget.js +48 -0
  15. package/src/lib/components/campaign-topic-options-widget.js.map +1 -0
  16. package/src/lib/components/campaign-topic-select.d.ts +32 -0
  17. package/src/lib/components/campaign-topic-select.js +87 -0
  18. package/src/lib/components/campaign-topic-select.js.map +1 -0
  19. package/src/lib/components/dynamic-list-rule-fields.d.ts +173 -0
  20. package/src/lib/components/dynamic-list-rule-fields.js +1473 -0
  21. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -0
  22. package/src/lib/components/email-blocks.d.ts +111 -0
  23. package/src/lib/components/email-blocks.js +875 -0
  24. package/src/lib/components/email-blocks.js.map +1 -0
  25. package/src/lib/components/email-design-preview.d.ts +62 -0
  26. package/src/lib/components/email-design-preview.js +174 -0
  27. package/src/lib/components/email-design-preview.js.map +1 -0
  28. package/src/lib/components/email-screens-card.d.ts +42 -0
  29. package/src/lib/components/email-screens-card.js +277 -0
  30. package/src/lib/components/email-screens-card.js.map +1 -0
  31. package/src/lib/components/email-template-detail.d.ts +48 -0
  32. package/src/lib/components/email-template-detail.js +681 -0
  33. package/src/lib/components/email-template-detail.js.map +1 -0
  34. package/src/lib/components/email-topic-detail.d.ts +32 -0
  35. package/src/lib/components/email-topic-detail.js +293 -0
  36. package/src/lib/components/email-topic-detail.js.map +1 -0
  37. package/src/lib/components/email-topics-card.d.ts +46 -0
  38. package/src/lib/components/email-topics-card.js +327 -0
  39. package/src/lib/components/email-topics-card.js.map +1 -0
  40. package/src/lib/components/email-zones.d.ts +28 -0
  41. package/src/lib/components/email-zones.js +20 -0
  42. package/src/lib/components/email-zones.js.map +1 -0
  43. package/src/lib/components/emails-console-page.d.ts +32 -0
  44. package/src/lib/components/emails-console-page.js +229 -0
  45. package/src/lib/components/emails-console-page.js.map +1 -0
  46. package/src/lib/components/emails-console-sections.d.ts +36 -0
  47. package/src/lib/components/emails-console-sections.js +108 -0
  48. package/src/lib/components/emails-console-sections.js.map +1 -0
  49. package/src/lib/components/list-detail-card.d.ts +47 -0
  50. package/src/lib/components/list-detail-card.js +273 -0
  51. package/src/lib/components/list-detail-card.js.map +1 -0
  52. package/src/lib/components/list-edit-card.d.ts +11 -0
  53. package/src/lib/components/list-edit-card.js +287 -0
  54. package/src/lib/components/list-edit-card.js.map +1 -0
  55. package/src/lib/components/list-import-drawer.d.ts +22 -0
  56. package/src/lib/components/list-import-drawer.js +662 -0
  57. package/src/lib/components/list-import-drawer.js.map +1 -0
  58. package/src/lib/components/list-members-panel.d.ts +94 -0
  59. package/src/lib/components/list-members-panel.js +686 -0
  60. package/src/lib/components/list-members-panel.js.map +1 -0
  61. package/src/lib/components/lists-card.d.ts +28 -0
  62. package/src/lib/components/lists-card.js +377 -0
  63. package/src/lib/components/lists-card.js.map +1 -0
  64. package/src/lib/components/sending-domain-detail.d.ts +26 -0
  65. package/src/lib/components/sending-domain-detail.js +496 -0
  66. package/src/lib/components/sending-domain-detail.js.map +1 -0
  67. package/src/lib/components/sending-domains-card.d.ts +33 -0
  68. package/src/lib/components/sending-domains-card.js +962 -0
  69. package/src/lib/components/sending-domains-card.js.map +1 -0
  70. package/src/lib/components/sending-sender-drawer.d.ts +94 -0
  71. package/src/lib/components/sending-sender-drawer.js +543 -0
  72. package/src/lib/components/sending-sender-drawer.js.map +1 -0
  73. package/src/lib/components/suppressions-card.d.ts +49 -0
  74. package/src/lib/components/suppressions-card.js +639 -0
  75. package/src/lib/components/suppressions-card.js.map +1 -0
  76. package/src/lib/components/use-org-email-topics.d.ts +79 -0
  77. package/src/lib/components/use-org-email-topics.js +111 -0
  78. package/src/lib/components/use-org-email-topics.js.map +1 -0
  79. package/src/lib/constants/bundle-common.d.ts +18 -0
  80. package/src/lib/constants/bundle-common.js +18 -0
  81. package/src/lib/constants/bundle-common.js.map +1 -0
  82. package/src/lib/hooks/use-org-company-options.d.ts +20 -0
  83. package/src/lib/hooks/use-org-company-options.js +138 -0
  84. package/src/lib/hooks/use-org-company-options.js.map +1 -0
  85. package/src/lib/hooks/use-org-contact-fields.d.ts +40 -0
  86. package/src/lib/hooks/use-org-contact-fields.js +91 -0
  87. package/src/lib/hooks/use-org-contact-fields.js.map +1 -0
  88. package/src/lib/hooks/use-org-contact-segments.d.ts +16 -0
  89. package/src/lib/hooks/use-org-contact-segments.js +55 -0
  90. package/src/lib/hooks/use-org-contact-segments.js.map +1 -0
  91. package/src/lib/hooks/use-org-crm-views.d.ts +8 -0
  92. package/src/lib/hooks/use-org-crm-views.js +74 -0
  93. package/src/lib/hooks/use-org-crm-views.js.map +1 -0
  94. package/src/lib/hooks/use-org-lists.d.ts +8 -0
  95. package/src/lib/hooks/use-org-lists.js +47 -0
  96. package/src/lib/hooks/use-org-lists.js.map +1 -0
  97. package/src/lib/model/email-design-document.d.ts +52 -0
  98. package/src/lib/model/email-design-document.js +62 -0
  99. package/src/lib/model/email-design-document.js.map +1 -0
  100. package/src/lib/model/index.d.ts +64 -0
  101. package/src/lib/model/index.js +71 -0
  102. package/src/lib/model/index.js.map +1 -0
  103. package/src/lib/model/sending-domain-status.d.ts +99 -0
  104. package/src/lib/model/sending-domain-status.js +196 -0
  105. package/src/lib/model/sending-domain-status.js.map +1 -0
  106. package/src/lib/model/template-provenance.d.ts +113 -0
  107. package/src/lib/model/template-provenance.js +107 -0
  108. package/src/lib/model/template-provenance.js.map +1 -0
  109. package/src/lib/model/template-report.d.ts +158 -0
  110. package/src/lib/model/template-report.js +249 -0
  111. package/src/lib/model/template-report.js.map +1 -0
  112. package/src/lib/plugin.d.ts +27 -0
  113. package/src/lib/plugin.js +163 -0
  114. package/src/lib/plugin.js.map +1 -0
  115. package/src/lib/server-console.d.ts +116 -0
  116. package/src/lib/server-console.js +422 -0
  117. package/src/lib/server-console.js.map +1 -0
  118. package/src/lib/server-email-drafts.d.ts +104 -0
  119. package/src/lib/server-email-drafts.js +381 -0
  120. package/src/lib/server-email-drafts.js.map +1 -0
  121. package/src/lib/server-list-gate.d.ts +183 -0
  122. package/src/lib/server-list-gate.js +365 -0
  123. package/src/lib/server-list-gate.js.map +1 -0
  124. package/src/lib/server-list-import.d.ts +199 -0
  125. package/src/lib/server-list-import.js +632 -0
  126. package/src/lib/server-list-import.js.map +1 -0
  127. package/src/lib/server-suppressions.d.ts +135 -0
  128. package/src/lib/server-suppressions.js +295 -0
  129. package/src/lib/server-suppressions.js.map +1 -0
  130. package/src/lib/server.d.ts +19 -0
  131. package/src/lib/server.js +834 -0
  132. package/src/lib/server.js.map +1 -0
  133. package/src/lib/site.d.ts +26 -0
  134. package/src/lib/site.js +81 -0
  135. package/src/lib/site.js.map +1 -0
  136. package/src/lib/unsubscribe-link.d.ts +311 -0
  137. package/src/lib/unsubscribe-link.js +398 -0
  138. package/src/lib/unsubscribe-link.js.map +1 -0
  139. package/src/lib/utils/create-email-screen.d.ts +59 -0
  140. package/src/lib/utils/create-email-screen.js +59 -0
  141. package/src/lib/utils/create-email-screen.js.map +1 -0
  142. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  143. package/src/lib/utils/generate-preset-id.js +25 -0
  144. package/src/lib/utils/generate-preset-id.js.map +1 -0
@@ -0,0 +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 importedBasisReason,\n LIST_IMPORT_MAX_ADDRESSES,\n LIST_IMPORT_MAX_CHARACTERS,\n parseListImport,\n readMarketingBasis,\n registerPluginApiRoute,\n screenListImport,\n type AssignmentRefusal,\n type ListImportRow,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { enrollListMember } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\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 > LIST_IMPORT_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 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","importedBasisReason","LIST_IMPORT_MAX_ADDRESSES","LIST_IMPORT_MAX_CHARACTERS","parseListImport","readMarketingBasis","registerPluginApiRoute","screenListImport","enrollListMember","FieldValue","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","consent","basis","nextCursor","increment","Object","fromEntries","entries","count","ranEnrolled","ranRefused","id","emailListImportStatusHandler","orderBy","limit","docs","registerEmailListImportApi"],"mappings":"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"}
@@ -0,0 +1,135 @@
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
+ /**
18
+ * Putting somebody on the suppression list by hand.
19
+ *
20
+ * ## What was missing
21
+ *
22
+ * The Suppressions card could show an entry and remove one, and there was no
23
+ * way to add one. Every row in production arrived from a machine — somebody
24
+ * clicking unsubscribe, or the Resend webhook on a permanent bounce or a
25
+ * complaint. So the request a merchant is most likely to receive in words
26
+ * rather than through a link — a reply saying "please stop emailing me", a
27
+ * phone call, a message at the counter — had no button.
28
+ *
29
+ * That is a compliance exposure and not only a missing feature: CAN-SPAM
30
+ * requires an opt-out received by ANY means to be honored within ten business
31
+ * days, and every product Aglyn is compared against lets a sender type an
32
+ * address in.
33
+ *
34
+ * ## Why this is a route and not a client write
35
+ *
36
+ * The rules do allow a site editor to write `hosts/{hostId}/suppressions` —
37
+ * that is how the card's Remove button works, and the argument for it holds:
38
+ * removing a row launders no counter and touches no money.
39
+ *
40
+ * ADDING one is a different act, for one concrete reason: **the document id
41
+ * is `sha256` of the normalized address**. A browser computing that itself
42
+ * would be a second derivation of the key that every reader and both writers
43
+ * share, and the failure mode is silent and one-directional — a row filed
44
+ * under `sha256('Bob@x.com')` is invisible to a send path looking up
45
+ * `sha256('bob@x.com')`, so the merchant is told the person is suppressed and
46
+ * the mail keeps going. `emailSuppressionKey` is the one derivation, it lives
47
+ * on the server, and it refuses to guess an id for a value that is not an
48
+ * address rather than filing one under a key nothing will look up.
49
+ *
50
+ * ## Why the per-site list and not the platform one
51
+ *
52
+ * "Stop emailing me" said to a merchant is about that merchant's mail. The
53
+ * platform list is what a hard bounce and a complaint write — evidence about
54
+ * the ADDRESS rather than a preference about one sender — and a merchant
55
+ * cannot put somebody on it, exactly as they cannot take somebody off it.
56
+ */
57
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
58
+ /**
59
+ * The `reason` a hand-added entry carries.
60
+ *
61
+ * A NEW value beside the three the machines write, not a reuse of one of
62
+ * them. Recording it as `'unsubscribe'` would say the person clicked a link
63
+ * they never saw, and the difference is exactly what a merchant asked to
64
+ * prove an opt-out was honored has to be able to show.
65
+ */
66
+ export declare const MANUAL_SUPPRESSION_REASON = "manual";
67
+ /** Bound on the note, so one row stays a small document. */
68
+ export declare const SUPPRESSION_NOTE_MAX = 200;
69
+ /** The most addresses one request may name. */
70
+ export declare const SUPPRESSION_ADD_BATCH_MAX = 50;
71
+ /** What happened to one address the operator typed. */
72
+ export interface SuppressionAddVerdict {
73
+ /** Exactly what was typed, so a bad line can be pointed at. */
74
+ input: string;
75
+ /** The normalized address, or null when there is not one. */
76
+ email: string | null;
77
+ /** True when this request created the entry. */
78
+ added: boolean;
79
+ /** Why not, when `added` is false. */
80
+ refusal?: 'not-an-address' | 'already-suppressed';
81
+ }
82
+ /**
83
+ * Splits the request's addresses without deciding anything about them.
84
+ *
85
+ * Newlines AND commas, because an operator pasting from a reply or a
86
+ * spreadsheet produces both, and a paste that silently became one giant
87
+ * "address" would be refused as a whole rather than acted on.
88
+ */
89
+ export declare function readSuppressionAddresses(raw: unknown): string[];
90
+ /**
91
+ * Adds addresses to one site's suppression list.
92
+ *
93
+ * `createdAt` is written only when the document is new, matching both machine
94
+ * writers: a hand-added entry over an existing one must not restamp the date
95
+ * the person actually left, because that is the date a merchant is asked for.
96
+ * An entry that already exists is reported rather than rewritten — a bounce
97
+ * that has been on the list for a month must not be relabeled `manual` and
98
+ * lose the reason a merchant needs to see before removing it.
99
+ */
100
+ export declare const emailSuppressionAddHandler: PluginApiHandler;
101
+ /**
102
+ * Which of a site's own suppressed addresses are ALSO suppressed
103
+ * platform-wide.
104
+ *
105
+ * ## The hole this closes
106
+ *
107
+ * The two lists are consulted together at send time and were visible
108
+ * separately: a merchant saw their own list and nothing else. So a merchant
109
+ * who removed their site's entry — because the person asked to be re-added,
110
+ * or because a link prescanner unsubscribed them — could still find that the
111
+ * address was never mailed, with no screen anywhere saying why. The platform
112
+ * entry is invisible to them and un-liftable by them, and the only signal was
113
+ * a recipient count that stayed short.
114
+ *
115
+ * ## What it does and does not disclose
116
+ *
117
+ * It answers ONLY for addresses the caller supplies, and the caller is a site
118
+ * admin or editor asking about their own suppression list — a list they can
119
+ * already read row by row. It is not a search: an address the merchant does
120
+ * not already hold produces `false`, which is what an address nobody has
121
+ * suppressed produces too, so nothing here turns into a lookup service for
122
+ * whether a stranger has ever bounced.
123
+ */
124
+ export declare const emailSuppressionStatusHandler: PluginApiHandler;
125
+ /**
126
+ * Registration.
127
+ *
128
+ * Not on the machine-path exemption list in `plugin-api-rate-limit.ts`, and
129
+ * both handlers refuse anything but POST, so both are counted against the
130
+ * console dispatcher's per-subject budget — which is far above a person
131
+ * pressing a button in a browser. The limiter is `consoleApiRateLimitRefusal`
132
+ * in `apps/console`; the visitor one is installed only in `apps/tenant` and
133
+ * has never seen a request to either of these paths.
134
+ */
135
+ export declare function registerEmailSuppressionsApi(): void;
@@ -0,0 +1,295 @@
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
+ */ /**
18
+ * Putting somebody on the suppression list by hand.
19
+ *
20
+ * ## What was missing
21
+ *
22
+ * The Suppressions card could show an entry and remove one, and there was no
23
+ * way to add one. Every row in production arrived from a machine — somebody
24
+ * clicking unsubscribe, or the Resend webhook on a permanent bounce or a
25
+ * complaint. So the request a merchant is most likely to receive in words
26
+ * rather than through a link — a reply saying "please stop emailing me", a
27
+ * phone call, a message at the counter — had no button.
28
+ *
29
+ * That is a compliance exposure and not only a missing feature: CAN-SPAM
30
+ * requires an opt-out received by ANY means to be honored within ten business
31
+ * days, and every product Aglyn is compared against lets a sender type an
32
+ * address in.
33
+ *
34
+ * ## Why this is a route and not a client write
35
+ *
36
+ * The rules do allow a site editor to write `hosts/{hostId}/suppressions` —
37
+ * that is how the card's Remove button works, and the argument for it holds:
38
+ * removing a row launders no counter and touches no money.
39
+ *
40
+ * ADDING one is a different act, for one concrete reason: **the document id
41
+ * is `sha256` of the normalized address**. A browser computing that itself
42
+ * would be a second derivation of the key that every reader and both writers
43
+ * share, and the failure mode is silent and one-directional — a row filed
44
+ * under `sha256('Bob@x.com')` is invisible to a send path looking up
45
+ * `sha256('bob@x.com')`, so the merchant is told the person is suppressed and
46
+ * the mail keeps going. `emailSuppressionKey` is the one derivation, it lives
47
+ * on the server, and it refuses to guess an id for a value that is not an
48
+ * address rather than filing one under a key nothing will look up.
49
+ *
50
+ * ## Why the per-site list and not the platform one
51
+ *
52
+ * "Stop emailing me" said to a merchant is about that merchant's mail. The
53
+ * platform list is what a hard bounce and a complaint write — evidence about
54
+ * the ADDRESS rather than a preference about one sender — and a merchant
55
+ * cannot put somebody on it, exactly as they cannot take somebody off it.
56
+ */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
57
+ import { emailSuppressionKey, firebaseAdmin, isEmailSuppressed } from "@aglyn/tenant-data-admin";
58
+ import { FieldValue } from "firebase-admin/firestore";
59
+ /**
60
+ * The `reason` a hand-added entry carries.
61
+ *
62
+ * A NEW value beside the three the machines write, not a reuse of one of
63
+ * them. Recording it as `'unsubscribe'` would say the person clicked a link
64
+ * they never saw, and the difference is exactly what a merchant asked to
65
+ * prove an opt-out was honored has to be able to show.
66
+ */ export const MANUAL_SUPPRESSION_REASON = 'manual';
67
+ /** Bound on the note, so one row stays a small document. */ export const SUPPRESSION_NOTE_MAX = 200;
68
+ /** The most addresses one request may name. */ export const SUPPRESSION_ADD_BATCH_MAX = 50;
69
+ /**
70
+ * Splits the request's addresses without deciding anything about them.
71
+ *
72
+ * Newlines AND commas, because an operator pasting from a reply or a
73
+ * spreadsheet produces both, and a paste that silently became one giant
74
+ * "address" would be refused as a whole rather than acted on.
75
+ */ export function readSuppressionAddresses(raw) {
76
+ const source = Array.isArray(raw) ? raw.join('\n') : String(raw != null ? raw : '');
77
+ return source.split(/[\n,;]+/).map((line)=>line.trim()).filter(Boolean).slice(0, SUPPRESSION_ADD_BATCH_MAX);
78
+ }
79
+ /**
80
+ * Adds addresses to one site's suppression list.
81
+ *
82
+ * `createdAt` is written only when the document is new, matching both machine
83
+ * writers: a hand-added entry over an existing one must not restamp the date
84
+ * the person actually left, because that is the date a merchant is asked for.
85
+ * An entry that already exists is reported rather than rewritten — a bounce
86
+ * that has been on the list for a month must not be relabeled `manual` and
87
+ * lose the reason a merchant needs to see before removing it.
88
+ */ export const emailSuppressionAddHandler = async (req, res)=>{
89
+ var _req_body, _body_hostId, _req_headers_authorization;
90
+ if (req.method !== 'POST') {
91
+ return res.status(405).json({
92
+ error: 'Method not allowed'
93
+ });
94
+ }
95
+ const body = typeof req.body === 'string' ? JSON.parse(req.body) : (_req_body = req.body) != null ? _req_body : {};
96
+ const hostId = String((_body_hostId = body.hostId) != null ? _body_hostId : '');
97
+ if (!hostId) return res.status(400).json({
98
+ error: 'Missing hostId'
99
+ });
100
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
101
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
102
+ if (!idToken) return res.status(401).json({
103
+ error: 'Unauthenticated'
104
+ });
105
+ try {
106
+ var _hostSnapshot_get, _body_note, _body_emails;
107
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
108
+ const firestore = firebaseAdmin.app().firestore();
109
+ const hostRef = firestore.collection('hosts').doc(hostId);
110
+ const hostSnapshot = await hostRef.get();
111
+ if (!hostSnapshot.exists) {
112
+ return res.status(404).json({
113
+ error: 'Unknown site'
114
+ });
115
+ }
116
+ /*
117
+ * AN ALLOWLIST, and the SITE's role rather than the organization's.
118
+ *
119
+ * The list being written is `hosts/{hostId}/suppressions` — one site's
120
+ * own — which the rules already grant a site editor. The list-membership
121
+ * routes beside this one additionally demand org-wide access because a
122
+ * marketing list lives on the ORG and every site in it can mail one;
123
+ * demanding that here would refuse a single-site editor the ability to
124
+ * honor an opt-out about their own site's mail, which is the opposite of
125
+ * what a suppression is for.
126
+ */ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
127
+ if (memberRole !== 'admin' && memberRole !== 'editor') {
128
+ return res.status(403).json({
129
+ error: 'Not a site admin or editor'
130
+ });
131
+ }
132
+ const note = String((_body_note = body.note) != null ? _body_note : '').trim().slice(0, SUPPRESSION_NOTE_MAX);
133
+ const inputs = readSuppressionAddresses((_body_emails = body.emails) != null ? _body_emails : body.email);
134
+ if (!inputs.length) {
135
+ return res.status(400).json({
136
+ error: 'No address to suppress'
137
+ });
138
+ }
139
+ const suppressions = hostRef.collection('suppressions');
140
+ const results = [];
141
+ const seen = new Set();
142
+ for (const input of inputs){
143
+ const key = emailSuppressionKey(input);
144
+ if (!key) {
145
+ results.push({
146
+ input,
147
+ email: null,
148
+ added: false,
149
+ refusal: 'not-an-address'
150
+ });
151
+ continue;
152
+ }
153
+ // One line naming the same person twice is one entry, and reporting it
154
+ // twice would tell the operator an address was already suppressed by a
155
+ // request they are still making.
156
+ if (seen.has(key)) continue;
157
+ seen.add(key);
158
+ const email = input.trim().toLowerCase();
159
+ const ref = suppressions.doc(key);
160
+ const existing = await ref.get();
161
+ if (existing.exists) {
162
+ results.push({
163
+ input,
164
+ email,
165
+ added: false,
166
+ refusal: 'already-suppressed'
167
+ });
168
+ continue;
169
+ }
170
+ await ref.set(_extends({
171
+ email,
172
+ reason: MANUAL_SUPPRESSION_REASON
173
+ }, note ? {
174
+ note
175
+ } : {}, {
176
+ // WHO recorded it. A suppression is evidence, and evidence with no
177
+ // author answers half the question it is kept for.
178
+ suppressedByUid: decoded.uid,
179
+ createdAt: FieldValue.serverTimestamp(),
180
+ suppressedAt: FieldValue.serverTimestamp()
181
+ }), {
182
+ merge: true
183
+ });
184
+ results.push({
185
+ input,
186
+ email,
187
+ added: true
188
+ });
189
+ }
190
+ return res.status(200).json({
191
+ added: results.filter((result)=>result.added).length,
192
+ results
193
+ });
194
+ } catch (error) {
195
+ console.error('[email] suppression add failed', error);
196
+ return res.status(500).json({
197
+ error: 'The address could not be suppressed.'
198
+ });
199
+ }
200
+ };
201
+ /**
202
+ * Which of a site's own suppressed addresses are ALSO suppressed
203
+ * platform-wide.
204
+ *
205
+ * ## The hole this closes
206
+ *
207
+ * The two lists are consulted together at send time and were visible
208
+ * separately: a merchant saw their own list and nothing else. So a merchant
209
+ * who removed their site's entry — because the person asked to be re-added,
210
+ * or because a link prescanner unsubscribed them — could still find that the
211
+ * address was never mailed, with no screen anywhere saying why. The platform
212
+ * entry is invisible to them and un-liftable by them, and the only signal was
213
+ * a recipient count that stayed short.
214
+ *
215
+ * ## What it does and does not disclose
216
+ *
217
+ * It answers ONLY for addresses the caller supplies, and the caller is a site
218
+ * admin or editor asking about their own suppression list — a list they can
219
+ * already read row by row. It is not a search: an address the merchant does
220
+ * not already hold produces `false`, which is what an address nobody has
221
+ * suppressed produces too, so nothing here turns into a lookup service for
222
+ * whether a stranger has ever bounced.
223
+ */ export const emailSuppressionStatusHandler = async (req, res)=>{
224
+ var _req_body, _body_hostId, _req_headers_authorization;
225
+ if (req.method !== 'POST') {
226
+ return res.status(405).json({
227
+ error: 'Method not allowed'
228
+ });
229
+ }
230
+ const body = typeof req.body === 'string' ? JSON.parse(req.body) : (_req_body = req.body) != null ? _req_body : {};
231
+ const hostId = String((_body_hostId = body.hostId) != null ? _body_hostId : '');
232
+ if (!hostId) return res.status(400).json({
233
+ error: 'Missing hostId'
234
+ });
235
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
236
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
237
+ if (!idToken) return res.status(401).json({
238
+ error: 'Unauthenticated'
239
+ });
240
+ try {
241
+ var _hostSnapshot_get, _body_emails;
242
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
243
+ const firestore = firebaseAdmin.app().firestore();
244
+ const hostSnapshot = await firestore.collection('hosts').doc(hostId).get();
245
+ if (!hostSnapshot.exists) {
246
+ return res.status(404).json({
247
+ error: 'Unknown site'
248
+ });
249
+ }
250
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
251
+ if (memberRole !== 'admin' && memberRole !== 'editor') {
252
+ return res.status(403).json({
253
+ error: 'Not a site admin or editor'
254
+ });
255
+ }
256
+ const addresses = readSuppressionAddresses((_body_emails = body.emails) != null ? _body_emails : body.email);
257
+ if (!addresses.length) return res.status(200).json({
258
+ platform: []
259
+ });
260
+ /*
261
+ * `isEmailSuppressed` per address, which fails CLOSED — an unreadable
262
+ * list answers "suppressed". That posture is right at send time and it is
263
+ * right here too: this screen exists to explain mail that is not
264
+ * arriving, so the reassuring answer is the one that must not be guessed.
265
+ */ const platform = [];
266
+ for (const address of addresses){
267
+ if (await isEmailSuppressed(address, firestore)) {
268
+ platform.push(String(address).trim().toLowerCase());
269
+ }
270
+ }
271
+ return res.status(200).json({
272
+ platform
273
+ });
274
+ } catch (error) {
275
+ console.error('[email] suppression status failed', error);
276
+ return res.status(500).json({
277
+ error: 'The list could not be checked.'
278
+ });
279
+ }
280
+ };
281
+ /**
282
+ * Registration.
283
+ *
284
+ * Not on the machine-path exemption list in `plugin-api-rate-limit.ts`, and
285
+ * both handlers refuse anything but POST, so both are counted against the
286
+ * console dispatcher's per-subject budget — which is far above a person
287
+ * pressing a button in a browser. The limiter is `consoleApiRateLimitRefusal`
288
+ * in `apps/console`; the visitor one is installed only in `apps/tenant` and
289
+ * has never seen a request to either of these paths.
290
+ */ export function registerEmailSuppressionsApi() {
291
+ registerPluginApiRoute('email/suppression-add', emailSuppressionAddHandler);
292
+ registerPluginApiRoute('email/suppression-status', emailSuppressionStatusHandler);
293
+ }
294
+
295
+ //# sourceMappingURL=server-suppressions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-suppressions.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 the suppression list by hand.\n *\n * ## What was missing\n *\n * The Suppressions card could show an entry and remove one, and there was no\n * way to add one. Every row in production arrived from a machine — somebody\n * clicking unsubscribe, or the Resend webhook on a permanent bounce or a\n * complaint. So the request a merchant is most likely to receive in words\n * rather than through a link — a reply saying \"please stop emailing me\", a\n * phone call, a message at the counter — had no button.\n *\n * That is a compliance exposure and not only a missing feature: CAN-SPAM\n * requires an opt-out received by ANY means to be honored within ten business\n * days, and every product Aglyn is compared against lets a sender type an\n * address in.\n *\n * ## Why this is a route and not a client write\n *\n * The rules do allow a site editor to write `hosts/{hostId}/suppressions` —\n * that is how the card's Remove button works, and the argument for it holds:\n * removing a row launders no counter and touches no money.\n *\n * ADDING one is a different act, for one concrete reason: **the document id\n * is `sha256` of the normalized address**. A browser computing that itself\n * would be a second derivation of the key that every reader and both writers\n * share, and the failure mode is silent and one-directional — a row filed\n * under `sha256('Bob@x.com')` is invisible to a send path looking up\n * `sha256('bob@x.com')`, so the merchant is told the person is suppressed and\n * the mail keeps going. `emailSuppressionKey` is the one derivation, it lives\n * on the server, and it refuses to guess an id for a value that is not an\n * address rather than filing one under a key nothing will look up.\n *\n * ## Why the per-site list and not the platform one\n *\n * \"Stop emailing me\" said to a merchant is about that merchant's mail. The\n * platform list is what a hard bounce and a complaint write — evidence about\n * the ADDRESS rather than a preference about one sender — and a merchant\n * cannot put somebody on it, exactly as they cannot take somebody off it.\n */\n\nimport {\n registerPluginApiRoute,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailSuppressionKey,\n firebaseAdmin,\n isEmailSuppressed,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The `reason` a hand-added entry carries.\n *\n * A NEW value beside the three the machines write, not a reuse of one of\n * them. Recording it as `'unsubscribe'` would say the person clicked a link\n * they never saw, and the difference is exactly what a merchant asked to\n * prove an opt-out was honored has to be able to show.\n */\nexport const MANUAL_SUPPRESSION_REASON = 'manual'\n\n/** Bound on the note, so one row stays a small document. */\nexport const SUPPRESSION_NOTE_MAX = 200\n\n/** The most addresses one request may name. */\nexport const SUPPRESSION_ADD_BATCH_MAX = 50\n\n/** What happened to one address the operator typed. */\nexport interface SuppressionAddVerdict {\n /** Exactly what was typed, so a bad line can be pointed at. */\n input: string\n /** The normalized address, or null when there is not one. */\n email: string | null\n /** True when this request created the entry. */\n added: boolean\n /** Why not, when `added` is false. */\n refusal?: 'not-an-address' | 'already-suppressed'\n}\n\n/**\n * Splits the request's addresses without deciding anything about them.\n *\n * Newlines AND commas, because an operator pasting from a reply or a\n * spreadsheet produces both, and a paste that silently became one giant\n * \"address\" would be refused as a whole rather than acted on.\n */\nexport function readSuppressionAddresses(raw: unknown): string[] {\n const source = Array.isArray(raw) ? raw.join('\\n') : String(raw ?? '')\n return source\n .split(/[\\n,;]+/)\n .map((line) => line.trim())\n .filter(Boolean)\n .slice(0, SUPPRESSION_ADD_BATCH_MAX)\n}\n\n/**\n * Adds addresses to one site's suppression list.\n *\n * `createdAt` is written only when the document is new, matching both machine\n * writers: a hand-added entry over an existing one must not restamp the date\n * the person actually left, because that is the date a merchant is asked for.\n * An entry that already exists is reported rather than rewritten — a bounce\n * that has been on the list for a month must not be relabeled `manual` and\n * lose the reason a merchant needs to see before removing it.\n */\nexport const emailSuppressionAddHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n /*\n * AN ALLOWLIST, and the SITE's role rather than the organization's.\n *\n * The list being written is `hosts/{hostId}/suppressions` — one site's\n * own — which the rules already grant a site editor. The list-membership\n * routes beside this one additionally demand org-wide access because a\n * marketing list lives on the ORG and every site in it can mail one;\n * demanding that here would refuse a single-site editor the ability to\n * honor an opt-out about their own site's mail, which is the opposite of\n * what a suppression is for.\n */\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n const note = String(body.note ?? '')\n .trim()\n .slice(0, SUPPRESSION_NOTE_MAX)\n const inputs = readSuppressionAddresses(body.emails ?? body.email)\n if (!inputs.length) {\n return res.status(400).json({ error: 'No address to suppress' })\n }\n\n const suppressions = hostRef.collection('suppressions')\n const results: SuppressionAddVerdict[] = []\n const seen = new Set<string>()\n for (const input of inputs) {\n const key = emailSuppressionKey(input)\n if (!key) {\n results.push({\n input,\n email: null,\n added: false,\n refusal: 'not-an-address',\n })\n continue\n }\n // One line naming the same person twice is one entry, and reporting it\n // twice would tell the operator an address was already suppressed by a\n // request they are still making.\n if (seen.has(key)) continue\n seen.add(key)\n const email = input.trim().toLowerCase()\n const ref = suppressions.doc(key)\n const existing = await ref.get()\n if (existing.exists) {\n results.push({\n input,\n email,\n added: false,\n refusal: 'already-suppressed',\n })\n continue\n }\n await ref.set(\n {\n email,\n reason: MANUAL_SUPPRESSION_REASON,\n ...(note ? { note } : {}),\n // WHO recorded it. A suppression is evidence, and evidence with no\n // author answers half the question it is kept for.\n suppressedByUid: decoded.uid,\n createdAt: FieldValue.serverTimestamp(),\n suppressedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n results.push({ input, email, added: true })\n }\n\n return res.status(200).json({\n added: results.filter((result) => result.added).length,\n results,\n })\n } catch (error) {\n console.error('[email] suppression add failed', error)\n return res\n .status(500)\n .json({ error: 'The address could not be suppressed.' })\n }\n}\n\n/**\n * Which of a site's own suppressed addresses are ALSO suppressed\n * platform-wide.\n *\n * ## The hole this closes\n *\n * The two lists are consulted together at send time and were visible\n * separately: a merchant saw their own list and nothing else. So a merchant\n * who removed their site's entry — because the person asked to be re-added,\n * or because a link prescanner unsubscribed them — could still find that the\n * address was never mailed, with no screen anywhere saying why. The platform\n * entry is invisible to them and un-liftable by them, and the only signal was\n * a recipient count that stayed short.\n *\n * ## What it does and does not disclose\n *\n * It answers ONLY for addresses the caller supplies, and the caller is a site\n * admin or editor asking about their own suppression list — a list they can\n * already read row by row. It is not a search: an address the merchant does\n * not already hold produces `false`, which is what an address nobody has\n * suppressed produces too, so nothing here turns into a lookup service for\n * whether a stranger has ever bounced.\n */\nexport const emailSuppressionStatusHandler: 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 body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n const addresses = readSuppressionAddresses(body.emails ?? body.email)\n if (!addresses.length) return res.status(200).json({ platform: [] })\n /*\n * `isEmailSuppressed` per address, which fails CLOSED — an unreadable\n * list answers \"suppressed\". That posture is right at send time and it is\n * right here too: this screen exists to explain mail that is not\n * arriving, so the reassuring answer is the one that must not be guessed.\n */\n const platform: string[] = []\n for (const address of addresses) {\n if (await isEmailSuppressed(address, firestore)) {\n platform.push(String(address).trim().toLowerCase())\n }\n }\n return res.status(200).json({ platform })\n } catch (error) {\n console.error('[email] suppression status failed', error)\n return res.status(500).json({ error: 'The list could not be checked.' })\n }\n}\n\n/**\n * Registration.\n *\n * Not on the machine-path exemption list in `plugin-api-rate-limit.ts`, and\n * both handlers refuse anything but POST, so both are counted against the\n * console dispatcher's per-subject budget — which is far above a person\n * pressing a button in a browser. The limiter is `consoleApiRateLimitRefusal`\n * in `apps/console`; the visitor one is installed only in `apps/tenant` and\n * has never seen a request to either of these paths.\n */\nexport function registerEmailSuppressionsApi(): void {\n registerPluginApiRoute('email/suppression-add', emailSuppressionAddHandler)\n registerPluginApiRoute(\n 'email/suppression-status',\n emailSuppressionStatusHandler,\n )\n}\n"],"names":["registerPluginApiRoute","emailSuppressionKey","firebaseAdmin","isEmailSuppressed","FieldValue","MANUAL_SUPPRESSION_REASON","SUPPRESSION_NOTE_MAX","SUPPRESSION_ADD_BATCH_MAX","readSuppressionAddresses","raw","source","Array","isArray","join","String","split","map","line","trim","filter","Boolean","slice","emailSuppressionAddHandler","req","res","body","method","status","json","error","JSON","parse","hostId","authorization","headers","idToken","startsWith","length","undefined","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","note","inputs","emails","email","suppressions","results","seen","Set","input","key","push","added","refusal","has","add","toLowerCase","ref","existing","set","reason","suppressedByUid","createdAt","serverTimestamp","suppressedAt","merge","result","console","emailSuppressionStatusHandler","addresses","platform","address","registerEmailSuppressionsApi"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GAED,SACEA,sBAAsB,QAEjB,sBAAqB;AAC5B,SACEC,mBAAmB,EACnBC,aAAa,EACbC,iBAAiB,QACZ,2BAA0B;AACjC,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;;;;CAOC,GACD,OAAO,MAAMC,4BAA4B,SAAQ;AAEjD,0DAA0D,GAC1D,OAAO,MAAMC,uBAAuB,IAAG;AAEvC,6CAA6C,GAC7C,OAAO,MAAMC,4BAA4B,GAAE;AAc3C;;;;;;CAMC,GACD,OAAO,SAASC,yBAAyBC,GAAY;IACnD,MAAMC,SAASC,MAAMC,OAAO,CAACH,OAAOA,IAAII,IAAI,CAAC,QAAQC,OAAOL,cAAAA,MAAO;IACnE,OAAOC,OACJK,KAAK,CAAC,WACNC,GAAG,CAAC,CAACC,OAASA,KAAKC,IAAI,IACvBC,MAAM,CAACC,SACPC,KAAK,CAAC,GAAGd;AACd;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMe,6BAA+C,OAAOC,KAAKC;QAKbD,WACnCE,cAGOF;IAR7B,IAAIA,IAAIG,MAAM,KAAK,QAAQ;QACzB,OAAOF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMJ,OACJ,OAAOF,IAAIE,IAAI,KAAK,WAAWK,KAAKC,KAAK,CAACR,IAAIE,IAAI,KAAKF,YAAAA,IAAIE,IAAI,YAARF,YAAY,CAAC;IACtE,MAAMS,SAASlB,QAAOW,eAAAA,KAAKO,MAAM,YAAXP,eAAe;IACrC,IAAI,CAACO,QAAQ,OAAOR,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAiB;IAEnE,MAAMI,gBAAgBnB,QAAOS,6BAAAA,IAAIW,OAAO,CAACD,aAAa,YAAzBV,6BAA6B;IAC1D,MAAMY,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcZ,KAAK,CAAC,UAAUgB,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS,OAAOX,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,IAAI;YAmBkBU,mBAKAd,YAGoBA;QA1BxC,MAAMe,UAAU,MAAMtC,cAAcuC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACR;QAC/D,MAAMS,YAAY1C,cAAcuC,GAAG,GAAGG,SAAS;QAC/C,MAAMC,UAAUD,UAAUE,UAAU,CAAC,SAASC,GAAG,CAACf;QAClD,MAAMO,eAAe,MAAMM,QAAQG,GAAG;QACtC,IAAI,CAACT,aAAaU,MAAM,EAAE;YACxB,OAAOzB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;QACtD;QACA;;;;;;;;;;KAUC,GACD,MAAMqB,aAAa,EAACX,oBAAAA,aAAaS,GAAG,CAAC,0BAAjBT,oBAAmC,CAAC,EAAE,CAACC,QAAQW,GAAG,CAAC;QACvE,IAAID,eAAe,WAAWA,eAAe,UAAU;YACrD,OAAO1B,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA6B;QACpE;QAEA,MAAMuB,OAAOtC,QAAOW,aAAAA,KAAK2B,IAAI,YAAT3B,aAAa,IAC9BP,IAAI,GACJG,KAAK,CAAC,GAAGf;QACZ,MAAM+C,SAAS7C,0BAAyBiB,eAAAA,KAAK6B,MAAM,YAAX7B,eAAeA,KAAK8B,KAAK;QACjE,IAAI,CAACF,OAAOhB,MAAM,EAAE;YAClB,OAAOb,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAyB;QAChE;QAEA,MAAM2B,eAAeX,QAAQC,UAAU,CAAC;QACxC,MAAMW,UAAmC,EAAE;QAC3C,MAAMC,OAAO,IAAIC;QACjB,KAAK,MAAMC,SAASP,OAAQ;YAC1B,MAAMQ,MAAM5D,oBAAoB2D;YAChC,IAAI,CAACC,KAAK;gBACRJ,QAAQK,IAAI,CAAC;oBACXF;oBACAL,OAAO;oBACPQ,OAAO;oBACPC,SAAS;gBACX;gBACA;YACF;YACA,uEAAuE;YACvE,uEAAuE;YACvE,iCAAiC;YACjC,IAAIN,KAAKO,GAAG,CAACJ,MAAM;YACnBH,KAAKQ,GAAG,CAACL;YACT,MAAMN,QAAQK,MAAM1C,IAAI,GAAGiD,WAAW;YACtC,MAAMC,MAAMZ,aAAaT,GAAG,CAACc;YAC7B,MAAMQ,WAAW,MAAMD,IAAIpB,GAAG;YAC9B,IAAIqB,SAASpB,MAAM,EAAE;gBACnBQ,QAAQK,IAAI,CAAC;oBACXF;oBACAL;oBACAQ,OAAO;oBACPC,SAAS;gBACX;gBACA;YACF;YACA,MAAMI,IAAIE,GAAG,CACX;gBACEf;gBACAgB,QAAQlE;eACJ+C,OAAO;gBAAEA;YAAK,IAAI,CAAC;gBACvB,mEAAmE;gBACnE,mDAAmD;gBACnDoB,iBAAiBhC,QAAQW,GAAG;gBAC5BsB,WAAWrE,WAAWsE,eAAe;gBACrCC,cAAcvE,WAAWsE,eAAe;gBAE1C;gBAAEE,OAAO;YAAK;YAEhBnB,QAAQK,IAAI,CAAC;gBAAEF;gBAAOL;gBAAOQ,OAAO;YAAK;QAC3C;QAEA,OAAOvC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BmC,OAAON,QAAQtC,MAAM,CAAC,CAAC0D,SAAWA,OAAOd,KAAK,EAAE1B,MAAM;YACtDoB;QACF;IACF,EAAE,OAAO5B,OAAO;QACdiD,QAAQjD,KAAK,CAAC,kCAAkCA;QAChD,OAAOL,IACJG,MAAM,CAAC,KACPC,IAAI,CAAC;YAAEC,OAAO;QAAuC;IAC1D;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,MAAMkD,gCAAkD,OAC7DxD,KACAC;QAMyDD,WACnCE,cAGOF;IAR7B,IAAIA,IAAIG,MAAM,KAAK,QAAQ;QACzB,OAAOF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMJ,OACJ,OAAOF,IAAIE,IAAI,KAAK,WAAWK,KAAKC,KAAK,CAACR,IAAIE,IAAI,KAAKF,YAAAA,IAAIE,IAAI,YAARF,YAAY,CAAC;IACtE,MAAMS,SAASlB,QAAOW,eAAAA,KAAKO,MAAM,YAAXP,eAAe;IACrC,IAAI,CAACO,QAAQ,OAAOR,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAiB;IAEnE,MAAMI,gBAAgBnB,QAAOS,6BAAAA,IAAIW,OAAO,CAACD,aAAa,YAAzBV,6BAA6B;IAC1D,MAAMY,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcZ,KAAK,CAAC,UAAUgB,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS,OAAOX,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,IAAI;YAOkBU,mBAKuBd;QAX3C,MAAMe,UAAU,MAAMtC,cAAcuC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACR;QAC/D,MAAMS,YAAY1C,cAAcuC,GAAG,GAAGG,SAAS;QAC/C,MAAML,eAAe,MAAMK,UAAUE,UAAU,CAAC,SAASC,GAAG,CAACf,QAAQgB,GAAG;QACxE,IAAI,CAACT,aAAaU,MAAM,EAAE;YACxB,OAAOzB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;QACtD;QACA,MAAMqB,aAAa,EAACX,oBAAAA,aAAaS,GAAG,CAAC,0BAAjBT,oBAAmC,CAAC,EAAE,CAACC,QAAQW,GAAG,CAAC;QACvE,IAAID,eAAe,WAAWA,eAAe,UAAU;YACrD,OAAO1B,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA6B;QACpE;QAEA,MAAMmD,YAAYxE,0BAAyBiB,eAAAA,KAAK6B,MAAM,YAAX7B,eAAeA,KAAK8B,KAAK;QACpE,IAAI,CAACyB,UAAU3C,MAAM,EAAE,OAAOb,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEqD,UAAU,EAAE;QAAC;QAClE;;;;;KAKC,GACD,MAAMA,WAAqB,EAAE;QAC7B,KAAK,MAAMC,WAAWF,UAAW;YAC/B,IAAI,MAAM7E,kBAAkB+E,SAAStC,YAAY;gBAC/CqC,SAASnB,IAAI,CAAChD,OAAOoE,SAAShE,IAAI,GAAGiD,WAAW;YAClD;QACF;QACA,OAAO3C,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEqD;QAAS;IACzC,EAAE,OAAOpD,OAAO;QACdiD,QAAQjD,KAAK,CAAC,qCAAqCA;QACnD,OAAOL,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiC;IACxE;AACF,EAAC;AAED;;;;;;;;;CASC,GACD,OAAO,SAASsD;IACdnF,uBAAuB,yBAAyBsB;IAChDtB,uBACE,4BACA+E;AAEJ"}
@@ -0,0 +1,19 @@
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
+ /** Registers the email plugin's public API routes (AGL-396). */
18
+ export declare function registerEmailApi(): void;
19
+ export { registerEmailConsoleApi, emailListMembersAddHandler, emailListMembersPreviewHandler, emailListRulePreviewHandler, CONSOLE_ADD_SOURCE, LIST_MEMBER_BATCH_MAX, } from './server-console';