@aglyn/plugins-email 1.0.0-beta.198 → 1.0.0-beta.200

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 (29) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/email-screens-card.d.ts +8 -9
  3. package/src/lib/components/email-screens-card.js +83 -100
  4. package/src/lib/components/email-screens-card.js.map +1 -1
  5. package/src/lib/components/email-template-detail.js +3 -2
  6. package/src/lib/components/email-template-detail.js.map +1 -1
  7. package/src/lib/components/list-edit-card.js +2 -2
  8. package/src/lib/components/list-edit-card.js.map +1 -1
  9. package/src/lib/components/list-members-panel.js +72 -93
  10. package/src/lib/components/list-members-panel.js.map +1 -1
  11. package/src/lib/components/lists-card.js +70 -77
  12. package/src/lib/components/lists-card.js.map +1 -1
  13. package/src/lib/components/org-email-templates-card.d.ts +10 -6
  14. package/src/lib/components/org-email-templates-card.js +145 -90
  15. package/src/lib/components/org-email-templates-card.js.map +1 -1
  16. package/src/lib/components/suppressions-card.js +73 -69
  17. package/src/lib/components/suppressions-card.js.map +1 -1
  18. package/src/lib/constants/list-queries.d.ts +34 -0
  19. package/src/lib/constants/list-queries.js +256 -0
  20. package/src/lib/constants/list-queries.js.map +1 -0
  21. package/src/lib/server-email-drafts.js +3 -4
  22. package/src/lib/server-email-drafts.js.map +1 -1
  23. package/src/lib/server-suppressions.js +3 -1
  24. package/src/lib/server-suppressions.js.map +1 -1
  25. package/src/lib/server.js +3 -1
  26. package/src/lib/server.js.map +1 -1
  27. package/src/lib/utils/email-template-soft-delete.d.ts +39 -0
  28. package/src/lib/utils/email-template-soft-delete.js +41 -0
  29. package/src/lib/utils/email-template-soft-delete.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-email-drafts.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 { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { uniqueDuplicateName } from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { nameSearchKey } from '@aglyn/aglyn/app-utils/name-search'\nimport { hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport {\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n type BillableScreenSource,\n} from '@aglyn/aglyn/app-utils/screen-route'\nimport { encodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { CANVAS_ROOT_ELEMENT_ID } from '@aglyn/aglyn/foundation/constants/canvas'\nimport {\n registerPluginResourceDraftWriter,\n type PluginDraftCheck,\n type PluginDraftCheckContext,\n type PluginDraftRecord,\n type PluginDraftRefusal,\n type PluginDraftWrite,\n type PluginResourceDraftWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\nimport {\n composeReusableComponentNodes,\n REUSABLE_INSTANCE_COMPONENT_ID,\n} from '@aglyn/aglyn/app-utils/compose-reusable-components'\nimport { hasSafeLinkScheme } from '@aglyn/shared-util-http/safe-url-scheme'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n emailPlainTextState,\n renderRecipientEmail,\n} from '@aglyn/aglyn/app-utils/recipient-email-render'\nimport { emailDesignDocuments } from './model/email-design-document'\n\n/**\n * AN EMAIL DESIGN ANOTHER PLUGIN ASKS FOR (AGL-2912).\n *\n * The draft writer this plugin registers for the `emailDesign` resource on the\n * core's resource-drafts seam. A plugin that produces an email — a generator\n * working from a brief, an importer bringing templates over — asks for the\n * writer by name and gets this plugin's rules, never a copy of them:\n *\n * - THE DOCUMENTS are the pair `createEmailScreen` makes, built by the same\n * function (`emailDesignDocuments`): a `kind: 'email'` screen and its first\n * version. So the design lists on the Templates tab, opens in the besigner\n * and is offered by the campaign composer like one a person made. The\n * screen carries the subject and preheader a campaign built from it opens\n * on, and the other lines worth trying beside them.\n * - THE ROOM is the flat platform ceiling on non-page screens, met inside the\n * transaction with the arithmetic `/api/hosts/resources` and the duplicate\n * module use, and refused in their words.\n * - THE ROLE is the resources route's: a member who may write the site's\n * content.\n * - THE CHECK renders the design through `renderRecipientEmail`, the function\n * the send path and the composer's preview both call, so content that\n * passes is content that would render in both.\n *\n * Nothing is sent, scheduled or routed. A design is inert until a campaign\n * names it, and the writer touches the two new documents and nothing else.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The resource name this writer is registered under. */\nexport const EMAIL_DESIGN_DRAFT_RESOURCE = 'emailDesign'\n\n/** The id `plugins.config.json` registers this plugin under. */\nconst EMAIL_PLUGIN_ID = 'email'\n\n/** A design's name when the caller asks for none, as the composer's create names one. */\nexport const EMAIL_DESIGN_DEFAULT_NAME = 'Untitled email'\n\n/** The longest subject or preheader stored, the send route's header cap. */\nexport const EMAIL_DESIGN_HEADER_MAX_CHARS = 200\n\n/** The most alternative subject lines or preheaders a design keeps. */\nexport const EMAIL_DESIGN_MAX_VARIANTS = 10\n\n/** The resources route's refusal at the non-page ceiling. */\nexport const EMAIL_DESIGN_LIMIT_REFUSAL =\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room'\n\n/** The resources route's refusal for a member who may not write the site. */\nexport const EMAIL_DESIGN_ROLE_REFUSAL = 'Editing requires the editor role'\n\n/*\n * What the check renders against. No mail is sent and nothing is fetched: the\n * origin makes the renderer keep images and links it would drop without one,\n * and the recipient fills the merge tokens a real send would fill.\n */\nconst CHECK_SITE_BASE = 'https://example.com'\nconst CHECK_RECIPIENT = { email: 'reader@example.com', name: 'Sample Reader' }\n\n/** One node of a design, as far as the writer reads it. */\nexport interface EmailDesignNode {\n componentId?: unknown\n props?: Record<string, unknown>\n nodes?: unknown\n}\n\n/**\n * What a caller sends as `content`: the node map rooted at the canvas root,\n * the subject and preheader, and the alternatives to them.\n */\nexport interface EmailDesignDraftContent {\n nodes: Record<string, EmailDesignNode>\n subject: string\n preheader: string\n subjectVariants: string[]\n preheaderVariants: string[]\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** A header value on one line: controls and runs of space folded to one space. */\nfunction headerLine(value: unknown): string {\n return String(value ?? '').replace(/[\\p{Cc}\\s]+/gu, ' ').trim()\n}\n\n/** Alternatives as the design keeps them: on one line, unique, non-empty, bounded. */\nfunction variantsOf(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const seen = new Set<string>()\n const lines: string[] = []\n for (const raw of value) {\n const line = headerLine(raw)\n const key = line.toLowerCase()\n if (!line || seen.has(key)) continue\n seen.add(key)\n lines.push(line)\n }\n return lines.slice(0, EMAIL_DESIGN_MAX_VARIANTS)\n}\n\n/** The ids reachable from the canvas root, or the structural problem that stops the walk. */\nfunction reachableIds(nodes: Record<string, EmailDesignNode>): { ids: Set<string>; problems: string[] } {\n const ids = new Set<string>()\n const problems = new Set<string>()\n const stack = [CANVAS_ROOT_ELEMENT_ID]\n while (stack.length) {\n const id = stack.pop() as string\n if (ids.has(id)) {\n problems.add('A block is placed in the design more than once')\n continue\n }\n const node = nodes[id]\n if (!isRecord(node)) {\n problems.add('The design lists a block it does not hold')\n continue\n }\n ids.add(id)\n for (const child of Array.isArray(node.nodes) ? node.nodes : []) {\n if (typeof child === 'string') stack.push(child)\n }\n }\n return { ids, problems: [...problems] }\n}\n\nexport type EmailDesignContentRead =\n | { ok: true; value: EmailDesignDraftContent; reachable: Set<string> }\n | { ok: false; problems: string[] }\n\n/** The content as the writer stores it, or every problem that stops it. */\nexport function readEmailDesignContent(content: Readonly<Record<string, unknown>>): EmailDesignContentRead {\n const nodes = content['nodes']\n if (!isRecord(nodes) || !Object.keys(nodes).length) {\n return { ok: false, problems: ['The design has no blocks'] }\n }\n const map = nodes as Record<string, EmailDesignNode>\n if (!isRecord(map[CANVAS_ROOT_ELEMENT_ID])) {\n return { ok: false, problems: ['The design has no root to render from'] }\n }\n const { ids, problems } = reachableIds(map)\n const stranded = Object.keys(map).filter((id) => !ids.has(id)).length\n if (stranded) {\n problems.push(\n `${stranded} ${stranded === 1 ? 'block is' : 'blocks are'} not placed in the design, so ${\n stranded === 1 ? 'it' : 'they'\n } would not be sent`,\n )\n }\n const value: EmailDesignDraftContent = {\n nodes: map,\n subject: headerLine(content['subject']),\n preheader: headerLine(content['preheader']),\n subjectVariants: variantsOf(content['subjectVariants']),\n preheaderVariants: variantsOf(content['preheaderVariants']),\n }\n const long = (lines: string[]) => lines.some((line) => line.length > EMAIL_DESIGN_HEADER_MAX_CHARS)\n if (long([value.subject, ...value.subjectVariants])) {\n problems.push(`A subject line is longer than ${EMAIL_DESIGN_HEADER_MAX_CHARS} characters`)\n }\n if (long([value.preheader, ...value.preheaderVariants])) {\n problems.push(`A preheader is longer than ${EMAIL_DESIGN_HEADER_MAX_CHARS} characters`)\n }\n return problems.length ? { ok: false, problems } : { ok: true, value, reachable: ids }\n}\n\n/** Why a link on a block would not open from an inbox, or `null`. */\nfunction linkProblem(label: string, raw: unknown, required: boolean): string | null {\n const written = String(raw ?? '').trim()\n if (/\\{\\{\\s*contact\\./.test(written)) {\n return `${label} puts the reader's own details in its link, which is never filled`\n }\n const href = written\n .replace(/\\{\\{\\s*site\\.url\\s*\\}\\}/g, CHECK_SITE_BASE)\n .replace(/\\{\\{\\s*unsubscribeUrl\\s*\\}\\}/g, `${CHECK_SITE_BASE}/preferences`)\n if (!href || href === '#') return required ? `${label} links nowhere` : null\n if (href.startsWith('/') && !href.startsWith('//')) {\n return `${label} links to a path with no site in front of it, which an inbox cannot open`\n }\n return hasSafeLinkScheme(href) ? null : `${label} links to something that is not a web address`\n}\n\n/** A placement still standing once the site's components are grafted. */\nexport const EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM =\n 'A reusable block names a component this site has not published, so it would send empty'\n\n/**\n * Whether content renders as an email: well-formed, every link one an inbox\n * can open, and a message once rendered through the send path's own renderer.\n *\n * Judged COMPOSED (AGL-3287). The reusable blocks the design places — a shared\n * header, a footer — are grafted from `context.components` first, as every send\n * grafts them, so a link inside a footer is checked like any other and a\n * header's text counts toward the message. A placement still standing after\n * that names a component the caller could not supply, and would go out as\n * nothing; it is refused rather than passed as a design that renders.\n */\nexport function checkEmailDesignContent(\n content: Readonly<Record<string, unknown>>,\n context: PluginDraftCheckContext,\n): PluginDraftCheck {\n const read = readEmailDesignContent(content)\n if (read.ok === false) return read\n const { value } = read\n // Property values and attribute overrides apply in the graft; style\n // overrides land in `sx`, which the mail renderer never reads.\n const nodes = composeReusableComponentNodes(\n value.nodes as never,\n context.components as never,\n ) as unknown as Record<string, EmailDesignNode>\n const reachable =\n nodes === value.nodes ? read.reachable : reachableIds(nodes).ids\n const problems: string[] = []\n for (const id of reachable) {\n const node = nodes[id]\n if (node.componentId === REUSABLE_INSTANCE_COMPONENT_ID) {\n problems.push(EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM)\n continue\n }\n if (node.componentId === 'emailButton') {\n const problem = linkProblem('A button', node.props?.['href'], true)\n if (problem) problems.push(problem)\n }\n if (node.componentId === 'emailImage') {\n const problem = linkProblem('An image', node.props?.['href'], false)\n if (problem) problems.push(problem)\n }\n }\n const rendered = renderRecipientEmail({\n subject: value.subject,\n preheader: value.preheader,\n content: {\n mode: 'design',\n template: { nodes, subject: value.subject, preheader: value.preheader },\n },\n recipient: CHECK_RECIPIENT,\n siteBase: CHECK_SITE_BASE,\n hostId: context.hostId,\n })\n if (!rendered.messageText.trim()) {\n problems.push('The design renders no message: it needs at least one block of text')\n }\n if (problems.length) return { ok: false, problems: [...new Set(problems)] }\n return {\n ok: true,\n facts: {\n htmlBytes: new TextEncoder().encode(rendered.html).length,\n messageText: rendered.messageText,\n // The blocks the design itself holds, as stored — a placed component\n // counts once, however many blocks it expands into.\n blocks: read.reachable.size,\n // No part is written by hand, so the text a campaign sends is the design's own.\n plainText: emailPlainTextState({}),\n },\n }\n}\n\ninterface ScreenRow extends BillableScreenSource {\n name: string\n}\n\nasync function readScreenRows(\n hostRef: FirebaseFirestore.DocumentReference,\n read: (query: FirebaseFirestore.Query) => Promise<FirebaseFirestore.QuerySnapshot>,\n): Promise<ScreenRow[]> {\n const snapshot = await read(hostRef.collection('screens').select('displayName', 'kind', 'deletedAt'))\n return snapshot.docs.map((doc) => ({\n id: doc.id,\n kind: doc.get('kind'),\n deletedAt: doc.get('deletedAt'),\n name: String(doc.get('displayName') ?? ''),\n }))\n}\n\nfunction roleRefusal(host: FirebaseFirestore.DocumentSnapshot, uid: string): PluginDraftRefusal | null {\n const role = (host.get('memberRoles') ?? {})[uid]\n return hostRoleCanWrite(role) ? null : { status: 403, error: EMAIL_DESIGN_ROLE_REFUSAL }\n}\n\nfunction roomRefusal(rows: ScreenRow[], host: FirebaseFirestore.DocumentSnapshot): PluginDraftRefusal | null {\n const used = nonPageScreenIds(rows, host.get('screens') as never).size\n return used >= NON_PAGE_SCREEN_MAX_PER_HOST ? { status: 403, error: EMAIL_DESIGN_LIMIT_REFUSAL } : null\n}\n\nfunction strings(value: unknown): string[] {\n return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []\n}\n\nfunction recordOf(screen: FirebaseFirestore.DocumentSnapshot): PluginDraftRecord {\n const versionId = screen.get('versionId')\n return {\n id: screen.id,\n name: String(screen.get('displayName') ?? ''),\n versionId: typeof versionId === 'string' && versionId ? versionId : null,\n facts: {\n subject: String(screen.get('emailSubject') ?? ''),\n preheader: String(screen.get('emailPreheader') ?? ''),\n subjectVariants: strings(screen.get('emailSubjectVariants')),\n preheaderVariants: strings(screen.get('emailPreheaderVariants')),\n },\n }\n}\n\n/** The subject, preheader and alternatives as the screen document stores them. */\nfunction copyFields(value: EmailDesignDraftContent): Record<string, unknown> {\n return {\n ...(value.subject ? { emailSubject: value.subject } : {}),\n ...(value.preheader ? { emailPreheader: value.preheader } : {}),\n ...(value.subjectVariants.length ? { emailSubjectVariants: value.subjectVariants } : {}),\n ...(value.preheaderVariants.length ? { emailPreheaderVariants: value.preheaderVariants } : {}),\n }\n}\n\nexport interface EmailDesignDraftWriterDeps {\n /** The Admin SDK handle; specs hand in a double. */\n firestore?: () => Firestore\n /** Mints the first version's id. */\n mintId?: () => string\n}\n\nexport function createEmailDesignDraftWriter(\n deps: EmailDesignDraftWriterDeps = {},\n): PluginResourceDraftWriter {\n const firestore = deps.firestore ?? (() => firebaseAdmin.app().firestore() as unknown as Firestore)\n const mintId = deps.mintId ?? createResourceUid\n return {\n refusal: async (context) => {\n const hostRef = firestore().collection('hosts').doc(context.hostId)\n const host = await hostRef.get()\n if (!host.exists) return { status: 404, error: 'Unknown site' }\n return (\n roleRefusal(host, context.uid) ??\n roomRefusal(await readScreenRows(hostRef, (query) => query.get()), host)\n )\n },\n\n check: checkEmailDesignContent,\n\n read: async ({ hostId, id }) => {\n const screen = await firestore().collection('hosts').doc(hostId).collection('screens').doc(id).get()\n return screen.exists && screen.get('kind') === SCREEN_KIND_EMAIL ? recordOf(screen) : null\n },\n\n write: async (request): Promise<PluginDraftWrite> => {\n const read = readEmailDesignContent(request.content)\n if (read.ok === false) return { ok: false, status: 400, error: read.problems[0] }\n const packed = encodeStoredNodes(read.value.nodes)\n if (!packed) return { ok: false, status: 400, error: 'The design has no blocks' }\n const db = firestore()\n const hostRef = db.collection('hosts').doc(request.hostId)\n const screenRef = hostRef.collection('screens').doc(request.id)\n const versionId = mintId()\n return db.runTransaction(async (tx): Promise<PluginDraftWrite> => {\n // Every read before any write, which Firestore requires.\n const [host, existing, rows] = await Promise.all([\n tx.get(hostRef),\n tx.get(screenRef),\n readScreenRows(hostRef, (query) => tx.get(query)),\n ])\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (existing.exists) {\n return existing.get('kind') === SCREEN_KIND_EMAIL\n ? { ok: true, replayed: true, ...recordOf(existing) }\n : { ok: false, status: 409, error: 'That id already names a screen that is not an email design' }\n }\n const refusal = roleRefusal(host, request.uid) ?? roomRefusal(rows, host)\n if (refusal) return { ok: false, ...refusal }\n const name = uniqueDuplicateName(\n headerLine(request.name) || EMAIL_DESIGN_DEFAULT_NAME,\n rows.filter((row) => row.deletedAt == null).map((row) => row.name),\n )\n const { screen, version } = emailDesignDocuments({\n screenId: request.id,\n versionId,\n displayName: name,\n nodes: read.value.nodes,\n })\n const stamps = { createdAt: request.now, updatedAt: request.now, createdBy: request.uid }\n const copy = copyFields(read.value)\n tx.create(screenRef, { ...screen, nameLower: nameSearchKey(name), ...copy, ...stamps })\n tx.create(screenRef.collection('versions').doc(versionId), {\n ...version,\n nodes: Buffer.from(packed),\n hostId: request.hostId,\n ...stamps,\n })\n return {\n ok: true,\n replayed: false,\n id: request.id,\n name,\n versionId,\n facts: {\n subject: read.value.subject,\n preheader: read.value.preheader,\n subjectVariants: read.value.subjectVariants,\n preheaderVariants: read.value.preheaderVariants,\n },\n }\n })\n },\n }\n}\n\nexport const emailDesignDraftWriter = createEmailDesignDraftWriter()\n\n/**\n * Registers the writer; the console surface calls it, since only the console\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerEmailDesignDraftWriter(): void {\n registerPluginResourceDraftWriter(EMAIL_DESIGN_DRAFT_RESOURCE, emailDesignDraftWriter, {\n pluginId: EMAIL_PLUGIN_ID,\n })\n}\n"],"names":["createResourceUid","uniqueDuplicateName","nameSearchKey","hostRoleCanWrite","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","encodeStoredNodes","CANVAS_ROOT_ELEMENT_ID","registerPluginResourceDraftWriter","composeReusableComponentNodes","REUSABLE_INSTANCE_COMPONENT_ID","hasSafeLinkScheme","firebaseAdmin","emailPlainTextState","renderRecipientEmail","emailDesignDocuments","EMAIL_DESIGN_DRAFT_RESOURCE","EMAIL_PLUGIN_ID","EMAIL_DESIGN_DEFAULT_NAME","EMAIL_DESIGN_HEADER_MAX_CHARS","EMAIL_DESIGN_MAX_VARIANTS","EMAIL_DESIGN_LIMIT_REFUSAL","EMAIL_DESIGN_ROLE_REFUSAL","CHECK_SITE_BASE","CHECK_RECIPIENT","email","name","isRecord","value","Array","isArray","headerLine","String","replace","trim","variantsOf","seen","Set","lines","raw","line","key","toLowerCase","has","add","push","slice","reachableIds","nodes","ids","problems","stack","length","id","pop","node","child","readEmailDesignContent","content","Object","keys","ok","map","stranded","filter","subject","preheader","subjectVariants","preheaderVariants","long","some","reachable","linkProblem","label","required","written","test","href","startsWith","EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM","checkEmailDesignContent","context","read","components","componentId","problem","props","rendered","mode","template","recipient","siteBase","hostId","messageText","facts","htmlBytes","TextEncoder","encode","html","blocks","size","plainText","readScreenRows","hostRef","snapshot","collection","select","docs","doc","kind","get","deletedAt","roleRefusal","host","uid","role","status","error","roomRefusal","rows","used","strings","entry","recordOf","screen","versionId","copyFields","emailSubject","emailPreheader","emailSubjectVariants","emailPreheaderVariants","createEmailDesignDraftWriter","deps","firestore","app","mintId","refusal","exists","query","check","write","request","packed","db","screenRef","runTransaction","tx","existing","Promise","all","replayed","row","version","screenId","displayName","stamps","createdAt","now","updatedAt","createdBy","copy","create","nameLower","Buffer","from","emailDesignDraftWriter","registerEmailDesignDraftWriter","pluginId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-email-drafts.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 { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { uniqueDuplicateName } from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { displayNameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport { hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport {\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n type BillableScreenSource,\n} from '@aglyn/aglyn/app-utils/screen-route'\nimport { encodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { CANVAS_ROOT_ELEMENT_ID } from '@aglyn/aglyn/foundation/constants/canvas'\nimport {\n registerPluginResourceDraftWriter,\n type PluginDraftCheck,\n type PluginDraftCheckContext,\n type PluginDraftRecord,\n type PluginDraftRefusal,\n type PluginDraftWrite,\n type PluginResourceDraftWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\nimport {\n composeReusableComponentNodes,\n REUSABLE_INSTANCE_COMPONENT_ID,\n} from '@aglyn/aglyn/app-utils/compose-reusable-components'\nimport { hasSafeLinkScheme } from '@aglyn/shared-util-http/safe-url-scheme'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n emailPlainTextState,\n renderRecipientEmail,\n} from '@aglyn/aglyn/app-utils/recipient-email-render'\nimport { emailDesignDocuments } from './model/email-design-document'\n\n/**\n * AN EMAIL DESIGN ANOTHER PLUGIN ASKS FOR (AGL-2912).\n *\n * The draft writer this plugin registers for the `emailDesign` resource on the\n * core's resource-drafts seam. A plugin that produces an email — a generator\n * working from a brief, an importer bringing templates over — asks for the\n * writer by name and gets this plugin's rules, never a copy of them:\n *\n * - THE DOCUMENTS are the pair `createEmailScreen` makes, built by the same\n * function (`emailDesignDocuments`): a `kind: 'email'` screen and its first\n * version. So the design lists on the Templates tab, opens in the besigner\n * and is offered by the campaign composer like one a person made. The\n * screen carries the subject and preheader a campaign built from it opens\n * on, and the other lines worth trying beside them.\n * - THE ROOM is the flat platform ceiling on non-page screens, met inside the\n * transaction with the arithmetic `/api/hosts/resources` and the duplicate\n * module use, and refused in their words.\n * - THE ROLE is the resources route's: a member who may write the site's\n * content.\n * - THE CHECK renders the design through `renderRecipientEmail`, the function\n * the send path and the composer's preview both call, so content that\n * passes is content that would render in both.\n *\n * Nothing is sent, scheduled or routed. A design is inert until a campaign\n * names it, and the writer touches the two new documents and nothing else.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The resource name this writer is registered under. */\nexport const EMAIL_DESIGN_DRAFT_RESOURCE = 'emailDesign'\n\n/** The id `plugins.config.json` registers this plugin under. */\nconst EMAIL_PLUGIN_ID = 'email'\n\n/** A design's name when the caller asks for none, as the composer's create names one. */\nexport const EMAIL_DESIGN_DEFAULT_NAME = 'Untitled email'\n\n/** The longest subject or preheader stored, the send route's header cap. */\nexport const EMAIL_DESIGN_HEADER_MAX_CHARS = 200\n\n/** The most alternative subject lines or preheaders a design keeps. */\nexport const EMAIL_DESIGN_MAX_VARIANTS = 10\n\n/** The resources route's refusal at the non-page ceiling. */\nexport const EMAIL_DESIGN_LIMIT_REFUSAL =\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room'\n\n/** The resources route's refusal for a member who may not write the site. */\nexport const EMAIL_DESIGN_ROLE_REFUSAL = 'Editing requires the editor role'\n\n/*\n * What the check renders against. No mail is sent and nothing is fetched: the\n * origin makes the renderer keep images and links it would drop without one,\n * and the recipient fills the merge tokens a real send would fill.\n */\nconst CHECK_SITE_BASE = 'https://example.com'\nconst CHECK_RECIPIENT = { email: 'reader@example.com', name: 'Sample Reader' }\n\n/** One node of a design, as far as the writer reads it. */\nexport interface EmailDesignNode {\n componentId?: unknown\n props?: Record<string, unknown>\n nodes?: unknown\n}\n\n/**\n * What a caller sends as `content`: the node map rooted at the canvas root,\n * the subject and preheader, and the alternatives to them.\n */\nexport interface EmailDesignDraftContent {\n nodes: Record<string, EmailDesignNode>\n subject: string\n preheader: string\n subjectVariants: string[]\n preheaderVariants: string[]\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** A header value on one line: controls and runs of space folded to one space. */\nfunction headerLine(value: unknown): string {\n return String(value ?? '').replace(/[\\p{Cc}\\s]+/gu, ' ').trim()\n}\n\n/** Alternatives as the design keeps them: on one line, unique, non-empty, bounded. */\nfunction variantsOf(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const seen = new Set<string>()\n const lines: string[] = []\n for (const raw of value) {\n const line = headerLine(raw)\n const key = line.toLowerCase()\n if (!line || seen.has(key)) continue\n seen.add(key)\n lines.push(line)\n }\n return lines.slice(0, EMAIL_DESIGN_MAX_VARIANTS)\n}\n\n/** The ids reachable from the canvas root, or the structural problem that stops the walk. */\nfunction reachableIds(nodes: Record<string, EmailDesignNode>): { ids: Set<string>; problems: string[] } {\n const ids = new Set<string>()\n const problems = new Set<string>()\n const stack = [CANVAS_ROOT_ELEMENT_ID]\n while (stack.length) {\n const id = stack.pop() as string\n if (ids.has(id)) {\n problems.add('A block is placed in the design more than once')\n continue\n }\n const node = nodes[id]\n if (!isRecord(node)) {\n problems.add('The design lists a block it does not hold')\n continue\n }\n ids.add(id)\n for (const child of Array.isArray(node.nodes) ? node.nodes : []) {\n if (typeof child === 'string') stack.push(child)\n }\n }\n return { ids, problems: [...problems] }\n}\n\nexport type EmailDesignContentRead =\n | { ok: true; value: EmailDesignDraftContent; reachable: Set<string> }\n | { ok: false; problems: string[] }\n\n/** The content as the writer stores it, or every problem that stops it. */\nexport function readEmailDesignContent(content: Readonly<Record<string, unknown>>): EmailDesignContentRead {\n const nodes = content['nodes']\n if (!isRecord(nodes) || !Object.keys(nodes).length) {\n return { ok: false, problems: ['The design has no blocks'] }\n }\n const map = nodes as Record<string, EmailDesignNode>\n if (!isRecord(map[CANVAS_ROOT_ELEMENT_ID])) {\n return { ok: false, problems: ['The design has no root to render from'] }\n }\n const { ids, problems } = reachableIds(map)\n const stranded = Object.keys(map).filter((id) => !ids.has(id)).length\n if (stranded) {\n problems.push(\n `${stranded} ${stranded === 1 ? 'block is' : 'blocks are'} not placed in the design, so ${\n stranded === 1 ? 'it' : 'they'\n } would not be sent`,\n )\n }\n const value: EmailDesignDraftContent = {\n nodes: map,\n subject: headerLine(content['subject']),\n preheader: headerLine(content['preheader']),\n subjectVariants: variantsOf(content['subjectVariants']),\n preheaderVariants: variantsOf(content['preheaderVariants']),\n }\n const long = (lines: string[]) => lines.some((line) => line.length > EMAIL_DESIGN_HEADER_MAX_CHARS)\n if (long([value.subject, ...value.subjectVariants])) {\n problems.push(`A subject line is longer than ${EMAIL_DESIGN_HEADER_MAX_CHARS} characters`)\n }\n if (long([value.preheader, ...value.preheaderVariants])) {\n problems.push(`A preheader is longer than ${EMAIL_DESIGN_HEADER_MAX_CHARS} characters`)\n }\n return problems.length ? { ok: false, problems } : { ok: true, value, reachable: ids }\n}\n\n/** Why a link on a block would not open from an inbox, or `null`. */\nfunction linkProblem(label: string, raw: unknown, required: boolean): string | null {\n const written = String(raw ?? '').trim()\n if (/\\{\\{\\s*contact\\./.test(written)) {\n return `${label} puts the reader's own details in its link, which is never filled`\n }\n const href = written\n .replace(/\\{\\{\\s*site\\.url\\s*\\}\\}/g, CHECK_SITE_BASE)\n .replace(/\\{\\{\\s*unsubscribeUrl\\s*\\}\\}/g, `${CHECK_SITE_BASE}/preferences`)\n if (!href || href === '#') return required ? `${label} links nowhere` : null\n if (href.startsWith('/') && !href.startsWith('//')) {\n return `${label} links to a path with no site in front of it, which an inbox cannot open`\n }\n return hasSafeLinkScheme(href) ? null : `${label} links to something that is not a web address`\n}\n\n/** A placement still standing once the site's components are grafted. */\nexport const EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM =\n 'A reusable block names a component this site has not published, so it would send empty'\n\n/**\n * Whether content renders as an email: well-formed, every link one an inbox\n * can open, and a message once rendered through the send path's own renderer.\n *\n * Judged COMPOSED (AGL-3287). The reusable blocks the design places — a shared\n * header, a footer — are grafted from `context.components` first, as every send\n * grafts them, so a link inside a footer is checked like any other and a\n * header's text counts toward the message. A placement still standing after\n * that names a component the caller could not supply, and would go out as\n * nothing; it is refused rather than passed as a design that renders.\n */\nexport function checkEmailDesignContent(\n content: Readonly<Record<string, unknown>>,\n context: PluginDraftCheckContext,\n): PluginDraftCheck {\n const read = readEmailDesignContent(content)\n if (read.ok === false) return read\n const { value } = read\n // Property values and attribute overrides apply in the graft; style\n // overrides land in `sx`, which the mail renderer never reads.\n const nodes = composeReusableComponentNodes(\n value.nodes as never,\n context.components as never,\n ) as unknown as Record<string, EmailDesignNode>\n const reachable =\n nodes === value.nodes ? read.reachable : reachableIds(nodes).ids\n const problems: string[] = []\n for (const id of reachable) {\n const node = nodes[id]\n if (node.componentId === REUSABLE_INSTANCE_COMPONENT_ID) {\n problems.push(EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM)\n continue\n }\n if (node.componentId === 'emailButton') {\n const problem = linkProblem('A button', node.props?.['href'], true)\n if (problem) problems.push(problem)\n }\n if (node.componentId === 'emailImage') {\n const problem = linkProblem('An image', node.props?.['href'], false)\n if (problem) problems.push(problem)\n }\n }\n const rendered = renderRecipientEmail({\n subject: value.subject,\n preheader: value.preheader,\n content: {\n mode: 'design',\n template: { nodes, subject: value.subject, preheader: value.preheader },\n },\n recipient: CHECK_RECIPIENT,\n siteBase: CHECK_SITE_BASE,\n hostId: context.hostId,\n })\n if (!rendered.messageText.trim()) {\n problems.push('The design renders no message: it needs at least one block of text')\n }\n if (problems.length) return { ok: false, problems: [...new Set(problems)] }\n return {\n ok: true,\n facts: {\n htmlBytes: new TextEncoder().encode(rendered.html).length,\n messageText: rendered.messageText,\n // The blocks the design itself holds, as stored — a placed component\n // counts once, however many blocks it expands into.\n blocks: read.reachable.size,\n // No part is written by hand, so the text a campaign sends is the design's own.\n plainText: emailPlainTextState({}),\n },\n }\n}\n\ninterface ScreenRow extends BillableScreenSource {\n name: string\n}\n\nasync function readScreenRows(\n hostRef: FirebaseFirestore.DocumentReference,\n read: (query: FirebaseFirestore.Query) => Promise<FirebaseFirestore.QuerySnapshot>,\n): Promise<ScreenRow[]> {\n const snapshot = await read(hostRef.collection('screens').select('displayName', 'kind', 'deletedAt'))\n return snapshot.docs.map((doc) => ({\n id: doc.id,\n kind: doc.get('kind'),\n deletedAt: doc.get('deletedAt'),\n name: String(doc.get('displayName') ?? ''),\n }))\n}\n\nfunction roleRefusal(host: FirebaseFirestore.DocumentSnapshot, uid: string): PluginDraftRefusal | null {\n const role = (host.get('memberRoles') ?? {})[uid]\n return hostRoleCanWrite(role) ? null : { status: 403, error: EMAIL_DESIGN_ROLE_REFUSAL }\n}\n\nfunction roomRefusal(rows: ScreenRow[], host: FirebaseFirestore.DocumentSnapshot): PluginDraftRefusal | null {\n const used = nonPageScreenIds(rows, host.get('screens') as never).size\n return used >= NON_PAGE_SCREEN_MAX_PER_HOST ? { status: 403, error: EMAIL_DESIGN_LIMIT_REFUSAL } : null\n}\n\nfunction strings(value: unknown): string[] {\n return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []\n}\n\nfunction recordOf(screen: FirebaseFirestore.DocumentSnapshot): PluginDraftRecord {\n const versionId = screen.get('versionId')\n return {\n id: screen.id,\n name: String(screen.get('displayName') ?? ''),\n versionId: typeof versionId === 'string' && versionId ? versionId : null,\n facts: {\n subject: String(screen.get('emailSubject') ?? ''),\n preheader: String(screen.get('emailPreheader') ?? ''),\n subjectVariants: strings(screen.get('emailSubjectVariants')),\n preheaderVariants: strings(screen.get('emailPreheaderVariants')),\n },\n }\n}\n\n/** The subject, preheader and alternatives as the screen document stores them. */\nfunction copyFields(value: EmailDesignDraftContent): Record<string, unknown> {\n return {\n ...(value.subject ? { emailSubject: value.subject } : {}),\n ...(value.preheader ? { emailPreheader: value.preheader } : {}),\n ...(value.subjectVariants.length ? { emailSubjectVariants: value.subjectVariants } : {}),\n ...(value.preheaderVariants.length ? { emailPreheaderVariants: value.preheaderVariants } : {}),\n }\n}\n\nexport interface EmailDesignDraftWriterDeps {\n /** The Admin SDK handle; specs hand in a double. */\n firestore?: () => Firestore\n /** Mints the first version's id. */\n mintId?: () => string\n}\n\nexport function createEmailDesignDraftWriter(\n deps: EmailDesignDraftWriterDeps = {},\n): PluginResourceDraftWriter {\n const firestore = deps.firestore ?? (() => firebaseAdmin.app().firestore() as unknown as Firestore)\n const mintId = deps.mintId ?? createResourceUid\n return {\n refusal: async (context) => {\n const hostRef = firestore().collection('hosts').doc(context.hostId)\n const host = await hostRef.get()\n if (!host.exists) return { status: 404, error: 'Unknown site' }\n return (\n roleRefusal(host, context.uid) ??\n roomRefusal(await readScreenRows(hostRef, (query) => query.get()), host)\n )\n },\n\n check: checkEmailDesignContent,\n\n read: async ({ hostId, id }) => {\n const screen = await firestore().collection('hosts').doc(hostId).collection('screens').doc(id).get()\n return screen.exists && screen.get('kind') === SCREEN_KIND_EMAIL ? recordOf(screen) : null\n },\n\n write: async (request): Promise<PluginDraftWrite> => {\n const read = readEmailDesignContent(request.content)\n if (read.ok === false) return { ok: false, status: 400, error: read.problems[0] }\n const packed = encodeStoredNodes(read.value.nodes)\n if (!packed) return { ok: false, status: 400, error: 'The design has no blocks' }\n const db = firestore()\n const hostRef = db.collection('hosts').doc(request.hostId)\n const screenRef = hostRef.collection('screens').doc(request.id)\n const versionId = mintId()\n return db.runTransaction(async (tx): Promise<PluginDraftWrite> => {\n // Every read before any write, which Firestore requires.\n const [host, existing, rows] = await Promise.all([\n tx.get(hostRef),\n tx.get(screenRef),\n readScreenRows(hostRef, (query) => tx.get(query)),\n ])\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (existing.exists) {\n return existing.get('kind') === SCREEN_KIND_EMAIL\n ? { ok: true, replayed: true, ...recordOf(existing) }\n : { ok: false, status: 409, error: 'That id already names a screen that is not an email design' }\n }\n const refusal = roleRefusal(host, request.uid) ?? roomRefusal(rows, host)\n if (refusal) return { ok: false, ...refusal }\n const name = uniqueDuplicateName(\n headerLine(request.name) || EMAIL_DESIGN_DEFAULT_NAME,\n rows.filter((row) => row.deletedAt == null).map((row) => row.name),\n )\n const { screen, version } = emailDesignDocuments({\n screenId: request.id,\n versionId,\n displayName: name,\n nodes: read.value.nodes,\n })\n const stamps = { createdAt: request.now, updatedAt: request.now, createdBy: request.uid }\n const copy = copyFields(read.value)\n // The keys the email templates list finds it by (AGL-3321).\n tx.create(screenRef, { ...screen, ...displayNameSearchFields(name), ...copy, ...stamps })\n tx.create(screenRef.collection('versions').doc(versionId), {\n ...version,\n nodes: Buffer.from(packed),\n hostId: request.hostId,\n ...stamps,\n })\n return {\n ok: true,\n replayed: false,\n id: request.id,\n name,\n versionId,\n facts: {\n subject: read.value.subject,\n preheader: read.value.preheader,\n subjectVariants: read.value.subjectVariants,\n preheaderVariants: read.value.preheaderVariants,\n },\n }\n })\n },\n }\n}\n\nexport const emailDesignDraftWriter = createEmailDesignDraftWriter()\n\n/**\n * Registers the writer; the console surface calls it, since only the console\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerEmailDesignDraftWriter(): void {\n registerPluginResourceDraftWriter(EMAIL_DESIGN_DRAFT_RESOURCE, emailDesignDraftWriter, {\n pluginId: EMAIL_PLUGIN_ID,\n })\n}\n"],"names":["createResourceUid","uniqueDuplicateName","displayNameSearchFields","hostRoleCanWrite","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","encodeStoredNodes","CANVAS_ROOT_ELEMENT_ID","registerPluginResourceDraftWriter","composeReusableComponentNodes","REUSABLE_INSTANCE_COMPONENT_ID","hasSafeLinkScheme","firebaseAdmin","emailPlainTextState","renderRecipientEmail","emailDesignDocuments","EMAIL_DESIGN_DRAFT_RESOURCE","EMAIL_PLUGIN_ID","EMAIL_DESIGN_DEFAULT_NAME","EMAIL_DESIGN_HEADER_MAX_CHARS","EMAIL_DESIGN_MAX_VARIANTS","EMAIL_DESIGN_LIMIT_REFUSAL","EMAIL_DESIGN_ROLE_REFUSAL","CHECK_SITE_BASE","CHECK_RECIPIENT","email","name","isRecord","value","Array","isArray","headerLine","String","replace","trim","variantsOf","seen","Set","lines","raw","line","key","toLowerCase","has","add","push","slice","reachableIds","nodes","ids","problems","stack","length","id","pop","node","child","readEmailDesignContent","content","Object","keys","ok","map","stranded","filter","subject","preheader","subjectVariants","preheaderVariants","long","some","reachable","linkProblem","label","required","written","test","href","startsWith","EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM","checkEmailDesignContent","context","read","components","componentId","problem","props","rendered","mode","template","recipient","siteBase","hostId","messageText","facts","htmlBytes","TextEncoder","encode","html","blocks","size","plainText","readScreenRows","hostRef","snapshot","collection","select","docs","doc","kind","get","deletedAt","roleRefusal","host","uid","role","status","error","roomRefusal","rows","used","strings","entry","recordOf","screen","versionId","copyFields","emailSubject","emailPreheader","emailSubjectVariants","emailPreheaderVariants","createEmailDesignDraftWriter","deps","firestore","app","mintId","refusal","exists","query","check","write","request","packed","db","screenRef","runTransaction","tx","existing","Promise","all","replayed","row","version","screenId","displayName","stamps","createdAt","now","updatedAt","createdBy","copy","create","Buffer","from","emailDesignDraftWriter","registerEmailDesignDraftWriter","pluginId"],"mappings":"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"}
@@ -54,7 +54,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
54
54
  * the ADDRESS rather than a preference about one sender — and a merchant
55
55
  * cannot put somebody on it, exactly as they cannot take somebody off it.
56
56
  */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
57
- import { emailSuppressionKey, firebaseAdmin, isEmailSuppressed } from "@aglyn/tenant-data-admin";
57
+ import { emailSearchTokens, emailSuppressionKey, firebaseAdmin, isEmailSuppressed } from "@aglyn/tenant-data-admin";
58
58
  import { FieldValue } from "firebase-admin/firestore";
59
59
  /**
60
60
  * The `reason` a hand-added entry carries.
@@ -169,6 +169,8 @@ import { FieldValue } from "firebase-admin/firestore";
169
169
  }
170
170
  await ref.set(_extends({
171
171
  email,
172
+ // What the list's search and Address filter query (AGL-3321).
173
+ emailTokens: emailSearchTokens(email),
172
174
  reason: MANUAL_SUPPRESSION_REASON
173
175
  }, note ? {
174
176
  note
@@ -1 +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":"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"}
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 emailSearchTokens,\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 // What the list's search and Address filter query (AGL-3321).\n emailTokens: emailSearchTokens(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","emailSearchTokens","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","emailTokens","reason","suppressedByUid","createdAt","serverTimestamp","suppressedAt","merge","result","console","emailSuppressionStatusHandler","addresses","platform","address","registerEmailSuppressionsApi"],"mappings":"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"}
package/src/lib/server.js CHANGED
@@ -23,7 +23,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
23
23
  * pure function or a constant that lives in one leaf file.
24
24
  */ import { activeEmailTopics, mergeEmailTopics, normalizeEmailTopic, readTopicSubscriptionState, resolveCampaignTopic, EMAIL_TOPICS_COLLECTION, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
25
25
  import { consentGroupOptOutHosts, soloConsentGroup } from "@aglyn/aglyn/app-utils/consent-groups";
26
- import { confirmTopicSubscription, consentGroupForSite, EMAIL_FREQUENCY_SUBCOLLECTION, firebaseAdmin, mirrorPlatformResubscribe, mirrorPlatformUnsubscribe, resolveCampaignSendRef, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
26
+ import { confirmTopicSubscription, consentGroupForSite, EMAIL_FREQUENCY_SUBCOLLECTION, emailSearchTokens, firebaseAdmin, mirrorPlatformResubscribe, mirrorPlatformUnsubscribe, resolveCampaignSendRef, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
27
27
  import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
28
28
  /*
29
29
  * The pure cadence rule from the shared email library, where the SEND path
@@ -368,6 +368,8 @@ const unsubscribeHandler = async (req, res)=>{
368
368
  created = !existing.exists;
369
369
  transaction.set(ref, _extends({
370
370
  email: fields.email,
371
+ // What the Suppressions list's search queries (AGL-3321).
372
+ emailTokens: emailSearchTokens(fields.email),
371
373
  reason: UNSUBSCRIBE_SUPPRESSION_REASON,
372
374
  suppressedAt: FieldValue.serverTimestamp()
373
375
  }, existing.exists ? {} : _extends({