@aglyn/plugins-email 1.0.0-beta.178 → 1.0.0-beta.180
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/README.md +2 -1
- package/package.json +17 -17
- package/src/lib/components/email-org-mount.d.ts +116 -0
- package/src/lib/components/email-org-mount.js +211 -0
- package/src/lib/components/email-org-mount.js.map +1 -0
- package/src/lib/components/email-topic-detail.d.ts +2 -1
- package/src/lib/components/email-topic-detail.js +7 -1
- package/src/lib/components/email-topic-detail.js.map +1 -1
- package/src/lib/components/email-topics-card.d.ts +2 -1
- package/src/lib/components/email-topics-card.js +7 -1
- package/src/lib/components/email-topics-card.js.map +1 -1
- package/src/lib/components/email-zones.d.ts +28 -1
- package/src/lib/components/email-zones.js.map +1 -1
- package/src/lib/components/emails-console-page.d.ts +4 -0
- package/src/lib/components/emails-console-page.js +107 -5
- package/src/lib/components/emails-console-page.js.map +1 -1
- package/src/lib/components/list-detail-card.d.ts +19 -3
- package/src/lib/components/list-detail-card.js +52 -13
- package/src/lib/components/list-detail-card.js.map +1 -1
- package/src/lib/components/list-edit-card.d.ts +2 -1
- package/src/lib/components/list-edit-card.js +56 -17
- package/src/lib/components/list-edit-card.js.map +1 -1
- package/src/lib/components/lists-card.d.ts +2 -1
- package/src/lib/components/lists-card.js +5 -5
- package/src/lib/components/lists-card.js.map +1 -1
- package/src/lib/components/org-email-templates-card.d.ts +34 -0
- package/src/lib/components/org-email-templates-card.js +470 -0
- package/src/lib/components/org-email-templates-card.js.map +1 -0
- package/src/lib/components/org-sending-card.d.ts +30 -0
- package/src/lib/components/org-sending-card.js +680 -0
- package/src/lib/components/org-sending-card.js.map +1 -0
- package/src/lib/components/org-sending-domain-detail.d.ts +28 -0
- package/src/lib/components/org-sending-domain-detail.js +497 -0
- package/src/lib/components/org-sending-domain-detail.js.map +1 -0
- package/src/lib/components/org-site-suppressions.d.ts +20 -0
- package/src/lib/components/org-site-suppressions.js +99 -0
- package/src/lib/components/org-site-suppressions.js.map +1 -0
- package/src/lib/components/org-suppressions-card.d.ts +22 -0
- package/src/lib/components/org-suppressions-card.js +196 -0
- package/src/lib/components/org-suppressions-card.js.map +1 -0
- package/src/lib/components/sending-domain-detail.d.ts +12 -0
- package/src/lib/components/sending-domain-detail.js +4 -1
- package/src/lib/components/sending-domain-detail.js.map +1 -1
- package/src/lib/components/suppression-totals.d.ts +63 -0
- package/src/lib/components/suppression-totals.js +105 -0
- package/src/lib/components/suppression-totals.js.map +1 -0
- package/src/lib/components/suppressions-card.js +7 -80
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/components/use-org-email-topics.d.ts +9 -1
- package/src/lib/components/use-org-email-topics.js +4 -3
- package/src/lib/components/use-org-email-topics.js.map +1 -1
- package/src/lib/components/use-org-sending-views.d.ts +39 -0
- package/src/lib/components/use-org-sending-views.js +124 -0
- package/src/lib/components/use-org-sending-views.js.map +1 -0
- package/src/lib/model/sending-domain-status.d.ts +36 -0
- package/src/lib/model/sending-domain-status.js +46 -0
- package/src/lib/model/sending-domain-status.js.map +1 -1
- package/src/lib/plugin.js +29 -1
- package/src/lib/plugin.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/suppressions-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 { pluginDocsHelp } from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { mdiEmailCheckOutline } from '@aglyn/shared-data-mdi'\nimport {\n CardDisplay,\n Container,\n MdiIcon,\n SrOnly,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\n/*\n * The shared drawer, reached by its own path.\n *\n * `@aglyn/shared-ui-jsx`'s barrel deliberately does not re-export this one, so\n * a deep import is the supported way in rather than an escape hatch. The\n * console's `CreateArtifactDrawer` — what Screens, Components, Layouts and\n * Templates create through — is this same component with a form inside it,\n * and it lives in `apps/console`, which a plugin library may not import. So\n * the chrome is composed from the same primitive rather than duplicated from\n * the wrapper.\n */\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Chip,\n IconButton,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Button from '@mui/material/Button'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n count,\n deleteDoc,\n doc,\n getAggregateFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport {\n useFirestore,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\n\nexport interface SuppressionsCardProps {\n hostId: string\n}\n\n/** A stored entry. `reason` is absent on anything written before AGL-2408. */\ninterface SuppressionRow {\n $id: string\n email?: string\n reason?: string\n suppressedAt?: { seconds?: number } | null\n createdAt?: { seconds?: number } | null\n}\n\n/**\n * What a reason means to a merchant, and how much it should worry them.\n *\n * An ABSENT reason reads as \"Unsubscribed\", and that is a compatibility rule\n * rather than a guess: until AGL-2408 the unsubscribe handler wrote\n * `{ email, createdAt }` and nothing else, while the Resend webhook has\n * stamped `'bounce'`/`'complaint'` since AGL-1918 — so an entry with no reason\n * can only have come from somebody clicking the link. New unsubscribes write\n * the reason explicitly, so this fallback covers history and nothing else.\n */\nconst REASONS: Record<string, { label: string; color: 'default' | 'warning' | 'error' }> = {\n unsubscribe: { label: 'Unsubscribed', color: 'default' },\n bounce: { label: 'Bounced', color: 'warning' },\n complaint: { label: 'Marked as spam', color: 'error' },\n /*\n * Recorded by a person, through the Add control.\n *\n * Its OWN value rather than a reuse of `unsubscribe`: an opt-out arriving\n * by reply, phone or in person is not somebody clicking a link, and the\n * difference is exactly what a merchant asked to prove the request was\n * honored has to be able to show.\n */\n manual: { label: 'Added by hand', color: 'default' },\n}\n\nconst describeReason = (reason: unknown) =>\n REASONS[String(reason ?? 'unsubscribe')] ?? {\n label: String(reason),\n color: 'default' as const,\n }\n\n/**\n * `YYYY-MM-DD` from a Firestore timestamp shape, or an em dash.\n *\n * `createdAt` first, and that ordering is the column's meaning rather than a\n * preference. Both writers restamp `suppressedAt` on every touch and write\n * `createdAt` only when the document is new, precisely so that a bounce\n * arriving after an unsubscribe does not move the date the person actually\n * unsubscribed. Reading `suppressedAt` first put the restamp on screen under a\n * heading that says \"Since\", and it is also the field the list is ordered by,\n * so a re-touched row would have sorted by one date and displayed another.\n */\nfunction onDate(row: SuppressionRow): string {\n const seconds = row.createdAt?.seconds ?? row.suppressedAt?.seconds\n if (!seconds) return '—'\n return new Date(seconds * 1000).toISOString().slice(0, 10)\n}\n\n/**\n * Suppressions (AGL-2410): who is not being emailed, and why.\n *\n * ## What was missing\n *\n * `hosts/{hostId}/suppressions` was written by two paths — the unsubscribe\n * handler and, since AGL-1918, the Resend webhook on a permanent bounce or a\n * complaint — and read by exactly one: `campaign-send.ts`, to filter an\n * audience. Nothing in the console displayed it. So a merchant could not\n * answer any of:\n *\n * - *\"My campaign says 500 recipients and 480 sent — who were the other\n * 20?\"* The send returns `{recipients, sent}` and the difference was\n * unexplained.\n * - *\"Is my list going stale?\"* A bounce rate is the single most useful\n * number about a list and there was nowhere to see it.\n * - *\"This address was suppressed by mistake.\"* There was no way to remove\n * an entry — and a link prescanner unsubscribing someone (AGL-2408 §2) was\n * therefore unrecoverable from inside the product.\n *\n * ## Why a surface and not another counter\n *\n * AGL-1918 deliberately did NOT write a `stats.bounces` counter alongside its\n * fix, because a number with no screen to show it is the written-but-never-\n * read shape this issue is about, one level up. So the fix is the READER, and\n * the breakdown here is derived from the rows on screen rather than from a\n * second stored figure that could disagree with them.\n *\n * ## Removing an entry\n *\n * A plain client `deleteDoc`, and that is a decision. The list belongs to the\n * merchant, host admins already read it through the same rules, and removing\n * a row does nothing except make an address targetable again — there is no\n * counter to launder and no money attached, which is the AGL-1367 test for\n * whether a write has to move server-side.\n *\n * The confirmation is not decoration either: for a `bounce` the address very\n * likely does not exist, and mailing it again is what a provider scores the\n * sending domain on. So the dialog says which reason is being overridden\n * rather than asking a generic \"are you sure\".\n */\nexport function SuppressionsCard(props: SuppressionsCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const [adding, setAdding] = useState(false)\n\n const [addInput, setAddInput] = useState('')\n const [addNote, setAddNote] = useState('')\n const [busy, setBusy] = useState(false)\n\n /*\n * The window IS the query, ordered by the server (AGL-2501, AGL-2292).\n *\n * This was `limit(500)` with no `orderBy`, sorted by date in the browser.\n * Firestore answers an unordered limit in DOCUMENT-ID order, and an entry\n * here is keyed by `sha256(email)` — so the window was five hundred\n * addresses chosen by the hash of the address, and the client sort dressed\n * that sample up as the newest five hundred. A list past the ceiling\n * therefore hid whoever bounced this morning behind whoever happened to\n * hash low, with no gap on screen to notice and no control asking for more.\n *\n * `createdAt` is the safe field to order on, and that is checked rather\n * than assumed: both writers — the unsubscribe handler and the Resend\n * bounce/complaint webhook — stamp it when the document is created, the\n * pre-AGL-2408 handler wrote `{ email, createdAt }`, and `suppressions` is\n * not in `IMPORTABLE_FIELDS`, so no restore path can produce a row without\n * one. `suppressedAt` would NOT be safe: it is absent on every entry\n * written before AGL-1918, and `orderBy` drops documents that lack the\n * field rather than mis-sorting them.\n */\n const {\n rows: entries,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<SuppressionRow>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'suppressions'),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE BREAKDOWN IS A SERVER AGGREGATE, not a tally of the page.\n *\n * These chips answer \"is my list going stale?\", and they were a `reduce`\n * over whatever the listener had fetched — so on a site past the old\n * ceiling \"Bounced: 140\" meant 140 of an arbitrary five hundred, and under\n * a ten-row page it would have meant 140 of ten. A bounce rate computed\n * from a sample is not a bounce rate, and nothing on screen said it was one.\n *\n * Three reads, not one per reason. `where('reason','==','unsubscribe')`\n * cannot be asked, because an entry written before AGL-2408 carries no\n * `reason` at all and an equality filter excludes it — the same\n * field-presence trap as the ordering above. Unsubscribes are therefore the\n * REMAINDER: total minus the two reasons that are always written\n * explicitly, which is exactly the compatibility rule `describeReason`\n * applies row by row.\n *=========================================*/\n const [totalsEpoch, setTotalsEpoch] = useState(0)\n const [totals, setTotals] = useState<Record<string, number> | null>(null)\n useEffect(() => {\n let active = true\n const suppressionsRef = collection(firestore, 'hosts', hostId, 'suppressions')\n void Promise.all([\n getAggregateFromServer(suppressionsRef, { total: count() }),\n getAggregateFromServer(\n query(suppressionsRef, where('reason', '==', 'bounce')),\n { total: count() },\n ),\n getAggregateFromServer(\n query(suppressionsRef, where('reason', '==', 'complaint')),\n { total: count() },\n ),\n // A FOURTH read, and it is not optional. Unsubscribes are the\n // REMAINDER, so every reason that is counted explicitly has to be\n // subtracted — a hand-added entry left out of this list would be\n // reported as somebody who clicked unsubscribe.\n getAggregateFromServer(\n query(suppressionsRef, where('reason', '==', 'manual')),\n { total: count() },\n ),\n ])\n .then(([all, bounced, complained, added]) => {\n if (!active) return\n const total = Number(all.data().total ?? 0)\n const bounce = Number(bounced.data().total ?? 0)\n const complaint = Number(complained.data().total ?? 0)\n const manual = Number(added.data().total ?? 0)\n setTotals({\n unsubscribe: Math.max(0, total - bounce - complaint - manual),\n bounce,\n complaint,\n manual,\n })\n })\n .catch(() => {\n // Held at null rather than zeroed. \"Bounced: 0\" is a confident wrong\n // number in the reassuring direction, and this card exists to warn.\n if (active) setTotals(null)\n })\n return () => {\n active = false\n }\n // The list is a live listener and refreshes itself; an aggregate is a\n // one-shot read and would otherwise keep reporting the breakdown from\n // before the address was put back.\n }, [firestore, hostId, totalsEpoch])\n\n /*\n * The ADD, through a route rather than a client write.\n *\n * The Remove button below writes straight from the browser, and this does\n * not, which looks inconsistent until the document id is considered: an\n * entry is keyed by `sha256` of the normalized address, and a browser\n * computing that itself would be a second derivation of the key every\n * reader shares. Getting it wrong is silent and one-directional — the\n * merchant is told the person is suppressed and the mail keeps going. A\n * removal has no such hazard: it names a row that is already on screen.\n */\n const handleAdd = useCallback(async () => {\n const typed = addInput.trim()\n if (!typed || busy) return\n setBusy(true)\n try {\n const response = await authorizedFetch(\n user,\n '/api/email/suppression-add',\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, emails: typed, note: addNote.trim() }),\n },\n )\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The address could not be suppressed.',\n { variant: 'error' },\n )\n }\n const results = (payload?.results ?? []) as Array<{\n input: string\n added: boolean\n refusal?: string\n }>\n const added = Number(payload?.added ?? 0)\n const rejected = results.filter(\n (result) => result.refusal === 'not-an-address',\n )\n // The refusals are NAMED, because \"3 of 5 added\" leaves an operator to\n // work out which two, and the two that failed are the ones somebody\n // asked to stop being emailed.\n if (rejected.length) {\n enqueueSnackbar(\n `Not an email address: ${rejected\n .map((result) => result.input)\n .join(', ')}`,\n { variant: 'warning' },\n )\n }\n if (added) {\n enqueueSnackbar(\n added === 1\n ? 'Added to the suppression list'\n : `${added} addresses added to the suppression list`,\n { variant: 'success', persist: false },\n )\n } else if (!rejected.length) {\n enqueueSnackbar('Already on the suppression list', { variant: 'info' })\n }\n if (added) {\n setAddInput('')\n setAddNote('')\n setAdding(false)\n setTotalsEpoch((epoch) => epoch + 1)\n }\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n }, [addInput, addNote, busy, user, hostId, enqueueSnackbar])\n\n /**\n * Whether one address is ALSO on the platform-wide list.\n *\n * The two lists are consulted together at send time and were visible\n * separately, so a merchant who removed their own entry could still find\n * the address was never mailed, with nothing anywhere saying why. The\n * platform entry is invisible to them and cannot be lifted by them, which\n * is precisely why it has to be said before the click rather than\n * discovered from a recipient count that stays short.\n */\n const isBlockedPlatformWide = useCallback(\n async (email: string): Promise<boolean> => {\n try {\n const response = await authorizedFetch(\n user,\n '/api/email/suppression-status',\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, emails: email }),\n },\n )\n if (!response.ok) return false\n const payload = await response.json().catch(() => ({}))\n return ((payload?.platform ?? []) as string[]).length > 0\n } catch (error) {\n console.error(error)\n return false\n }\n },\n [user, hostId],\n )\n\n const handleRemove = async (row: SuppressionRow) => {\n const reason = describeReason(row.reason).label.toLowerCase()\n // The platform entry is invisible to a merchant and cannot be lifted by\n // one, so removing the site's row here changes nothing about whether the\n // address is mailed. Saying so BEFORE the click is the whole point: the\n // alternative is a merchant who removes the row, sends again, and sees a\n // recipient count that is still short with nothing explaining it.\n /*\n * ASKED, NOT MOUNTED.\n *\n * One keyed read for the one address the merchant is acting on, at the\n * moment they act. Fetching this for every visible row on mount would be\n * a request per page render for an answer that is only ever needed on a\n * click, and this card is one tab of a page a merchant opens to read.\n *\n * A failed check answers \"not blocked\", which is the ordinary case: the\n * dialog then reads exactly as it did before, and the removal still\n * works. Refusing to open the dialog because a supplementary lookup\n * failed would make an outage on an explanation into an outage on the\n * control it explains.\n */\n const alsoPlatform = row.email\n ? await isBlockedPlatformWide(row.email)\n : false\n const accepted = await confirm({\n title: alsoPlatform\n ? 'This address will still be skipped'\n : 'Put this address back on your list?',\n description: alsoPlatform\n ? `${row.email ?? 'This address'} bounced permanently or reported ` +\n 'spam somewhere else in Aglyn, so it is on the platform-wide list ' +\n 'as well as yours. Removing your entry will not start mail ' +\n 'reaching it — contact support to have the platform entry lifted.'\n : `${row.email ?? 'This address'} is suppressed because it ` +\n `${reason === 'bounced' ? 'bounced permanently' : reason === 'marked as spam' ? 'was marked as spam' : reason === 'added by hand' ? 'was added by hand' : 'unsubscribed'}. ` +\n 'Removing the entry means your next campaign will email it again.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\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 try {\n await deleteDoc(\n doc(firestore, 'hosts', hostId, 'suppressions', row.$id),\n )\n enqueueSnackbar('Removed from the suppression list', {\n variant: 'success',\n persist: false,\n })\n setTotalsEpoch((epoch) => epoch + 1)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n }\n }\n\n const columns: GridColDef<SuppressionRow>[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 240,\n /*\n Entries are keyed by `sha256(email)` because addresses are PII, and the\n address itself is stored in the document. An older row written before\n the address was stored has only its hash — which tells a merchant\n nothing, so it says so rather than displaying 64 hex characters.\n */\n renderCell: ({ row }) =>\n row.email || (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'(address not recorded)'}\n </Typography>\n ),\n },\n {\n field: 'reason',\n headerName: 'Reason',\n width: 170,\n valueGetter: (_value, row) => describeReason(row.reason).label,\n renderCell: ({ row }) => {\n const described = describeReason(row.reason)\n return (\n <Chip\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={described.label}\n />\n )\n },\n },\n {\n field: 'since',\n headerName: 'Since',\n width: 140,\n valueGetter: (_value, row) => onDate(row),\n },\n /*\n A SUPPRESSION HAS NO PAGE, so the row does not open one — this is the\n surface's one list whose rows are not a way in to anything. What it does\n have is the one act a merchant performs on an entry, and the trailing\n cluster is where every other list on this surface keeps that: a bare\n `Remove` in the row is a click that puts an address back into mailing\n range, sitting where the reader's eye is running along the row.\n */\n listActionsColumn(\n (row: SuppressionRow) => (\n <ListRowActions\n label={row.email || 'this entry'}\n items={[\n {\n key: 'remove',\n label: 'Remove from the list',\n icon: <MdiIcon path={mdiEmailCheckOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header=\"Suppressions\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#compliance' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setAdding(true)}\n >\n {'Add'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Addresses this site’s marketing email skips. Someone lands here ' +\n 'by clicking unsubscribe, by bouncing permanently, by marking a ' +\n 'message as spam, or because you added them — this is where the ' +\n 'gap between a campaign’s recipient count and what it actually ' +\n 'sent comes from.'}\n </Typography>\n {entries.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is suppressed. Every address in your audiences is ' +\n 'currently mailable.'}\n </Typography>\n ) : (\n <>\n <Stack direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap' }}>\n {totals === null ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Could not read the breakdown. This is not the same as ' +\n 'nobody having bounced.'}\n </Typography>\n ) : (\n Object.entries(totals)\n // A reason nobody has hit is not news, and three chips\n // reading zero make the two that matter harder to find.\n .filter(([, total]) => total > 0)\n .map(([reason, total]) => {\n const described = describeReason(reason)\n return (\n <Chip\n key={reason}\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={`${described.label}: ${total}`}\n />\n )\n })\n )}\n </Stack>\n <ListTable\n aria-label=\"Suppressed addresses\"\n rows={entries}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={entries.length}\n hasMore={hasMore}\n // The collection's real size, so the footer's count line says\n // \"1–10 of 812\" rather than \"of more than 10\" — the aggregate\n // above already knows it, and it is the same number the chips\n // are a breakdown of.\n count={totals ? Object.values(totals).reduce((a, b) => a + b, 0) : undefined}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n </Stack>\n {/*\n A DRAWER, not a form stacked above the table.\n Creating is a drawer and picking is a dialog, which is how Screens,\n Components, Layouts and Templates all take a name before they create\n one; the chrome here is the same `NavigationDrawerComponent` those go\n through, composed directly because the console's wrapper around it\n lives in an application a plugin library may not import.\n */}\n <NavigationDrawerComponent\n open={adding}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={() => setAdding(false)}\n AppBarProps={{ color: 'surface' }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={() => setAdding(false)}\n sx={{ mr: 2 }}\n >\n <MdiIcon path={ICON_VARIANT_CLOSE.path} />\n <SrOnly>{'close drawer'}</SrOnly>\n </IconButton>\n <Typography variant=\"h6\" component=\"div\">\n {'Stop emailing an address'}\n </Typography>\n </>\n }\n appBarRight={\n <Button\n variant=\"outlined\"\n color=\"inherit\"\n onClick={() => setAdding(false)}\n >\n {'Cancel'}\n </Button>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Use this when somebody asks you to stop emailing them by ' +\n 'reply, by phone, or in person. They stay on your audiences ' +\n 'and keep every record you hold about them — this only stops ' +\n 'this site’s marketing email reaching them.'}\n </Typography>\n <TextField\n label=\"Email addresses\"\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n multiline\n minRows={3}\n fullWidth\n autoFocus\n helperText={\n 'One per line, or separated by commas. Up to 50 at a time.'\n }\n />\n <TextField\n label=\"Note (optional)\"\n value={addNote}\n onChange={(event) => setAddNote(event.target.value)}\n fullWidth\n helperText={\n 'How the request reached you. Kept with the entry as the ' +\n 'record that it was honored.'\n }\n slotProps={{ htmlInput: { maxLength: 200 } }}\n />\n <Alert severity=\"info\">\n {'Order confirmations, booking reminders and password resets ' +\n 'are unaffected. Somebody who asked to stop hearing from ' +\n 'your marketing still gets their receipt.'}\n </Alert>\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <Button\n variant=\"contained\"\n disabled={busy || !addInput.trim()}\n onClick={() => void handleAdd()}\n >\n {'Add to suppression list'}\n </Button>\n </Stack>\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n </CardDisplay>\n )\n}\nSuppressionsCard.displayName = 'SuppressionsCard'\n\nexport default SuppressionsCard\n"],"names":["pluginDocsHelp","ICON_VARIANT_CLOSE","mdiEmailCheckOutline","CardDisplay","Container","MdiIcon","SrOnly","useConfirmationContext","ListPagination","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","NavigationDrawerComponent","useSnackbar","Alert","Chip","IconButton","Stack","TextField","Typography","Button","collection","count","deleteDoc","doc","getAggregateFromServer","limit","orderBy","query","where","useCallback","useEffect","useState","useFirestore","usePagedCollection","useUser","authorizedFetch","REASONS","unsubscribe","label","color","bounce","complaint","manual","describeReason","reason","String","onDate","row","seconds","createdAt","suppressedAt","Date","toISOString","slice","SuppressionsCard","props","hostId","firestore","data","user","enqueueSnackbar","confirm","adding","setAdding","addInput","setAddInput","addNote","setAddNote","busy","setBusy","rows","entries","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","totalsEpoch","setTotalsEpoch","totals","setTotals","active","suppressionsRef","Promise","all","total","then","bounced","complained","added","Number","Math","max","catch","handleAdd","typed","trim","response","method","headers","body","JSON","stringify","emails","note","payload","json","ok","error","variant","results","rejected","filter","result","refusal","length","map","input","join","persist","epoch","console","isBlockedPlatformWide","email","platform","handleRemove","toLowerCase","alsoPlatform","accepted","title","description","confirmationText","confirmationButtonProps","$id","columns","field","headerName","flex","minWidth","renderCell","width","valueGetter","_value","described","size","items","key","icon","path","destructive","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","direction","sx","flexWrap","Object","aria-label","rowHeight","hideFooter","rowCount","values","reduce","a","b","undefined","onPageChange","onPageSizeChange","open","onClose","AppBarProps","appBarLeft","edge","mr","component","appBarRight","gutterY","value","onChange","event","target","multiline","minRows","fullWidth","autoFocus","helperText","slotProps","htmlInput","maxLength","severity","justifyContent","disabled","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/suppressions-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 { pluginDocsHelp } from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { mdiEmailCheckOutline } from '@aglyn/shared-data-mdi'\nimport {\n CardDisplay,\n Container,\n MdiIcon,\n SrOnly,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\n/*\n * The shared drawer, reached by its own path.\n *\n * `@aglyn/shared-ui-jsx`'s barrel deliberately does not re-export this one, so\n * a deep import is the supported way in rather than an escape hatch. The\n * console's `CreateArtifactDrawer` — what Screens, Components, Layouts and\n * Templates create through — is this same component with a form inside it,\n * and it lives in `apps/console`, which a plugin library may not import. So\n * the chrome is composed from the same primitive rather than duplicated from\n * the wrapper.\n */\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Chip,\n IconButton,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Button from '@mui/material/Button'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n limit,\n orderBy,\n query,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport {\n useFirestore,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n describeSuppressionReason as describeReason,\n readSuppressionTotals,\n type SuppressionTotals,\n} from './suppression-totals'\n\nexport interface SuppressionsCardProps {\n hostId: string\n}\n\n/** A stored entry. `reason` is absent on anything written before AGL-2408. */\ninterface SuppressionRow {\n $id: string\n email?: string\n reason?: string\n suppressedAt?: { seconds?: number } | null\n createdAt?: { seconds?: number } | null\n}\n\n\n/**\n * `YYYY-MM-DD` from a Firestore timestamp shape, or an em dash.\n *\n * `createdAt` first, and that ordering is the column's meaning rather than a\n * preference. Both writers restamp `suppressedAt` on every touch and write\n * `createdAt` only when the document is new, precisely so that a bounce\n * arriving after an unsubscribe does not move the date the person actually\n * unsubscribed. Reading `suppressedAt` first put the restamp on screen under a\n * heading that says \"Since\", and it is also the field the list is ordered by,\n * so a re-touched row would have sorted by one date and displayed another.\n */\nfunction onDate(row: SuppressionRow): string {\n const seconds = row.createdAt?.seconds ?? row.suppressedAt?.seconds\n if (!seconds) return '—'\n return new Date(seconds * 1000).toISOString().slice(0, 10)\n}\n\n/**\n * Suppressions (AGL-2410): who is not being emailed, and why.\n *\n * ## What was missing\n *\n * `hosts/{hostId}/suppressions` was written by two paths — the unsubscribe\n * handler and, since AGL-1918, the Resend webhook on a permanent bounce or a\n * complaint — and read by exactly one: `campaign-send.ts`, to filter an\n * audience. Nothing in the console displayed it. So a merchant could not\n * answer any of:\n *\n * - *\"My campaign says 500 recipients and 480 sent — who were the other\n * 20?\"* The send returns `{recipients, sent}` and the difference was\n * unexplained.\n * - *\"Is my list going stale?\"* A bounce rate is the single most useful\n * number about a list and there was nowhere to see it.\n * - *\"This address was suppressed by mistake.\"* There was no way to remove\n * an entry — and a link prescanner unsubscribing someone (AGL-2408 §2) was\n * therefore unrecoverable from inside the product.\n *\n * ## Why a surface and not another counter\n *\n * AGL-1918 deliberately did NOT write a `stats.bounces` counter alongside its\n * fix, because a number with no screen to show it is the written-but-never-\n * read shape this issue is about, one level up. So the fix is the READER, and\n * the breakdown here is derived from the rows on screen rather than from a\n * second stored figure that could disagree with them.\n *\n * ## Removing an entry\n *\n * A plain client `deleteDoc`, and that is a decision. The list belongs to the\n * merchant, host admins already read it through the same rules, and removing\n * a row does nothing except make an address targetable again — there is no\n * counter to launder and no money attached, which is the AGL-1367 test for\n * whether a write has to move server-side.\n *\n * The confirmation is not decoration either: for a `bounce` the address very\n * likely does not exist, and mailing it again is what a provider scores the\n * sending domain on. So the dialog says which reason is being overridden\n * rather than asking a generic \"are you sure\".\n */\nexport function SuppressionsCard(props: SuppressionsCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const [adding, setAdding] = useState(false)\n\n const [addInput, setAddInput] = useState('')\n const [addNote, setAddNote] = useState('')\n const [busy, setBusy] = useState(false)\n\n /*\n * The window IS the query, ordered by the server (AGL-2501, AGL-2292).\n *\n * This was `limit(500)` with no `orderBy`, sorted by date in the browser.\n * Firestore answers an unordered limit in DOCUMENT-ID order, and an entry\n * here is keyed by `sha256(email)` — so the window was five hundred\n * addresses chosen by the hash of the address, and the client sort dressed\n * that sample up as the newest five hundred. A list past the ceiling\n * therefore hid whoever bounced this morning behind whoever happened to\n * hash low, with no gap on screen to notice and no control asking for more.\n *\n * `createdAt` is the safe field to order on, and that is checked rather\n * than assumed: both writers — the unsubscribe handler and the Resend\n * bounce/complaint webhook — stamp it when the document is created, the\n * pre-AGL-2408 handler wrote `{ email, createdAt }`, and `suppressions` is\n * not in `IMPORTABLE_FIELDS`, so no restore path can produce a row without\n * one. `suppressedAt` would NOT be safe: it is absent on every entry\n * written before AGL-1918, and `orderBy` drops documents that lack the\n * field rather than mis-sorting them.\n */\n const {\n rows: entries,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<SuppressionRow>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'suppressions'),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE BREAKDOWN IS A SERVER AGGREGATE, not a tally of the page.\n *\n * These chips answer \"is my list going stale?\", and they were a `reduce`\n * over whatever the listener had fetched — so on a site past the old\n * ceiling \"Bounced: 140\" meant 140 of an arbitrary five hundred, and under\n * a ten-row page it would have meant 140 of ten. A bounce rate computed\n * from a sample is not a bounce rate, and nothing on screen said it was one.\n *\n * `readSuppressionTotals` holds the four reads and the remainder rule for\n * unsubscribes, and the organization's summary of every site's list asks\n * the same function, so a site's figures read the same on both pages.\n *=========================================*/\n const [totalsEpoch, setTotalsEpoch] = useState(0)\n const [totals, setTotals] = useState<SuppressionTotals | null>(null)\n useEffect(() => {\n let active = true\n void readSuppressionTotals(firestore, hostId)\n .then((read) => {\n if (active) setTotals(read)\n })\n .catch(() => {\n // Held at null rather than zeroed. \"Bounced: 0\" is a confident wrong\n // number in the reassuring direction, and this card exists to warn.\n if (active) setTotals(null)\n })\n return () => {\n active = false\n }\n // The list is a live listener and refreshes itself; an aggregate is a\n // one-shot read and would otherwise keep reporting the breakdown from\n // before the address was put back.\n }, [firestore, hostId, totalsEpoch])\n\n /*\n * The ADD, through a route rather than a client write.\n *\n * The Remove button below writes straight from the browser, and this does\n * not, which looks inconsistent until the document id is considered: an\n * entry is keyed by `sha256` of the normalized address, and a browser\n * computing that itself would be a second derivation of the key every\n * reader shares. Getting it wrong is silent and one-directional — the\n * merchant is told the person is suppressed and the mail keeps going. A\n * removal has no such hazard: it names a row that is already on screen.\n */\n const handleAdd = useCallback(async () => {\n const typed = addInput.trim()\n if (!typed || busy) return\n setBusy(true)\n try {\n const response = await authorizedFetch(\n user,\n '/api/email/suppression-add',\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, emails: typed, note: addNote.trim() }),\n },\n )\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The address could not be suppressed.',\n { variant: 'error' },\n )\n }\n const results = (payload?.results ?? []) as Array<{\n input: string\n added: boolean\n refusal?: string\n }>\n const added = Number(payload?.added ?? 0)\n const rejected = results.filter(\n (result) => result.refusal === 'not-an-address',\n )\n // The refusals are NAMED, because \"3 of 5 added\" leaves an operator to\n // work out which two, and the two that failed are the ones somebody\n // asked to stop being emailed.\n if (rejected.length) {\n enqueueSnackbar(\n `Not an email address: ${rejected\n .map((result) => result.input)\n .join(', ')}`,\n { variant: 'warning' },\n )\n }\n if (added) {\n enqueueSnackbar(\n added === 1\n ? 'Added to the suppression list'\n : `${added} addresses added to the suppression list`,\n { variant: 'success', persist: false },\n )\n } else if (!rejected.length) {\n enqueueSnackbar('Already on the suppression list', { variant: 'info' })\n }\n if (added) {\n setAddInput('')\n setAddNote('')\n setAdding(false)\n setTotalsEpoch((epoch) => epoch + 1)\n }\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n }, [addInput, addNote, busy, user, hostId, enqueueSnackbar])\n\n /**\n * Whether one address is ALSO on the platform-wide list.\n *\n * The two lists are consulted together at send time and were visible\n * separately, so a merchant who removed their own entry could still find\n * the address was never mailed, with nothing anywhere saying why. The\n * platform entry is invisible to them and cannot be lifted by them, which\n * is precisely why it has to be said before the click rather than\n * discovered from a recipient count that stays short.\n */\n const isBlockedPlatformWide = useCallback(\n async (email: string): Promise<boolean> => {\n try {\n const response = await authorizedFetch(\n user,\n '/api/email/suppression-status',\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, emails: email }),\n },\n )\n if (!response.ok) return false\n const payload = await response.json().catch(() => ({}))\n return ((payload?.platform ?? []) as string[]).length > 0\n } catch (error) {\n console.error(error)\n return false\n }\n },\n [user, hostId],\n )\n\n const handleRemove = async (row: SuppressionRow) => {\n const reason = describeReason(row.reason).label.toLowerCase()\n // The platform entry is invisible to a merchant and cannot be lifted by\n // one, so removing the site's row here changes nothing about whether the\n // address is mailed. Saying so BEFORE the click is the whole point: the\n // alternative is a merchant who removes the row, sends again, and sees a\n // recipient count that is still short with nothing explaining it.\n /*\n * ASKED, NOT MOUNTED.\n *\n * One keyed read for the one address the merchant is acting on, at the\n * moment they act. Fetching this for every visible row on mount would be\n * a request per page render for an answer that is only ever needed on a\n * click, and this card is one tab of a page a merchant opens to read.\n *\n * A failed check answers \"not blocked\", which is the ordinary case: the\n * dialog then reads exactly as it did before, and the removal still\n * works. Refusing to open the dialog because a supplementary lookup\n * failed would make an outage on an explanation into an outage on the\n * control it explains.\n */\n const alsoPlatform = row.email\n ? await isBlockedPlatformWide(row.email)\n : false\n const accepted = await confirm({\n title: alsoPlatform\n ? 'This address will still be skipped'\n : 'Put this address back on your list?',\n description: alsoPlatform\n ? `${row.email ?? 'This address'} bounced permanently or reported ` +\n 'spam somewhere else in Aglyn, so it is on the platform-wide list ' +\n 'as well as yours. Removing your entry will not start mail ' +\n 'reaching it — contact support to have the platform entry lifted.'\n : `${row.email ?? 'This address'} is suppressed because it ` +\n `${reason === 'bounced' ? 'bounced permanently' : reason === 'marked as spam' ? 'was marked as spam' : reason === 'added by hand' ? 'was added by hand' : 'unsubscribed'}. ` +\n 'Removing the entry means your next campaign will email it again.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\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 try {\n await deleteDoc(\n doc(firestore, 'hosts', hostId, 'suppressions', row.$id),\n )\n enqueueSnackbar('Removed from the suppression list', {\n variant: 'success',\n persist: false,\n })\n setTotalsEpoch((epoch) => epoch + 1)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n }\n }\n\n const columns: GridColDef<SuppressionRow>[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 240,\n /*\n Entries are keyed by `sha256(email)` because addresses are PII, and the\n address itself is stored in the document. An older row written before\n the address was stored has only its hash — which tells a merchant\n nothing, so it says so rather than displaying 64 hex characters.\n */\n renderCell: ({ row }) =>\n row.email || (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'(address not recorded)'}\n </Typography>\n ),\n },\n {\n field: 'reason',\n headerName: 'Reason',\n width: 170,\n valueGetter: (_value, row) => describeReason(row.reason).label,\n renderCell: ({ row }) => {\n const described = describeReason(row.reason)\n return (\n <Chip\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={described.label}\n />\n )\n },\n },\n {\n field: 'since',\n headerName: 'Since',\n width: 140,\n valueGetter: (_value, row) => onDate(row),\n },\n /*\n A SUPPRESSION HAS NO PAGE, so the row does not open one — this is the\n surface's one list whose rows are not a way in to anything. What it does\n have is the one act a merchant performs on an entry, and the trailing\n cluster is where every other list on this surface keeps that: a bare\n `Remove` in the row is a click that puts an address back into mailing\n range, sitting where the reader's eye is running along the row.\n */\n listActionsColumn(\n (row: SuppressionRow) => (\n <ListRowActions\n label={row.email || 'this entry'}\n items={[\n {\n key: 'remove',\n label: 'Remove from the list',\n icon: <MdiIcon path={mdiEmailCheckOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header=\"Suppressions\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#compliance' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setAdding(true)}\n >\n {'Add'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Addresses this site’s marketing email skips. Someone lands here ' +\n 'by clicking unsubscribe, by bouncing permanently, by marking a ' +\n 'message as spam, or because you added them — this is where the ' +\n 'gap between a campaign’s recipient count and what it actually ' +\n 'sent comes from.'}\n </Typography>\n {entries.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is suppressed. Every address in your audiences is ' +\n 'currently mailable.'}\n </Typography>\n ) : (\n <>\n <Stack direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap' }}>\n {totals === null ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Could not read the breakdown. This is not the same as ' +\n 'nobody having bounced.'}\n </Typography>\n ) : (\n Object.entries(totals)\n // A reason nobody has hit is not news, and three chips\n // reading zero make the two that matter harder to find.\n .filter(([, total]) => total > 0)\n .map(([reason, total]) => {\n const described = describeReason(reason)\n return (\n <Chip\n key={reason}\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={`${described.label}: ${total}`}\n />\n )\n })\n )}\n </Stack>\n <ListTable\n aria-label=\"Suppressed addresses\"\n rows={entries}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={entries.length}\n hasMore={hasMore}\n // The collection's real size, so the footer's count line says\n // \"1–10 of 812\" rather than \"of more than 10\" — the aggregate\n // above already knows it, and it is the same number the chips\n // are a breakdown of.\n count={totals ? Object.values(totals).reduce((a, b) => a + b, 0) : undefined}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n </Stack>\n {/*\n A DRAWER, not a form stacked above the table.\n Creating is a drawer and picking is a dialog, which is how Screens,\n Components, Layouts and Templates all take a name before they create\n one; the chrome here is the same `NavigationDrawerComponent` those go\n through, composed directly because the console's wrapper around it\n lives in an application a plugin library may not import.\n */}\n <NavigationDrawerComponent\n open={adding}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={() => setAdding(false)}\n AppBarProps={{ color: 'surface' }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={() => setAdding(false)}\n sx={{ mr: 2 }}\n >\n <MdiIcon path={ICON_VARIANT_CLOSE.path} />\n <SrOnly>{'close drawer'}</SrOnly>\n </IconButton>\n <Typography variant=\"h6\" component=\"div\">\n {'Stop emailing an address'}\n </Typography>\n </>\n }\n appBarRight={\n <Button\n variant=\"outlined\"\n color=\"inherit\"\n onClick={() => setAdding(false)}\n >\n {'Cancel'}\n </Button>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Use this when somebody asks you to stop emailing them by ' +\n 'reply, by phone, or in person. They stay on your audiences ' +\n 'and keep every record you hold about them — this only stops ' +\n 'this site’s marketing email reaching them.'}\n </Typography>\n <TextField\n label=\"Email addresses\"\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n multiline\n minRows={3}\n fullWidth\n autoFocus\n helperText={\n 'One per line, or separated by commas. Up to 50 at a time.'\n }\n />\n <TextField\n label=\"Note (optional)\"\n value={addNote}\n onChange={(event) => setAddNote(event.target.value)}\n fullWidth\n helperText={\n 'How the request reached you. Kept with the entry as the ' +\n 'record that it was honored.'\n }\n slotProps={{ htmlInput: { maxLength: 200 } }}\n />\n <Alert severity=\"info\">\n {'Order confirmations, booking reminders and password resets ' +\n 'are unaffected. Somebody who asked to stop hearing from ' +\n 'your marketing still gets their receipt.'}\n </Alert>\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <Button\n variant=\"contained\"\n disabled={busy || !addInput.trim()}\n onClick={() => void handleAdd()}\n >\n {'Add to suppression list'}\n </Button>\n </Stack>\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n </CardDisplay>\n )\n}\nSuppressionsCard.displayName = 'SuppressionsCard'\n\nexport default SuppressionsCard\n"],"names":["pluginDocsHelp","ICON_VARIANT_CLOSE","mdiEmailCheckOutline","CardDisplay","Container","MdiIcon","SrOnly","useConfirmationContext","ListPagination","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","NavigationDrawerComponent","useSnackbar","Alert","Chip","IconButton","Stack","TextField","Typography","Button","collection","deleteDoc","doc","limit","orderBy","query","useCallback","useEffect","useState","useFirestore","usePagedCollection","useUser","authorizedFetch","describeSuppressionReason","describeReason","readSuppressionTotals","onDate","row","seconds","createdAt","suppressedAt","Date","toISOString","slice","SuppressionsCard","props","hostId","firestore","data","user","enqueueSnackbar","confirm","adding","setAdding","addInput","setAddInput","addNote","setAddNote","busy","setBusy","rows","entries","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","totalsEpoch","setTotalsEpoch","totals","setTotals","active","then","read","catch","handleAdd","typed","trim","response","method","headers","body","JSON","stringify","emails","note","payload","json","ok","error","variant","results","added","Number","rejected","filter","result","refusal","length","map","input","join","persist","epoch","console","isBlockedPlatformWide","email","platform","handleRemove","reason","label","toLowerCase","alsoPlatform","accepted","title","description","confirmationText","confirmationButtonProps","color","$id","columns","field","headerName","flex","minWidth","renderCell","width","valueGetter","_value","described","size","items","key","icon","path","destructive","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","direction","sx","flexWrap","Object","total","aria-label","rowHeight","hideFooter","rowCount","count","values","reduce","a","b","undefined","onPageChange","onPageSizeChange","open","onClose","AppBarProps","appBarLeft","edge","mr","component","appBarRight","gutterY","value","onChange","event","target","multiline","minRows","fullWidth","autoFocus","helperText","slotProps","htmlInput","maxLength","severity","justifyContent","disabled","displayName"],"mappings":"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"}
|
|
@@ -61,7 +61,9 @@ export declare function writeEmailTopic(firestore: Firestore, scope: readonly [s
|
|
|
61
61
|
* `orderBy` DROPS documents that lack the field rather than mis-sorting them,
|
|
62
62
|
* so that claim is about the writers and not a preference.
|
|
63
63
|
*/
|
|
64
|
-
export declare function useOrgEmailTopics(
|
|
64
|
+
export declare function useOrgEmailTopics(
|
|
65
|
+
/** The site the catalog is read through, or `null` on the org page. */
|
|
66
|
+
hostId: string | null, options?: {
|
|
65
67
|
/**
|
|
66
68
|
* Whether to read at all. Default true.
|
|
67
69
|
*
|
|
@@ -72,6 +74,12 @@ export declare function useOrgEmailTopics(hostId: string, options?: {
|
|
|
72
74
|
* does, and the catalog reads as empty until it flips.
|
|
73
75
|
*/
|
|
74
76
|
enabled?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* The organization itself, for a caller mounted over the org rather than
|
|
79
|
+
* under one of its sites. It skips the site's org lookup, and it is the
|
|
80
|
+
* only way the catalog can be read with no site at all.
|
|
81
|
+
*/
|
|
82
|
+
orgId?: string | null;
|
|
75
83
|
}): {
|
|
76
84
|
topics: EmailTopic[];
|
|
77
85
|
/** `['orgs', orgId]`, or null until the org lookup settles. */
|
|
@@ -80,14 +80,15 @@ import { useFirestore, useFirestoreCollection, useOrgDataScope } from "@aglyn/te
|
|
|
80
80
|
* collection is the topics card, which refuses to save a nameless topic —
|
|
81
81
|
* `orderBy` DROPS documents that lack the field rather than mis-sorting them,
|
|
82
82
|
* so that claim is about the writers and not a preference.
|
|
83
|
-
*/ export function useOrgEmailTopics(hostId, options) {
|
|
83
|
+
*/ export function useOrgEmailTopics(/** The site the catalog is read through, or `null` on the org page. */ hostId, options) {
|
|
84
84
|
var _ref;
|
|
85
85
|
const firestore = useFirestore();
|
|
86
86
|
const enabled = (_ref = options == null ? void 0 : options.enabled) != null ? _ref : true;
|
|
87
87
|
// The org lookup is async (AGL-1061): null until it settles, and null
|
|
88
|
-
// forever for a host with no owning org.
|
|
88
|
+
// forever for a host with no owning org. An explicit org is ready at once.
|
|
89
89
|
const { scope } = useOrgDataScope({
|
|
90
|
-
hostId
|
|
90
|
+
hostId: hostId || undefined,
|
|
91
|
+
orgId: (options == null ? void 0 : options.orgId) || undefined
|
|
91
92
|
});
|
|
92
93
|
const { data: stored } = useFirestoreCollection(()=>scope && enabled ? query(collection(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION), orderBy('name'), limit(TOPIC_READ_CEILING)) : null, [
|
|
93
94
|
firestore,
|
|
@@ -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 EMAIL_TOPICS_COLLECTION,\n mergeEmailTopics,\n normalizeEmailTopic,\n type EmailTopic,\n} from '@aglyn/aglyn'\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 * `DEFAULT_EMAIL_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 `email-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 hostId: string,\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): {\n topics: EmailTopic[]\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.\n const { scope } = useOrgDataScope({ hostId })\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 mergeEmailTopics(\n (stored ?? [])\n .map((doc) => normalizeEmailTopic(String(doc['$id'] ?? ''), doc))\n .filter((topic): topic is EmailTopic => !!topic),\n ),\n [stored],\n )\n return { topics, scope: scope as readonly [string, string] | null }\n}\n"],"names":["EMAIL_TOPICS_COLLECTION","mergeEmailTopics","normalizeEmailTopic","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","data","stored","idField","topics","map","String","filter"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,uBAAuB,EACvBC,gBAAgB,EAChBC,mBAAmB,QAEd,eAAc;AAErB,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,EAAEhB,yBAAyBiB,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,
|
|
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 EMAIL_TOPICS_COLLECTION,\n mergeEmailTopics,\n normalizeEmailTopic,\n type EmailTopic,\n} from '@aglyn/aglyn'\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 * `DEFAULT_EMAIL_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 `email-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: EmailTopic[]\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 mergeEmailTopics(\n (stored ?? [])\n .map((doc) => normalizeEmailTopic(String(doc['$id'] ?? ''), doc))\n .filter((topic): topic is EmailTopic => !!topic),\n ),\n [stored],\n )\n return { topics, scope: scope as readonly [string, string] | null }\n}\n"],"names":["EMAIL_TOPICS_COLLECTION","mergeEmailTopics","normalizeEmailTopic","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,gBAAgB,EAChBC,mBAAmB,QAEd,eAAc;AAErB,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,EAAEhB,yBAAyBiB,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,EAAEhB,0BAC1CO,QAAQ,SACRD,MAAMmB,uBAER,MACN;QAACV;QAAWC;QAAOa;KAAQ,EAC3B;QAAEI,SAAS;IAAM;IAEnB,MAAMC,SAASxB,QACb,IACET,iBACE,CAAC+B,iBAAAA,SAAU,EAAE,EACVG,GAAG,CAAC,CAAC9B;gBAAmCA;mBAA3BH,oBAAoBkC,QAAO/B,WAAAA,GAAG,CAAC,MAAM,YAAVA,WAAc,KAAKA;WAC3DgC,MAAM,CAAC,CAACpB,QAA+B,CAAC,CAACA,SAEhD;QAACe;KAAO;IAEV,OAAO;QAAEE;QAAQlB,OAAOA;IAA0C;AACpE"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ConsolePluginOrgHost } from '@aglyn/aglyn';
|
|
2
|
+
import { type SendingIdentityView } from '@aglyn/tenant-feature-instance/hooks/use-sending-identity-api';
|
|
3
|
+
/** What the organization's page knows about each site's sending identity. */
|
|
4
|
+
export interface OrgSendingViews {
|
|
5
|
+
/** Each site's identity, as the route resolved it, by site id. */
|
|
6
|
+
views: Record<string, SendingIdentityView>;
|
|
7
|
+
/** The route's own refusal for a site whose identity could not be read. */
|
|
8
|
+
errors: Record<string, string>;
|
|
9
|
+
/** Some site on the page has not answered yet. */
|
|
10
|
+
loading: boolean;
|
|
11
|
+
/** Re-reads one site, or every site on the page. */
|
|
12
|
+
reload: (hostId?: string) => Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* The first site that answered.
|
|
15
|
+
*
|
|
16
|
+
* The organization-wide half of every answer is the same whichever site is
|
|
17
|
+
* asked — the org's domains, whether the reader may manage them, and whether
|
|
18
|
+
* the plan carries a domain of the customer's own — so the page reads those
|
|
19
|
+
* from here rather than asking the domains route a second time.
|
|
20
|
+
*/
|
|
21
|
+
first: SendingIdentityView | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* EACH SITE'S SENDING IDENTITY, for a page of the organization's sites.
|
|
25
|
+
*
|
|
26
|
+
* One `GET /api/email/sending-identity?hostId=` per site, because what a site
|
|
27
|
+
* sends as is resolved per site — its selection, its senders and its issued
|
|
28
|
+
* domain — through the same resolver the send path calls. It is the read the
|
|
29
|
+
* site's own Sending section makes, once per site on a page, and never more:
|
|
30
|
+
* paging asks for the sites not yet read, and a write re-reads only the site
|
|
31
|
+
* it changed.
|
|
32
|
+
*
|
|
33
|
+
* The same response carries the organization's domains, which is why the page
|
|
34
|
+
* has no second read for them. It is also why an editor can see the domains
|
|
35
|
+
* here: the identity route's read gate is the site role, where the domains
|
|
36
|
+
* route is owner-or-admin, and the site's own page shows editors the same
|
|
37
|
+
* table read-only.
|
|
38
|
+
*/
|
|
39
|
+
export declare function useOrgSendingViews(sites: readonly ConsolePluginOrgHost[]): OrgSendingViews;
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { useSendingApi } from "@aglyn/tenant-feature-instance/hooks/use-sending-identity-api";
|
|
19
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
20
|
+
/**
|
|
21
|
+
* EACH SITE'S SENDING IDENTITY, for a page of the organization's sites.
|
|
22
|
+
*
|
|
23
|
+
* One `GET /api/email/sending-identity?hostId=` per site, because what a site
|
|
24
|
+
* sends as is resolved per site — its selection, its senders and its issued
|
|
25
|
+
* domain — through the same resolver the send path calls. It is the read the
|
|
26
|
+
* site's own Sending section makes, once per site on a page, and never more:
|
|
27
|
+
* paging asks for the sites not yet read, and a write re-reads only the site
|
|
28
|
+
* it changed.
|
|
29
|
+
*
|
|
30
|
+
* The same response carries the organization's domains, which is why the page
|
|
31
|
+
* has no second read for them. It is also why an editor can see the domains
|
|
32
|
+
* here: the identity route's read gate is the site role, where the domains
|
|
33
|
+
* route is owner-or-admin, and the site's own page shows editors the same
|
|
34
|
+
* table read-only.
|
|
35
|
+
*/ export function useOrgSendingViews(sites) {
|
|
36
|
+
const call = useSendingApi();
|
|
37
|
+
const [views, setViews] = useState({});
|
|
38
|
+
const [errors, setErrors] = useState({});
|
|
39
|
+
const requested = useRef(new Set());
|
|
40
|
+
const mounted = useRef(true);
|
|
41
|
+
useEffect(()=>{
|
|
42
|
+
mounted.current = true;
|
|
43
|
+
return ()=>{
|
|
44
|
+
mounted.current = false;
|
|
45
|
+
};
|
|
46
|
+
}, []);
|
|
47
|
+
const load = useCallback(async (hostId)=>{
|
|
48
|
+
let failure = '';
|
|
49
|
+
let view = null;
|
|
50
|
+
try {
|
|
51
|
+
const { response, payload } = await call({
|
|
52
|
+
path: 'sending-identity',
|
|
53
|
+
method: 'GET',
|
|
54
|
+
query: {
|
|
55
|
+
hostId
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
if (response.ok) view = payload;
|
|
59
|
+
else {
|
|
60
|
+
var _ref;
|
|
61
|
+
failure = (_ref = payload == null ? void 0 : payload.error) != null ? _ref : 'This site’s sending identity could not be read.';
|
|
62
|
+
}
|
|
63
|
+
} catch (unused) {
|
|
64
|
+
failure = 'This site’s sending identity could not be read.';
|
|
65
|
+
}
|
|
66
|
+
if (!mounted.current) return;
|
|
67
|
+
if (view) {
|
|
68
|
+
const answered = view;
|
|
69
|
+
setViews((current)=>_extends({}, current, {
|
|
70
|
+
[hostId]: answered
|
|
71
|
+
}));
|
|
72
|
+
setErrors((current)=>{
|
|
73
|
+
if (!(hostId in current)) return current;
|
|
74
|
+
const rest = _extends({}, current);
|
|
75
|
+
delete rest[hostId];
|
|
76
|
+
return rest;
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
setErrors((current)=>_extends({}, current, {
|
|
80
|
+
[hostId]: failure
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
}, [
|
|
84
|
+
call
|
|
85
|
+
]);
|
|
86
|
+
useEffect(()=>{
|
|
87
|
+
for (const site of sites){
|
|
88
|
+
if (requested.current.has(site.id)) continue;
|
|
89
|
+
requested.current.add(site.id);
|
|
90
|
+
void load(site.id);
|
|
91
|
+
}
|
|
92
|
+
}, [
|
|
93
|
+
sites,
|
|
94
|
+
load
|
|
95
|
+
]);
|
|
96
|
+
const reload = useCallback(async (hostId)=>{
|
|
97
|
+
await Promise.all((hostId ? [
|
|
98
|
+
hostId
|
|
99
|
+
] : sites.map((site)=>site.id)).map(load));
|
|
100
|
+
}, [
|
|
101
|
+
sites,
|
|
102
|
+
load
|
|
103
|
+
]);
|
|
104
|
+
const loading = sites.some((site)=>!views[site.id] && !errors[site.id]);
|
|
105
|
+
// The page on screen first, then any site read earlier: the org half of
|
|
106
|
+
// the answer is the same from every site, and a page still loading must
|
|
107
|
+
// not blank the domain table for the moment it takes.
|
|
108
|
+
const first = useMemo(()=>{
|
|
109
|
+
var _ref, _sites_map_find;
|
|
110
|
+
return (_ref = (_sites_map_find = sites.map((site)=>views[site.id]).find((view)=>Boolean(view))) != null ? _sites_map_find : Object.values(views)[0]) != null ? _ref : null;
|
|
111
|
+
}, [
|
|
112
|
+
sites,
|
|
113
|
+
views
|
|
114
|
+
]);
|
|
115
|
+
return {
|
|
116
|
+
views,
|
|
117
|
+
errors,
|
|
118
|
+
loading,
|
|
119
|
+
reload,
|
|
120
|
+
first
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
//# sourceMappingURL=use-org-sending-views.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/use-org-sending-views.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 type { ConsolePluginOrgHost } from '@aglyn/aglyn'\nimport {\n useSendingApi,\n type SendingIdentityView,\n} from '@aglyn/tenant-feature-instance/hooks/use-sending-identity-api'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\n\n/** What the organization's page knows about each site's sending identity. */\nexport interface OrgSendingViews {\n /** Each site's identity, as the route resolved it, by site id. */\n views: Record<string, SendingIdentityView>\n /** The route's own refusal for a site whose identity could not be read. */\n errors: Record<string, string>\n /** Some site on the page has not answered yet. */\n loading: boolean\n /** Re-reads one site, or every site on the page. */\n reload: (hostId?: string) => Promise<void>\n /**\n * The first site that answered.\n *\n * The organization-wide half of every answer is the same whichever site is\n * asked — the org's domains, whether the reader may manage them, and whether\n * the plan carries a domain of the customer's own — so the page reads those\n * from here rather than asking the domains route a second time.\n */\n first: SendingIdentityView | null\n}\n\n/**\n * EACH SITE'S SENDING IDENTITY, for a page of the organization's sites.\n *\n * One `GET /api/email/sending-identity?hostId=` per site, because what a site\n * sends as is resolved per site — its selection, its senders and its issued\n * domain — through the same resolver the send path calls. It is the read the\n * site's own Sending section makes, once per site on a page, and never more:\n * paging asks for the sites not yet read, and a write re-reads only the site\n * it changed.\n *\n * The same response carries the organization's domains, which is why the page\n * has no second read for them. It is also why an editor can see the domains\n * here: the identity route's read gate is the site role, where the domains\n * route is owner-or-admin, and the site's own page shows editors the same\n * table read-only.\n */\nexport function useOrgSendingViews(\n sites: readonly ConsolePluginOrgHost[],\n): OrgSendingViews {\n const call = useSendingApi()\n const [views, setViews] = useState<Record<string, SendingIdentityView>>({})\n const [errors, setErrors] = useState<Record<string, string>>({})\n const requested = useRef(new Set<string>())\n const mounted = useRef(true)\n useEffect(() => {\n mounted.current = true\n return () => {\n mounted.current = false\n }\n }, [])\n\n const load = useCallback(\n async (hostId: string) => {\n let failure = ''\n let view: SendingIdentityView | null = null\n try {\n const { response, payload } = await call({\n path: 'sending-identity',\n method: 'GET',\n query: { hostId },\n })\n if (response.ok) view = payload as SendingIdentityView\n else {\n failure =\n payload?.error ?? 'This site’s sending identity could not be read.'\n }\n } catch {\n failure = 'This site’s sending identity could not be read.'\n }\n if (!mounted.current) return\n if (view) {\n const answered = view\n setViews((current) => ({ ...current, [hostId]: answered }))\n setErrors((current) => {\n if (!(hostId in current)) return current\n const rest = { ...current }\n delete rest[hostId]\n return rest\n })\n } else {\n setErrors((current) => ({ ...current, [hostId]: failure }))\n }\n },\n [call],\n )\n\n useEffect(() => {\n for (const site of sites) {\n if (requested.current.has(site.id)) continue\n requested.current.add(site.id)\n void load(site.id)\n }\n }, [sites, load])\n\n const reload = useCallback(\n async (hostId?: string) => {\n await Promise.all(\n (hostId ? [hostId] : sites.map((site) => site.id)).map(load),\n )\n },\n [sites, load],\n )\n\n const loading = sites.some((site) => !views[site.id] && !errors[site.id])\n // The page on screen first, then any site read earlier: the org half of\n // the answer is the same from every site, and a page still loading must\n // not blank the domain table for the moment it takes.\n const first = useMemo(\n () =>\n sites.map((site) => views[site.id]).find((view) => Boolean(view)) ??\n Object.values(views)[0] ??\n null,\n [sites, views],\n )\n return { views, errors, loading, reload, first }\n}\n"],"names":["useSendingApi","useCallback","useEffect","useMemo","useRef","useState","useOrgSendingViews","sites","call","views","setViews","errors","setErrors","requested","Set","mounted","current","load","hostId","failure","view","response","payload","path","method","query","ok","error","answered","rest","site","has","id","add","reload","Promise","all","map","loading","some","first","find","Boolean","Object","values"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAGA,SACEA,aAAa,QAER,gEAA+D;AACtE,SAASC,WAAW,EAAEC,SAAS,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAO;AAuBzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,mBACdC,KAAsC;IAEtC,MAAMC,OAAOR;IACb,MAAM,CAACS,OAAOC,SAAS,GAAGL,SAA8C,CAAC;IACzE,MAAM,CAACM,QAAQC,UAAU,GAAGP,SAAiC,CAAC;IAC9D,MAAMQ,YAAYT,OAAO,IAAIU;IAC7B,MAAMC,UAAUX,OAAO;IACvBF,UAAU;QACRa,QAAQC,OAAO,GAAG;QAClB,OAAO;YACLD,QAAQC,OAAO,GAAG;QACpB;IACF,GAAG,EAAE;IAEL,MAAMC,OAAOhB,YACX,OAAOiB;QACL,IAAIC,UAAU;QACd,IAAIC,OAAmC;QACvC,IAAI;YACF,MAAM,EAAEC,QAAQ,EAAEC,OAAO,EAAE,GAAG,MAAMd,KAAK;gBACvCe,MAAM;gBACNC,QAAQ;gBACRC,OAAO;oBAAEP;gBAAO;YAClB;YACA,IAAIG,SAASK,EAAE,EAAEN,OAAOE;iBACnB;;gBACHH,kBACEG,2BAAAA,QAASK,KAAK,mBAAI;YACtB;QACF,EAAE,eAAM;YACNR,UAAU;QACZ;QACA,IAAI,CAACJ,QAAQC,OAAO,EAAE;QACtB,IAAII,MAAM;YACR,MAAMQ,WAAWR;YACjBV,SAAS,CAACM,UAAa,aAAKA;oBAAS,CAACE,OAAO,EAAEU;;YAC/ChB,UAAU,CAACI;gBACT,IAAI,CAAEE,CAAAA,UAAUF,OAAM,GAAI,OAAOA;gBACjC,MAAMa,OAAO,aAAKb;gBAClB,OAAOa,IAAI,CAACX,OAAO;gBACnB,OAAOW;YACT;QACF,OAAO;YACLjB,UAAU,CAACI,UAAa,aAAKA;oBAAS,CAACE,OAAO,EAAEC;;QAClD;IACF,GACA;QAACX;KAAK;IAGRN,UAAU;QACR,KAAK,MAAM4B,QAAQvB,MAAO;YACxB,IAAIM,UAAUG,OAAO,CAACe,GAAG,CAACD,KAAKE,EAAE,GAAG;YACpCnB,UAAUG,OAAO,CAACiB,GAAG,CAACH,KAAKE,EAAE;YAC7B,KAAKf,KAAKa,KAAKE,EAAE;QACnB;IACF,GAAG;QAACzB;QAAOU;KAAK;IAEhB,MAAMiB,SAASjC,YACb,OAAOiB;QACL,MAAMiB,QAAQC,GAAG,CACf,AAAClB,CAAAA,SAAS;YAACA;SAAO,GAAGX,MAAM8B,GAAG,CAAC,CAACP,OAASA,KAAKE,EAAE,CAAA,EAAGK,GAAG,CAACpB;IAE3D,GACA;QAACV;QAAOU;KAAK;IAGf,MAAMqB,UAAU/B,MAAMgC,IAAI,CAAC,CAACT,OAAS,CAACrB,KAAK,CAACqB,KAAKE,EAAE,CAAC,IAAI,CAACrB,MAAM,CAACmB,KAAKE,EAAE,CAAC;IACxE,wEAAwE;IACxE,wEAAwE;IACxE,sDAAsD;IACtD,MAAMQ,QAAQrC,QACZ;YACEI,MAAAA;gBAAAA,QAAAA,kBAAAA,MAAM8B,GAAG,CAAC,CAACP,OAASrB,KAAK,CAACqB,KAAKE,EAAE,CAAC,EAAES,IAAI,CAAC,CAACrB,OAASsB,QAAQtB,kBAA3Db,kBACAoC,OAAOC,MAAM,CAACnC,MAAM,CAAC,EAAE,YADvBF,OAEA;OACF;QAACA;QAAOE;KAAM;IAEhB,OAAO;QAAEA;QAAOE;QAAQ2B;QAASJ;QAAQM;IAAM;AACjD"}
|
|
@@ -97,3 +97,39 @@ export declare function describeSendingDomainRemoval(input: {
|
|
|
97
97
|
description: string;
|
|
98
98
|
confirmationText: string;
|
|
99
99
|
};
|
|
100
|
+
/** One site that sends as a domain, as the organization's page read it. */
|
|
101
|
+
export interface SendingDomainSite {
|
|
102
|
+
/** The site's name, as a person reads it. */
|
|
103
|
+
name: string;
|
|
104
|
+
/**
|
|
105
|
+
* True when the domain is the one Aglyn issued to THIS site — its
|
|
106
|
+
* `platformDomain` — rather than a domain the customer owns.
|
|
107
|
+
*/
|
|
108
|
+
issued: boolean;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* THE SAME THREE ANSWERS, asked over the ORGANIZATION rather than from one
|
|
112
|
+
* site's page.
|
|
113
|
+
*
|
|
114
|
+
* On the organization's page a domain can be what several sites send as, and
|
|
115
|
+
* the confirmation has to name them: "this site" there is nobody. The three
|
|
116
|
+
* cases are {@link describeSendingDomainRemoval}'s, per site — a site standing
|
|
117
|
+
* on the domain issued to it drops to the shared pool, a site on a domain the
|
|
118
|
+
* customer owns stops sending, and when nothing sends as it only the claim
|
|
119
|
+
* goes.
|
|
120
|
+
*
|
|
121
|
+
* `unchecked` is the number of sites the page did not read. The page reads a
|
|
122
|
+
* batch of sites at a time, so "nothing sends as it" can only be said of the
|
|
123
|
+
* sites it read, and the sentence says so rather than overstating it.
|
|
124
|
+
*/
|
|
125
|
+
export declare function describeOrgSendingDomainRemoval(input: {
|
|
126
|
+
domain: string;
|
|
127
|
+
/** The sites, of those read, that send as this domain. */
|
|
128
|
+
sites: readonly SendingDomainSite[];
|
|
129
|
+
/** Sites the page has not read, so cannot say anything about. */
|
|
130
|
+
unchecked?: number;
|
|
131
|
+
}): {
|
|
132
|
+
title: string;
|
|
133
|
+
description: string;
|
|
134
|
+
confirmationText: string;
|
|
135
|
+
};
|
|
@@ -192,5 +192,51 @@
|
|
|
192
192
|
confirmationText: 'Remove domain'
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
|
+
/** `A`, `A and B`, `A, B and C`. */ function listNames(names) {
|
|
196
|
+
var _names_;
|
|
197
|
+
if (names.length <= 1) return (_names_ = names[0]) != null ? _names_ : '';
|
|
198
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* THE SAME THREE ANSWERS, asked over the ORGANIZATION rather than from one
|
|
202
|
+
* site's page.
|
|
203
|
+
*
|
|
204
|
+
* On the organization's page a domain can be what several sites send as, and
|
|
205
|
+
* the confirmation has to name them: "this site" there is nobody. The three
|
|
206
|
+
* cases are {@link describeSendingDomainRemoval}'s, per site — a site standing
|
|
207
|
+
* on the domain issued to it drops to the shared pool, a site on a domain the
|
|
208
|
+
* customer owns stops sending, and when nothing sends as it only the claim
|
|
209
|
+
* goes.
|
|
210
|
+
*
|
|
211
|
+
* `unchecked` is the number of sites the page did not read. The page reads a
|
|
212
|
+
* batch of sites at a time, so "nothing sends as it" can only be said of the
|
|
213
|
+
* sites it read, and the sentence says so rather than overstating it.
|
|
214
|
+
*/ export function describeOrgSendingDomainRemoval(input) {
|
|
215
|
+
var _ref, _ref1, _ref2;
|
|
216
|
+
const domain = String((_ref = input == null ? void 0 : input.domain) != null ? _ref : '');
|
|
217
|
+
const sites = (_ref1 = input == null ? void 0 : input.sites) != null ? _ref1 : [];
|
|
218
|
+
const issued = sites.filter((site)=>site.issued).map((site)=>site.name);
|
|
219
|
+
const owned = sites.filter((site)=>!site.issued).map((site)=>site.name);
|
|
220
|
+
const unchecked = Math.max(0, Number((_ref2 = input == null ? void 0 : input.unchecked) != null ? _ref2 : 0));
|
|
221
|
+
const parts = [];
|
|
222
|
+
if (!sites.length) {
|
|
223
|
+
parts.push('The claim and the signing key are dropped. The DNS records stay in ' + 'your zone — nothing is changed at your registrar — and you can add ' + 'the domain again later, which issues a new key.');
|
|
224
|
+
}
|
|
225
|
+
if (issued.length) {
|
|
226
|
+
parts.push(`${listNames(issued)} ${issued.length === 1 ? 'is' : 'are'} currently ` + `sending as ${domain}, the domain issued to ` + `${issued.length === 1 ? 'it' : 'each'}. Removing it moves all of ` + `that email back to the shared address, whose delivery reputation is ` + 'pooled with the other sites on it — so campaigns there are held to ' + 'tighter complaint and bounce limits. Nothing in your own DNS is ' + 'involved — we published these records and we remove them.');
|
|
227
|
+
}
|
|
228
|
+
if (owned.length) {
|
|
229
|
+
const one = owned.length === 1;
|
|
230
|
+
parts.push(`${listNames(owned)} ${one ? 'is' : 'are'} currently sending as ` + `${domain}. Removing the domain does not move ${one ? 'it' : 'them'} ` + 'onto another address — not the one each site is issued, and not the ' + `shared address. It stops ${one ? 'that site' : 'those sites'} ` + `sending at all, receipts included, until ${one ? 'it is' : 'each is'} given another identity. The DNS records stay in your zone; nothing ` + 'is changed at your registrar.');
|
|
231
|
+
}
|
|
232
|
+
if (unchecked) {
|
|
233
|
+
parts.push(unchecked === 1 ? 'One site not shown on this page was not checked; if it sends as ' + 'this domain, it is affected the same way.' : `${unchecked} sites not shown on this page were not checked; any ` + 'of them sending as this domain is affected the same way.');
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
title: `Remove ${domain}?`,
|
|
237
|
+
description: parts.join(' '),
|
|
238
|
+
confirmationText: 'Remove domain'
|
|
239
|
+
};
|
|
240
|
+
}
|
|
195
241
|
|
|
196
242
|
//# sourceMappingURL=sending-domain-status.js.map
|