@aglyn/plugins-email 1.0.0-beta.182 → 1.0.0-beta.184
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/lib/components/email-blocks.d.ts +20 -0
- package/src/lib/components/email-blocks.js +135 -2
- package/src/lib/components/email-blocks.js.map +1 -1
- package/src/lib/components/email-design-preview.d.ts +7 -0
- package/src/lib/components/email-design-preview.js +79 -3
- package/src/lib/components/email-design-preview.js.map +1 -1
- package/src/lib/server-email-drafts.d.ts +11 -4
- package/src/lib/server-email-drafts.js +23 -4
- package/src/lib/server-email-drafts.js.map +1 -1
- package/src/lib/server-list-gate.js +8 -4
- package/src/lib/server-list-gate.js.map +1 -1
- package/src/lib/server.js +269 -68
- package/src/lib/server.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-design-preview.tsx"],"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'use client'\n\nimport { decodeStoredNodes } from '@aglyn/aglyn'\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n renderTextEmailHtml,\n} from '@aglyn/shared-util-email'\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport { Box, Stack, Typography } from '@mui/material'\nimport { useEffect, useMemo, useState } from 'react'\n\nexport interface EmailDesignPreviewProps {\n hostId: string\n /** The version document's raw `nodes` field, in any of its stored forms. */\n nodes: unknown\n /**\n * The plain-text body, for a message composed without a template.\n *\n * Such a message is not previewless — the send path synthesizes an HTML\n * part for it through `renderTextEmailHtml`, and that part is what the\n * inbox receives. Rendering the same function here shows the mailed copy\n * rather than reporting that there is nothing to show. Ignored when there\n * are nodes: a template supersedes it, exactly as it does at send time.\n */\n text?: string\n /** The version document has not arrived yet, as against not existing. */\n loading?: boolean\n subject?: string\n preheader?: string\n /** What to say when there is nothing to draw. */\n emptyMessage: string\n /**\n * An extra line under the frame — for a surface where the rendering has a\n * caveat of its own, such as a message sent before the template changed.\n */\n note?: string\n}\n\n/**\n * THE EMAIL AS AN INBOX RECEIVES IT, DRAWN SAFELY.\n *\n * ## The same renderer the send path calls\n *\n * `renderEmailHtml` is the function `campaign-send.ts` calls, given the same\n * `rootId` and the same stored node map — so what is drawn here is the\n * document that gets mailed, rather than a second rendering that agrees with\n * it until it does not. The besigner is NOT that: it draws canvas nodes as\n * React in a browser, which is a different pipeline with different output.\n *\n * Two inputs differ from a real send and both are said on screen. Merge\n * tokens are left standing — no merge map is passed, and the renderer leaves\n * unknown tokens visible for exactly this reason, so `{{contact.firstName}}`\n * shows where personalization lands instead of a blank. Product blocks\n * resolve against the catalog at send time and are not resolved here, because\n * doing so would put a catalog read on every preview for a picture that can\n * change between now and the send anyway.\n *\n * ## Sandboxed, because the HTML is tenant-authored\n *\n * The design is written by a site's own editors — or by a marketplace\n * publisher, on an installed template — and may contain a custom-HTML block.\n * It is rendered into an iframe with an EMPTY `sandbox` attribute, the\n * maximally restrictive form: no scripts, no forms, no popups, no top-level\n * navigation and, critically, no same-origin. The frame gets an opaque\n * origin, so nothing inside it can reach the console's cookies, storage or\n * DOM. `srcDoc` rather than a URL keeps the markup from ever being served\n * from the console's own origin, where the sandbox attribute would be the\n * only thing between tenant HTML and a live session.\n */\nexport function EmailDesignPreview(props: EmailDesignPreviewProps) {\n const {\n hostId,\n nodes: rawNodes,\n text,\n loading,\n subject,\n preheader,\n emptyMessage,\n note,\n } = props\n\n /*\n * The origin the preview's images are fetched from.\n *\n * A picked image is stored as a `media:` reference that resolves to the\n * site-RELATIVE CDN path, and the console mounts that route too — but a\n * sandboxed `srcDoc` frame has an opaque origin, so a relative `src` inside\n * it has nothing dependable to resolve against. Read after mount rather\n * than during render because a server render has no `window`, and a value\n * that differs between the two renders is a hydration mismatch on a string\n * the size of a whole email.\n */\n const [origin, setOrigin] = useState('')\n useEffect(() => setOrigin(window.location.origin), [])\n\n const nodes = useMemo(\n () => decodeStoredNodes<Record<string, any>>(rawNodes) ?? {},\n [rawNodes],\n )\n const productBlocks = useMemo(\n () =>\n Object.values(nodes).filter(\n (node: any) => node?.componentId === 'emailProduct',\n ).length,\n [nodes],\n )\n const rendered = useMemo(() => {\n if (!Object.keys(nodes).length) {\n /*\n * A plain-text message renders through the send path's own text\n * synthesizer, so the frame below draws what was actually mailed.\n * `renderTextEmailHtml` escapes the body itself, which is why no\n * sanitizer is threaded here — there is no author markup to strip.\n */\n const body = String(text ?? '').trim()\n if (!body) return null\n const html = renderTextEmailHtml(body, subject, preheader)\n return html ? { html, text: body } : null\n }\n return renderEmailHtml({\n /*\n * The same policy the send path applies, so what is previewed here is\n * what an inbox receives. The sandboxed frame below is a second wall,\n * not this one's replacement — a preview that showed markup the mailed\n * copy strips would be a preview of a different email.\n */\n sanitize: sanitizeAuthorHtml,\n nodes,\n // Besigner maps are rooted at '_@_'. Rendering one as the default\n // 'root' finds no root and emits an empty 600px shell.\n rootId: EMAIL_NODE_ROOT_ID,\n subject,\n preheader,\n mediaOrigin: origin,\n mediaHostId: hostId,\n })\n }, [nodes, text, subject, preheader, origin, hostId])\n\n if (!rendered) {\n return (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {loading ? 'Loading this email…' : emptyMessage}\n </Typography>\n )\n }\n\n return (\n <Stack spacing={1}>\n <Typography variant=\"body2\">\n {subject\n ? `Subject: ${subject}`\n : 'This template carries no subject line of its own — each ' +\n 'email supplies one.'}\n </Typography>\n <Box\n component=\"iframe\"\n title=\"Email preview\"\n /*\n * EMPTY sandbox — every restriction on, nothing allowed back. The\n * markup below is authored outside this console and may contain a\n * custom-HTML block, so it is rendered with no scripts, no forms, no\n * navigation and an opaque origin: it cannot reach the console's\n * session, storage or DOM even though it is drawn inside the console.\n */\n sandbox=\"\"\n referrerPolicy=\"no-referrer\"\n srcDoc={rendered.html}\n sx={{\n width: '100%',\n // Fixed, with the frame's own scrollbar. A sandbox with no scripts\n // is a frame that cannot measure or report its content height, and\n // an iframe cannot size to its content on its own.\n height: 640,\n border: 1,\n borderColor: 'divider',\n borderRadius: 1,\n bgcolor: 'common.white',\n }}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Rendered by the same code that builds the mail. Merge tokens are ' +\n 'left standing — a real send fills them from each recipient.'}\n </Typography>\n {productBlocks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${productBlocks} product ${\n productBlocks === 1 ? 'block is' : 'blocks are'\n } not drawn here — each resolves against the catalog when an email ` +\n 'sends, so what it shows depends on the product at that moment.'}\n </Typography>\n ) : null}\n {note ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {note}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nEmailDesignPreview.displayName = 'EmailDesignPreview'\n\nexport default EmailDesignPreview\n"],"names":["decodeStoredNodes","EMAIL_NODE_ROOT_ID","renderEmailHtml","renderTextEmailHtml","sanitizeAuthorHtml","Box","Stack","Typography","useEffect","useMemo","useState","EmailDesignPreview","props","hostId","nodes","rawNodes","text","loading","subject","preheader","emptyMessage","note","origin","setOrigin","window","location","productBlocks","Object","values","filter","node","componentId","length","rendered","keys","body","String","trim","html","sanitize","rootId","mediaOrigin","mediaHostId","variant","color","spacing","component","title","sandbox","referrerPolicy","srcDoc","sx","width","height","border","borderColor","borderRadius","bgcolor","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,iBAAiB,QAAQ,eAAc;AAChD,SACEC,kBAAkB,EAClBC,eAAe,EACfC,mBAAmB,QACd,2BAA0B;AACjC,SAASC,kBAAkB,QAAQ,qCAAoC;AACvE,SAASC,GAAG,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACtD,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AA6BpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,mBAAmBC,KAA8B;IAC/D,MAAM,EACJC,MAAM,EACNC,OAAOC,QAAQ,EACfC,IAAI,EACJC,OAAO,EACPC,OAAO,EACPC,SAAS,EACTC,YAAY,EACZC,IAAI,EACL,GAAGT;IAEJ;;;;;;;;;;GAUC,GACD,MAAM,CAACU,QAAQC,UAAU,GAAGb,SAAS;IACrCF,UAAU,IAAMe,UAAUC,OAAOC,QAAQ,CAACH,MAAM,GAAG,EAAE;IAErD,MAAMR,QAAQL,QACZ;YAAMT;gBAAAA,qBAAAA,kBAAuCe,qBAAvCf,qBAAoD,CAAC;OAC3D;QAACe;KAAS;IAEZ,MAAMW,gBAAgBjB,QACpB,IACEkB,OAAOC,MAAM,CAACd,OAAOe,MAAM,CACzB,CAACC,OAAcA,CAAAA,wBAAAA,KAAMC,WAAW,MAAK,gBACrCC,MAAM,EACV;QAAClB;KAAM;IAET,MAAMmB,WAAWxB,QAAQ;QACvB,IAAI,CAACkB,OAAOO,IAAI,CAACpB,OAAOkB,MAAM,EAAE;YAC9B;;;;;OAKC,GACD,MAAMG,OAAOC,OAAOpB,eAAAA,OAAQ,IAAIqB,IAAI;YACpC,IAAI,CAACF,MAAM,OAAO;YAClB,MAAMG,OAAOnC,oBAAoBgC,MAAMjB,SAASC;YAChD,OAAOmB,OAAO;gBAAEA;gBAAMtB,MAAMmB;YAAK,IAAI;QACvC;QACA,OAAOjC,gBAAgB;YACrB;;;;;OAKC,GACDqC,UAAUnC;YACVU;YACA,kEAAkE;YAClE,uDAAuD;YACvD0B,QAAQvC;YACRiB;YACAC;YACAsB,aAAanB;YACboB,aAAa7B;QACf;IACF,GAAG;QAACC;QAAOE;QAAME;QAASC;QAAWG;QAAQT;KAAO;IAEpD,IAAI,CAACoB,UAAU;QACb,qBACE,KAAC1B;YAAWoC,SAAQ;YAAQC,OAAM;sBAC/B3B,UAAU,wBAAwBG;;IAGzC;IAEA,qBACE,MAACd;QAAMuC,SAAS;;0BACd,KAACtC;gBAAWoC,SAAQ;0BACjBzB,UACG,CAAC,SAAS,EAAEA,SAAS,GACrB,6DACA;;0BAEN,KAACb;gBACCyC,WAAU;gBACVC,OAAM;gBACN;;;;;;SAMC,GACDC,SAAQ;gBACRC,gBAAe;gBACfC,QAAQjB,SAASK,IAAI;gBACrBa,IAAI;oBACFC,OAAO;oBACP,mEAAmE;oBACnE,mEAAmE;oBACnE,mDAAmD;oBACnDC,QAAQ;oBACRC,QAAQ;oBACRC,aAAa;oBACbC,cAAc;oBACdC,SAAS;gBACX;;0BAEF,KAAClD;gBAAWoC,SAAQ;gBAAUC,OAAM;0BACjC,sEACC;;YAEHlB,8BACC,KAACnB;gBAAWoC,SAAQ;gBAAUC,OAAM;0BACjC,GAAGlB,cAAc,SAAS,EACzBA,kBAAkB,IAAI,aAAa,aACpC,kEAAkE,CAAC,GAClE;iBAEF;YACHL,qBACC,KAACd;gBAAWoC,SAAQ;gBAAUC,OAAM;0BACjCvB;iBAED;;;AAGV;AACAV,mBAAmB+C,WAAW,GAAG;AAEjC,eAAe/C,mBAAkB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-design-preview.tsx"],"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'use client'\n\nimport { decodeStoredNodes } from '@aglyn/aglyn'\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n renderTextEmailHtml,\n} from '@aglyn/shared-util-email'\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport { collectReferencedComponentIds } from '@aglyn/aglyn/app-utils/compose-reusable-components'\nimport {\n composeReferencedComponents,\n type ReadComponentDocument,\n type StoredComponentDocument,\n} from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { Box, Stack, Typography } from '@mui/material'\nimport { doc, getDoc, type Firestore } from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\n\n/**\n * One of the site's component documents through the CLIENT SDK, as the\n * on-demand loader reads it. `snapshot.data()` hands a compressed definition\n * back as a Firestore `Bytes`, which `readStoredComponentTree` decodes.\n */\nfunction clientComponentReader(\n firestore: Firestore,\n hostId: string,\n): ReadComponentDocument {\n return async (componentId) => {\n const snapshot = await getDoc(\n doc(firestore, 'hosts', hostId, 'components', componentId),\n )\n return snapshot.exists()\n ? (snapshot.data() as StoredComponentDocument)\n : null\n }\n}\n\nexport interface EmailDesignPreviewProps {\n hostId: string\n /** The version document's raw `nodes` field, in any of its stored forms. */\n nodes: unknown\n /**\n * The plain-text body, for a message composed without a template.\n *\n * Such a message is not previewless — the send path synthesizes an HTML\n * part for it through `renderTextEmailHtml`, and that part is what the\n * inbox receives. Rendering the same function here shows the mailed copy\n * rather than reporting that there is nothing to show. Ignored when there\n * are nodes: a template supersedes it, exactly as it does at send time.\n */\n text?: string\n /** The version document has not arrived yet, as against not existing. */\n loading?: boolean\n subject?: string\n preheader?: string\n /** What to say when there is nothing to draw. */\n emptyMessage: string\n /**\n * An extra line under the frame — for a surface where the rendering has a\n * caveat of its own, such as a message sent before the template changed.\n */\n note?: string\n}\n\n/**\n * THE EMAIL AS AN INBOX RECEIVES IT, DRAWN SAFELY.\n *\n * ## The same renderer the send path calls\n *\n * `renderEmailHtml` is the function `campaign-send.ts` calls, given the same\n * `rootId` and the same stored node map — so what is drawn here is the\n * document that gets mailed, rather than a second rendering that agrees with\n * it until it does not. The besigner is NOT that: it draws canvas nodes as\n * React in a browser, which is a different pipeline with different output.\n *\n * Two inputs differ from a real send and both are said on screen. Merge\n * tokens are left standing — no merge map is passed, and the renderer leaves\n * unknown tokens visible for exactly this reason, so `{{contact.firstName}}`\n * shows where personalization lands instead of a blank. Product blocks\n * resolve against the catalog at send time and are not resolved here, because\n * doing so would put a catalog read on every preview for a picture that can\n * change between now and the send anyway.\n *\n * The site's reusable blocks are NOT one of the differences (AGL-3287). A\n * shared header or footer placed in the design is grafted from the site's\n * published components before rendering, by the same composition the send\n * runs, so the preview shows the header the inbox will get. Only the\n * components the design places are read, in parallel, and a design that\n * places none costs no read.\n *\n * ## Sandboxed, because the HTML is tenant-authored\n *\n * The design is written by a site's own editors — or by a marketplace\n * publisher, on an installed template — and may contain a custom-HTML block.\n * It is rendered into an iframe with an EMPTY `sandbox` attribute, the\n * maximally restrictive form: no scripts, no forms, no popups, no top-level\n * navigation and, critically, no same-origin. The frame gets an opaque\n * origin, so nothing inside it can reach the console's cookies, storage or\n * DOM. `srcDoc` rather than a URL keeps the markup from ever being served\n * from the console's own origin, where the sandbox attribute would be the\n * only thing between tenant HTML and a live session.\n */\nexport function EmailDesignPreview(props: EmailDesignPreviewProps) {\n const {\n hostId,\n nodes: rawNodes,\n text,\n loading,\n subject,\n preheader,\n emptyMessage,\n note,\n } = props\n\n /*\n * The origin the preview's images are fetched from.\n *\n * A picked image is stored as a `media:` reference that resolves to the\n * site-RELATIVE CDN path, and the console mounts that route too — but a\n * sandboxed `srcDoc` frame has an opaque origin, so a relative `src` inside\n * it has nothing dependable to resolve against. Read after mount rather\n * than during render because a server render has no `window`, and a value\n * that differs between the two renders is a hydration mismatch on a string\n * the size of a whole email.\n */\n const [origin, setOrigin] = useState('')\n useEffect(() => setOrigin(window.location.origin), [])\n\n const firestore = useFirestore()\n const stored = useMemo(\n () => decodeStoredNodes<Record<string, any>>(rawNodes) ?? {},\n [rawNodes],\n )\n /*\n * THE SITE'S REUSABLE BLOCKS, GRAFTED AS THE SEND GRAFTS THEM (AGL-3287).\n *\n * Tagged with the map it was composed FROM, so a stale answer — the design\n * changed while its components were being read — is never drawn as the\n * current one. Until the answer for this map arrives the frame shows the\n * loading line rather than the email with its header missing, which would be\n * a preview of a message nobody sends.\n *\n * A failed read draws the design without the blocks it could not load, and\n * says so: an empty space presented as the mail is the one thing a preview\n * must not do silently.\n */\n const placesComponents = useMemo(\n () => collectReferencedComponentIds(stored).size > 0,\n [stored],\n )\n const [composition, setComposition] = useState<{\n source: Record<string, any>\n hostId: string\n nodes: Record<string, any>\n failed: boolean\n } | null>(null)\n /** The composition of THIS map for THIS site, once there is one. */\n const composed =\n composition?.source === stored && composition.hostId === hostId\n ? composition\n : null\n useEffect(() => {\n // Composed already: a re-render asks for nothing more, so the reads are\n // one per design shown rather than one per render.\n if (!placesComponents || composed) return\n let active = true\n const settle = (nodes: Record<string, any>, failed: boolean) => {\n if (active) setComposition({ source: stored, hostId, nodes, failed })\n }\n composeReferencedComponents(\n stored,\n clientComponentReader(firestore, hostId),\n )\n .then((nodes) => settle(nodes, false))\n .catch((error) => {\n console.error(error)\n settle(stored, true)\n })\n return () => {\n active = false\n }\n }, [stored, placesComponents, composed, firestore, hostId])\n const composing = placesComponents && !composed\n /** What is drawn: the design with its blocks grafted; `null` while grafting. */\n const nodes: Record<string, any> | null = !placesComponents\n ? stored\n : (composed?.nodes ?? null)\n\n const productBlocks = useMemo(\n () =>\n Object.values(nodes ?? {}).filter(\n (node: any) => node?.componentId === 'emailProduct',\n ).length,\n [nodes],\n )\n const rendered = useMemo(() => {\n // Still reading the components this design places.\n if (!nodes) return null\n if (!Object.keys(nodes).length) {\n /*\n * A plain-text message renders through the send path's own text\n * synthesizer, so the frame below draws what was actually mailed.\n * `renderTextEmailHtml` escapes the body itself, which is why no\n * sanitizer is threaded here — there is no author markup to strip.\n */\n const body = String(text ?? '').trim()\n if (!body) return null\n const html = renderTextEmailHtml(body, subject, preheader)\n return html ? { html, text: body } : null\n }\n return renderEmailHtml({\n /*\n * The same policy the send path applies, so what is previewed here is\n * what an inbox receives. The sandboxed frame below is a second wall,\n * not this one's replacement — a preview that showed markup the mailed\n * copy strips would be a preview of a different email.\n */\n sanitize: sanitizeAuthorHtml,\n nodes,\n // Besigner maps are rooted at '_@_'. Rendering one as the default\n // 'root' finds no root and emits an empty 600px shell.\n rootId: EMAIL_NODE_ROOT_ID,\n subject,\n preheader,\n mediaOrigin: origin,\n mediaHostId: hostId,\n })\n }, [nodes, text, subject, preheader, origin, hostId])\n\n if (!rendered) {\n return (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {loading || composing ? 'Loading this email…' : emptyMessage}\n </Typography>\n )\n }\n\n return (\n <Stack spacing={1}>\n <Typography variant=\"body2\">\n {subject\n ? `Subject: ${subject}`\n : 'This template carries no subject line of its own — each ' +\n 'email supplies one.'}\n </Typography>\n <Box\n component=\"iframe\"\n title=\"Email preview\"\n /*\n * EMPTY sandbox — every restriction on, nothing allowed back. The\n * markup below is authored outside this console and may contain a\n * custom-HTML block, so it is rendered with no scripts, no forms, no\n * navigation and an opaque origin: it cannot reach the console's\n * session, storage or DOM even though it is drawn inside the console.\n */\n sandbox=\"\"\n referrerPolicy=\"no-referrer\"\n srcDoc={rendered.html}\n sx={{\n width: '100%',\n // Fixed, with the frame's own scrollbar. A sandbox with no scripts\n // is a frame that cannot measure or report its content height, and\n // an iframe cannot size to its content on its own.\n height: 640,\n border: 1,\n borderColor: 'divider',\n borderRadius: 1,\n bgcolor: 'common.white',\n }}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Rendered by the same code that builds the mail. Merge tokens are ' +\n 'left standing — a real send fills them from each recipient.'}\n </Typography>\n {productBlocks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${productBlocks} product ${\n productBlocks === 1 ? 'block is' : 'blocks are'\n } not drawn here — each resolves against the catalog when an email ` +\n 'sends, so what it shows depends on the product at that moment.'}\n </Typography>\n ) : null}\n {composed?.failed ? (\n <Typography variant=\"caption\" color=\"warning.main\">\n {'A reusable block in this email could not be loaded, so it is ' +\n 'not drawn here.'}\n </Typography>\n ) : null}\n {note ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {note}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nEmailDesignPreview.displayName = 'EmailDesignPreview'\n\nexport default EmailDesignPreview\n"],"names":["decodeStoredNodes","EMAIL_NODE_ROOT_ID","renderEmailHtml","renderTextEmailHtml","sanitizeAuthorHtml","collectReferencedComponentIds","composeReferencedComponents","useFirestore","Box","Stack","Typography","doc","getDoc","useEffect","useMemo","useState","clientComponentReader","firestore","hostId","componentId","snapshot","exists","data","EmailDesignPreview","props","nodes","rawNodes","text","loading","subject","preheader","emptyMessage","note","origin","setOrigin","window","location","stored","placesComponents","size","composition","setComposition","composed","source","active","settle","failed","then","catch","error","console","composing","productBlocks","Object","values","filter","node","length","rendered","keys","body","String","trim","html","sanitize","rootId","mediaOrigin","mediaHostId","variant","color","spacing","component","title","sandbox","referrerPolicy","srcDoc","sx","width","height","border","borderColor","borderRadius","bgcolor","displayName"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type PluginDraftCheck, type PluginResourceDraftWriter } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts';
|
|
17
|
+
import { type PluginDraftCheck, type PluginDraftCheckContext, type PluginResourceDraftWriter } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts';
|
|
18
18
|
/**
|
|
19
19
|
* AN EMAIL DESIGN ANOTHER PLUGIN ASKS FOR (AGL-2912).
|
|
20
20
|
*
|
|
@@ -81,13 +81,20 @@ export type EmailDesignContentRead = {
|
|
|
81
81
|
};
|
|
82
82
|
/** The content as the writer stores it, or every problem that stops it. */
|
|
83
83
|
export declare function readEmailDesignContent(content: Readonly<Record<string, unknown>>): EmailDesignContentRead;
|
|
84
|
+
/** A placement still standing once the site's components are grafted. */
|
|
85
|
+
export declare const EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM = "A reusable block names a component this site has not published, so it would send empty";
|
|
84
86
|
/**
|
|
85
87
|
* Whether content renders as an email: well-formed, every link one an inbox
|
|
86
88
|
* can open, and a message once rendered through the send path's own renderer.
|
|
89
|
+
*
|
|
90
|
+
* Judged COMPOSED (AGL-3287). The reusable blocks the design places — a shared
|
|
91
|
+
* header, a footer — are grafted from `context.components` first, as every send
|
|
92
|
+
* grafts them, so a link inside a footer is checked like any other and a
|
|
93
|
+
* header's text counts toward the message. A placement still standing after
|
|
94
|
+
* that names a component the caller could not supply, and would go out as
|
|
95
|
+
* nothing; it is refused rather than passed as a design that renders.
|
|
87
96
|
*/
|
|
88
|
-
export declare function checkEmailDesignContent(content: Readonly<Record<string, unknown>>, context:
|
|
89
|
-
hostId: string;
|
|
90
|
-
}): PluginDraftCheck;
|
|
97
|
+
export declare function checkEmailDesignContent(content: Readonly<Record<string, unknown>>, context: PluginDraftCheckContext): PluginDraftCheck;
|
|
91
98
|
export interface EmailDesignDraftWriterDeps {
|
|
92
99
|
/** The Admin SDK handle; specs hand in a double. */
|
|
93
100
|
firestore?: () => Firestore;
|
|
@@ -22,6 +22,7 @@ import { NON_PAGE_SCREEN_MAX_PER_HOST, nonPageScreenIds, SCREEN_KIND_EMAIL } fro
|
|
|
22
22
|
import { encodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
|
|
23
23
|
import { CANVAS_ROOT_ELEMENT_ID } from "@aglyn/aglyn/foundation/constants/canvas";
|
|
24
24
|
import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/plugin-resource-drafts";
|
|
25
|
+
import { composeReusableComponentNodes, REUSABLE_INSTANCE_COMPONENT_ID } from "@aglyn/aglyn/app-utils/compose-reusable-components";
|
|
25
26
|
import { hasSafeLinkScheme } from "@aglyn/shared-util-http/safe-url-scheme";
|
|
26
27
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
27
28
|
import { emailPlainTextState, renderRecipientEmail } from "@aglyn/aglyn/app-utils/recipient-email-render";
|
|
@@ -155,16 +156,32 @@ function isRecord(value) {
|
|
|
155
156
|
}
|
|
156
157
|
return hasSafeLinkScheme(href) ? null : `${label} links to something that is not a web address`;
|
|
157
158
|
}
|
|
159
|
+
/** A placement still standing once the site's components are grafted. */ export const EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM = 'A reusable block names a component this site has not published, so it would send empty';
|
|
158
160
|
/**
|
|
159
161
|
* Whether content renders as an email: well-formed, every link one an inbox
|
|
160
162
|
* can open, and a message once rendered through the send path's own renderer.
|
|
163
|
+
*
|
|
164
|
+
* Judged COMPOSED (AGL-3287). The reusable blocks the design places — a shared
|
|
165
|
+
* header, a footer — are grafted from `context.components` first, as every send
|
|
166
|
+
* grafts them, so a link inside a footer is checked like any other and a
|
|
167
|
+
* header's text counts toward the message. A placement still standing after
|
|
168
|
+
* that names a component the caller could not supply, and would go out as
|
|
169
|
+
* nothing; it is refused rather than passed as a design that renders.
|
|
161
170
|
*/ export function checkEmailDesignContent(content, context) {
|
|
162
171
|
const read = readEmailDesignContent(content);
|
|
163
172
|
if (read.ok === false) return read;
|
|
164
|
-
const { value
|
|
173
|
+
const { value } = read;
|
|
174
|
+
// Property values and attribute overrides apply in the graft; style
|
|
175
|
+
// overrides land in `sx`, which the mail renderer never reads.
|
|
176
|
+
const nodes = composeReusableComponentNodes(value.nodes, context.components);
|
|
177
|
+
const reachable = nodes === value.nodes ? read.reachable : reachableIds(nodes).ids;
|
|
165
178
|
const problems = [];
|
|
166
179
|
for (const id of reachable){
|
|
167
|
-
const node =
|
|
180
|
+
const node = nodes[id];
|
|
181
|
+
if (node.componentId === REUSABLE_INSTANCE_COMPONENT_ID) {
|
|
182
|
+
problems.push(EMAIL_DESIGN_UNRESOLVED_BLOCK_PROBLEM);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
168
185
|
if (node.componentId === 'emailButton') {
|
|
169
186
|
var _node_props;
|
|
170
187
|
const problem = linkProblem('A button', (_node_props = node.props) == null ? void 0 : _node_props['href'], true);
|
|
@@ -182,7 +199,7 @@ function isRecord(value) {
|
|
|
182
199
|
content: {
|
|
183
200
|
mode: 'design',
|
|
184
201
|
template: {
|
|
185
|
-
nodes
|
|
202
|
+
nodes,
|
|
186
203
|
subject: value.subject,
|
|
187
204
|
preheader: value.preheader
|
|
188
205
|
}
|
|
@@ -205,7 +222,9 @@ function isRecord(value) {
|
|
|
205
222
|
facts: {
|
|
206
223
|
htmlBytes: new TextEncoder().encode(rendered.html).length,
|
|
207
224
|
messageText: rendered.messageText,
|
|
208
|
-
blocks
|
|
225
|
+
// The blocks the design itself holds, as stored — a placed component
|
|
226
|
+
// counts once, however many blocks it expands into.
|
|
227
|
+
blocks: read.reachable.size,
|
|
209
228
|
// No part is written by hand, so the text a campaign sends is the design's own.
|
|
210
229
|
plainText: emailPlainTextState({})
|
|
211
230
|
}
|
|
@@ -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 PluginDraftRecord,\n type PluginDraftRefusal,\n type PluginDraftWrite,\n type PluginResourceDraftWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\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/**\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 */\nexport function checkEmailDesignContent(\n content: Readonly<Record<string, unknown>>,\n context: { hostId: string },\n): PluginDraftCheck {\n const read = readEmailDesignContent(content)\n if (read.ok === false) return read\n const { value, reachable } = read\n const problems: string[] = []\n for (const id of reachable) {\n const node = value.nodes[id]\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: value.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 blocks: 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","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","checkEmailDesignContent","context","read","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 { 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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"}
|
|
@@ -212,7 +212,7 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
|
|
|
212
212
|
* — resolves to itself.
|
|
213
213
|
*/ const group = await consentGroupForSite(input.hostId);
|
|
214
214
|
const [suppression, stored] = await Promise.all([
|
|
215
|
-
suppressionFor(input.hostId, addresses),
|
|
215
|
+
suppressionFor(input.hostId, group, addresses),
|
|
216
216
|
storedConsentFor(input.hostId, group, addresses)
|
|
217
217
|
]);
|
|
218
218
|
const unrecorded = readMarketingBasis(null, group);
|
|
@@ -267,14 +267,18 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
|
|
|
267
267
|
* for the enrollment check and the send-time check to disagree about — which
|
|
268
268
|
* is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`
|
|
269
269
|
* exists to stop.
|
|
270
|
-
|
|
270
|
+
*
|
|
271
|
+
* Read across the site's consent group, as the send reads it: somebody who
|
|
272
|
+
* unsubscribed from a sibling of a declared group is refused here too, rather
|
|
273
|
+
* than enrolled onto a list whose campaigns will never reach them.
|
|
274
|
+
*/ async function suppressionFor(hostId, group, addresses) {
|
|
271
275
|
const refusals = new Map();
|
|
272
276
|
if (!addresses.length) return refusals;
|
|
273
|
-
const sendable = new Set(await filterSendableForHost(hostId, addresses));
|
|
277
|
+
const sendable = new Set(await filterSendableForHost(hostId, addresses, undefined, group));
|
|
274
278
|
const blocked = addresses.filter((email)=>!sendable.has(email));
|
|
275
279
|
if (!blocked.length) return refusals;
|
|
276
280
|
// Only the blocked ones, and only to say WHICH list. Survivors of the
|
|
277
|
-
// platform half are held by this site's own list.
|
|
281
|
+
// platform half are held by this site's own list, or a group sibling's.
|
|
278
282
|
const platformSendable = new Set(await filterSuppressedEmails(blocked));
|
|
279
283
|
for (const email of blocked){
|
|
280
284
|
refusals.set(email, platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-gate.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 * THE GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.\n *\n * Who is allowed to change a list's membership, and what is true about each\n * address they name. Three route modules ask it — the audience card's add and\n * preview, the filter search, and the file importer — and they ask this one\n * copy of it.\n *\n * ## Why it is a module of its own\n *\n * It began inside `server-console.ts`, beside its first two callers, and that\n * was right while there were two. The importer made three, and an importer is\n * exactly the surface where a second, laxer idea of \"may this person be\n * enrolled\" is most tempting: a file is bulk, bulk is slow, and skipping the\n * per-address consent read is the obvious saving. A bulk path that reached the\n * membership without the suppression check and the attestation would be a way\n * to enroll precisely the people the one-at-a-time path refuses, which is the\n * defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry\n * for.\n *\n * Sharing the gate is also what stops the cheaper failure: two modules that\n * merely agree today, with nothing in either one saying the other exists.\n *\n * ## What it decides, and what it deliberately does not\n *\n * {@link resolveAddresses} answers the questions that are FACTS about a\n * person — a stored refusal, a suppression on either list, an opt-in already\n * on record, a line that is not an address. It never consults an attestation,\n * because an attestation is a fact about the operator and folding it in here\n * would make the preview's numbers move as the box was ticked. The basis is\n * decided once, at the write, by `assignmentBasis`.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentReadout,\n isOrgWideMember,\n normalizeContactEmail,\n readMarketingBasis,\n type AddressRefusal,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n filterSendableForHost,\n filterSuppressedEmails,\n firebaseAdmin,\n getOrgForHost,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n\n/**\n * The most addresses one request may name.\n *\n * A ceiling on the WRITE, not on the audience. It bounds the work one request\n * does — the resolution below is a handful of reads per address — and it can\n * never refuse a person already enrolled or make room by removing one, which\n * is the difference between a batch size and a capacity limit. Nothing in the\n * product caps list membership: `contactsPerHost` is the audience band and it\n * is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody\n * on a list writes no contact. If a membership ceiling is ever introduced,\n * this function is where it refuses, because this is where something is\n * added.\n */\nexport const LIST_MEMBER_BATCH_MAX = 100\n\n/** How many contact addresses one `in` query may carry. */\nconst CONTACT_LOOKUP_CHUNK = 30\n\n/** Everything a list-membership route needs, or the refusal to send back. */\nexport type ListContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n listRef: FirebaseFirestore.DocumentReference\n listName: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, and about which list.\n *\n * ## Two gates, not one — the same two the Inbox assignment route applies\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the rules put both\n * behind `isOrgWideMember()`. An editor invited to ONE site is an org member\n * with `allHosts: false`, and gating an org-wide write on the host role alone\n * would let a single-site collaborator enroll people into an audience every\n * other site in the org can mail — and, through the preview, read the consent\n * record of any address they care to type. The Admin SDK evaluates no rules,\n * so this route is the enforcement rather than an echo of it.\n *\n * The plugin's own gates sit above this and are the dispatcher's: a workspace\n * that has switched the email plugin off, or a site it is disabled for, or a\n * release flag that has not reached this org, all 404 before a handler runs.\n */\nexport async function resolveListContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<ListContext> {\n const hostId = String(req.body?.hostId ?? '')\n const listId = String(req.body?.listId ?? '')\n if (!hostId || !listId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or listId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\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 { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String(\n (await getOrgForHost(hostId).catch(() => null))?.orgId ?? '',\n )\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so changing who ' +\n 'is on one needs organization-wide access rather than access to ' +\n 'this site.',\n },\n }\n }\n\n const listRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown list' } }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n listRef,\n listName: String(listSnapshot.get('name') ?? listId),\n }\n}\n\n/** What would happen to one address, and why. */\nexport interface AddressVerdict {\n /** Exactly what the operator 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 /**\n * Why this address can never go on the list, or `null`.\n *\n * A HARD refusal only — a stored `declined`, either suppression list, or a\n * line that is not an address. `no-basis` is deliberately NOT one of them:\n * \"you have not said you have permission yet\" is an unanswered question,\n * not a refusal, and reporting it as one would list the very people an\n * attestation is about to admit under the heading of people it cannot.\n * That distinction is {@link requiresAttestation}.\n */\n refusal: AssignmentRefusal | null\n /** True when only an attestation stands between this address and the list. */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/** The whole answer, per address and in totals. */\nexport interface AddressResolution {\n verdicts: AddressVerdict[]\n /** Addresses a stored opt-in already covers — no attestation needed. */\n optedIn: number\n /** Addresses that need the operator to state they have permission. */\n needAttestation: number\n /** Addresses nothing can enroll, whatever the operator says. */\n refused: number\n}\n\n/**\n * The resolution plus the consent records it was computed from.\n *\n * The records ride along so the write path can derive the basis it STORES\n * from the same read the verdicts were built on. Re-reading them would be a\n * second answer to \"what does this person's record say\", between which a\n * concurrent edit fits — and the operator would have attested against the\n * first one.\n */\nexport interface ResolvedBatch extends AddressResolution {\n stored: Map<string, MarketingConsentRecord>\n /**\n * The consent group the resolution was made against, so the WRITE records\n * a basis for exactly the controller the verdicts were computed for.\n *\n * Returned rather than re-resolved by the caller for the same reason\n * `stored` is: two resolutions of the same question are two answers a\n * concurrent edit can fit between, and the operator attested against the\n * first one.\n */\n group: ConsentGroup\n}\n\n/**\n * Every address, normalized, deduplicated and put through the policy.\n *\n * ## Deduplicated on the NORMALIZED address, and reported once\n *\n * A pasted column routinely names the same person twice with different\n * casing, and `enrollListMember` keys the membership from the normalized\n * address, so two lines would be one row. Counting them twice would tell the\n * operator they are attesting for more people than they are.\n *\n * ## An unusable line is REPORTED, never dropped\n *\n * A paste that silently discarded its malformed lines would tell an operator\n * that 100 addresses went on the list when 94 did, and the six they never\n * hear about are the six they typed wrong. Every input line comes back with a\n * verdict, including the ones that are not addresses at all.\n *\n * ## Suppression is attributed, and costs nothing when there is none\n *\n * `filterSendableForHost` answers both lists in one pass and fails CLOSED, so\n * it is asked first; only the addresses it refused are put through the\n * platform half again to find out WHICH list holds them. In the ordinary case\n * that second call is never made.\n */\nexport async function resolveAddresses(input: {\n hostId: string\n inputs: readonly string[]\n}): Promise<ResolvedBatch> {\n const seen = new Set<string>()\n const rows: Array<{ input: string; email: string | null }> = []\n for (const raw of input.inputs) {\n const email = normalizeContactEmail(raw)\n if (email && seen.has(email)) continue\n if (email) seen.add(email)\n rows.push({ input: String(raw ?? '').trim(), email })\n }\n\n const addresses = rows\n .map((row) => row.email)\n .filter((email): email is string => Boolean(email))\n\n /*\n * The group first, because every answer below is about a CONTROLLER and\n * not about a site: a business running three sites as one sender enrolls\n * into all three at once, and an agency's client — which declared no group\n * — resolves to itself.\n */\n const group = await consentGroupForSite(input.hostId)\n const [suppression, stored] = await Promise.all([\n suppressionFor(input.hostId, addresses),\n storedConsentFor(input.hostId, group, addresses),\n ])\n\n const unrecorded: MarketingConsentRecord = readMarketingBasis(null, group)\n const verdicts = rows.map((row): AddressVerdict => {\n if (!row.email) {\n return {\n input: row.input,\n email: null,\n refusal: 'unroutable-address',\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['unroutable-address'],\n }\n }\n const record = stored.get(row.email) ?? unrecorded\n const readout = assignmentReadout({\n stored: record,\n suppression: suppression.get(row.email) ?? null,\n })\n return {\n input: row.input,\n email: row.email,\n /*\n * Straight off the readout, with no attestation in the question.\n *\n * `assignmentReadout` answers what is TRUE about this person — a stored\n * refusal, a suppression, an opt-in, or nothing on record — and none of\n * those changes because the operator did or did not tick a box. Feeding\n * the flag in here would make the preview's answer depend on it, so the\n * count the operator stands behind would move as they answered. The\n * basis itself is decided once, at the write, by `assignmentBasis`.\n */\n refusal: readout.enrollable\n ? null\n : (suppression.get(row.email) ?? 'declined'),\n requiresAttestation: readout.requiresAttestation,\n summary: readout.summary,\n }\n })\n\n return {\n verdicts,\n stored,\n group,\n optedIn: verdicts.filter(\n (verdict) => !verdict.refusal && !verdict.requiresAttestation,\n ).length,\n needAttestation: verdicts.filter((verdict) => verdict.requiresAttestation)\n .length,\n refused: verdicts.filter(\n (verdict) => verdict.refusal && !verdict.requiresAttestation,\n ).length,\n }\n}\n\n/**\n * Which of these addresses is suppressed, and by which list.\n *\n * Composed from the shipped helpers rather than reading the two collections\n * here: normalization, de-duplication and the fail-closed posture live in\n * `email-suppression.ts`, and a second copy of them is a second set of rules\n * for the enrollment check and the send-time check to disagree about — which\n * is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`\n * exists to stop.\n */\nasync function suppressionFor(\n hostId: string,\n addresses: readonly string[],\n): Promise<Map<string, AddressRefusal>> {\n const refusals = new Map<string, AddressRefusal>()\n if (!addresses.length) return refusals\n const sendable = new Set(await filterSendableForHost(hostId, addresses))\n const blocked = addresses.filter((email) => !sendable.has(email))\n if (!blocked.length) return refusals\n // Only the blocked ones, and only to say WHICH list. Survivors of the\n // platform half are held by this site's own list.\n const platformSendable = new Set(await filterSuppressedEmails(blocked))\n for (const email of blocked) {\n refusals.set(\n email,\n platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform',\n )\n }\n return refusals\n}\n\n/**\n * The stored consent facts for these addresses, off the org's contacts.\n *\n * Read UNSCOPED, deliberately, exactly as the Inbox route reads it:\n * `scopedToHost` narrows an org collection to what one site may see, and a\n * refusal filtered out by that narrowing is a refusal this route would then\n * step over — the failure mode is enrolling somebody who said no. It is safe\n * because the caller has already been proved an org-wide member, which is the\n * tier the rules grant the whole org's contacts to.\n *\n * ## A refusal wins over an opt-in when the CRM holds both\n *\n * Nothing guarantees one contact per address — the collection is keyed by\n * resource id, not by email — so two records for one person can disagree.\n * Taking whichever the query happened to answer first would make the outcome\n * depend on document order. A recorded refusal is the answer whenever one\n * exists, which is the same precedence `assignmentBasis` applies within a\n * single record.\n *\n * A failed read falls to `declined` for the whole batch, for the reason the\n * Inbox route states: a read that throws can neither say the person consented\n * nor that they refused, and the direction that costs a retry is the one that\n * does not enroll somebody whose stored refusal we simply failed to see.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, MarketingConsentRecord>> {\n const found = new Map<string, MarketingConsentRecord>()\n if (!addresses.length) return found\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const chunks: string[][] = []\n for (let at = 0; at < addresses.length; at += CONTACT_LOOKUP_CHUNK) {\n chunks.push(addresses.slice(at, at + CONTACT_LOOKUP_CHUNK))\n }\n const snapshots = await Promise.all(\n chunks.map((chunk) => contacts.where('email', 'in', chunk).get()),\n )\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (!email) continue\n const record = readMarketingBasis(\n doc.data() as Record<string, unknown>,\n group,\n )\n const already = found.get(email)\n if (already?.basis === 'declined') continue\n if (already && record.basis !== 'declined') continue\n found.set(email, record)\n }\n }\n return found\n } catch (error) {\n console.error('[email] consent lookup failed', error)\n const refused: MarketingConsentRecord = {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody: this is what a failed read falls back to, not a\n // refusal anybody recorded.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n }\n return new Map(addresses.map((email) => [email, refused]))\n }\n}\n\n/** The addresses named by one request, or the refusal to send back. */\nexport function readAddresses(\n req: Parameters<PluginApiHandler>[0],\n): { emails: string[] } | { error: string } {\n const body = req.body as { emails?: unknown; email?: unknown } | undefined\n const raw = Array.isArray(body?.emails)\n ? body.emails\n : body?.email === undefined\n ? []\n : [body.email]\n const emails = raw\n .map((value) => String(value ?? '').trim())\n .filter((value) => value.length > 0)\n if (!emails.length) return { error: 'No addresses' }\n if (emails.length > LIST_MEMBER_BATCH_MAX) {\n return {\n error:\n `${emails.length} addresses is more than one go can take. Add up to ` +\n `${LIST_MEMBER_BATCH_MAX} at a time.`,\n }\n }\n return { emails }\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentReadout","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","LIST_MEMBER_BATCH_MAX","CONTACT_LOOKUP_CHUNK","resolveListContext","req","hostSnapshot","listSnapshot","hostId","String","body","listId","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","collection","doc","get","exists","memberRole","uid","orgId","catch","membership","member","orgWideWriter","role","orgSuspended","listRef","listName","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","Promise","all","suppressionFor","storedConsentFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","found","contacts","chunks","at","snapshots","chunk","where","snapshot","docs","data","already","basis","console","assertedBy","source","basisAtMs","capturedAtMs","readAddresses","Array","isArray","emails","value"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-gate.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 * THE GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.\n *\n * Who is allowed to change a list's membership, and what is true about each\n * address they name. Three route modules ask it — the audience card's add and\n * preview, the filter search, and the file importer — and they ask this one\n * copy of it.\n *\n * ## Why it is a module of its own\n *\n * It began inside `server-console.ts`, beside its first two callers, and that\n * was right while there were two. The importer made three, and an importer is\n * exactly the surface where a second, laxer idea of \"may this person be\n * enrolled\" is most tempting: a file is bulk, bulk is slow, and skipping the\n * per-address consent read is the obvious saving. A bulk path that reached the\n * membership without the suppression check and the attestation would be a way\n * to enroll precisely the people the one-at-a-time path refuses, which is the\n * defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry\n * for.\n *\n * Sharing the gate is also what stops the cheaper failure: two modules that\n * merely agree today, with nothing in either one saying the other exists.\n *\n * ## What it decides, and what it deliberately does not\n *\n * {@link resolveAddresses} answers the questions that are FACTS about a\n * person — a stored refusal, a suppression on either list, an opt-in already\n * on record, a line that is not an address. It never consults an attestation,\n * because an attestation is a fact about the operator and folding it in here\n * would make the preview's numbers move as the box was ticked. The basis is\n * decided once, at the write, by `assignmentBasis`.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentReadout,\n isOrgWideMember,\n normalizeContactEmail,\n readMarketingBasis,\n type AddressRefusal,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n filterSendableForHost,\n filterSuppressedEmails,\n firebaseAdmin,\n getOrgForHost,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n\n/**\n * The most addresses one request may name.\n *\n * A ceiling on the WRITE, not on the audience. It bounds the work one request\n * does — the resolution below is a handful of reads per address — and it can\n * never refuse a person already enrolled or make room by removing one, which\n * is the difference between a batch size and a capacity limit. Nothing in the\n * product caps list membership: `contactsPerHost` is the audience band and it\n * is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody\n * on a list writes no contact. If a membership ceiling is ever introduced,\n * this function is where it refuses, because this is where something is\n * added.\n */\nexport const LIST_MEMBER_BATCH_MAX = 100\n\n/** How many contact addresses one `in` query may carry. */\nconst CONTACT_LOOKUP_CHUNK = 30\n\n/** Everything a list-membership route needs, or the refusal to send back. */\nexport type ListContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n listRef: FirebaseFirestore.DocumentReference\n listName: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, and about which list.\n *\n * ## Two gates, not one — the same two the Inbox assignment route applies\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the rules put both\n * behind `isOrgWideMember()`. An editor invited to ONE site is an org member\n * with `allHosts: false`, and gating an org-wide write on the host role alone\n * would let a single-site collaborator enroll people into an audience every\n * other site in the org can mail — and, through the preview, read the consent\n * record of any address they care to type. The Admin SDK evaluates no rules,\n * so this route is the enforcement rather than an echo of it.\n *\n * The plugin's own gates sit above this and are the dispatcher's: a workspace\n * that has switched the email plugin off, or a site it is disabled for, or a\n * release flag that has not reached this org, all 404 before a handler runs.\n */\nexport async function resolveListContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<ListContext> {\n const hostId = String(req.body?.hostId ?? '')\n const listId = String(req.body?.listId ?? '')\n if (!hostId || !listId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or listId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\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 { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String(\n (await getOrgForHost(hostId).catch(() => null))?.orgId ?? '',\n )\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so changing who ' +\n 'is on one needs organization-wide access rather than access to ' +\n 'this site.',\n },\n }\n }\n\n const listRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown list' } }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n listRef,\n listName: String(listSnapshot.get('name') ?? listId),\n }\n}\n\n/** What would happen to one address, and why. */\nexport interface AddressVerdict {\n /** Exactly what the operator 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 /**\n * Why this address can never go on the list, or `null`.\n *\n * A HARD refusal only — a stored `declined`, either suppression list, or a\n * line that is not an address. `no-basis` is deliberately NOT one of them:\n * \"you have not said you have permission yet\" is an unanswered question,\n * not a refusal, and reporting it as one would list the very people an\n * attestation is about to admit under the heading of people it cannot.\n * That distinction is {@link requiresAttestation}.\n */\n refusal: AssignmentRefusal | null\n /** True when only an attestation stands between this address and the list. */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/** The whole answer, per address and in totals. */\nexport interface AddressResolution {\n verdicts: AddressVerdict[]\n /** Addresses a stored opt-in already covers — no attestation needed. */\n optedIn: number\n /** Addresses that need the operator to state they have permission. */\n needAttestation: number\n /** Addresses nothing can enroll, whatever the operator says. */\n refused: number\n}\n\n/**\n * The resolution plus the consent records it was computed from.\n *\n * The records ride along so the write path can derive the basis it STORES\n * from the same read the verdicts were built on. Re-reading them would be a\n * second answer to \"what does this person's record say\", between which a\n * concurrent edit fits — and the operator would have attested against the\n * first one.\n */\nexport interface ResolvedBatch extends AddressResolution {\n stored: Map<string, MarketingConsentRecord>\n /**\n * The consent group the resolution was made against, so the WRITE records\n * a basis for exactly the controller the verdicts were computed for.\n *\n * Returned rather than re-resolved by the caller for the same reason\n * `stored` is: two resolutions of the same question are two answers a\n * concurrent edit can fit between, and the operator attested against the\n * first one.\n */\n group: ConsentGroup\n}\n\n/**\n * Every address, normalized, deduplicated and put through the policy.\n *\n * ## Deduplicated on the NORMALIZED address, and reported once\n *\n * A pasted column routinely names the same person twice with different\n * casing, and `enrollListMember` keys the membership from the normalized\n * address, so two lines would be one row. Counting them twice would tell the\n * operator they are attesting for more people than they are.\n *\n * ## An unusable line is REPORTED, never dropped\n *\n * A paste that silently discarded its malformed lines would tell an operator\n * that 100 addresses went on the list when 94 did, and the six they never\n * hear about are the six they typed wrong. Every input line comes back with a\n * verdict, including the ones that are not addresses at all.\n *\n * ## Suppression is attributed, and costs nothing when there is none\n *\n * `filterSendableForHost` answers both lists in one pass and fails CLOSED, so\n * it is asked first; only the addresses it refused are put through the\n * platform half again to find out WHICH list holds them. In the ordinary case\n * that second call is never made.\n */\nexport async function resolveAddresses(input: {\n hostId: string\n inputs: readonly string[]\n}): Promise<ResolvedBatch> {\n const seen = new Set<string>()\n const rows: Array<{ input: string; email: string | null }> = []\n for (const raw of input.inputs) {\n const email = normalizeContactEmail(raw)\n if (email && seen.has(email)) continue\n if (email) seen.add(email)\n rows.push({ input: String(raw ?? '').trim(), email })\n }\n\n const addresses = rows\n .map((row) => row.email)\n .filter((email): email is string => Boolean(email))\n\n /*\n * The group first, because every answer below is about a CONTROLLER and\n * not about a site: a business running three sites as one sender enrolls\n * into all three at once, and an agency's client — which declared no group\n * — resolves to itself.\n */\n const group = await consentGroupForSite(input.hostId)\n const [suppression, stored] = await Promise.all([\n suppressionFor(input.hostId, group, addresses),\n storedConsentFor(input.hostId, group, addresses),\n ])\n\n const unrecorded: MarketingConsentRecord = readMarketingBasis(null, group)\n const verdicts = rows.map((row): AddressVerdict => {\n if (!row.email) {\n return {\n input: row.input,\n email: null,\n refusal: 'unroutable-address',\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['unroutable-address'],\n }\n }\n const record = stored.get(row.email) ?? unrecorded\n const readout = assignmentReadout({\n stored: record,\n suppression: suppression.get(row.email) ?? null,\n })\n return {\n input: row.input,\n email: row.email,\n /*\n * Straight off the readout, with no attestation in the question.\n *\n * `assignmentReadout` answers what is TRUE about this person — a stored\n * refusal, a suppression, an opt-in, or nothing on record — and none of\n * those changes because the operator did or did not tick a box. Feeding\n * the flag in here would make the preview's answer depend on it, so the\n * count the operator stands behind would move as they answered. The\n * basis itself is decided once, at the write, by `assignmentBasis`.\n */\n refusal: readout.enrollable\n ? null\n : (suppression.get(row.email) ?? 'declined'),\n requiresAttestation: readout.requiresAttestation,\n summary: readout.summary,\n }\n })\n\n return {\n verdicts,\n stored,\n group,\n optedIn: verdicts.filter(\n (verdict) => !verdict.refusal && !verdict.requiresAttestation,\n ).length,\n needAttestation: verdicts.filter((verdict) => verdict.requiresAttestation)\n .length,\n refused: verdicts.filter(\n (verdict) => verdict.refusal && !verdict.requiresAttestation,\n ).length,\n }\n}\n\n/**\n * Which of these addresses is suppressed, and by which list.\n *\n * Composed from the shipped helpers rather than reading the two collections\n * here: normalization, de-duplication and the fail-closed posture live in\n * `email-suppression.ts`, and a second copy of them is a second set of rules\n * for the enrollment check and the send-time check to disagree about — which\n * is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`\n * exists to stop.\n *\n * Read across the site's consent group, as the send reads it: somebody who\n * unsubscribed from a sibling of a declared group is refused here too, rather\n * than enrolled onto a list whose campaigns will never reach them.\n */\nasync function suppressionFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, AddressRefusal>> {\n const refusals = new Map<string, AddressRefusal>()\n if (!addresses.length) return refusals\n const sendable = new Set(\n await filterSendableForHost(hostId, addresses, undefined, group),\n )\n const blocked = addresses.filter((email) => !sendable.has(email))\n if (!blocked.length) return refusals\n // Only the blocked ones, and only to say WHICH list. Survivors of the\n // platform half are held by this site's own list, or a group sibling's.\n const platformSendable = new Set(await filterSuppressedEmails(blocked))\n for (const email of blocked) {\n refusals.set(\n email,\n platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform',\n )\n }\n return refusals\n}\n\n/**\n * The stored consent facts for these addresses, off the org's contacts.\n *\n * Read UNSCOPED, deliberately, exactly as the Inbox route reads it:\n * `scopedToHost` narrows an org collection to what one site may see, and a\n * refusal filtered out by that narrowing is a refusal this route would then\n * step over — the failure mode is enrolling somebody who said no. It is safe\n * because the caller has already been proved an org-wide member, which is the\n * tier the rules grant the whole org's contacts to.\n *\n * ## A refusal wins over an opt-in when the CRM holds both\n *\n * Nothing guarantees one contact per address — the collection is keyed by\n * resource id, not by email — so two records for one person can disagree.\n * Taking whichever the query happened to answer first would make the outcome\n * depend on document order. A recorded refusal is the answer whenever one\n * exists, which is the same precedence `assignmentBasis` applies within a\n * single record.\n *\n * A failed read falls to `declined` for the whole batch, for the reason the\n * Inbox route states: a read that throws can neither say the person consented\n * nor that they refused, and the direction that costs a retry is the one that\n * does not enroll somebody whose stored refusal we simply failed to see.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, MarketingConsentRecord>> {\n const found = new Map<string, MarketingConsentRecord>()\n if (!addresses.length) return found\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const chunks: string[][] = []\n for (let at = 0; at < addresses.length; at += CONTACT_LOOKUP_CHUNK) {\n chunks.push(addresses.slice(at, at + CONTACT_LOOKUP_CHUNK))\n }\n const snapshots = await Promise.all(\n chunks.map((chunk) => contacts.where('email', 'in', chunk).get()),\n )\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (!email) continue\n const record = readMarketingBasis(\n doc.data() as Record<string, unknown>,\n group,\n )\n const already = found.get(email)\n if (already?.basis === 'declined') continue\n if (already && record.basis !== 'declined') continue\n found.set(email, record)\n }\n }\n return found\n } catch (error) {\n console.error('[email] consent lookup failed', error)\n const refused: MarketingConsentRecord = {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody: this is what a failed read falls back to, not a\n // refusal anybody recorded.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n }\n return new Map(addresses.map((email) => [email, refused]))\n }\n}\n\n/** The addresses named by one request, or the refusal to send back. */\nexport function readAddresses(\n req: Parameters<PluginApiHandler>[0],\n): { emails: string[] } | { error: string } {\n const body = req.body as { emails?: unknown; email?: unknown } | undefined\n const raw = Array.isArray(body?.emails)\n ? body.emails\n : body?.email === undefined\n ? []\n : [body.email]\n const emails = raw\n .map((value) => String(value ?? '').trim())\n .filter((value) => value.length > 0)\n if (!emails.length) return { error: 'No addresses' }\n if (emails.length > LIST_MEMBER_BATCH_MAX) {\n return {\n error:\n `${emails.length} addresses is more than one go can take. Add up to ` +\n `${LIST_MEMBER_BATCH_MAX} at a time.`,\n }\n }\n return { emails }\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentReadout","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","LIST_MEMBER_BATCH_MAX","CONTACT_LOOKUP_CHUNK","resolveListContext","req","hostSnapshot","listSnapshot","hostId","String","body","listId","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","collection","doc","get","exists","memberRole","uid","orgId","catch","membership","member","orgWideWriter","role","orgSuspended","listRef","listName","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","Promise","all","suppressionFor","storedConsentFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","found","contacts","chunks","at","snapshots","chunk","where","snapshot","docs","data","already","basis","console","assertedBy","source","basisAtMs","capturedAtMs","readAddresses","Array","isArray","emails","value"],"mappings":"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"}
|