@aglyn/plugins-email 1.0.0-beta.219 → 1.0.0-beta.221
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 +18 -18
- package/src/lib/components/campaign-topic-options-widget.js +2 -2
- package/src/lib/components/campaign-topic-options-widget.js.map +1 -1
- package/src/lib/components/campaign-topic-select.js +3 -3
- package/src/lib/components/campaign-topic-select.js.map +1 -1
- package/src/lib/components/dynamic-list-rule-fields.d.ts +20 -4
- package/src/lib/components/dynamic-list-rule-fields.js +462 -411
- package/src/lib/components/dynamic-list-rule-fields.js.map +1 -1
- package/src/lib/components/email-topic-detail.d.ts +1 -1
- package/src/lib/components/email-topic-detail.js +5 -5
- package/src/lib/components/email-topic-detail.js.map +1 -1
- package/src/lib/components/email-topics-card.d.ts +1 -1
- package/src/lib/components/email-topics-card.js +6 -5
- package/src/lib/components/email-topics-card.js.map +1 -1
- package/src/lib/components/use-org-email-topics.d.ts +4 -4
- package/src/lib/components/use-org-email-topics.js +6 -5
- package/src/lib/components/use-org-email-topics.js.map +1 -1
- package/src/lib/list-import.js +2 -2
- package/src/lib/list-import.js.map +1 -1
- package/src/lib/model/email-topic-catalog.d.ts +36 -0
- package/src/lib/model/email-topic-catalog.js +36 -0
- package/src/lib/model/email-topic-catalog.js.map +1 -0
- package/src/lib/model/index.d.ts +0 -7
- package/src/lib/model/index.js +0 -6
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/server-console.js +6 -2
- package/src/lib/server-console.js.map +1 -1
- package/src/lib/server-list-import.js +1 -1
- package/src/lib/server-list-import.js.map +1 -1
- package/src/lib/server-topic-index.d.ts +52 -0
- package/src/lib/server-topic-index.js +92 -0
- package/src/lib/server-topic-index.js.map +1 -0
- package/src/lib/server.js +11 -11
- package/src/lib/server.js.map +1 -1
- package/src/lib/unsubscribe-link.d.ts +1 -1
- package/src/lib/unsubscribe-link.js +1 -1
- package/src/lib/unsubscribe-link.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topic-detail.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 {\n PageHeaderRecord,\n pluginDocsHelp,\n resolveCampaignTopic,\n DEFAULT_EMAIL_TOPICS,\n} from '@aglyn/aglyn'\nimport { CardDisplay, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Link from 'next/link'\nimport { useCallback, useEffect, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useEmailOrgMount } from './email-org-mount'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicDetailProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n topicId: string\n /** `/…/emails`, so the page can route back to the list. */\n basePath: string\n}\n\n/**\n * One topic's own page — where every change to an existing topic is made.\n *\n * The console's shape for a list surface is create-in-a-drawer, edit-on-the\n * record's page, and this is the second half of it for topics. The list card\n * lists and routes; nothing about a saved topic is editable from a form\n * stacked above that table.\n *\n * ## Saving a built-in\n *\n * The four built-ins have no stored document until somebody changes one, so\n * this page's save is the same `setDoc` for a built-in and a custom topic:\n * the write CREATES the override document at the built-in's id. That is the\n * whole overlay design — see `email-topics.ts`.\n *\n * ## Retire, not delete\n *\n * A retired topic leaves the composer's picker and the recipient's preference\n * page, and stays resolvable everywhere else. It has to: campaigns already\n * sent under it minted unsubscribe links carrying its id, those links are in\n * inboxes, and an id that stops resolving is a preference page that cannot\n * name the message the recipient is holding. There is no delete on this page,\n * and the Firestore rules refuse one too.\n */\nexport function EmailTopicDetail(props: EmailTopicDetailProps) {\n const { hostId, topicId, basePath } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The catalog is the org's: read through the site under one, and straight\n // from the organization on its own page.\n const orgMount = useEmailOrgMount()\n const { topics, scope } = useOrgEmailTopics(hostId, {\n orgId: orgMount?.orgId,\n })\n\n /*\n * `resolveCampaignTopic` rather than a bare `find`, so an id that no longer\n * names anything lands on a real topic instead of an empty form. A stale\n * bookmark is the common way to arrive here with one.\n */\n const topic = resolveCampaignTopic(topicId, topics)\n const known = topics.some((it) => it.id === topicId)\n\n const [name, setName] = useState('')\n const [description, setDescription] = useState('')\n /**\n * Whether joining this stream needs a confirmation click.\n *\n * Three states, held as a string because a `Select` carries one and a\n * boolean cannot: `''` defers to the site, `'on'` and `'off'` are decisions\n * about this stream. Collapsing the first into a boolean would make \"not\n * chosen\" indistinguishable from \"off\", and the site default would then be\n * unreachable once anybody saved a topic.\n */\n const [doubleOptIn, setDoubleOptIn] = useState('')\n const [loaded, setLoaded] = useState(false)\n const [busy, setBusy] = useState(false)\n\n /*\n * Seed the fields ONCE, when the catalog first arrives.\n *\n * Re-seeding on every change of `topic` would overwrite what the operator is\n * typing the moment the collection listener fires — and it fires on this\n * page's own save.\n */\n useEffect(() => {\n if (loaded || !topics.length) return\n setName(topic.name)\n setDescription(topic.description)\n setDoubleOptIn(\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : '',\n )\n setLoaded(true)\n }, [loaded, topics, topic])\n\n const storedDoubleOptIn =\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : ''\n const dirty =\n loaded &&\n (name !== topic.name ||\n description !== topic.description ||\n doubleOptIn !== storedDoubleOptIn)\n\n /**\n * Save what is in the form, optionally changing the retired flag with it.\n *\n * The write itself is `writeEmailTopic`, which the list card's row menu also\n * goes through — a topic retired from the list and one retired here have to\n * leave the same document behind.\n */\n const save = useCallback(\n async (patch?: { archived?: boolean }) => {\n if (!scope || busy) return\n const nextName = name.trim()\n if (!nextName) {\n return void enqueueSnackbar('Give the topic a name', {\n variant: 'warning',\n persist: false,\n })\n }\n setBusy(true)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: nextName,\n description: description.trim(),\n archived: patch?.archived ?? !!topic.archived,\n // `null` clears the field, which is how \"whatever the site says\"\n // is expressed. A stored `false` is a different answer and has to\n // survive as one.\n doubleOptIn:\n doubleOptIn === 'on' ? true : doubleOptIn === 'off' ? false : null,\n })\n enqueueSnackbar('Topic saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n },\n [\n scope,\n busy,\n name,\n description,\n doubleOptIn,\n firestore,\n topic,\n enqueueSnackbar,\n ],\n )\n\n const toggleArchived = useCallback(async () => {\n if (topic.archived) return void save({ archived: false })\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new can ' +\n 'be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their unsubscribe ' +\n 'links still name this topic. You can bring it back at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on\n // the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n await save({ archived: true })\n }, [topic, confirm, save])\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Topic'}\n subheader={\n <Stack direction=\"row\" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DEFAULT_EMAIL_TOPICS.some((it) => it.id === topic.id) ? (\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n }\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button component={Link} href={`${basePath}/topics`} size=\"small\">\n {'All topics'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <>\n {!known && loaded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'That topic no longer exists, so this is the one campaigns ' +\n 'fall back to.'}\n </Typography>\n ) : null}\n <TextField\n label=\"Name\"\n size=\"small\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"What recipients see on the preference page.\"\n />\n <TextField\n label=\"Description\"\n size=\"small\"\n multiline\n minRows={2}\n value={description}\n onChange={(event) => setDescription(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"One sentence telling them what this stream is.\"\n />\n {/*\n CONFIRMATION — off unless somebody chooses it.\n\n No jurisdiction verified requires a double opt-in by statute.\n They require prior express consent plus a burden of proof on\n the sender, and a confirmation click is the proof the courts\n have so far accepted — so this is an option a merchant reaches\n for, never a default the product imposes.\n\n A three-way select rather than a switch, because the third\n state is real: this topic can defer to the site's own setting,\n and a switch has nowhere to put that.\n */}\n <TextField\n select\n label=\"Confirmation\"\n size=\"small\"\n value={doubleOptIn}\n onChange={(event) => setDoubleOptIn(event.target.value)}\n disabled={busy || !loaded}\n helperText={\n 'Ask new subscribers to confirm by email before anything is ' +\n 'sent to them. Until they do, nothing on this stream reaches ' +\n 'them and they stay on your list.'\n }\n slotProps={{ select: { native: true } }}\n >\n <option value=\"\">{'Use the site setting'}</option>\n <option value=\"on\">{'Require a confirmation click'}</option>\n <option value=\"off\">{'No confirmation for this stream'}</option>\n </TextField>\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n disabled={!dirty || busy}\n onClick={() => void save()}\n >\n {'Save'}\n </Button>\n <Button\n color={topic.archived ? 'primary' : 'error'}\n disabled={busy || !loaded}\n onClick={() => void toggleArchived()}\n >\n {topic.archived ? 'Restore' : 'Retire'}\n </Button>\n </Stack>\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the topic; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={topic.name || topic.id} />\n {card}\n </>\n )\n}\n\nexport default EmailTopicDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","resolveCampaignTopic","DEFAULT_EMAIL_TOPICS","CardDisplay","useConfirmationContext","useSnackbar","Button","Chip","Stack","TextField","Typography","Link","useCallback","useEffect","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicDetail","props","hostId","topicId","basePath","firestore","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","topic","known","some","it","id","name","setName","description","setDescription","doubleOptIn","setDoubleOptIn","loaded","setLoaded","busy","setBusy","length","storedDoubleOptIn","dirty","save","patch","nextName","trim","variant","persist","archived","error","console","toggleArchived","accepted","title","confirmationText","then","catch","card","header","subheader","direction","spacing","sx","mt","alignItems","size","color","label","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","component","href","value","onChange","event","target","disabled","helperText","multiline","minRows","select","slotProps","native","option","onClick"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topic-detail.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 {\n PageHeaderRecord,\n pluginDocsHelp,\n resolveSubscriptionTopic,\n DECLARED_SUBSCRIPTION_TOPICS,\n} from '@aglyn/aglyn'\nimport { CardDisplay, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Link from 'next/link'\nimport { useCallback, useEffect, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useEmailOrgMount } from './email-org-mount'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicDetailProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n topicId: string\n /** `/…/emails`, so the page can route back to the list. */\n basePath: string\n}\n\n/**\n * One topic's own page — where every change to an existing topic is made.\n *\n * The console's shape for a list surface is create-in-a-drawer, edit-on-the\n * record's page, and this is the second half of it for topics. The list card\n * lists and routes; nothing about a saved topic is editable from a form\n * stacked above that table.\n *\n * ## Saving a built-in\n *\n * The four built-ins have no stored document until somebody changes one, so\n * this page's save is the same `setDoc` for a built-in and a custom topic:\n * the write CREATES the override document at the built-in's id. That is the\n * whole overlay design — see `subscription-topics.ts`.\n *\n * ## Retire, not delete\n *\n * A retired topic leaves the composer's picker and the recipient's preference\n * page, and stays resolvable everywhere else. It has to: campaigns already\n * sent under it minted unsubscribe links carrying its id, those links are in\n * inboxes, and an id that stops resolving is a preference page that cannot\n * name the message the recipient is holding. There is no delete on this page,\n * and the Firestore rules refuse one too.\n */\nexport function EmailTopicDetail(props: EmailTopicDetailProps) {\n const { hostId, topicId, basePath } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The catalog is the org's: read through the site under one, and straight\n // from the organization on its own page.\n const orgMount = useEmailOrgMount()\n const { topics, scope } = useOrgEmailTopics(hostId, {\n orgId: orgMount?.orgId,\n })\n\n /*\n * `resolveSubscriptionTopic` rather than a bare `find`, so an id that no longer\n * names anything lands on a real topic instead of an empty form. A stale\n * bookmark is the common way to arrive here with one.\n */\n const topic = resolveSubscriptionTopic(topicId, topics)\n const known = topics.some((it) => it.id === topicId)\n\n const [name, setName] = useState('')\n const [description, setDescription] = useState('')\n /**\n * Whether joining this stream needs a confirmation click.\n *\n * Three states, held as a string because a `Select` carries one and a\n * boolean cannot: `''` defers to the site, `'on'` and `'off'` are decisions\n * about this stream. Collapsing the first into a boolean would make \"not\n * chosen\" indistinguishable from \"off\", and the site default would then be\n * unreachable once anybody saved a topic.\n */\n const [doubleOptIn, setDoubleOptIn] = useState('')\n const [loaded, setLoaded] = useState(false)\n const [busy, setBusy] = useState(false)\n\n /*\n * Seed the fields ONCE, when the catalog first arrives.\n *\n * Re-seeding on every change of `topic` would overwrite what the operator is\n * typing the moment the collection listener fires — and it fires on this\n * page's own save.\n */\n useEffect(() => {\n if (loaded || !topics.length) return\n setName(topic.name)\n setDescription(topic.description)\n setDoubleOptIn(\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : '',\n )\n setLoaded(true)\n }, [loaded, topics, topic])\n\n const storedDoubleOptIn =\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : ''\n const dirty =\n loaded &&\n (name !== topic.name ||\n description !== topic.description ||\n doubleOptIn !== storedDoubleOptIn)\n\n /**\n * Save what is in the form, optionally changing the retired flag with it.\n *\n * The write itself is `writeEmailTopic`, which the list card's row menu also\n * goes through — a topic retired from the list and one retired here have to\n * leave the same document behind.\n */\n const save = useCallback(\n async (patch?: { archived?: boolean }) => {\n if (!scope || busy) return\n const nextName = name.trim()\n if (!nextName) {\n return void enqueueSnackbar('Give the topic a name', {\n variant: 'warning',\n persist: false,\n })\n }\n setBusy(true)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: nextName,\n description: description.trim(),\n archived: patch?.archived ?? !!topic.archived,\n // `null` clears the field, which is how \"whatever the site says\"\n // is expressed. A stored `false` is a different answer and has to\n // survive as one.\n doubleOptIn:\n doubleOptIn === 'on' ? true : doubleOptIn === 'off' ? false : null,\n })\n enqueueSnackbar('Topic saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n },\n [\n scope,\n busy,\n name,\n description,\n doubleOptIn,\n firestore,\n topic,\n enqueueSnackbar,\n ],\n )\n\n const toggleArchived = useCallback(async () => {\n if (topic.archived) return void save({ archived: false })\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new can ' +\n 'be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their unsubscribe ' +\n 'links still name this topic. You can bring it back at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on\n // the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n await save({ archived: true })\n }, [topic, confirm, save])\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Topic'}\n subheader={\n <Stack direction=\"row\" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DECLARED_SUBSCRIPTION_TOPICS.some((it) => it.id === topic.id) ? (\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n }\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button component={Link} href={`${basePath}/topics`} size=\"small\">\n {'All topics'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <>\n {!known && loaded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'That topic no longer exists, so this is the one campaigns ' +\n 'fall back to.'}\n </Typography>\n ) : null}\n <TextField\n label=\"Name\"\n size=\"small\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"What recipients see on the preference page.\"\n />\n <TextField\n label=\"Description\"\n size=\"small\"\n multiline\n minRows={2}\n value={description}\n onChange={(event) => setDescription(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"One sentence telling them what this stream is.\"\n />\n {/*\n CONFIRMATION — off unless somebody chooses it.\n\n No jurisdiction verified requires a double opt-in by statute.\n They require prior express consent plus a burden of proof on\n the sender, and a confirmation click is the proof the courts\n have so far accepted — so this is an option a merchant reaches\n for, never a default the product imposes.\n\n A three-way select rather than a switch, because the third\n state is real: this topic can defer to the site's own setting,\n and a switch has nowhere to put that.\n */}\n <TextField\n select\n label=\"Confirmation\"\n size=\"small\"\n value={doubleOptIn}\n onChange={(event) => setDoubleOptIn(event.target.value)}\n disabled={busy || !loaded}\n helperText={\n 'Ask new subscribers to confirm by email before anything is ' +\n 'sent to them. Until they do, nothing on this stream reaches ' +\n 'them and they stay on your list.'\n }\n slotProps={{ select: { native: true } }}\n >\n <option value=\"\">{'Use the site setting'}</option>\n <option value=\"on\">{'Require a confirmation click'}</option>\n <option value=\"off\">{'No confirmation for this stream'}</option>\n </TextField>\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n disabled={!dirty || busy}\n onClick={() => void save()}\n >\n {'Save'}\n </Button>\n <Button\n color={topic.archived ? 'primary' : 'error'}\n disabled={busy || !loaded}\n onClick={() => void toggleArchived()}\n >\n {topic.archived ? 'Restore' : 'Retire'}\n </Button>\n </Stack>\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the topic; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={topic.name || topic.id} />\n {card}\n </>\n )\n}\n\nexport default EmailTopicDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","resolveSubscriptionTopic","DECLARED_SUBSCRIPTION_TOPICS","CardDisplay","useConfirmationContext","useSnackbar","Button","Chip","Stack","TextField","Typography","Link","useCallback","useEffect","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicDetail","props","hostId","topicId","basePath","firestore","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","topic","known","some","it","id","name","setName","description","setDescription","doubleOptIn","setDoubleOptIn","loaded","setLoaded","busy","setBusy","length","storedDoubleOptIn","dirty","save","patch","nextName","trim","variant","persist","archived","error","console","toggleArchived","accepted","title","confirmationText","then","catch","card","header","subheader","direction","spacing","sx","mt","alignItems","size","color","label","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","component","href","value","onChange","event","target","disabled","helperText","multiline","minRows","select","slotProps","native","option","onClick"],"mappings":"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"}
|
|
@@ -19,7 +19,7 @@ export interface EmailTopicsCardProps {
|
|
|
19
19
|
*
|
|
20
20
|
* Every org that exists today has an empty `emailTopics` collection, and a
|
|
21
21
|
* seeding migration would leave any org created while it ran with none. So
|
|
22
|
-
* `
|
|
22
|
+
* `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog rather than its initial
|
|
23
23
|
* contents: the four are present for every org with no write anywhere, and
|
|
24
24
|
* saving one on its detail page writes a document at the same id that renames
|
|
25
25
|
* or retires it. A built-in with no stored document is not a missing row, it
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
18
|
-
import { createResourceUid,
|
|
18
|
+
import { createResourceUid, isSubscriptionTopicId, pluginDocsHelp, DECLARED_SUBSCRIPTION_TOPICS } from "@aglyn/aglyn";
|
|
19
|
+
import { EMAIL_TOPICS_COLLECTION } from "../model/email-topic-catalog.js";
|
|
19
20
|
import { mdiArchiveArrowUpOutline, mdiArchiveOutline, mdiPencilOutline } from "@aglyn/shared-data-mdi";
|
|
20
21
|
import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
21
22
|
import RowActionsMenu from "@aglyn/shared-ui-jsx/components/row-actions-menu.component";
|
|
@@ -44,7 +45,7 @@ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
|
|
|
44
45
|
*
|
|
45
46
|
* Every org that exists today has an empty `emailTopics` collection, and a
|
|
46
47
|
* seeding migration would leave any org created while it ran with none. So
|
|
47
|
-
* `
|
|
48
|
+
* `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog rather than its initial
|
|
48
49
|
* contents: the four are present for every org with no write anywhere, and
|
|
49
50
|
* saving one on its detail page writes a document at the same id that renames
|
|
50
51
|
* or retires it. A built-in with no stored document is not a missing row, it
|
|
@@ -91,12 +92,12 @@ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
|
|
|
91
92
|
const name = String((_ref = values == null ? void 0 : values.displayName) != null ? _ref : '').trim();
|
|
92
93
|
if (!name) return;
|
|
93
94
|
// `createResourceUid` is nanoid's `A-Za-z0-9_-`, so the id it mints
|
|
94
|
-
// always satisfies `
|
|
95
|
+
// always satisfies `isSubscriptionTopicId`. Checked anyway, because this id is
|
|
95
96
|
// signed into every unsubscribe link the topic ever mints and the send
|
|
96
97
|
// path refuses one that is not usable — a topic nothing can be sent
|
|
97
98
|
// under is a row the merchant would have no way to diagnose.
|
|
98
99
|
const id = createResourceUid();
|
|
99
|
-
if (!
|
|
100
|
+
if (!isSubscriptionTopicId(id)) {
|
|
100
101
|
return void enqueueSnackbar('Could not create the topic', {
|
|
101
102
|
variant: 'error'
|
|
102
103
|
});
|
|
@@ -276,7 +277,7 @@ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
|
|
|
276
277
|
color: "warning",
|
|
277
278
|
label: "Retired"
|
|
278
279
|
}) : null,
|
|
279
|
-
|
|
280
|
+
DECLARED_SUBSCRIPTION_TOPICS.some((it)=>it.id === topic.id) ? /*#__PURE__*/ _jsx(Typography, {
|
|
280
281
|
variant: "overline",
|
|
281
282
|
color: "text.secondary",
|
|
282
283
|
children: 'Built in'
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topics-card.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 {\n createResourceUid,\n isEmailTopicId,\n pluginDocsHelp,\n DEFAULT_EMAIL_TOPICS,\n EMAIL_TOPICS_COLLECTION,\n} from '@aglyn/aglyn'\nimport {\n mdiArchiveArrowUpOutline,\n mdiArchiveOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { doc, setDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useEmailOrgMount } from './email-org-mount'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicsCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The emails hub URL, which every topic route hangs beneath. */\n basePath: string\n}\n\n/**\n * Email topics: the streams a recipient can leave one at a time.\n *\n * ## What this closes\n *\n * `docs/specs/email-competitive-gaps.md` §1f is a table of what six\n * competitors have, and the preference-center row was the one where every one\n * of them had something and we had nothing: unsubscribe was all-or-nothing per\n * site. This card is the merchant's half of the fix — the recipient's half is\n * the hosted preference page the campaign footer now links to.\n *\n * ## Why the four built-ins are not documents\n *\n * Every org that exists today has an empty `emailTopics` collection, and a\n * seeding migration would leave any org created while it ran with none. So\n * `DEFAULT_EMAIL_TOPICS` is the FLOOR of the catalog rather than its initial\n * contents: the four are present for every org with no write anywhere, and\n * saving one on its detail page writes a document at the same id that renames\n * or retires it. A built-in with no stored document is not a missing row, it\n * is a row nobody has needed to change.\n *\n * ## Create is a drawer; edit is the topic's own page\n *\n * The console's standing shape for a list surface: creating opens the shared\n * `CreateArtifactDrawer`, the same one Screens, Components, Layouts and\n * Templates use, and changing an existing record happens on that record's\n * page rather than in a form stacked above the table. So this card lists and\n * routes, and `email-topic-detail.tsx` edits.\n *\n * ## The row grammar the rest of the surface uses\n *\n * The same one the audiences table sets: the row opens the topic, the topic's\n * name is ALSO a real link so it can be middle-clicked and copied, and the\n * secondary actions sit behind the shared `RowActionsMenu` rather than as text\n * buttons in the row. Retire is the reason the menu has more than one entry —\n * it is the only thing that can be done to a topic without opening it, and it\n * is destructive enough that it should not sit one mis-click from the row's\n * own open handler.\n */\nexport function EmailTopicsCard(props: EmailTopicsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The catalog is the org's: read through the site under one, and straight\n // from the organization on its own page.\n const orgMount = useEmailOrgMount()\n const { topics, scope } = useOrgEmailTopics(hostId, {\n orgId: orgMount?.orgId,\n })\n\n const [drawerOpen, setDrawerOpen] = useState(false)\n const [error, setError] = useState<unknown>(null)\n const [busyTopicId, setBusyTopicId] = useState('')\n\n /** The topic's own page, which is also its edit page. */\n const topicHref = useCallback(\n (topicId: string) => `${basePath}/topics/${topicId}`,\n [basePath],\n )\n\n const create = useCallback(\n async (values: Record<string, any>) => {\n if (!scope) return\n const name = String(values?.displayName ?? '').trim()\n if (!name) return\n // `createResourceUid` is nanoid's `A-Za-z0-9_-`, so the id it mints\n // always satisfies `isEmailTopicId`. Checked anyway, because this id is\n // signed into every unsubscribe link the topic ever mints and the send\n // path refuses one that is not usable — a topic nothing can be sent\n // under is a row the merchant would have no way to diagnose.\n const id = createResourceUid()\n if (!isEmailTopicId(id)) {\n return void enqueueSnackbar('Could not create the topic', {\n variant: 'error',\n })\n }\n try {\n await setDoc(\n doc(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION, id),\n {\n name,\n description: String(values?.description ?? '').trim(),\n archived: false,\n },\n )\n setDrawerOpen(false)\n setError(null)\n enqueueSnackbar('Topic added', { variant: 'success', persist: false })\n // Straight to the record, which is where every further change is made.\n router.push(topicHref(id))\n } catch (caught) {\n console.error(caught)\n setError(caught)\n }\n },\n [scope, firestore, enqueueSnackbar, topicHref, router],\n )\n\n /**\n * Retire a topic, or bring one back.\n *\n * Retire and not delete: campaigns already sent under a topic minted\n * unsubscribe links carrying its id, those links are sitting in inboxes, and\n * an id that stops resolving is a preference page that cannot name the\n * message the recipient is holding. The Firestore rules refuse a delete for\n * the same reason.\n *\n * Restoring is not asked about — it puts a choice back rather than taking\n * one away, and it is undone by retiring again.\n */\n const toggleArchived = useCallback(\n async (topic: { id: string; name: string; description: string; archived?: boolean }) => {\n if (!scope || busyTopicId) return\n if (!topic.archived) {\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new ' +\n 'can be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their ' +\n 'unsubscribe links still name this topic. You can bring it back ' +\n 'at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating\n // on the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n setBusyTopicId(topic.id)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: topic.name,\n description: topic.description,\n archived: !topic.archived,\n })\n enqueueSnackbar(topic.archived ? 'Topic restored' : 'Topic retired', {\n variant: 'success',\n persist: false,\n })\n } catch (caught) {\n console.error(caught)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusyTopicId('')\n }\n },\n [scope, busyTopicId, confirm, firestore, enqueueSnackbar],\n )\n\n const rowActions = (topic: {\n id: string\n name: string\n description: string\n archived?: boolean\n }): RowActionsMenuItem[] => [\n {\n key: 'edit',\n label: 'Edit topic',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: topicHref(topic.id),\n },\n {\n key: 'archive',\n label: topic.archived ? 'Restore' : 'Retire',\n icon: (\n <MdiIcon\n path={\n topic.archived\n ? mdiArchiveArrowUpOutline.path\n : mdiArchiveOutline.path\n }\n size={0.8}\n />\n ),\n destructive: !topic.archived,\n disabled: Boolean(busyTopicId),\n disabledReason: busyTopicId ? 'Another topic is being saved' : undefined,\n onClick: () => void toggleArchived(topic),\n },\n ]\n\n return (\n <CardDisplay\n header=\"Topics\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: scope ? (\n <Button variant=\"contained\" onClick={() => setDrawerOpen(true)}>\n {'New topic'}\n </Button>\n ) : null,\n }}\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The streams your recipients can leave one at a time. Every ' +\n 'campaign belongs to one, and the preference page linked from ' +\n 'each email lets someone stop that stream without stopping ' +\n 'everything. Topics are shared across every site in this ' +\n 'organization, the same way your lists are.'}\n </Typography>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Topic'}</TableCell>\n <TableCell>{'What recipients are told they get'}</TableCell>\n <TableCell align=\"right\" />\n </TableRow>\n </TableHead>\n <TableBody>\n {topics.map((topic) => {\n const href = topicHref(topic.id)\n return (\n <TableRow\n key={topic.id}\n hover\n onClick={() => router.push(href)}\n sx={{ cursor: 'pointer' }}\n >\n <TableCell>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {/*\n The row's own handler would fire too and push the\n same route twice — one history entry per back press.\n */}\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {topic.name}\n </AppLink>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DEFAULT_EMAIL_TOPICS.some(\n (it) => it.id === topic.id,\n ) ? (\n <Typography\n variant=\"overline\"\n color=\"text.secondary\"\n >\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n </TableCell>\n <TableCell>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {topic.description || '—'}\n </Typography>\n </TableCell>\n <TableCell\n align=\"right\"\n sx={{ width: 56 }}\n onClick={(event) => event.stopPropagation()}\n >\n <RowActionsMenu\n label={topic.name}\n items={rowActions(topic)}\n />\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </ScrollTable>\n )}\n </Stack>\n <CreateArtifactDrawer\n open={drawerOpen}\n onClose={() => setDrawerOpen(false)}\n title=\"New email topic\"\n submitLabel=\"Create topic\"\n onSubmit={create}\n errorSlot={\n error ? (\n <Typography variant=\"body2\" color=\"error\" sx={{ mt: 2 }}>\n {'Could not create the topic. Try again.'}\n </Typography>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n\nexport default EmailTopicsCard\n"],"names":["createResourceUid","isEmailTopicId","pluginDocsHelp","DEFAULT_EMAIL_TOPICS","EMAIL_TOPICS_COLLECTION","mdiArchiveArrowUpOutline","mdiArchiveOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","CreateArtifactDrawer","ScrollTable","useSnackbar","Button","Chip","Stack","TableBody","TableCell","TableHead","TableRow","Typography","doc","setDoc","useRouter","useCallback","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","drawerOpen","setDrawerOpen","error","setError","busyTopicId","setBusyTopicId","topicHref","topicId","create","values","name","String","displayName","trim","id","variant","description","archived","persist","push","caught","console","toggleArchived","topic","accepted","title","confirmationText","then","catch","rowActions","key","label","icon","path","size","href","destructive","disabled","Boolean","disabledReason","undefined","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","color","align","map","hover","sx","cursor","direction","flexWrap","alignItems","event","stopPropagation","some","it","width","items","open","onClose","submitLabel","onSubmit","errorSlot","mt"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topics-card.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 {\n createResourceUid,\n isSubscriptionTopicId,\n pluginDocsHelp,\n DECLARED_SUBSCRIPTION_TOPICS,\n} from '@aglyn/aglyn'\nimport { EMAIL_TOPICS_COLLECTION } from '../model/email-topic-catalog'\nimport {\n mdiArchiveArrowUpOutline,\n mdiArchiveOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { doc, setDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useEmailOrgMount } from './email-org-mount'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicsCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The emails hub URL, which every topic route hangs beneath. */\n basePath: string\n}\n\n/**\n * Email topics: the streams a recipient can leave one at a time.\n *\n * ## What this closes\n *\n * `docs/specs/email-competitive-gaps.md` §1f is a table of what six\n * competitors have, and the preference-center row was the one where every one\n * of them had something and we had nothing: unsubscribe was all-or-nothing per\n * site. This card is the merchant's half of the fix — the recipient's half is\n * the hosted preference page the campaign footer now links to.\n *\n * ## Why the four built-ins are not documents\n *\n * Every org that exists today has an empty `emailTopics` collection, and a\n * seeding migration would leave any org created while it ran with none. So\n * `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog rather than its initial\n * contents: the four are present for every org with no write anywhere, and\n * saving one on its detail page writes a document at the same id that renames\n * or retires it. A built-in with no stored document is not a missing row, it\n * is a row nobody has needed to change.\n *\n * ## Create is a drawer; edit is the topic's own page\n *\n * The console's standing shape for a list surface: creating opens the shared\n * `CreateArtifactDrawer`, the same one Screens, Components, Layouts and\n * Templates use, and changing an existing record happens on that record's\n * page rather than in a form stacked above the table. So this card lists and\n * routes, and `email-topic-detail.tsx` edits.\n *\n * ## The row grammar the rest of the surface uses\n *\n * The same one the audiences table sets: the row opens the topic, the topic's\n * name is ALSO a real link so it can be middle-clicked and copied, and the\n * secondary actions sit behind the shared `RowActionsMenu` rather than as text\n * buttons in the row. Retire is the reason the menu has more than one entry —\n * it is the only thing that can be done to a topic without opening it, and it\n * is destructive enough that it should not sit one mis-click from the row's\n * own open handler.\n */\nexport function EmailTopicsCard(props: EmailTopicsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The catalog is the org's: read through the site under one, and straight\n // from the organization on its own page.\n const orgMount = useEmailOrgMount()\n const { topics, scope } = useOrgEmailTopics(hostId, {\n orgId: orgMount?.orgId,\n })\n\n const [drawerOpen, setDrawerOpen] = useState(false)\n const [error, setError] = useState<unknown>(null)\n const [busyTopicId, setBusyTopicId] = useState('')\n\n /** The topic's own page, which is also its edit page. */\n const topicHref = useCallback(\n (topicId: string) => `${basePath}/topics/${topicId}`,\n [basePath],\n )\n\n const create = useCallback(\n async (values: Record<string, any>) => {\n if (!scope) return\n const name = String(values?.displayName ?? '').trim()\n if (!name) return\n // `createResourceUid` is nanoid's `A-Za-z0-9_-`, so the id it mints\n // always satisfies `isSubscriptionTopicId`. Checked anyway, because this id is\n // signed into every unsubscribe link the topic ever mints and the send\n // path refuses one that is not usable — a topic nothing can be sent\n // under is a row the merchant would have no way to diagnose.\n const id = createResourceUid()\n if (!isSubscriptionTopicId(id)) {\n return void enqueueSnackbar('Could not create the topic', {\n variant: 'error',\n })\n }\n try {\n await setDoc(\n doc(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION, id),\n {\n name,\n description: String(values?.description ?? '').trim(),\n archived: false,\n },\n )\n setDrawerOpen(false)\n setError(null)\n enqueueSnackbar('Topic added', { variant: 'success', persist: false })\n // Straight to the record, which is where every further change is made.\n router.push(topicHref(id))\n } catch (caught) {\n console.error(caught)\n setError(caught)\n }\n },\n [scope, firestore, enqueueSnackbar, topicHref, router],\n )\n\n /**\n * Retire a topic, or bring one back.\n *\n * Retire and not delete: campaigns already sent under a topic minted\n * unsubscribe links carrying its id, those links are sitting in inboxes, and\n * an id that stops resolving is a preference page that cannot name the\n * message the recipient is holding. The Firestore rules refuse a delete for\n * the same reason.\n *\n * Restoring is not asked about — it puts a choice back rather than taking\n * one away, and it is undone by retiring again.\n */\n const toggleArchived = useCallback(\n async (topic: { id: string; name: string; description: string; archived?: boolean }) => {\n if (!scope || busyTopicId) return\n if (!topic.archived) {\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new ' +\n 'can be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their ' +\n 'unsubscribe links still name this topic. You can bring it back ' +\n 'at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating\n // on the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n setBusyTopicId(topic.id)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: topic.name,\n description: topic.description,\n archived: !topic.archived,\n })\n enqueueSnackbar(topic.archived ? 'Topic restored' : 'Topic retired', {\n variant: 'success',\n persist: false,\n })\n } catch (caught) {\n console.error(caught)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusyTopicId('')\n }\n },\n [scope, busyTopicId, confirm, firestore, enqueueSnackbar],\n )\n\n const rowActions = (topic: {\n id: string\n name: string\n description: string\n archived?: boolean\n }): RowActionsMenuItem[] => [\n {\n key: 'edit',\n label: 'Edit topic',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: topicHref(topic.id),\n },\n {\n key: 'archive',\n label: topic.archived ? 'Restore' : 'Retire',\n icon: (\n <MdiIcon\n path={\n topic.archived\n ? mdiArchiveArrowUpOutline.path\n : mdiArchiveOutline.path\n }\n size={0.8}\n />\n ),\n destructive: !topic.archived,\n disabled: Boolean(busyTopicId),\n disabledReason: busyTopicId ? 'Another topic is being saved' : undefined,\n onClick: () => void toggleArchived(topic),\n },\n ]\n\n return (\n <CardDisplay\n header=\"Topics\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: scope ? (\n <Button variant=\"contained\" onClick={() => setDrawerOpen(true)}>\n {'New topic'}\n </Button>\n ) : null,\n }}\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The streams your recipients can leave one at a time. Every ' +\n 'campaign belongs to one, and the preference page linked from ' +\n 'each email lets someone stop that stream without stopping ' +\n 'everything. Topics are shared across every site in this ' +\n 'organization, the same way your lists are.'}\n </Typography>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Topic'}</TableCell>\n <TableCell>{'What recipients are told they get'}</TableCell>\n <TableCell align=\"right\" />\n </TableRow>\n </TableHead>\n <TableBody>\n {topics.map((topic) => {\n const href = topicHref(topic.id)\n return (\n <TableRow\n key={topic.id}\n hover\n onClick={() => router.push(href)}\n sx={{ cursor: 'pointer' }}\n >\n <TableCell>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {/*\n The row's own handler would fire too and push the\n same route twice — one history entry per back press.\n */}\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {topic.name}\n </AppLink>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DECLARED_SUBSCRIPTION_TOPICS.some(\n (it) => it.id === topic.id,\n ) ? (\n <Typography\n variant=\"overline\"\n color=\"text.secondary\"\n >\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n </TableCell>\n <TableCell>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {topic.description || '—'}\n </Typography>\n </TableCell>\n <TableCell\n align=\"right\"\n sx={{ width: 56 }}\n onClick={(event) => event.stopPropagation()}\n >\n <RowActionsMenu\n label={topic.name}\n items={rowActions(topic)}\n />\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </ScrollTable>\n )}\n </Stack>\n <CreateArtifactDrawer\n open={drawerOpen}\n onClose={() => setDrawerOpen(false)}\n title=\"New email topic\"\n submitLabel=\"Create topic\"\n onSubmit={create}\n errorSlot={\n error ? (\n <Typography variant=\"body2\" color=\"error\" sx={{ mt: 2 }}>\n {'Could not create the topic. Try again.'}\n </Typography>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n\nexport default EmailTopicsCard\n"],"names":["createResourceUid","isSubscriptionTopicId","pluginDocsHelp","DECLARED_SUBSCRIPTION_TOPICS","EMAIL_TOPICS_COLLECTION","mdiArchiveArrowUpOutline","mdiArchiveOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","CreateArtifactDrawer","ScrollTable","useSnackbar","Button","Chip","Stack","TableBody","TableCell","TableHead","TableRow","Typography","doc","setDoc","useRouter","useCallback","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","drawerOpen","setDrawerOpen","error","setError","busyTopicId","setBusyTopicId","topicHref","topicId","create","values","name","String","displayName","trim","id","variant","description","archived","persist","push","caught","console","toggleArchived","topic","accepted","title","confirmationText","then","catch","rowActions","key","label","icon","path","size","href","destructive","disabled","Boolean","disabledReason","undefined","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","color","align","map","hover","sx","cursor","direction","flexWrap","alignItems","event","stopPropagation","some","it","width","items","open","onClose","submitLabel","onSubmit","errorSlot","mt"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type SubscriptionTopic } from '@aglyn/aglyn';
|
|
2
2
|
import type { Firestore } from 'firebase/firestore';
|
|
3
3
|
/**
|
|
4
4
|
* Write one topic's document — the ONE writer of the topic catalog.
|
|
@@ -18,7 +18,7 @@ import type { Firestore } from 'firebase/firestore';
|
|
|
18
18
|
*
|
|
19
19
|
* ## Why a built-in saves the same way
|
|
20
20
|
*
|
|
21
|
-
* `
|
|
21
|
+
* `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog, not its initial
|
|
22
22
|
* contents: the four built-ins have no stored document until somebody changes
|
|
23
23
|
* one. `setDoc` at the built-in's own id is what creates the override, so the
|
|
24
24
|
* same call serves a custom topic and a built-in being retired for the first
|
|
@@ -52,7 +52,7 @@ export declare function writeEmailTopic(firestore: Firestore, scope: readonly [s
|
|
|
52
52
|
* different merge rules is how the composer comes to send a campaign under a
|
|
53
53
|
* topic nobody can unsubscribe from.
|
|
54
54
|
*
|
|
55
|
-
* ORG-scoped, following `lists` (AGL-254) — see `
|
|
55
|
+
* ORG-scoped, following `lists` (AGL-254) — see `subscription-topics.ts` for why the
|
|
56
56
|
* definitions are org-shared while the recipient's opt-outs are per site.
|
|
57
57
|
*
|
|
58
58
|
* Ordered by the server rather than capped and re-sorted here (AGL-2501,
|
|
@@ -81,7 +81,7 @@ hostId: string | null, options?: {
|
|
|
81
81
|
*/
|
|
82
82
|
orgId?: string | null;
|
|
83
83
|
}): {
|
|
84
|
-
topics:
|
|
84
|
+
topics: SubscriptionTopic[];
|
|
85
85
|
/** `['orgs', orgId]`, or null until the org lookup settles. */
|
|
86
86
|
scope: readonly [string, string] | null;
|
|
87
87
|
};
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
-
import {
|
|
18
|
+
import { mergeSubscriptionTopics, normalizeSubscriptionTopic } from "@aglyn/aglyn";
|
|
19
|
+
import { EMAIL_TOPICS_COLLECTION } from "../model/email-topic-catalog.js";
|
|
19
20
|
import { collection, deleteField, doc, limit, orderBy, query, setDoc } from "firebase/firestore";
|
|
20
21
|
import { useMemo } from "react";
|
|
21
22
|
import { useFirestore, useFirestoreCollection, useOrgDataScope } from "@aglyn/tenant-feature-instance";
|
|
@@ -37,7 +38,7 @@ import { useFirestore, useFirestoreCollection, useOrgDataScope } from "@aglyn/te
|
|
|
37
38
|
*
|
|
38
39
|
* ## Why a built-in saves the same way
|
|
39
40
|
*
|
|
40
|
-
* `
|
|
41
|
+
* `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog, not its initial
|
|
41
42
|
* contents: the four built-ins have no stored document until somebody changes
|
|
42
43
|
* one. `setDoc` at the built-in's own id is what creates the override, so the
|
|
43
44
|
* same call serves a custom topic and a built-in being retired for the first
|
|
@@ -72,7 +73,7 @@ import { useFirestore, useFirestoreCollection, useOrgDataScope } from "@aglyn/te
|
|
|
72
73
|
* different merge rules is how the composer comes to send a campaign under a
|
|
73
74
|
* topic nobody can unsubscribe from.
|
|
74
75
|
*
|
|
75
|
-
* ORG-scoped, following `lists` (AGL-254) — see `
|
|
76
|
+
* ORG-scoped, following `lists` (AGL-254) — see `subscription-topics.ts` for why the
|
|
76
77
|
* definitions are org-shared while the recipient's opt-outs are per site.
|
|
77
78
|
*
|
|
78
79
|
* Ordered by the server rather than capped and re-sorted here (AGL-2501,
|
|
@@ -97,9 +98,9 @@ import { useFirestore, useFirestoreCollection, useOrgDataScope } from "@aglyn/te
|
|
|
97
98
|
], {
|
|
98
99
|
idField: '$id'
|
|
99
100
|
});
|
|
100
|
-
const topics = useMemo(()=>
|
|
101
|
+
const topics = useMemo(()=>mergeSubscriptionTopics((stored != null ? stored : []).map((doc)=>{
|
|
101
102
|
var _doc_$id;
|
|
102
|
-
return
|
|
103
|
+
return normalizeSubscriptionTopic(String((_doc_$id = doc['$id']) != null ? _doc_$id : ''), doc);
|
|
103
104
|
}).filter((topic)=>!!topic)), [
|
|
104
105
|
stored
|
|
105
106
|
]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/use-org-email-topics.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'use client'\n\nimport {\n
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/use-org-email-topics.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'use client'\n\nimport {\n mergeSubscriptionTopics,\n normalizeSubscriptionTopic,\n type SubscriptionTopic,\n} from '@aglyn/aglyn'\nimport { EMAIL_TOPICS_COLLECTION } from '../model/email-topic-catalog'\nimport type { Firestore } from 'firebase/firestore'\nimport {\n collection,\n deleteField,\n doc,\n limit,\n orderBy,\n query,\n setDoc,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\n\n/**\n * Write one topic's document — the ONE writer of the topic catalog.\n *\n * Two surfaces change a topic: its own page, which edits the name and the\n * description, and the list card's row menu, which retires and restores. They\n * write the same document and have to agree about its shape, so the write\n * lives here rather than once in each.\n *\n * ## Why it is a complete statement and not a patch\n *\n * `archived` is written on every save rather than only when it changes. A\n * merge that omitted it would leave a restored topic carrying `archived: true`\n * from an earlier save — which reads on screen as restored and behaves as\n * retired, because the composer's picker and the recipient's preference page\n * both read the stored flag.\n *\n * ## Why a built-in saves the same way\n *\n * `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog, not its initial\n * contents: the four built-ins have no stored document until somebody changes\n * one. `setDoc` at the built-in's own id is what creates the override, so the\n * same call serves a custom topic and a built-in being retired for the first\n * time.\n */\nexport async function writeEmailTopic(\n firestore: Firestore,\n scope: readonly [string, string],\n topic: {\n id: string\n name: string\n description: string\n archived: boolean\n /**\n * Whether this stream needs a confirmation click, or `null`/omitted for\n * \"whatever the site says\".\n *\n * The only field on this document with THREE states, which is why it is\n * the only one written conditionally: `archived` is written on every save\n * because a merge that omitted it would carry an old value forward, and a\n * `doubleOptIn` written on every save could never be un-chosen back to\n * the site default. `deleteField()` is what returns it, so clearing is a\n * real act rather than a value that happens to look like an absence.\n */\n doubleOptIn?: boolean | null\n },\n): Promise<void> {\n await setDoc(\n doc(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION, topic.id),\n {\n name: topic.name,\n description: topic.description,\n archived: topic.archived,\n ...(topic.doubleOptIn === undefined\n ? {}\n : {\n doubleOptIn:\n topic.doubleOptIn === null ? deleteField() : topic.doubleOptIn,\n }),\n },\n { merge: true },\n )\n}\n\n/**\n * How many stored topics one read fetches.\n *\n * A ceiling on the READ, not on the catalog. A preference page stops being a\n * preference page somewhere well before two hundred checkboxes, so an org that\n * reaches this has a different problem than a truncated list — and the read is\n * ordered, so what a merchant past it loses is the tail of the alphabet rather\n * than an arbitrary sample.\n */\nconst TOPIC_READ_CEILING = 200\n\n/**\n * The org's email topic catalog, as both the console card and the composer's\n * picker read it.\n *\n * ONE hook for both surfaces, because they have to agree: a topic the composer\n * offers must be one the preference page can render, and a topic the card\n * archives must leave the picker. Two reads of the same collection with two\n * different merge rules is how the composer comes to send a campaign under a\n * topic nobody can unsubscribe from.\n *\n * ORG-scoped, following `lists` (AGL-254) — see `subscription-topics.ts` for why the\n * definitions are org-shared while the recipient's opt-outs are per site.\n *\n * Ordered by the server rather than capped and re-sorted here (AGL-2501,\n * AGL-2292). `name` is safe to order on because every writer of this\n * collection is the topics card, which refuses to save a nameless topic —\n * `orderBy` DROPS documents that lack the field rather than mis-sorting them,\n * so that claim is about the writers and not a preference.\n */\nexport function useOrgEmailTopics(\n /** The site the catalog is read through, or `null` on the org page. */\n hostId: string | null,\n options?: {\n /**\n * Whether to read at all. Default true.\n *\n * For a caller whose only use of the catalog is a picker inside a control\n * somebody has to open — a create drawer, an edit drawer — where reading\n * on mount charges every reader of the list for a field none of them\n * asked for. A `false` here opens no listener, exactly as a null query\n * does, and the catalog reads as empty until it flips.\n */\n enabled?: boolean\n /**\n * The organization itself, for a caller mounted over the org rather than\n * under one of its sites. It skips the site's org lookup, and it is the\n * only way the catalog can be read with no site at all.\n */\n orgId?: string | null\n },\n): {\n topics: SubscriptionTopic[]\n /** `['orgs', orgId]`, or null until the org lookup settles. */\n scope: readonly [string, string] | null\n} {\n const firestore = useFirestore()\n const enabled = options?.enabled ?? true\n // The org lookup is async (AGL-1061): null until it settles, and null\n // forever for a host with no owning org. An explicit org is ready at once.\n const { scope } = useOrgDataScope({\n hostId: hostId || undefined,\n orgId: options?.orgId || undefined,\n })\n const { data: stored } = useFirestoreCollection<Record<string, unknown>>(\n () =>\n scope && enabled\n ? query(\n collection(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION),\n orderBy('name'),\n limit(TOPIC_READ_CEILING),\n )\n : null,\n [firestore, scope, enabled],\n { idField: '$id' },\n )\n const topics = useMemo(\n () =>\n mergeSubscriptionTopics(\n (stored ?? [])\n .map((doc) => normalizeSubscriptionTopic(String(doc['$id'] ?? ''), doc))\n .filter((topic): topic is SubscriptionTopic => !!topic),\n ),\n [stored],\n )\n return { topics, scope: scope as readonly [string, string] | null }\n}\n"],"names":["mergeSubscriptionTopics","normalizeSubscriptionTopic","EMAIL_TOPICS_COLLECTION","collection","deleteField","doc","limit","orderBy","query","setDoc","useMemo","useFirestore","useFirestoreCollection","useOrgDataScope","writeEmailTopic","firestore","scope","topic","id","name","description","archived","doubleOptIn","undefined","merge","TOPIC_READ_CEILING","useOrgEmailTopics","hostId","options","enabled","orgId","data","stored","idField","topics","map","String","filter"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,uBAAuB,EACvBC,0BAA0B,QAErB,eAAc;AACrB,SAASC,uBAAuB,QAAQ,kCAA8B;AAEtE,SACEC,UAAU,EACVC,WAAW,EACXC,GAAG,EACHC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,MAAM,QACD,qBAAoB;AAC3B,SAASC,OAAO,QAAQ,QAAO;AAC/B,SACEC,YAAY,EACZC,sBAAsB,EACtBC,eAAe,QACV,iCAAgC;AAEvC;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,eAAeC,gBACpBC,SAAoB,EACpBC,KAAgC,EAChCC,KAiBC;IAED,MAAMR,OACJJ,IAAIU,WAAWC,KAAK,CAAC,EAAE,EAAEA,KAAK,CAAC,EAAE,EAAEd,yBAAyBe,MAAMC,EAAE,GACpE;QACEC,MAAMF,MAAME,IAAI;QAChBC,aAAaH,MAAMG,WAAW;QAC9BC,UAAUJ,MAAMI,QAAQ;OACpBJ,MAAMK,WAAW,KAAKC,YACtB,CAAC,IACD;QACED,aACEL,MAAMK,WAAW,KAAK,OAAOlB,gBAAgBa,MAAMK,WAAW;IAClE,IAEN;QAAEE,OAAO;IAAK;AAElB;AAEA;;;;;;;;CAQC,GACD,MAAMC,qBAAqB;AAE3B;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBACd,qEAAqE,GACrEC,MAAqB,EACrBC,OAiBC;;IAMD,MAAMb,YAAYJ;IAClB,MAAMkB,kBAAUD,2BAAAA,QAASC,OAAO,mBAAI;IACpC,sEAAsE;IACtE,2EAA2E;IAC3E,MAAM,EAAEb,KAAK,EAAE,GAAGH,gBAAgB;QAChCc,QAAQA,UAAUJ;QAClBO,OAAOF,CAAAA,2BAAAA,QAASE,KAAK,KAAIP;IAC3B;IACA,MAAM,EAAEQ,MAAMC,MAAM,EAAE,GAAGpB,uBACvB,IACEI,SAASa,UACLrB,MACEL,WAAWY,WAAWC,KAAK,CAAC,EAAE,EAAEA,KAAK,CAAC,EAAE,EAAEd,0BAC1CK,QAAQ,SACRD,MAAMmB,uBAER,MACN;QAACV;QAAWC;QAAOa;KAAQ,EAC3B;QAAEI,SAAS;IAAM;IAEnB,MAAMC,SAASxB,QACb,IACEV,wBACE,CAACgC,iBAAAA,SAAU,EAAE,EACVG,GAAG,CAAC,CAAC9B;gBAA0CA;mBAAlCJ,2BAA2BmC,QAAO/B,WAAAA,GAAG,CAAC,MAAM,YAAVA,WAAc,KAAKA;WAClEgC,MAAM,CAAC,CAACpB,QAAsC,CAAC,CAACA,SAEvD;QAACe;KAAO;IAEV,OAAO;QAAEE;QAAQlB,OAAOA;IAA0C;AACpE"}
|
package/src/lib/list-import.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* abuse vector, so it ships WITH its controls or not at all. This module is
|
|
23
23
|
* the pure half of those controls. It parses, it de-duplicates, and it
|
|
24
24
|
* SCREENS. It decides nothing about consent and it enrolls nobody: the
|
|
25
|
-
* consent question belongs to `
|
|
25
|
+
* consent question belongs to `enrollment-basis`, which the one-address
|
|
26
26
|
* add path already asks, and an import that answered it a second way would be
|
|
27
27
|
* exactly the defect class the register has a P1 entry for.
|
|
28
28
|
*
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
* an operator assertion however many columns it arrives in. The basis stays
|
|
52
52
|
* `operator-attested` and the declared source rides along as the reason.
|
|
53
53
|
*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
54
|
-
import { parseCsv } from "@aglyn/aglyn/app-utils/
|
|
54
|
+
import { parseCsv } from "@aglyn/aglyn/app-utils/csv";
|
|
55
55
|
/**
|
|
56
56
|
* The most addresses one uploaded file may name.
|
|
57
57
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/list-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * READING A MERCHANT'S CONTACT FILE, and saying what is in it.\n *\n * `docs/specs/email-competitive-gaps.md` G5 is the product gap — a customer\n * arriving with a list has no way to bring it — and P4 is its condition: an\n * importer is simultaneously the biggest onboarding blocker and the biggest\n * abuse vector, so it ships WITH its controls or not at all. This module is\n * the pure half of those controls. It parses, it de-duplicates, and it\n * SCREENS. It decides nothing about consent and it enrolls nobody: the\n * consent question belongs to `list-assignment-policy`, which the one-address\n * add path already asks, and an import that answered it a second way would be\n * exactly the defect class the register has a P1 entry for.\n *\n * ## The screening reports, it never refuses\n *\n * M3AAWG's Vetting BCP supplies two cheap mechanical checks and both are\n * signals rather than verdicts. A role account (`sales@`, `info@`) is, in\n * M3AAWG's words, indicative of poor acquisition practice — it is not proof\n * of one, and plenty of legitimate B2B lists carry a handful. A column named\n * `jigsaw` or `append` is a purchase tell, and again a merchant may simply\n * have named a column badly.\n *\n * So neither one drops an address. Both are put in front of the operator\n * BEFORE they attest, because the attestation is the thing with teeth: an\n * operator who states they have permission for a file whose headers say it\n * was appended has made a claim their own console showed them the evidence\n * against. That is what makes the record worth keeping.\n *\n * ## A declared basis per address, and why it is still an assertion\n *\n * P4 asks for \"a declared basis per address, not one checkbox over the file\".\n * A file may carry an opt-in source and an opt-in date per row, and when it\n * does they are read here and carried onto the import as evidence. They do\n * NOT become the person's own opt-in. Nothing arriving in a spreadsheet is a\n * checkbox somebody ticked; it is the merchant telling us about one, which is\n * an operator assertion however many columns it arrives in. The basis stays\n * `operator-attested` and the declared source rides along as the reason.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { parseCsv } from '@aglyn/aglyn/app-utils/dataset-csv'\n\n/**\n * The most addresses one uploaded file may name.\n *\n * A bound on the WORK an import represents, not a capacity limit: it refuses\n * a FILE, before anything is written, and it can never drop a person already\n * on a list or trim one to make room. A merchant with more than this splits\n * the file, which is a nuisance; a merchant whose audience got silently\n * truncated has an audience they cannot reason about.\n *\n * 50,000 is Klaviyo's shape rather than a number of our own — its only\n * published hard import limit is a 50 MB CSV, which is the same statement in\n * bytes. Above this the parse itself is the cost, and it happens in one\n * request.\n */\nexport const LIST_IMPORT_MAX_ADDRESSES = 50_000\n\n/**\n * Local parts that make an address a ROLE account.\n *\n * A mailbox several people read, or none. M3AAWG's Vetting BCP: their\n * appearance on a customer list \"may be indicative of poor acquisition\n * practices\" — a list built by scraping a website collects these, a list\n * built from signups does not.\n *\n * Reported, never dropped. A merchant importing their own supplier contacts\n * legitimately has `orders@`, and an importer that quietly removed addresses\n * would be telling the operator a different number went on the list than did.\n */\nexport const ROLE_ACCOUNT_LOCAL_PARTS: readonly string[] = [\n 'abuse',\n 'admin',\n 'billing',\n 'contact',\n 'enquiries',\n 'help',\n 'hello',\n 'hr',\n 'info',\n 'inquiries',\n 'mail',\n 'marketing',\n 'noreply',\n 'no-reply',\n 'office',\n 'orders',\n 'postmaster',\n 'sales',\n 'security',\n 'staff',\n 'support',\n 'team',\n 'webmaster',\n]\n\n/**\n * Column names that say where the file came from, when the answer is \"not\n * from people who asked\".\n *\n * M3AAWG names `jigsaw` and `append` specifically. The rest are the same tell\n * in the vocabulary the data brokers actually use, and they are matched as\n * substrings of a normalized header so `Appended Email` and `append_date`\n * both hit.\n *\n * Every vendor surveyed prohibits purchased lists outright, Apple bans\n * purchased, rented and appended lists, and M3AAWG calls appending \"a direct\n * violation of core M3AAWG values\". This is not a policy we invented and the\n * screening exists so that a merchant cannot attest their way past it without\n * having been shown it.\n */\nexport const PURCHASE_TELL_COLUMNS: readonly string[] = [\n 'jigsaw',\n 'append',\n 'purchased',\n 'rented',\n 'databroker',\n 'data broker',\n 'leadgen',\n 'lead gen',\n 'scraped',\n]\n\n/** Header aliases that name the address column. */\nconst EMAIL_COLUMNS = [\n 'email',\n 'emailaddress',\n 'email address',\n 'e-mail',\n 'e-mail address',\n 'mail',\n 'contact email',\n 'primary email',\n]\n\n/** Header aliases that name a display name. */\nconst NAME_COLUMNS = [\n 'name',\n 'full name',\n 'fullname',\n 'display name',\n 'contact name',\n 'first name',\n 'firstname',\n 'given name',\n]\n\n/** Header aliases carrying the merchant's declared opt-in source. */\nconst OPT_IN_SOURCE_COLUMNS = [\n 'opt-in source',\n 'optin source',\n 'opt in source',\n 'consent source',\n 'signup source',\n 'subscription source',\n 'source',\n]\n\n/** Header aliases carrying the merchant's declared opt-in date. */\nconst OPT_IN_DATE_COLUMNS = [\n 'opt-in date',\n 'optin date',\n 'opt in date',\n 'consent date',\n 'signup date',\n 'subscribed at',\n 'date subscribed',\n 'confirmed at',\n]\n\n/** What the merchant's file says about one person. */\nexport interface ListImportRow {\n /** The source line, verbatim, so a bad one can be pointed at on screen. */\n input: string\n /** The normalized address, or `null` when the line does not carry one. */\n email: string | null\n /** A display name from the file, or `''`. */\n name: string\n /**\n * Where the file says this person opted in, or `''`.\n *\n * Evidence for the operator's assertion and never a basis of its own — see\n * the module note.\n */\n declaredSource: string\n /** When the file says they opted in, or `''`, kept as written. */\n declaredAt: string\n}\n\n/** What one file turned out to be. */\nexport interface ParsedListImport {\n /** The header row's column names, or `[]` for a bare list of addresses. */\n columns: string[]\n /**\n * One row per usable, first-seen address, plus one per unusable line.\n *\n * A repeat of an address already seen is collapsed and counted in\n * {@link duplicates} rather than carried: the membership is keyed by the\n * normalized address, so two lines would be one row and reporting them as\n * two would tell the operator they are attesting for more people than they\n * are. That is exactly the rule `resolveAddresses` applies to a paste.\n */\n rows: ListImportRow[]\n /** Rows carrying a usable address. */\n usable: number\n /** Lines that are not addresses. Reported, never dropped. */\n unusable: number\n /** Repeat appearances of an address already counted. */\n duplicates: number\n /** True when the file names more than {@link LIST_IMPORT_MAX_ADDRESSES}. */\n overCeiling: boolean\n}\n\n/** What the mechanical screening found. Signals, not verdicts. */\nexport interface ListImportScreening {\n /** Addresses at a shared or unattended mailbox. */\n roleAccounts: string[]\n /** Column names in the file that read as purchase or append tells. */\n purchaseTellColumns: string[]\n /** True when the file declares an opt-in source or date per row. */\n declaresBasis: boolean\n}\n\n/** Normalizes a header cell for alias matching. */\nfunction headerKey(value: string): string {\n return String(value ?? '')\n .trim()\n .toLowerCase()\n .replace(/[_]+/g, ' ')\n .replace(/\\s+/g, ' ')\n}\n\n/** The index of the first column matching one of `aliases`, or -1. */\nfunction columnIndex(columns: string[], aliases: readonly string[]): number {\n const keys = columns.map(headerKey)\n for (const alias of aliases) {\n const at = keys.indexOf(alias)\n if (at !== -1) return at\n }\n return -1\n}\n\n/**\n * Whether a parsed first row is a HEADER rather than a person.\n *\n * A header is a row with no address in it. That test rather than an alias\n * match, because the common shape of a merchant's export is a header whose\n * address column is called something we have never seen — and treating that\n * file as headerless would import the word \"Email Address\" as a line that is\n * not an address, and shift every subsequent column read by one.\n *\n * A one-column file of bare addresses therefore has no header, correctly:\n * every row including the first carries an address.\n */\nfunction looksLikeHeader(cells: readonly string[]): boolean {\n return !cells.some((cell) => normalizeContactEmail(cell))\n}\n\n/**\n * Reads an uploaded or pasted file into rows.\n *\n * Handles the two shapes a merchant's list actually arrives in: a CSV with a\n * header, and a bare column of addresses with no header at all. Both go\n * through one parser — `parseCsv` — because a newline-separated list of\n * addresses IS a single-column CSV, and a second code path for it would be a\n * second place for the quoting rules to differ.\n *\n * ## Every line comes back, including the bad ones\n *\n * A file 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. This is the rule `resolveAddresses`\n * already applies to a paste, restated for a file.\n */\nexport function parseListImport(text: string): ParsedListImport {\n const table = parseCsv(String(text ?? ''))\n if (!table.length) {\n return {\n columns: [],\n rows: [],\n usable: 0,\n unusable: 0,\n duplicates: 0,\n overCeiling: false,\n }\n }\n const headed = table.length > 1 && looksLikeHeader(table[0])\n const columns = headed ? table[0].map((cell) => String(cell ?? '').trim()) : []\n const body = headed ? table.slice(1) : table\n\n const emailAt = headed ? columnIndex(columns, EMAIL_COLUMNS) : 0\n const nameAt = headed ? columnIndex(columns, NAME_COLUMNS) : -1\n const sourceAt = headed ? columnIndex(columns, OPT_IN_SOURCE_COLUMNS) : -1\n const dateAt = headed ? columnIndex(columns, OPT_IN_DATE_COLUMNS) : -1\n\n const seen = new Set<string>()\n const rows: ListImportRow[] = []\n let usable = 0\n let unusable = 0\n let duplicates = 0\n let overCeiling = false\n\n for (const cells of body) {\n /*\n * The named column when the header named one, and otherwise the first\n * cell in the row that IS an address.\n *\n * The fallback is what makes an unrecognized header survivable: an\n * export whose address column is called `Primary contact e-mail (work)`\n * matches no alias, and refusing the whole file over a column name would\n * be refusing the customer's migration over our vocabulary.\n */\n const raw =\n emailAt >= 0 && emailAt < cells.length\n ? String(cells[emailAt] ?? '')\n : (cells.find((cell) => normalizeContactEmail(cell)) ?? '')\n const email = normalizeContactEmail(raw)\n const input = String(raw ?? '').trim() || cells.join(', ').trim()\n if (!email) {\n unusable += 1\n rows.push({\n input,\n email: null,\n name: '',\n declaredSource: '',\n declaredAt: '',\n })\n continue\n }\n if (seen.has(email)) {\n duplicates += 1\n continue\n }\n if (seen.size >= LIST_IMPORT_MAX_ADDRESSES) {\n overCeiling = true\n break\n }\n seen.add(email)\n usable += 1\n rows.push({\n input,\n email,\n name: nameAt >= 0 ? String(cells[nameAt] ?? '').trim() : '',\n declaredSource: sourceAt >= 0 ? String(cells[sourceAt] ?? '').trim() : '',\n declaredAt: dateAt >= 0 ? String(cells[dateAt] ?? '').trim() : '',\n })\n }\n\n return { columns, rows, usable, unusable, duplicates, overCeiling }\n}\n\n/**\n * The mechanical checks, over a parsed file.\n *\n * Cheap by construction: two substring passes and one set membership per\n * address. It is deliberately not a deliverability service — Omnisend's own\n * caveat on the one that exists is that \"list cleaning confirms\n * deliverability only and does not establish recipient consent\", and consent\n * is the only question this import actually turns on.\n */\nexport function screenListImport(\n parsed: Pick<ParsedListImport, 'columns' | 'rows'>,\n): ListImportScreening {\n const roleAccounts: string[] = []\n for (const row of parsed.rows) {\n if (!row.email) continue\n const local = row.email.slice(0, row.email.indexOf('@'))\n if (ROLE_ACCOUNT_LOCAL_PARTS.includes(local)) roleAccounts.push(row.email)\n }\n const purchaseTellColumns = parsed.columns.filter((column) => {\n const key = headerKey(column)\n return PURCHASE_TELL_COLUMNS.some((tell) => key.includes(tell))\n })\n return {\n roleAccounts,\n purchaseTellColumns,\n declaresBasis: parsed.rows.some(\n (row) => !!row.declaredSource || !!row.declaredAt,\n ),\n }\n}\n\n/**\n * The sentence recorded as the REASON behind an imported person's basis.\n *\n * One line, built from what the file actually declared, so a compliance\n * question about one address gets an answer about that address rather than\n * \"somebody ticked a box on an import once\". Empty declarations produce the\n * plain sentence rather than a sentence with holes in it.\n */\nexport function importedBasisReason(row: {\n declaredSource: string\n declaredAt: string\n}): string {\n const parts: string[] = []\n if (row.declaredSource) parts.push(`declared source: ${row.declaredSource}`)\n if (row.declaredAt) parts.push(`declared opt-in: ${row.declaredAt}`)\n return parts.length\n ? `Imported from a file, attested by the operator (${parts.join('; ')}).`\n : 'Imported from a file, attested by the operator.'\n}\n"],"names":["normalizeContactEmail","parseCsv","LIST_IMPORT_MAX_ADDRESSES","ROLE_ACCOUNT_LOCAL_PARTS","PURCHASE_TELL_COLUMNS","EMAIL_COLUMNS","NAME_COLUMNS","OPT_IN_SOURCE_COLUMNS","OPT_IN_DATE_COLUMNS","headerKey","value","String","trim","toLowerCase","replace","columnIndex","columns","aliases","keys","map","alias","at","indexOf","looksLikeHeader","cells","some","cell","parseListImport","text","table","length","rows","usable","unusable","duplicates","overCeiling","headed","body","slice","emailAt","nameAt","sourceAt","dateAt","seen","Set","raw","find","email","input","join","push","name","declaredSource","declaredAt","has","size","add","screenListImport","parsed","roleAccounts","row","local","includes","purchaseTellColumns","filter","column","key","tell","declaresBasis","importedBasisReason","parts"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/list-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * READING A MERCHANT'S CONTACT FILE, and saying what is in it.\n *\n * `docs/specs/email-competitive-gaps.md` G5 is the product gap — a customer\n * arriving with a list has no way to bring it — and P4 is its condition: an\n * importer is simultaneously the biggest onboarding blocker and the biggest\n * abuse vector, so it ships WITH its controls or not at all. This module is\n * the pure half of those controls. It parses, it de-duplicates, and it\n * SCREENS. It decides nothing about consent and it enrolls nobody: the\n * consent question belongs to `enrollment-basis`, which the one-address\n * add path already asks, and an import that answered it a second way would be\n * exactly the defect class the register has a P1 entry for.\n *\n * ## The screening reports, it never refuses\n *\n * M3AAWG's Vetting BCP supplies two cheap mechanical checks and both are\n * signals rather than verdicts. A role account (`sales@`, `info@`) is, in\n * M3AAWG's words, indicative of poor acquisition practice — it is not proof\n * of one, and plenty of legitimate B2B lists carry a handful. A column named\n * `jigsaw` or `append` is a purchase tell, and again a merchant may simply\n * have named a column badly.\n *\n * So neither one drops an address. Both are put in front of the operator\n * BEFORE they attest, because the attestation is the thing with teeth: an\n * operator who states they have permission for a file whose headers say it\n * was appended has made a claim their own console showed them the evidence\n * against. That is what makes the record worth keeping.\n *\n * ## A declared basis per address, and why it is still an assertion\n *\n * P4 asks for \"a declared basis per address, not one checkbox over the file\".\n * A file may carry an opt-in source and an opt-in date per row, and when it\n * does they are read here and carried onto the import as evidence. They do\n * NOT become the person's own opt-in. Nothing arriving in a spreadsheet is a\n * checkbox somebody ticked; it is the merchant telling us about one, which is\n * an operator assertion however many columns it arrives in. The basis stays\n * `operator-attested` and the declared source rides along as the reason.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { parseCsv } from '@aglyn/aglyn/app-utils/csv'\n\n/**\n * The most addresses one uploaded file may name.\n *\n * A bound on the WORK an import represents, not a capacity limit: it refuses\n * a FILE, before anything is written, and it can never drop a person already\n * on a list or trim one to make room. A merchant with more than this splits\n * the file, which is a nuisance; a merchant whose audience got silently\n * truncated has an audience they cannot reason about.\n *\n * 50,000 is Klaviyo's shape rather than a number of our own — its only\n * published hard import limit is a 50 MB CSV, which is the same statement in\n * bytes. Above this the parse itself is the cost, and it happens in one\n * request.\n */\nexport const LIST_IMPORT_MAX_ADDRESSES = 50_000\n\n/**\n * Local parts that make an address a ROLE account.\n *\n * A mailbox several people read, or none. M3AAWG's Vetting BCP: their\n * appearance on a customer list \"may be indicative of poor acquisition\n * practices\" — a list built by scraping a website collects these, a list\n * built from signups does not.\n *\n * Reported, never dropped. A merchant importing their own supplier contacts\n * legitimately has `orders@`, and an importer that quietly removed addresses\n * would be telling the operator a different number went on the list than did.\n */\nexport const ROLE_ACCOUNT_LOCAL_PARTS: readonly string[] = [\n 'abuse',\n 'admin',\n 'billing',\n 'contact',\n 'enquiries',\n 'help',\n 'hello',\n 'hr',\n 'info',\n 'inquiries',\n 'mail',\n 'marketing',\n 'noreply',\n 'no-reply',\n 'office',\n 'orders',\n 'postmaster',\n 'sales',\n 'security',\n 'staff',\n 'support',\n 'team',\n 'webmaster',\n]\n\n/**\n * Column names that say where the file came from, when the answer is \"not\n * from people who asked\".\n *\n * M3AAWG names `jigsaw` and `append` specifically. The rest are the same tell\n * in the vocabulary the data brokers actually use, and they are matched as\n * substrings of a normalized header so `Appended Email` and `append_date`\n * both hit.\n *\n * Every vendor surveyed prohibits purchased lists outright, Apple bans\n * purchased, rented and appended lists, and M3AAWG calls appending \"a direct\n * violation of core M3AAWG values\". This is not a policy we invented and the\n * screening exists so that a merchant cannot attest their way past it without\n * having been shown it.\n */\nexport const PURCHASE_TELL_COLUMNS: readonly string[] = [\n 'jigsaw',\n 'append',\n 'purchased',\n 'rented',\n 'databroker',\n 'data broker',\n 'leadgen',\n 'lead gen',\n 'scraped',\n]\n\n/** Header aliases that name the address column. */\nconst EMAIL_COLUMNS = [\n 'email',\n 'emailaddress',\n 'email address',\n 'e-mail',\n 'e-mail address',\n 'mail',\n 'contact email',\n 'primary email',\n]\n\n/** Header aliases that name a display name. */\nconst NAME_COLUMNS = [\n 'name',\n 'full name',\n 'fullname',\n 'display name',\n 'contact name',\n 'first name',\n 'firstname',\n 'given name',\n]\n\n/** Header aliases carrying the merchant's declared opt-in source. */\nconst OPT_IN_SOURCE_COLUMNS = [\n 'opt-in source',\n 'optin source',\n 'opt in source',\n 'consent source',\n 'signup source',\n 'subscription source',\n 'source',\n]\n\n/** Header aliases carrying the merchant's declared opt-in date. */\nconst OPT_IN_DATE_COLUMNS = [\n 'opt-in date',\n 'optin date',\n 'opt in date',\n 'consent date',\n 'signup date',\n 'subscribed at',\n 'date subscribed',\n 'confirmed at',\n]\n\n/** What the merchant's file says about one person. */\nexport interface ListImportRow {\n /** The source line, verbatim, so a bad one can be pointed at on screen. */\n input: string\n /** The normalized address, or `null` when the line does not carry one. */\n email: string | null\n /** A display name from the file, or `''`. */\n name: string\n /**\n * Where the file says this person opted in, or `''`.\n *\n * Evidence for the operator's assertion and never a basis of its own — see\n * the module note.\n */\n declaredSource: string\n /** When the file says they opted in, or `''`, kept as written. */\n declaredAt: string\n}\n\n/** What one file turned out to be. */\nexport interface ParsedListImport {\n /** The header row's column names, or `[]` for a bare list of addresses. */\n columns: string[]\n /**\n * One row per usable, first-seen address, plus one per unusable line.\n *\n * A repeat of an address already seen is collapsed and counted in\n * {@link duplicates} rather than carried: the membership is keyed by the\n * normalized address, so two lines would be one row and reporting them as\n * two would tell the operator they are attesting for more people than they\n * are. That is exactly the rule `resolveAddresses` applies to a paste.\n */\n rows: ListImportRow[]\n /** Rows carrying a usable address. */\n usable: number\n /** Lines that are not addresses. Reported, never dropped. */\n unusable: number\n /** Repeat appearances of an address already counted. */\n duplicates: number\n /** True when the file names more than {@link LIST_IMPORT_MAX_ADDRESSES}. */\n overCeiling: boolean\n}\n\n/** What the mechanical screening found. Signals, not verdicts. */\nexport interface ListImportScreening {\n /** Addresses at a shared or unattended mailbox. */\n roleAccounts: string[]\n /** Column names in the file that read as purchase or append tells. */\n purchaseTellColumns: string[]\n /** True when the file declares an opt-in source or date per row. */\n declaresBasis: boolean\n}\n\n/** Normalizes a header cell for alias matching. */\nfunction headerKey(value: string): string {\n return String(value ?? '')\n .trim()\n .toLowerCase()\n .replace(/[_]+/g, ' ')\n .replace(/\\s+/g, ' ')\n}\n\n/** The index of the first column matching one of `aliases`, or -1. */\nfunction columnIndex(columns: string[], aliases: readonly string[]): number {\n const keys = columns.map(headerKey)\n for (const alias of aliases) {\n const at = keys.indexOf(alias)\n if (at !== -1) return at\n }\n return -1\n}\n\n/**\n * Whether a parsed first row is a HEADER rather than a person.\n *\n * A header is a row with no address in it. That test rather than an alias\n * match, because the common shape of a merchant's export is a header whose\n * address column is called something we have never seen — and treating that\n * file as headerless would import the word \"Email Address\" as a line that is\n * not an address, and shift every subsequent column read by one.\n *\n * A one-column file of bare addresses therefore has no header, correctly:\n * every row including the first carries an address.\n */\nfunction looksLikeHeader(cells: readonly string[]): boolean {\n return !cells.some((cell) => normalizeContactEmail(cell))\n}\n\n/**\n * Reads an uploaded or pasted file into rows.\n *\n * Handles the two shapes a merchant's list actually arrives in: a CSV with a\n * header, and a bare column of addresses with no header at all. Both go\n * through one parser — `parseCsv` — because a newline-separated list of\n * addresses IS a single-column CSV, and a second code path for it would be a\n * second place for the quoting rules to differ.\n *\n * ## Every line comes back, including the bad ones\n *\n * A file 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. This is the rule `resolveAddresses`\n * already applies to a paste, restated for a file.\n */\nexport function parseListImport(text: string): ParsedListImport {\n const table = parseCsv(String(text ?? ''))\n if (!table.length) {\n return {\n columns: [],\n rows: [],\n usable: 0,\n unusable: 0,\n duplicates: 0,\n overCeiling: false,\n }\n }\n const headed = table.length > 1 && looksLikeHeader(table[0])\n const columns = headed ? table[0].map((cell) => String(cell ?? '').trim()) : []\n const body = headed ? table.slice(1) : table\n\n const emailAt = headed ? columnIndex(columns, EMAIL_COLUMNS) : 0\n const nameAt = headed ? columnIndex(columns, NAME_COLUMNS) : -1\n const sourceAt = headed ? columnIndex(columns, OPT_IN_SOURCE_COLUMNS) : -1\n const dateAt = headed ? columnIndex(columns, OPT_IN_DATE_COLUMNS) : -1\n\n const seen = new Set<string>()\n const rows: ListImportRow[] = []\n let usable = 0\n let unusable = 0\n let duplicates = 0\n let overCeiling = false\n\n for (const cells of body) {\n /*\n * The named column when the header named one, and otherwise the first\n * cell in the row that IS an address.\n *\n * The fallback is what makes an unrecognized header survivable: an\n * export whose address column is called `Primary contact e-mail (work)`\n * matches no alias, and refusing the whole file over a column name would\n * be refusing the customer's migration over our vocabulary.\n */\n const raw =\n emailAt >= 0 && emailAt < cells.length\n ? String(cells[emailAt] ?? '')\n : (cells.find((cell) => normalizeContactEmail(cell)) ?? '')\n const email = normalizeContactEmail(raw)\n const input = String(raw ?? '').trim() || cells.join(', ').trim()\n if (!email) {\n unusable += 1\n rows.push({\n input,\n email: null,\n name: '',\n declaredSource: '',\n declaredAt: '',\n })\n continue\n }\n if (seen.has(email)) {\n duplicates += 1\n continue\n }\n if (seen.size >= LIST_IMPORT_MAX_ADDRESSES) {\n overCeiling = true\n break\n }\n seen.add(email)\n usable += 1\n rows.push({\n input,\n email,\n name: nameAt >= 0 ? String(cells[nameAt] ?? '').trim() : '',\n declaredSource: sourceAt >= 0 ? String(cells[sourceAt] ?? '').trim() : '',\n declaredAt: dateAt >= 0 ? String(cells[dateAt] ?? '').trim() : '',\n })\n }\n\n return { columns, rows, usable, unusable, duplicates, overCeiling }\n}\n\n/**\n * The mechanical checks, over a parsed file.\n *\n * Cheap by construction: two substring passes and one set membership per\n * address. It is deliberately not a deliverability service — Omnisend's own\n * caveat on the one that exists is that \"list cleaning confirms\n * deliverability only and does not establish recipient consent\", and consent\n * is the only question this import actually turns on.\n */\nexport function screenListImport(\n parsed: Pick<ParsedListImport, 'columns' | 'rows'>,\n): ListImportScreening {\n const roleAccounts: string[] = []\n for (const row of parsed.rows) {\n if (!row.email) continue\n const local = row.email.slice(0, row.email.indexOf('@'))\n if (ROLE_ACCOUNT_LOCAL_PARTS.includes(local)) roleAccounts.push(row.email)\n }\n const purchaseTellColumns = parsed.columns.filter((column) => {\n const key = headerKey(column)\n return PURCHASE_TELL_COLUMNS.some((tell) => key.includes(tell))\n })\n return {\n roleAccounts,\n purchaseTellColumns,\n declaresBasis: parsed.rows.some(\n (row) => !!row.declaredSource || !!row.declaredAt,\n ),\n }\n}\n\n/**\n * The sentence recorded as the REASON behind an imported person's basis.\n *\n * One line, built from what the file actually declared, so a compliance\n * question about one address gets an answer about that address rather than\n * \"somebody ticked a box on an import once\". Empty declarations produce the\n * plain sentence rather than a sentence with holes in it.\n */\nexport function importedBasisReason(row: {\n declaredSource: string\n declaredAt: string\n}): string {\n const parts: string[] = []\n if (row.declaredSource) parts.push(`declared source: ${row.declaredSource}`)\n if (row.declaredAt) parts.push(`declared opt-in: ${row.declaredAt}`)\n return parts.length\n ? `Imported from a file, attested by the operator (${parts.join('; ')}).`\n : 'Imported from a file, attested by the operator.'\n}\n"],"names":["normalizeContactEmail","parseCsv","LIST_IMPORT_MAX_ADDRESSES","ROLE_ACCOUNT_LOCAL_PARTS","PURCHASE_TELL_COLUMNS","EMAIL_COLUMNS","NAME_COLUMNS","OPT_IN_SOURCE_COLUMNS","OPT_IN_DATE_COLUMNS","headerKey","value","String","trim","toLowerCase","replace","columnIndex","columns","aliases","keys","map","alias","at","indexOf","looksLikeHeader","cells","some","cell","parseListImport","text","table","length","rows","usable","unusable","duplicates","overCeiling","headed","body","slice","emailAt","nameAt","sourceAt","dateAt","seen","Set","raw","find","email","input","join","push","name","declaredSource","declaredAt","has","size","add","screenListImport","parsed","roleAccounts","row","local","includes","purchaseTellColumns","filter","column","key","tell","declaresBasis","importedBasisReason","parts"],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type SubscriptionTopic } from '@aglyn/aglyn/app-utils/subscription-topics';
|
|
18
|
+
/**
|
|
19
|
+
* THE TOPIC CATALOG AN ORG AUTHORS — this plugin's storage.
|
|
20
|
+
*
|
|
21
|
+
* The streams themselves, what a recipient's opt-out means and how a send
|
|
22
|
+
* honors it are the mail rail's (`app-utils/subscription-topics.ts`), and the
|
|
23
|
+
* built-in streams are declared by the plugins that send under them. What an
|
|
24
|
+
* org writes — a built-in renamed, re-described, archived or confirmed, and
|
|
25
|
+
* the topics it adds of its own — is stored here, one document per topic, at
|
|
26
|
+
* `orgs/{orgId}/emailTopics/{topicId}`, org-shared like `lists`. Another
|
|
27
|
+
* plugin that needs an org's catalog asks the `subscriptionTopic` record
|
|
28
|
+
* index this plugin publishes, never the collection.
|
|
29
|
+
*/
|
|
30
|
+
/** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */
|
|
31
|
+
export declare const EMAIL_TOPICS_COLLECTION = "emailTopics";
|
|
32
|
+
/** The stored documents as topics, skipping any that is not one. */
|
|
33
|
+
export declare function emailTopicsFromDocs(docs: ReadonlyArray<{
|
|
34
|
+
id: string;
|
|
35
|
+
data: () => Record<string, unknown> | undefined;
|
|
36
|
+
}>): SubscriptionTopic[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { normalizeSubscriptionTopic } from "@aglyn/aglyn/app-utils/subscription-topics";
|
|
17
|
+
/**
|
|
18
|
+
* THE TOPIC CATALOG AN ORG AUTHORS — this plugin's storage.
|
|
19
|
+
*
|
|
20
|
+
* The streams themselves, what a recipient's opt-out means and how a send
|
|
21
|
+
* honors it are the mail rail's (`app-utils/subscription-topics.ts`), and the
|
|
22
|
+
* built-in streams are declared by the plugins that send under them. What an
|
|
23
|
+
* org writes — a built-in renamed, re-described, archived or confirmed, and
|
|
24
|
+
* the topics it adds of its own — is stored here, one document per topic, at
|
|
25
|
+
* `orgs/{orgId}/emailTopics/{topicId}`, org-shared like `lists`. Another
|
|
26
|
+
* plugin that needs an org's catalog asks the `subscriptionTopic` record
|
|
27
|
+
* index this plugin publishes, never the collection.
|
|
28
|
+
*/ /** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */ export const EMAIL_TOPICS_COLLECTION = 'emailTopics';
|
|
29
|
+
/** The stored documents as topics, skipping any that is not one. */ export function emailTopicsFromDocs(docs) {
|
|
30
|
+
return docs.map((doc)=>{
|
|
31
|
+
var _doc_data;
|
|
32
|
+
return normalizeSubscriptionTopic(doc.id, (_doc_data = doc.data()) != null ? _doc_data : null);
|
|
33
|
+
}).filter((topic)=>!!topic);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//# sourceMappingURL=email-topic-catalog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/email-topic-catalog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n normalizeSubscriptionTopic,\n type SubscriptionTopic,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\n\n/**\n * THE TOPIC CATALOG AN ORG AUTHORS — this plugin's storage.\n *\n * The streams themselves, what a recipient's opt-out means and how a send\n * honors it are the mail rail's (`app-utils/subscription-topics.ts`), and the\n * built-in streams are declared by the plugins that send under them. What an\n * org writes — a built-in renamed, re-described, archived or confirmed, and\n * the topics it adds of its own — is stored here, one document per topic, at\n * `orgs/{orgId}/emailTopics/{topicId}`, org-shared like `lists`. Another\n * plugin that needs an org's catalog asks the `subscriptionTopic` record\n * index this plugin publishes, never the collection.\n */\n\n/** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */\nexport const EMAIL_TOPICS_COLLECTION = 'emailTopics'\n\n/** The stored documents as topics, skipping any that is not one. */\nexport function emailTopicsFromDocs(\n docs: ReadonlyArray<{ id: string; data: () => Record<string, unknown> | undefined }>,\n): SubscriptionTopic[] {\n return docs\n .map((doc) => normalizeSubscriptionTopic(doc.id, doc.data() ?? null))\n .filter((topic): topic is SubscriptionTopic => !!topic)\n}\n"],"names":["normalizeSubscriptionTopic","EMAIL_TOPICS_COLLECTION","emailTopicsFromDocs","docs","map","doc","id","data","filter","topic"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,0BAA0B,QAErB,6CAA4C;AAEnD;;;;;;;;;;;CAWC,GAED,uEAAuE,GACvE,OAAO,MAAMC,0BAA0B,cAAa;AAEpD,kEAAkE,GAClE,OAAO,SAASC,oBACdC,IAAoF;IAEpF,OAAOA,KACJC,GAAG,CAAC,CAACC;YAA2CA;eAAnCL,2BAA2BK,IAAIC,EAAE,GAAED,YAAAA,IAAIE,IAAI,cAARF,YAAc;OAC9DG,MAAM,CAAC,CAACC,QAAsC,CAAC,CAACA;AACrD"}
|
package/src/lib/model/index.d.ts
CHANGED
|
@@ -26,13 +26,6 @@ export { renderEmailHtml, substituteMergeTokens, resolveMergeTags, type EmailRen
|
|
|
26
26
|
* rather than chosen in JSX.
|
|
27
27
|
*/
|
|
28
28
|
export * from '@aglyn/shared-ui-email-campaigns/model/campaign-report';
|
|
29
|
-
/**
|
|
30
|
-
* What a campaign EARNED: the last-click window, the gross/refunded pair and
|
|
31
|
-
* the per-currency buckets that are never added together. Separate from the
|
|
32
|
-
* rate math because it reads a different document and answers the merchant's
|
|
33
|
-
* second question rather than their first.
|
|
34
|
-
*/
|
|
35
|
-
export * from '@aglyn/shared-ui-email-campaigns/model/campaign-revenue';
|
|
36
29
|
/**
|
|
37
30
|
* The same math taken across every message sent from one TEMPLATE. Separate
|
|
38
31
|
* from `campaign-report` because a sum has a membership: which messages went
|