@aglyn/plugins-email 1.0.0-beta.234 → 1.0.0-beta.236
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -16
- package/src/lib/components/email-screens-card.js +2 -2
- package/src/lib/components/email-screens-card.js.map +1 -1
- package/src/lib/components/email-template-detail.js +2 -4
- package/src/lib/components/email-template-detail.js.map +1 -1
- package/src/lib/components/email-topic-detail.js +1 -1
- package/src/lib/components/email-topic-detail.js.map +1 -1
- package/src/lib/components/list-detail-card.js +5 -6
- package/src/lib/components/list-detail-card.js.map +1 -1
- package/src/lib/components/list-edit-card.js +1 -1
- package/src/lib/components/list-edit-card.js.map +1 -1
- package/src/lib/components/lists-card.js +2 -2
- package/src/lib/components/lists-card.js.map +1 -1
- package/src/lib/components/org-email-templates-card.js +4 -4
- package/src/lib/components/org-email-templates-card.js.map +1 -1
- package/src/lib/components/org-sending-card.js +1 -1
- package/src/lib/components/org-sending-card.js.map +1 -1
- package/src/lib/components/org-sending-domain-detail.js +1 -1
- package/src/lib/components/org-sending-domain-detail.js.map +1 -1
- package/src/lib/components/org-suppressions-card.js +1 -1
- package/src/lib/components/org-suppressions-card.js.map +1 -1
- package/src/lib/components/sending-domain-detail.js +1 -1
- package/src/lib/components/sending-domain-detail.js.map +1 -1
- package/src/lib/components/sending-domains-card.js +1 -3
- package/src/lib/components/sending-domains-card.js.map +1 -1
- package/src/lib/components/sending-sender-drawer.js +1 -3
- package/src/lib/components/sending-sender-drawer.js.map +1 -1
- package/src/lib/components/site-consent-group-card.js +1 -1
- package/src/lib/components/site-consent-group-card.js.map +1 -1
- package/src/lib/components/suppressions-card.js +1 -1
- package/src/lib/components/suppressions-card.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/sending-sender-drawer.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 { hostRoleFor, HOST_ACCESS_ROLES, pluginDocsHelp } from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { Container, HelpTip, MdiIcon, SrOnly } from '@aglyn/shared-ui-jsx'\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport {\n normalizeLocalPart,\n validateSendingLocalPart,\n} from '@aglyn/shared-util-email'\nimport {\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Divider,\n IconButton,\n InputAdornment,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { collection, limit, query, where } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport {\n useSendingApi,\n type SendingIdentityView,\n} from '@aglyn/tenant-feature-instance/hooks/use-sending-identity-api'\n\n/**\n * How many people each of the roster reads may return.\n *\n * A ceiling rather than a page, because this control is a shortcut and not a\n * roster: somebody choosing a sender knows who they are looking for, and a\n * site large enough to overflow this has an admin who can type the three\n * fields directly. Bounded because the read happens on a merchant's settings\n * screen, and an unbounded one would grow with the site for no benefit.\n *\n * 250 is the largest per-site collaborator allowance any finite plan grants\n * (`membersPerHost`), so on every plan below Enterprise the whole entitled\n * set fits and nothing is cut. Where it does bite it cuts in document-id\n * order, which is a stable slice and not a ranked one — the queries below are\n * already narrowed to people who can reach this site, so what is dropped is a\n * tail of an entitled roster rather than an arbitrary sample of a workspace.\n */\nconst SENDER_PICKER_CEILING = 250\n\nexport interface SendingSenderDrawerProps {\n open: boolean\n hostId: string\n /** The identity as the route reports it. Null while the card is loading. */\n view: SendingIdentityView | null\n /**\n * WHICH SENDER THIS DRAWER IS EDITING, or `null` to add one.\n *\n * The two modes differ in what the fields start as and in nothing else: an\n * edit opens on the row's stored values, and an add opens empty so the\n * picker below fills a NEW sender rather than overwriting the one this site\n * already has.\n */\n senderId?: string | null\n onClose: () => void\n /**\n * Called after a successful save, so the caller can re-read the identity.\n *\n * Carries the id the route answered with, which is what a caller that has to\n * SELECT the new sender needs — the composer opens this drawer from its From\n * picker, and the row it must then choose does not exist in any list it\n * holds until it re-reads. A caller that only refreshes ignores it.\n */\n onSaved: (senderId?: string) => void\n}\n\n/** One row of the picker, reduced to what a sender needs from a person. */\ninterface SenderCandidate {\n id: string\n email: string\n displayName: string\n}\n\n/**\n * ONE SENDER THIS SITE MAY SEND AS.\n *\n * Three fields, and they are not three of a kind. The MAILBOX is the part of\n * the address in front of the `@`; the domain behind it is whatever this\n * site's verified identity resolves to and is not editable here, because\n * DMARC on the sending apex is published `adkim=s` and the `From:` domain has\n * to be exactly the domain whose DKIM key signed the message. The NAME is\n * what a recipient reads in their inbox list. The REPLY ADDRESS is the only\n * one of the three that may name a mailbox on a domain nobody here has\n * verified — which is what makes \"replies reach me personally\" possible\n * without pretending the mail came from a personal account.\n *\n * ## Why a site holds a LIST, and why the composer picks from it\n *\n * The composer already carries a from name and a reply-to per campaign, and\n * keeps them: those are per-message facts. The mailbox is not, because it is\n * an address that has to work — a bounce returns to it, and a mail client\n * that ignores `Reply-To:` answers to it. A mailbox that exists in one\n * campaign's headers and nowhere else is an address nobody serves.\n *\n * So a mailbox is configured HERE, once, and a campaign chooses among the\n * senders that were configured. A per-send address field would be the thing\n * that closure removed; a per-send choice between approved senders is not.\n *\n * The gates differ for the same reason. Deciding which addresses this site's\n * mail may leave on is an organization-admin decision and lives here beside\n * the domain it depends on; the composer is admin-or-editor and may pick\n * among them and name the person in front of the address.\n *\n * ## The picker\n *\n * \"Send as a person\" is mostly one action — take a teammate's name and their\n * mailbox and make the mail read as theirs — so the picker does all three\n * fields at once and then lets any of them be edited. The address it proposes\n * is derived from the part of their email in front of the `@`, which is the\n * name they already answer to, and their real address becomes the reply\n * target rather than the sender: a `From:` on their own mail provider's\n * domain could never align, and would be rejected rather than delivered.\n *\n * Opened to ADD, it fills a new sender and leaves the ones this site already\n * has alone — which is what makes \"send as Jamie\" an address beside the\n * others rather than a rename of the only one.\n *\n * ## Who is on the list\n *\n * Everybody who can reach this site, from wherever their access comes from:\n * the organization's team, whose members reach a site through an org role or\n * an `allHosts` flag or a per-site grant, and the site's own collaborators,\n * who are on it and nowhere else. Both, because either alone is a picker that\n * is missing people — and a workspace that manages its team entirely at the\n * org level has an empty site roster, so reading only that one offered no\n * teammates at all.\n *\n * Nobody else. An org member scoped to other sites is not a candidate sender\n * for this one: the address would work, and the person would be signing mail\n * for a site they cannot open. `hostRoleFor` draws that line, and it is the\n * same one the rules and every server gate draw.\n *\n * The roster is read only while this drawer is open. It is four collection\n * reads on a settings surface, and ones that happen because somebody asked for\n * them are a different thing from ones that happen because a card mounted.\n */\nexport function SendingSenderDrawer(props: SendingSenderDrawerProps) {\n const { open, hostId, view, senderId, onClose, onSaved } = props\n const call = useSendingApi()\n const firestore = useFirestore()\n\n const [mailbox, setMailbox] = useState('')\n const [fromName, setFromName] = useState('')\n const [replyTo, setReplyTo] = useState('')\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState('')\n\n /**\n * The row being edited, or null while adding one.\n *\n * Resolved from the view the card already holds rather than read again here:\n * the list and the editor open from one answer, so they cannot disagree\n * about what a sender currently is.\n */\n const editing = senderId\n ? ((view?.senders ?? []).find((sender) => sender.id === senderId) ?? null)\n : null\n\n /*\n * The stored values are copied in when the drawer OPENS, not on every\n * render of the card.\n *\n * A settings form seeded from a prop that keeps arriving would discard what\n * somebody was halfway through typing the moment the card re-read the\n * identity behind them — so the dependencies are the row's FIELDS and not\n * the array they came in, which is a new array on every read.\n */\n useEffect(() => {\n if (!open) return\n setMailbox(editing?.localPart ?? '')\n setFromName(editing?.fromName ?? '')\n setReplyTo(editing?.replyTo ?? '')\n setError('')\n }, [\n open,\n senderId,\n editing?.localPart,\n editing?.fromName,\n editing?.replyTo,\n ])\n\n /*\n * The org this site belongs to, as the identity route resolved it.\n *\n * Taken from the view rather than read again here, so the drawer and the\n * card it opens from agree about which workspace they are looking at, and\n * so the org reads below cannot start before the route has answered.\n */\n const orgId = String(view?.orgId ?? '')\n const reading = open && Boolean(hostId)\n\n /*\n * THE SITE'S OWN COLLABORATOR ROSTER — `hosts/{hostId}/members`.\n *\n * Not the whole answer, and reading it alone was the defect: this\n * subcollection holds only people added TO ONE SITE by\n * `POST /api/hosts/members`. A workspace whose team reaches every site\n * through their org membership has no rows here at all, so a picker built\n * on it offered whichever one address happened to have been added this way\n * and no teammates whatsoever.\n *\n * It stays because it is the only source for one population: somebody\n * invited to the site by an address with no Aglyn account behind it yet has\n * a roster row and, until they accept, no org member document. They are a\n * real person with a real mailbox who has been granted access, which is all\n * a reply address needs.\n *\n * Ordered by nothing, and capped: see {@link SENDER_PICKER_CEILING}. This\n * subcollection has exactly one writer and it writes `email` on every\n * document, so an `orderBy('email')` would be safe here — it buys nothing,\n * because the merged set below is arranged for display after the fact and a\n * server order over one of four reads cannot survive the merge.\n */\n const { data: siteMemberDocs } = useFirestoreCollection<any>(\n () =>\n reading\n ? query(\n collection(firestore, 'hosts', hostId, 'members'),\n limit(SENDER_PICKER_CEILING),\n )\n : null,\n [firestore, hostId, reading],\n { idField: '$id' },\n )\n\n /*\n * THE ORG'S TEAM — `orgs/{orgId}/members`, in the three shapes that reach\n * one host.\n *\n * `hostRoleFor` is the predicate the rules, the ~20 server gates and\n * `useHostRole` all resolve a member's access to a site with, and it is a\n * disjunction of exactly three cases: an owner or admin spans every site; an\n * editor or viewer with `allHosts` spans every site at their org role; and\n * anybody may carry an explicit per-site grant in `hostAccess`. Firestore\n * cannot express that as one query, so it is asked as three and merged.\n *\n * ## Three narrow reads rather than one broad one\n *\n * The alternative — read the roster and filter in the browser — reads every\n * member of the workspace to find the few who work on this site, and the cap\n * would then apply BEFORE the filter: in an org of 400 where five people\n * have this site, the picker could contain none of them. Splitting the\n * predicate across the wire means the cap only ever cuts a tail of people\n * who all belong in the list.\n *\n * ## The `orderBy` trap, and why there is no `orderBy` here\n *\n * `orderBy` matches only documents that HAVE the field, so ordering on one\n * some writer omits hides rows instead of arranging them. `email` is NOT\n * safe to order on in this collection: `AglynOrgMember` declares it\n * optional, and two production paths create a member document without it —\n * `PATCH /api/hosts/members` re-grants host access with no identity fields,\n * and `POST /api/orgs/members` omits it entirely when the auth record\n * carries no email. Ordering on it would drop exactly the teammates this\n * change exists to surface, silently.\n *\n * Each `where` below is safe in a way an `orderBy` is not, and for the same\n * reason it looks unsafe: a document missing `role`, missing `allHosts` or\n * missing `hostAccess` is dropped by the filter — and `hostRoleFor` resolves\n * every one of those to \"no access\" anyway. The predicate and the index\n * agree about what absence means, so the query cannot hide anybody the\n * filter would have kept.\n */\n const orgMembers = (constrain: (base: any) => any) => () =>\n reading && orgId\n ? constrain(collection(firestore, 'orgs', orgId, 'members'))\n : null\n\n const { data: orgAdminDocs } = useFirestoreCollection<any>(\n orgMembers((base) =>\n query(\n base,\n where('role', 'in', ['owner', 'admin']),\n limit(SENDER_PICKER_CEILING),\n ),\n ),\n [firestore, orgId, reading],\n { idField: '$id' },\n )\n const { data: orgWideDocs } = useFirestoreCollection<any>(\n orgMembers((base) =>\n query(base, where('allHosts', '==', true), limit(SENDER_PICKER_CEILING)),\n ),\n [firestore, orgId, reading],\n { idField: '$id' },\n )\n /*\n * `HOST_ACCESS_ROLES` rather than a written-out array, for the reason it\n * exists: a role added to `HostAccessRole` and missing from an `in` list is\n * a person who holds access and cannot be seen to, with no error to explain\n * it. The constant is built from a `Record` so a new role fails to compile\n * there instead of quietly shortening this query.\n */\n const { data: orgScopedDocs } = useFirestoreCollection<any>(\n orgMembers((base) =>\n query(\n base,\n where(`hostAccess.${hostId}`, 'in', HOST_ACCESS_ROLES),\n limit(SENDER_PICKER_CEILING),\n ),\n ),\n [firestore, orgId, hostId, reading],\n { idField: '$id' },\n )\n\n const candidates = useMemo<SenderCandidate[]>(() => {\n /*\n * ONE ROW PER PERSON, keyed by uid.\n *\n * The same teammate legitimately appears in several of these reads — an\n * org admin who is also an explicit collaborator is in two, and an org\n * member who accepted a site invite has a roster row as well — so the\n * merge is by identity rather than by document. The org member document\n * and the site roster row are keyed by the same uid, which is what makes\n * that possible: `POST /api/hosts/members` names the roster document after\n * the auth uid whenever an account exists, and falls back to a generated\n * id only for an address that has none. Such a row has no org document to\n * collide with, so the fallback cannot merge two people into one.\n *\n * Fields are taken from the first read that has them rather than from a\n * chosen winner. Neither source is complete on its own: the site roster\n * always carries an `email` and never a `displayName`, and an org member\n * document usually carries both but is allowed to omit either.\n */\n const byPerson = new Map<string, SenderCandidate>()\n const fold = (id: string, entry: any) => {\n if (!id) return\n const current = byPerson.get(id) ?? { id, email: '', displayName: '' }\n byPerson.set(id, {\n id,\n email: current.email || String(entry?.email ?? '').trim(),\n displayName:\n current.displayName || String(entry?.displayName ?? '').trim(),\n })\n }\n\n /*\n * THE PREDICATE IS APPLIED AGAIN HERE, over the documents themselves.\n *\n * The three queries are the predicate projected onto an index, and an\n * index answers a coarser question than the function does — `allHosts`\n * reaches every site only for a member who HAS an org role, and a document\n * carrying the flag with no role is a member of nothing. `hostRoleFor` is\n * the one definition of \"can this person reach this site\", so it decides,\n * and the queries are only what keeps the reads narrow.\n */\n for (const entry of [\n ...(orgAdminDocs ?? []),\n ...(orgWideDocs ?? []),\n ...(orgScopedDocs ?? []),\n ]) {\n if (!hostRoleFor(entry, hostId as never)) continue\n fold(String(entry?.$id ?? ''), entry)\n }\n\n /*\n * The site's roster rows carry no role of their own to resolve — being in\n * this subcollection IS access to this site, which is what the Users card\n * on the site's own settings page renders it as. `uid` before `$id` so an\n * accepted collaborator merges onto their org member document rather than\n * arriving as a second row for the same person.\n */\n for (const entry of siteMemberDocs ?? []) {\n fold(String(entry?.uid || entry?.$id || ''), entry)\n }\n\n return (\n [...byPerson.values()]\n /*\n * An address is not optional. It is what the mailbox is derived from\n * and what replies are pointed at, so a person recorded without one\n * has nothing this picker could fill in.\n */\n .filter((entry) => entry.email)\n /*\n * Sorted here rather than by the server, because this is a COMPLETE\n * set and not a page: four reads have been merged, so no server order\n * survives, and arranging a page in the browser is what makes an\n * arbitrary slice read as a ranked one. By the name a person is\n * looking for, falling back to the address for somebody who has none.\n */\n .sort((left, right) =>\n (left.displayName || left.email).localeCompare(\n right.displayName || right.email,\n undefined,\n { sensitivity: 'base' },\n ),\n )\n )\n }, [orgAdminDocs, orgWideDocs, orgScopedDocs, siteMemberDocs, hostId])\n\n /*\n * The domain half, shown as a fixed suffix rather than as a field.\n *\n * A person setting a sender is choosing an address, and an address with its\n * domain missing from the screen is one they have to assemble in their head\n * from the alert above. Read from the identity the server resolved, so it\n * is the domain a send would actually use.\n */\n const domain = String(view?.selected ?? '')\n const pooled = Boolean(view) && !view?.localPartInUse\n\n const handleSave = useCallback(async () => {\n if (busy) return\n /*\n * A POOLED SITE SENDS NO MAILBOX AT ALL, rather than sending one the\n * route will refuse.\n *\n * The field is disabled above and the alert says why, and this is the\n * half that matters: a merchant on the shared address editing their\n * sender name would otherwise have the whole save rejected over a field\n * they were never offered.\n */\n const checked = pooled ? null : validateSendingLocalPart(mailbox)\n if (checked?.error) return setError(checked.error)\n setError('')\n setBusy(true)\n const { response, payload } = await call({\n path: 'sending-identity',\n method: 'POST',\n /*\n * No `domain` key. Absent means \"this is not a decision about the\n * domain\" — an empty one would mean \"move this site back to the address\n * Aglyn issues it\", which would reset a site sending as its own\n * verified name every time somebody edited the sender.\n */\n body: {\n hostId,\n action: senderId ? 'updateSender' : 'createSender',\n ...(senderId ? { senderId } : {}),\n ...(checked?.localPart ? { localPart: checked.localPart } : {}),\n fromName: fromName.trim(),\n replyTo: replyTo.trim(),\n },\n })\n setBusy(false)\n if (!response.ok) {\n return setError(payload?.error ?? 'Could not save who this site sends as')\n }\n onSaved(String(payload?.senderId ?? '') || undefined)\n onClose()\n }, [\n busy,\n call,\n hostId,\n senderId,\n mailbox,\n pooled,\n fromName,\n replyTo,\n onSaved,\n onClose,\n ])\n\n const applyCandidate = useCallback((candidate: SenderCandidate) => {\n const at = candidate.email.lastIndexOf('@')\n const proposed = normalizeLocalPart(\n at > 0 ? candidate.email.slice(0, at) : '',\n )\n if (proposed) setMailbox(proposed)\n if (candidate.displayName) setFromName(candidate.displayName)\n setReplyTo(candidate.email)\n setError('')\n }, [])\n\n return (\n <NavigationDrawerComponent\n open={open}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={onClose}\n AppBarProps={{ color: 'surface' }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={onClose}\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 {senderId ? 'Edit this sender' : 'Add a sender'}\n </Typography>\n {/*\n The help affordance every console surface carries, pointed at the\n section that says what a sender is and which of its three fields\n the shared address restricts — which is the question somebody\n standing in this drawer with a disabled Mailbox field has.\n */}\n <HelpTip\n {...pluginDocsHelp('emailCampaigns', {\n anchor: '#senders',\n title: 'Senders',\n excerpt:\n 'A mailbox this site may send as, with the name and reply ' +\n 'address that go out in front of it. A campaign picks one of ' +\n 'them rather than typing an address.',\n })}\n />\n </>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'An address your email may leave on, and the name in front of ' +\n 'it. The domain is your site’s verified sending domain and ' +\n 'cannot be changed here — the part before the @ can. To have ' +\n 'replies reach a personal or company mailbox, use the reply ' +\n 'address: sending as an address on somebody else’s mail ' +\n 'provider is refused by the receiving side, not delivered.'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {editing?.isDefault\n ? 'Every email you compose picks a sender. This is the one an ' +\n 'email that names no other goes out as.'\n : 'Every email you compose picks a sender, and this will be ' +\n 'one of the choices.'}\n </Typography>\n\n {candidates.length ? (\n <>\n <TextField\n select\n label=\"Send as a person\"\n value=\"\"\n size=\"small\"\n onChange={(event) => {\n const match = candidates.find(\n (entry) => entry.id === event.target.value,\n )\n if (match) applyCandidate(match)\n }}\n helperText=\"Fills the three fields below from someone who works on this site\"\n >\n {/*\n THE NAME LEADS, and the address sits under it.\n\n A sender picker is answering \"which of us is this from\",\n which is a question about a person; a list of bare addresses\n makes the reader parse a local part to recognise a colleague.\n The address stays on the row because it is what the mailbox\n is derived from and where replies will land, so it is part of\n the choice rather than a detail behind it — and it is the\n whole row for somebody whose name nothing has recorded, which\n is an ordinary state for an invited teammate who has not\n signed in yet.\n */}\n {candidates.map((candidate) => (\n <MenuItem key={candidate.id} value={candidate.id}>\n <Stack spacing={0}>\n {candidate.displayName ? (\n <Typography variant=\"body2\">\n {candidate.displayName}\n </Typography>\n ) : null}\n <Typography\n variant={candidate.displayName ? 'caption' : 'body2'}\n color={\n candidate.displayName ? 'text.secondary' : undefined\n }\n >\n {candidate.email}\n </Typography>\n </Stack>\n </MenuItem>\n ))}\n </TextField>\n <Divider />\n </>\n ) : null}\n\n {pooled ? (\n <Alert severity=\"info\">\n {'This site sends on a shared Aglyn address, whose mailbox is ' +\n 'fixed and shared with the other sites on it. The name and ' +\n 'reply address below apply to that mail; the mailbox takes ' +\n 'effect once this site has a domain of its own.'}\n </Alert>\n ) : null}\n\n <TextField\n label=\"Mailbox\"\n value={mailbox}\n onChange={(event) => setMailbox(event.target.value)}\n size=\"small\"\n disabled={pooled}\n slotProps={{\n input: domain\n ? {\n endAdornment: (\n <InputAdornment position=\"end\">\n {`@${domain}`}\n </InputAdornment>\n ),\n }\n : undefined,\n }}\n helperText={\n 'The part before the @ — for example hello, sales or jamie'\n }\n />\n <TextField\n label=\"Sender name\"\n value={fromName}\n onChange={(event) => setFromName(event.target.value)}\n size=\"small\"\n helperText=\"Shown in front of the address. A person’s name, or your brand\"\n />\n <TextField\n label=\"Reply address\"\n value={replyTo}\n onChange={(event) => setReplyTo(event.target.value)}\n size=\"small\"\n type=\"email\"\n helperText=\"Where replies go. May be any mailbox, including a personal one\"\n />\n\n {error ? <Alert severity=\"error\">{error}</Alert> : null}\n\n <Button\n variant=\"contained\"\n disabled={busy || !view}\n onClick={() => void handleSave()}\n >\n {busy ? 'Saving…' : senderId ? 'Save sender' : 'Add sender'}\n </Button>\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n )\n}\nSendingSenderDrawer.displayName = 'SendingSenderDrawer'\n\nexport default SendingSenderDrawer\n"],"names":["hostRoleFor","HOST_ACCESS_ROLES","pluginDocsHelp","ICON_VARIANT_CLOSE","Container","HelpTip","MdiIcon","SrOnly","NavigationDrawerComponent","normalizeLocalPart","validateSendingLocalPart","useFirestore","useFirestoreCollection","Alert","Button","Divider","IconButton","InputAdornment","MenuItem","Stack","TextField","Typography","collection","limit","query","where","useCallback","useEffect","useMemo","useState","useSendingApi","SENDER_PICKER_CEILING","SendingSenderDrawer","props","open","hostId","view","senderId","onClose","onSaved","call","firestore","mailbox","setMailbox","fromName","setFromName","replyTo","setReplyTo","busy","setBusy","error","setError","editing","senders","find","sender","id","localPart","orgId","String","reading","Boolean","data","siteMemberDocs","idField","orgMembers","constrain","orgAdminDocs","base","orgWideDocs","orgScopedDocs","candidates","byPerson","Map","fold","entry","current","get","email","displayName","set","trim","$id","uid","values","filter","sort","left","right","localeCompare","undefined","sensitivity","domain","selected","pooled","localPartInUse","handleSave","checked","response","payload","path","method","body","action","ok","applyCandidate","candidate","at","lastIndexOf","proposed","slice","anchor","variant","AppBarProps","color","appBarLeft","edge","onClick","sx","mr","component","title","excerpt","gutterY","spacing","isDefault","length","select","label","value","size","onChange","event","match","target","helperText","map","severity","disabled","slotProps","input","endAdornment","position","type"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/sending-sender-drawer.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 { hostRoleFor, HOST_ACCESS_ROLES, pluginDocsHelp } from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { Container, HelpTip, MdiIcon, SrOnly } from '@aglyn/shared-ui-jsx'\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport {\n normalizeLocalPart,\n validateSendingLocalPart,\n} from '@aglyn/shared-util-email'\nimport {\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Divider,\n IconButton,\n InputAdornment,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { collection, limit, query, where } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport {\n useSendingApi,\n type SendingIdentityView,\n} from '@aglyn/tenant-feature-instance/hooks/use-sending-identity-api'\n\n/**\n * How many people each of the roster reads may return.\n *\n * A ceiling rather than a page, because this control is a shortcut and not a\n * roster: somebody choosing a sender knows who they are looking for, and a\n * site large enough to overflow this has an admin who can type the three\n * fields directly. Bounded because the read happens on a merchant's settings\n * screen, and an unbounded one would grow with the site for no benefit.\n *\n * 250 is the largest per-site collaborator allowance any finite plan grants\n * (`membersPerHost`), so on every plan below Enterprise the whole entitled\n * set fits and nothing is cut. Where it does bite it cuts in document-id\n * order, which is a stable slice and not a ranked one — the queries below are\n * already narrowed to people who can reach this site, so what is dropped is a\n * tail of an entitled roster rather than an arbitrary sample of a workspace.\n */\nconst SENDER_PICKER_CEILING = 250\n\nexport interface SendingSenderDrawerProps {\n open: boolean\n hostId: string\n /** The identity as the route reports it. Null while the card is loading. */\n view: SendingIdentityView | null\n /**\n * WHICH SENDER THIS DRAWER IS EDITING, or `null` to add one.\n *\n * The two modes differ in what the fields start as and in nothing else: an\n * edit opens on the row's stored values, and an add opens empty so the\n * picker below fills a NEW sender rather than overwriting the one this site\n * already has.\n */\n senderId?: string | null\n onClose: () => void\n /**\n * Called after a successful save, so the caller can re-read the identity.\n *\n * Carries the id the route answered with, which is what a caller that has to\n * SELECT the new sender needs — the composer opens this drawer from its From\n * picker, and the row it must then choose does not exist in any list it\n * holds until it re-reads. A caller that only refreshes ignores it.\n */\n onSaved: (senderId?: string) => void\n}\n\n/** One row of the picker, reduced to what a sender needs from a person. */\ninterface SenderCandidate {\n id: string\n email: string\n displayName: string\n}\n\n/**\n * ONE SENDER THIS SITE MAY SEND AS.\n *\n * Three fields, and they are not three of a kind. The MAILBOX is the part of\n * the address in front of the `@`; the domain behind it is whatever this\n * site's verified identity resolves to and is not editable here, because\n * DMARC on the sending apex is published `adkim=s` and the `From:` domain has\n * to be exactly the domain whose DKIM key signed the message. The NAME is\n * what a recipient reads in their inbox list. The REPLY ADDRESS is the only\n * one of the three that may name a mailbox on a domain nobody here has\n * verified — which is what makes \"replies reach me personally\" possible\n * without pretending the mail came from a personal account.\n *\n * ## Why a site holds a LIST, and why the composer picks from it\n *\n * The composer already carries a from name and a reply-to per campaign, and\n * keeps them: those are per-message facts. The mailbox is not, because it is\n * an address that has to work — a bounce returns to it, and a mail client\n * that ignores `Reply-To:` answers to it. A mailbox that exists in one\n * campaign's headers and nowhere else is an address nobody serves.\n *\n * So a mailbox is configured HERE, once, and a campaign chooses among the\n * senders that were configured. A per-send address field would be the thing\n * that closure removed; a per-send choice between approved senders is not.\n *\n * The gates differ for the same reason. Deciding which addresses this site's\n * mail may leave on is an organization-admin decision and lives here beside\n * the domain it depends on; the composer is admin-or-editor and may pick\n * among them and name the person in front of the address.\n *\n * ## The picker\n *\n * \"Send as a person\" is mostly one action — take a teammate's name and their\n * mailbox and make the mail read as theirs — so the picker does all three\n * fields at once and then lets any of them be edited. The address it proposes\n * is derived from the part of their email in front of the `@`, which is the\n * name they already answer to, and their real address becomes the reply\n * target rather than the sender: a `From:` on their own mail provider's\n * domain could never align, and would be rejected rather than delivered.\n *\n * Opened to ADD, it fills a new sender and leaves the ones this site already\n * has alone — which is what makes \"send as Jamie\" an address beside the\n * others rather than a rename of the only one.\n *\n * ## Who is on the list\n *\n * Everybody who can reach this site, from wherever their access comes from:\n * the organization's team, whose members reach a site through an org role or\n * an `allHosts` flag or a per-site grant, and the site's own collaborators,\n * who are on it and nowhere else. Both, because either alone is a picker that\n * is missing people — and a workspace that manages its team entirely at the\n * org level has an empty site roster, so reading only that one offered no\n * teammates at all.\n *\n * Nobody else. An org member scoped to other sites is not a candidate sender\n * for this one: the address would work, and the person would be signing mail\n * for a site they cannot open. `hostRoleFor` draws that line, and it is the\n * same one the rules and every server gate draw.\n *\n * The roster is read only while this drawer is open. It is four collection\n * reads on a settings surface, and ones that happen because somebody asked for\n * them are a different thing from ones that happen because a card mounted.\n */\nexport function SendingSenderDrawer(props: SendingSenderDrawerProps) {\n const { open, hostId, view, senderId, onClose, onSaved } = props\n const call = useSendingApi()\n const firestore = useFirestore()\n\n const [mailbox, setMailbox] = useState('')\n const [fromName, setFromName] = useState('')\n const [replyTo, setReplyTo] = useState('')\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState('')\n\n /**\n * The row being edited, or null while adding one.\n *\n * Resolved from the view the card already holds rather than read again here:\n * the list and the editor open from one answer, so they cannot disagree\n * about what a sender currently is.\n */\n const editing = senderId\n ? ((view?.senders ?? []).find((sender) => sender.id === senderId) ?? null)\n : null\n\n /*\n * The stored values are copied in when the drawer OPENS, not on every\n * render of the card.\n *\n * A settings form seeded from a prop that keeps arriving would discard what\n * somebody was halfway through typing the moment the card re-read the\n * identity behind them — so the dependencies are the row's FIELDS and not\n * the array they came in, which is a new array on every read.\n */\n useEffect(() => {\n if (!open) return\n setMailbox(editing?.localPart ?? '')\n setFromName(editing?.fromName ?? '')\n setReplyTo(editing?.replyTo ?? '')\n setError('')\n }, [\n open,\n senderId,\n editing?.localPart,\n editing?.fromName,\n editing?.replyTo,\n ])\n\n /*\n * The org this site belongs to, as the identity route resolved it.\n *\n * Taken from the view rather than read again here, so the drawer and the\n * card it opens from agree about which workspace they are looking at, and\n * so the org reads below cannot start before the route has answered.\n */\n const orgId = String(view?.orgId ?? '')\n const reading = open && Boolean(hostId)\n\n /*\n * THE SITE'S OWN COLLABORATOR ROSTER — `hosts/{hostId}/members`.\n *\n * Not the whole answer, and reading it alone was the defect: this\n * subcollection holds only people added TO ONE SITE by\n * `POST /api/hosts/members`. A workspace whose team reaches every site\n * through their org membership has no rows here at all, so a picker built\n * on it offered whichever one address happened to have been added this way\n * and no teammates whatsoever.\n *\n * It stays because it is the only source for one population: somebody\n * invited to the site by an address with no Aglyn account behind it yet has\n * a roster row and, until they accept, no org member document. They are a\n * real person with a real mailbox who has been granted access, which is all\n * a reply address needs.\n *\n * Ordered by nothing, and capped: see {@link SENDER_PICKER_CEILING}. This\n * subcollection has exactly one writer and it writes `email` on every\n * document, so an `orderBy('email')` would be safe here — it buys nothing,\n * because the merged set below is arranged for display after the fact and a\n * server order over one of four reads cannot survive the merge.\n */\n const { data: siteMemberDocs } = useFirestoreCollection<any>(\n () =>\n reading\n ? query(\n collection(firestore, 'hosts', hostId, 'members'),\n limit(SENDER_PICKER_CEILING),\n )\n : null,\n [firestore, hostId, reading],\n { idField: '$id' },\n )\n\n /*\n * THE ORG'S TEAM — `orgs/{orgId}/members`, in the three shapes that reach\n * one host.\n *\n * `hostRoleFor` is the predicate the rules, the ~20 server gates and\n * `useHostRole` all resolve a member's access to a site with, and it is a\n * disjunction of exactly three cases: an owner or admin spans every site; an\n * editor or viewer with `allHosts` spans every site at their org role; and\n * anybody may carry an explicit per-site grant in `hostAccess`. Firestore\n * cannot express that as one query, so it is asked as three and merged.\n *\n * ## Three narrow reads rather than one broad one\n *\n * The alternative — read the roster and filter in the browser — reads every\n * member of the workspace to find the few who work on this site, and the cap\n * would then apply BEFORE the filter: in an org of 400 where five people\n * have this site, the picker could contain none of them. Splitting the\n * predicate across the wire means the cap only ever cuts a tail of people\n * who all belong in the list.\n *\n * ## The `orderBy` trap, and why there is no `orderBy` here\n *\n * `orderBy` matches only documents that HAVE the field, so ordering on one\n * some writer omits hides rows instead of arranging them. `email` is NOT\n * safe to order on in this collection: `AglynOrgMember` declares it\n * optional, and two production paths create a member document without it —\n * `PATCH /api/hosts/members` re-grants host access with no identity fields,\n * and `POST /api/orgs/members` omits it entirely when the auth record\n * carries no email. Ordering on it would drop exactly the teammates this\n * change exists to surface, silently.\n *\n * Each `where` below is safe in a way an `orderBy` is not, and for the same\n * reason it looks unsafe: a document missing `role`, missing `allHosts` or\n * missing `hostAccess` is dropped by the filter — and `hostRoleFor` resolves\n * every one of those to \"no access\" anyway. The predicate and the index\n * agree about what absence means, so the query cannot hide anybody the\n * filter would have kept.\n */\n const orgMembers = (constrain: (base: any) => any) => () =>\n reading && orgId\n ? constrain(collection(firestore, 'orgs', orgId, 'members'))\n : null\n\n const { data: orgAdminDocs } = useFirestoreCollection<any>(\n orgMembers((base) =>\n query(\n base,\n where('role', 'in', ['owner', 'admin']),\n limit(SENDER_PICKER_CEILING),\n ),\n ),\n [firestore, orgId, reading],\n { idField: '$id' },\n )\n const { data: orgWideDocs } = useFirestoreCollection<any>(\n orgMembers((base) =>\n query(base, where('allHosts', '==', true), limit(SENDER_PICKER_CEILING)),\n ),\n [firestore, orgId, reading],\n { idField: '$id' },\n )\n /*\n * `HOST_ACCESS_ROLES` rather than a written-out array, for the reason it\n * exists: a role added to `HostAccessRole` and missing from an `in` list is\n * a person who holds access and cannot be seen to, with no error to explain\n * it. The constant is built from a `Record` so a new role fails to compile\n * there instead of quietly shortening this query.\n */\n const { data: orgScopedDocs } = useFirestoreCollection<any>(\n orgMembers((base) =>\n query(\n base,\n where(`hostAccess.${hostId}`, 'in', HOST_ACCESS_ROLES),\n limit(SENDER_PICKER_CEILING),\n ),\n ),\n [firestore, orgId, hostId, reading],\n { idField: '$id' },\n )\n\n const candidates = useMemo<SenderCandidate[]>(() => {\n /*\n * ONE ROW PER PERSON, keyed by uid.\n *\n * The same teammate legitimately appears in several of these reads — an\n * org admin who is also an explicit collaborator is in two, and an org\n * member who accepted a site invite has a roster row as well — so the\n * merge is by identity rather than by document. The org member document\n * and the site roster row are keyed by the same uid, which is what makes\n * that possible: `POST /api/hosts/members` names the roster document after\n * the auth uid whenever an account exists, and falls back to a generated\n * id only for an address that has none. Such a row has no org document to\n * collide with, so the fallback cannot merge two people into one.\n *\n * Fields are taken from the first read that has them rather than from a\n * chosen winner. Neither source is complete on its own: the site roster\n * always carries an `email` and never a `displayName`, and an org member\n * document usually carries both but is allowed to omit either.\n */\n const byPerson = new Map<string, SenderCandidate>()\n const fold = (id: string, entry: any) => {\n if (!id) return\n const current = byPerson.get(id) ?? { id, email: '', displayName: '' }\n byPerson.set(id, {\n id,\n email: current.email || String(entry?.email ?? '').trim(),\n displayName:\n current.displayName || String(entry?.displayName ?? '').trim(),\n })\n }\n\n /*\n * THE PREDICATE IS APPLIED AGAIN HERE, over the documents themselves.\n *\n * The three queries are the predicate projected onto an index, and an\n * index answers a coarser question than the function does — `allHosts`\n * reaches every site only for a member who HAS an org role, and a document\n * carrying the flag with no role is a member of nothing. `hostRoleFor` is\n * the one definition of \"can this person reach this site\", so it decides,\n * and the queries are only what keeps the reads narrow.\n */\n for (const entry of [\n ...(orgAdminDocs ?? []),\n ...(orgWideDocs ?? []),\n ...(orgScopedDocs ?? []),\n ]) {\n if (!hostRoleFor(entry, hostId as never)) continue\n fold(String(entry?.$id ?? ''), entry)\n }\n\n /*\n * The site's roster rows carry no role of their own to resolve — being in\n * this subcollection IS access to this site, which is what the Users card\n * on the site's own settings page renders it as. `uid` before `$id` so an\n * accepted collaborator merges onto their org member document rather than\n * arriving as a second row for the same person.\n */\n for (const entry of siteMemberDocs ?? []) {\n fold(String(entry?.uid || entry?.$id || ''), entry)\n }\n\n return (\n [...byPerson.values()]\n /*\n * An address is not optional. It is what the mailbox is derived from\n * and what replies are pointed at, so a person recorded without one\n * has nothing this picker could fill in.\n */\n .filter((entry) => entry.email)\n /*\n * Sorted here rather than by the server, because this is a COMPLETE\n * set and not a page: four reads have been merged, so no server order\n * survives, and arranging a page in the browser is what makes an\n * arbitrary slice read as a ranked one. By the name a person is\n * looking for, falling back to the address for somebody who has none.\n */\n .sort((left, right) =>\n (left.displayName || left.email).localeCompare(\n right.displayName || right.email,\n undefined,\n { sensitivity: 'base' },\n ),\n )\n )\n }, [orgAdminDocs, orgWideDocs, orgScopedDocs, siteMemberDocs, hostId])\n\n /*\n * The domain half, shown as a fixed suffix rather than as a field.\n *\n * A person setting a sender is choosing an address, and an address with its\n * domain missing from the screen is one they have to assemble in their head\n * from the alert above. Read from the identity the server resolved, so it\n * is the domain a send would actually use.\n */\n const domain = String(view?.selected ?? '')\n const pooled = Boolean(view) && !view?.localPartInUse\n\n const handleSave = useCallback(async () => {\n if (busy) return\n /*\n * A POOLED SITE SENDS NO MAILBOX AT ALL, rather than sending one the\n * route will refuse.\n *\n * The field is disabled above and the alert says why, and this is the\n * half that matters: a merchant on the shared address editing their\n * sender name would otherwise have the whole save rejected over a field\n * they were never offered.\n */\n const checked = pooled ? null : validateSendingLocalPart(mailbox)\n if (checked?.error) return setError(checked.error)\n setError('')\n setBusy(true)\n const { response, payload } = await call({\n path: 'sending-identity',\n method: 'POST',\n /*\n * No `domain` key. Absent means \"this is not a decision about the\n * domain\" — an empty one would mean \"move this site back to the address\n * Aglyn issues it\", which would reset a site sending as its own\n * verified name every time somebody edited the sender.\n */\n body: {\n hostId,\n action: senderId ? 'updateSender' : 'createSender',\n ...(senderId ? { senderId } : {}),\n ...(checked?.localPart ? { localPart: checked.localPart } : {}),\n fromName: fromName.trim(),\n replyTo: replyTo.trim(),\n },\n })\n setBusy(false)\n if (!response.ok) {\n return setError(payload?.error ?? 'Could not save who this site sends as')\n }\n onSaved(String(payload?.senderId ?? '') || undefined)\n onClose()\n }, [\n busy,\n call,\n hostId,\n senderId,\n mailbox,\n pooled,\n fromName,\n replyTo,\n onSaved,\n onClose,\n ])\n\n const applyCandidate = useCallback((candidate: SenderCandidate) => {\n const at = candidate.email.lastIndexOf('@')\n const proposed = normalizeLocalPart(\n at > 0 ? candidate.email.slice(0, at) : '',\n )\n if (proposed) setMailbox(proposed)\n if (candidate.displayName) setFromName(candidate.displayName)\n setReplyTo(candidate.email)\n setError('')\n }, [])\n\n return (\n <NavigationDrawerComponent\n open={open}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={onClose}\n AppBarProps={{ color: 'surface' }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={onClose}\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 {senderId ? 'Edit this sender' : 'Add a sender'}\n </Typography>\n {/*\n The help affordance every console surface carries, pointed at the\n section that says what a sender is and which of its three fields\n the shared address restricts — which is the question somebody\n standing in this drawer with a disabled Mailbox field has.\n */}\n <HelpTip\n {...pluginDocsHelp('emailCampaigns', { anchor: '#add-a-sender' })}\n />\n </>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'An address your email may leave on, and the name in front of ' +\n 'it. The domain is your site’s verified sending domain and ' +\n 'cannot be changed here — the part before the @ can. To have ' +\n 'replies reach a personal or company mailbox, use the reply ' +\n 'address: sending as an address on somebody else’s mail ' +\n 'provider is refused by the receiving side, not delivered.'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {editing?.isDefault\n ? 'Every email you compose picks a sender. This is the one an ' +\n 'email that names no other goes out as.'\n : 'Every email you compose picks a sender, and this will be ' +\n 'one of the choices.'}\n </Typography>\n\n {candidates.length ? (\n <>\n <TextField\n select\n label=\"Send as a person\"\n value=\"\"\n size=\"small\"\n onChange={(event) => {\n const match = candidates.find(\n (entry) => entry.id === event.target.value,\n )\n if (match) applyCandidate(match)\n }}\n helperText=\"Fills the three fields below from someone who works on this site\"\n >\n {/*\n THE NAME LEADS, and the address sits under it.\n\n A sender picker is answering \"which of us is this from\",\n which is a question about a person; a list of bare addresses\n makes the reader parse a local part to recognise a colleague.\n The address stays on the row because it is what the mailbox\n is derived from and where replies will land, so it is part of\n the choice rather than a detail behind it — and it is the\n whole row for somebody whose name nothing has recorded, which\n is an ordinary state for an invited teammate who has not\n signed in yet.\n */}\n {candidates.map((candidate) => (\n <MenuItem key={candidate.id} value={candidate.id}>\n <Stack spacing={0}>\n {candidate.displayName ? (\n <Typography variant=\"body2\">\n {candidate.displayName}\n </Typography>\n ) : null}\n <Typography\n variant={candidate.displayName ? 'caption' : 'body2'}\n color={\n candidate.displayName ? 'text.secondary' : undefined\n }\n >\n {candidate.email}\n </Typography>\n </Stack>\n </MenuItem>\n ))}\n </TextField>\n <Divider />\n </>\n ) : null}\n\n {pooled ? (\n <Alert severity=\"info\">\n {'This site sends on a shared Aglyn address, whose mailbox is ' +\n 'fixed and shared with the other sites on it. The name and ' +\n 'reply address below apply to that mail; the mailbox takes ' +\n 'effect once this site has a domain of its own.'}\n </Alert>\n ) : null}\n\n <TextField\n label=\"Mailbox\"\n value={mailbox}\n onChange={(event) => setMailbox(event.target.value)}\n size=\"small\"\n disabled={pooled}\n slotProps={{\n input: domain\n ? {\n endAdornment: (\n <InputAdornment position=\"end\">\n {`@${domain}`}\n </InputAdornment>\n ),\n }\n : undefined,\n }}\n helperText={\n 'The part before the @ — for example hello, sales or jamie'\n }\n />\n <TextField\n label=\"Sender name\"\n value={fromName}\n onChange={(event) => setFromName(event.target.value)}\n size=\"small\"\n helperText=\"Shown in front of the address. A person’s name, or your brand\"\n />\n <TextField\n label=\"Reply address\"\n value={replyTo}\n onChange={(event) => setReplyTo(event.target.value)}\n size=\"small\"\n type=\"email\"\n helperText=\"Where replies go. May be any mailbox, including a personal one\"\n />\n\n {error ? <Alert severity=\"error\">{error}</Alert> : null}\n\n <Button\n variant=\"contained\"\n disabled={busy || !view}\n onClick={() => void handleSave()}\n >\n {busy ? 'Saving…' : senderId ? 'Save sender' : 'Add sender'}\n </Button>\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n )\n}\nSendingSenderDrawer.displayName = 'SendingSenderDrawer'\n\nexport default SendingSenderDrawer\n"],"names":["hostRoleFor","HOST_ACCESS_ROLES","pluginDocsHelp","ICON_VARIANT_CLOSE","Container","HelpTip","MdiIcon","SrOnly","NavigationDrawerComponent","normalizeLocalPart","validateSendingLocalPart","useFirestore","useFirestoreCollection","Alert","Button","Divider","IconButton","InputAdornment","MenuItem","Stack","TextField","Typography","collection","limit","query","where","useCallback","useEffect","useMemo","useState","useSendingApi","SENDER_PICKER_CEILING","SendingSenderDrawer","props","open","hostId","view","senderId","onClose","onSaved","call","firestore","mailbox","setMailbox","fromName","setFromName","replyTo","setReplyTo","busy","setBusy","error","setError","editing","senders","find","sender","id","localPart","orgId","String","reading","Boolean","data","siteMemberDocs","idField","orgMembers","constrain","orgAdminDocs","base","orgWideDocs","orgScopedDocs","candidates","byPerson","Map","fold","entry","current","get","email","displayName","set","trim","$id","uid","values","filter","sort","left","right","localeCompare","undefined","sensitivity","domain","selected","pooled","localPartInUse","handleSave","checked","response","payload","path","method","body","action","ok","applyCandidate","candidate","at","lastIndexOf","proposed","slice","anchor","variant","AppBarProps","color","appBarLeft","edge","onClick","sx","mr","component","gutterY","spacing","isDefault","length","select","label","value","size","onChange","event","match","target","helperText","map","severity","disabled","slotProps","input","endAdornment","position","type"],"mappings":"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"}
|
|
@@ -40,7 +40,7 @@ import { readConsentGroupChangeMarker } from "./consent-groups-api.js";
|
|
|
40
40
|
return /*#__PURE__*/ _jsx(CardDisplay, {
|
|
41
41
|
header: "Consent group",
|
|
42
42
|
help: pluginDocsHelp('emailCampaigns', {
|
|
43
|
-
anchor: '#consent-
|
|
43
|
+
anchor: '#consent-group-join'
|
|
44
44
|
}),
|
|
45
45
|
contentGutterX: true,
|
|
46
46
|
contentGutterY: true,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/site-consent-group-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, type ConsentGroup } from '@aglyn/aglyn'\nimport { consentGroupDisclosure } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { readConsentGroupChangeMarker } from './consent-groups-api'\n\nexport interface SiteConsentGroupCardProps {\n hostId: string\n /** The group this site sends as, resolved by the page from the org document. */\n consentGroup: ConsentGroup\n /** The org document, for its slug and any change in flight. */\n org?: Record<string, unknown> | null\n}\n\n/**\n * Which sender this site is, read-only (AGL-3320).\n *\n * A consent group is declared for the organization, so a site's Emails page\n * says what it is part of and where that is changed, and changes nothing\n * itself. Pure, from the org document the shell already holds: it costs no\n * read, which is also why it can say how many other sites share the group\n * but not which — naming them would mean reading sites this reader may not\n * be able to open.\n */\nexport function SiteConsentGroupCard(props: SiteConsentGroupCardProps) {\n const { hostId, consentGroup, org } = props\n const slug = typeof org?.['slug'] === 'string' ? org['slug'] : ''\n const orgPage = slug ? `/${slug}/emails/consent-groups` : null\n const marker = readConsentGroupChangeMarker(org)\n const changing = marker != null && marker.hostIds.includes(hostId)\n const disclosure = consentGroupDisclosure(consentGroup)\n const others = consentGroup.hostIds.length - 1\n\n return (\n <CardDisplay\n header=\"Consent group\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#consent-
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/site-consent-group-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, type ConsentGroup } from '@aglyn/aglyn'\nimport { consentGroupDisclosure } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { readConsentGroupChangeMarker } from './consent-groups-api'\n\nexport interface SiteConsentGroupCardProps {\n hostId: string\n /** The group this site sends as, resolved by the page from the org document. */\n consentGroup: ConsentGroup\n /** The org document, for its slug and any change in flight. */\n org?: Record<string, unknown> | null\n}\n\n/**\n * Which sender this site is, read-only (AGL-3320).\n *\n * A consent group is declared for the organization, so a site's Emails page\n * says what it is part of and where that is changed, and changes nothing\n * itself. Pure, from the org document the shell already holds: it costs no\n * read, which is also why it can say how many other sites share the group\n * but not which — naming them would mean reading sites this reader may not\n * be able to open.\n */\nexport function SiteConsentGroupCard(props: SiteConsentGroupCardProps) {\n const { hostId, consentGroup, org } = props\n const slug = typeof org?.['slug'] === 'string' ? org['slug'] : ''\n const orgPage = slug ? `/${slug}/emails/consent-groups` : null\n const marker = readConsentGroupChangeMarker(org)\n const changing = marker != null && marker.hostIds.includes(hostId)\n const disclosure = consentGroupDisclosure(consentGroup)\n const others = consentGroup.hostIds.length - 1\n\n return (\n <CardDisplay\n header=\"Consent group\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#consent-group-join' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {changing ? (\n <Alert severity=\"info\">\n {marker.phase === 'carry'\n ? 'Your organization is changing its consent groups, and this ' +\n 'site is part of the change. Nothing has changed yet: until it ' +\n 'takes effect, this site sends as described below.'\n : 'Your organization changed its consent groups, and this site is ' +\n 'part of the change. What is described below is already in ' +\n 'effect; the CRM records it shares are still being combined.'}\n </Alert>\n ) : null}\n {consentGroup.declared && consentGroup.name ? (\n <Typography variant=\"body2\">\n {`This site sends as part of ${consentGroup.name}, with ${others} ` +\n `other ${others === 1 ? 'site' : 'sites'}.` +\n (disclosure ? ` Signup forms here say: “${disclosure}”` : '') +\n ' Someone who unsubscribes from any of them stops getting ' +\n 'marketing email from all of them.'}\n </Typography>\n ) : (\n <Typography variant=\"body2\">\n {'This site sends on its own: someone who signs up here hears only ' +\n 'from this site, and an unsubscribe here applies to this site ' +\n 'alone.'}\n </Typography>\n )}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Change this on your organization’s '}\n {orgPage ? <AppLink href={orgPage}>{'Emails page'}</AppLink> : 'Emails page'}\n {'.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nSiteConsentGroupCard.displayName = 'SiteConsentGroupCard'\n\nexport default SiteConsentGroupCard\n"],"names":["pluginDocsHelp","consentGroupDisclosure","AppLink","CardDisplay","Alert","Stack","Typography","readConsentGroupChangeMarker","SiteConsentGroupCard","props","hostId","consentGroup","org","slug","orgPage","marker","changing","hostIds","includes","disclosure","others","length","header","help","anchor","contentGutterX","contentGutterY","spacing","severity","phase","declared","name","variant","color","href","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,cAAc,QAA2B,eAAc;AAChE,SAASC,sBAAsB,QAAQ,wCAAuC;AAC9E,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,KAAK,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACxD,SAASC,4BAA4B,QAAQ,0BAAsB;AAUnE;;;;;;;;;CASC,GACD,OAAO,SAASC,qBAAqBC,KAAgC;IACnE,MAAM,EAAEC,MAAM,EAAEC,YAAY,EAAEC,GAAG,EAAE,GAAGH;IACtC,MAAMI,OAAO,QAAOD,uBAAAA,GAAK,CAAC,OAAO,MAAK,WAAWA,GAAG,CAAC,OAAO,GAAG;IAC/D,MAAME,UAAUD,OAAO,CAAC,CAAC,EAAEA,KAAK,sBAAsB,CAAC,GAAG;IAC1D,MAAME,SAASR,6BAA6BK;IAC5C,MAAMI,WAAWD,UAAU,QAAQA,OAAOE,OAAO,CAACC,QAAQ,CAACR;IAC3D,MAAMS,aAAalB,uBAAuBU;IAC1C,MAAMS,SAAST,aAAaM,OAAO,CAACI,MAAM,GAAG;IAE7C,qBACE,KAAClB;QACCmB,QAAO;QACPC,MAAMvB,eAAe,kBAAkB;YAAEwB,QAAQ;QAAsB;QACvEC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAACrB;YAAMsB,SAAS;;gBACbX,yBACC,KAACZ;oBAAMwB,UAAS;8BACbb,OAAOc,KAAK,KAAK,UACd,gEACA,mEACA,sDACA,oEACA,+DACA;qBAEJ;gBACHlB,aAAamB,QAAQ,IAAInB,aAAaoB,IAAI,iBACzC,KAACzB;oBAAW0B,SAAQ;8BACjB,CAAC,2BAA2B,EAAErB,aAAaoB,IAAI,CAAC,OAAO,EAAEX,OAAO,CAAC,CAAC,GACjE,CAAC,MAAM,EAAEA,WAAW,IAAI,SAAS,QAAQ,CAAC,CAAC,GAC1CD,CAAAA,aAAa,CAAC,yBAAyB,EAAEA,WAAW,CAAC,CAAC,GAAG,EAAC,IAC3D,8DACA;mCAGJ,KAACb;oBAAW0B,SAAQ;8BACjB,sEACC,kEACA;;8BAGN,MAAC1B;oBAAW0B,SAAQ;oBAAQC,OAAM;;wBAC/B;wBACAnB,wBAAU,KAACZ;4BAAQgC,MAAMpB;sCAAU;6BAA2B;wBAC9D;;;;;;AAKX;AACAN,qBAAqB2B,WAAW,GAAG;AAEnC,eAAe3B,qBAAoB"}
|
|
@@ -484,7 +484,7 @@ const SUPPRESSION_SELECT_FIELDS = [
|
|
|
484
484
|
return /*#__PURE__*/ _jsxs(CardDisplay, {
|
|
485
485
|
header: "Suppressions",
|
|
486
486
|
help: pluginDocsHelp('emailCampaigns', {
|
|
487
|
-
anchor: '#
|
|
487
|
+
anchor: '#suppressions'
|
|
488
488
|
}),
|
|
489
489
|
contentGutterX: true,
|
|
490
490
|
contentGutterY: true,
|
|
@@ -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 { TransferResumeImport } from '@aglyn/aglyn/app-utils/transfer-resume-import'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { useTransferLauncher } from '@aglyn/aglyn/app-utils/transfer-launcher-context'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport {\n mdiEmailCheckOutline,\n mdiTrayArrowDown,\n mdiTrayArrowUp,\n} from '@aglyn/shared-data-mdi'\nimport {\n CardDisplay,\n Container,\n MdiIcon,\n SrOnly,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\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 Tooltip,\n Typography,\n} from '@mui/material'\nimport Button from '@mui/material/Button'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, deleteDoc, doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { useListColumnSort } from '@aglyn/shared-ui-jsx/hooks/use-list-column-sort'\nimport type { ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n SUPPRESSION_FILTER_HEADERS,\n SUPPRESSION_LIST_QUERY,\n} from '../constants/list-queries'\nimport {\n describeSuppressionReason as describeReason,\n readSuppressionTotals,\n SUPPRESSION_REASONS,\n type SuppressionTotals,\n} from './suppression-totals'\nimport { orgSiteName, useEmailOrgMount } from './email-org-mount'\nimport { SUPPRESSIONS_RESOURCE } from '../transfer/email-transfer-catalog'\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 * The site this entry was copied from when the two stopped being one\n * sender (AGL-3320). The copy keeps the original's reason and dates.\n */\n carriedFromHostId?: string\n}\n\n/*\n * What the suppressions grid's Filters panel offers, every clause and the\n * search on the list's query (AGL-3321): see `SUPPRESSION_LIST_QUERY`. The\n * Reason choices are the stored reasons, by the words the column uses.\n */\nconst SUPPRESSION_FILTER_OPTIONS = {\n reason: Object.entries(SUPPRESSION_REASONS).map(([value, described]) => ({\n value,\n label: described.label,\n })),\n}\nconst SUPPRESSION_SELECT_FIELDS = ['reason']\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 // Present on the organization's page, which can name the other site.\n const orgMount = useEmailOrgMount()\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 page IS the query, ordered by the server (AGL-2501, AGL-2292), and\n * every filter and search word is a predicate on it (AGL-3321).\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 gridFilter = useListGridFilter({ selectFields: SUPPRESSION_SELECT_FIELDS })\n /*\n * EVERY HEADER SORTS ON THE QUERY (AGL-3680): Since, Address and Reason\n * are stored on every entry (see `SUPPRESSION_LIST_QUERY`). Newest first\n * until a header is clicked.\n */\n const [askedSort, setAskedSort] = useState<ListQuerySort | null>(\n SUPPRESSION_LIST_QUERY.sorts[0],\n )\n const {\n rows: entries,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<SuppressionRow>({\n collection: collection(firestore, 'hosts', hostId, 'suppressions'),\n declaration: SUPPRESSION_LIST_QUERY,\n request: {\n clauses: gridFilter.clauses,\n search: gridFilter.searchWords,\n sort: askedSort,\n },\n deps: [firestore, hostId],\n idField: '$id',\n })\n const columnSort = useListColumnSort<SuppressionRow>({\n sorts: SUPPRESSION_LIST_QUERY.sorts,\n defaultSort: SUPPRESSION_LIST_QUERY.sorts[0],\n sort: askedSort,\n onSortChange: setAskedSort,\n orderBy: plan.orderBy,\n rows: entries,\n })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n /*\n * IMPORT AND EXPORT (AGL-3529) open the console's transfer wizard and\n * export dialog on this site's list. An import only ever adds: an address\n * already here keeps its entry, and nothing removes one. The export's\n * \"current filter\" is this table's own query.\n */\n const transfer = useTransferLauncher()\n const exportFilter = filtering\n ? {\n label: 'Entries matching the table’s filters',\n value: { filters: plan.filters },\n }\n : null\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: SUPPRESSION_LIST_QUERY.fields,\n headers: SUPPRESSION_FILTER_HEADERS,\n options: SUPPRESSION_FILTER_OPTIONS,\n }),\n [plan.refused],\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: 230,\n // An absent reason is an unsubscribe, as the chip says.\n valueGetter: (_value, row) => String(row.reason ?? 'unsubscribe'),\n renderCell: ({ row }) => {\n const described = describeReason(row.reason)\n return (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%' }}\n >\n <Chip\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={described.label}\n />\n {/*\n A copy made when sites stopped being one sender. It is an\n entry like any other; the marker says why an address nobody\n on this site acted on is on its list.\n */}\n {row.carriedFromHostId ? (\n <Tooltip\n title={`Copied from ${\n orgMount\n ? orgSiteName(orgMount, row.carriedFromHostId)\n : 'another site'\n } when the two stopped being one sender`}\n >\n <Chip size=\"small\" variant=\"outlined\" label=\"Copied\" />\n </Tooltip>\n ) : null}\n </Stack>\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 <Stack direction=\"row\" spacing={1}>\n {/* Each only for whom the route takes it (AGL-3554). */}\n {transfer?.can('export', { resource: SUPPRESSIONS_RESOURCE, scope: 'host', hostId }) ? (\n <Button\n size=\"small\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiTrayArrowDown.path} size={0.8} />}\n onClick={() =>\n transfer.openExport({\n resource: SUPPRESSIONS_RESOURCE,\n scope: 'host',\n hostId,\n ...(exportFilter ? { filter: exportFilter } : {}),\n })\n }\n >\n {'Export'}\n </Button>\n ) : null}\n {/* An import left unfinished, reopened where it stopped (AGL-3549). */}\n <TransferResumeImport\n target={{ resource: SUPPRESSIONS_RESOURCE, scope: 'host', hostId }}\n onFinished={() => setTotalsEpoch((epoch) => epoch + 1)}\n />\n {transfer?.can('import', { resource: SUPPRESSIONS_RESOURCE, scope: 'host', hostId }) ? (\n <Button\n size=\"small\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiTrayArrowUp.path} size={0.8} />}\n onClick={() =>\n transfer.openImport({\n resource: SUPPRESSIONS_RESOURCE,\n scope: 'host',\n hostId,\n onFinished: () => setTotalsEpoch((epoch) => epoch + 1),\n })\n }\n >\n {'Import'}\n </Button>\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setAdding(true)}\n >\n {'Add'}\n </Button>\n </Stack>\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 && !hasMore && !filtering ? (\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 useFlexGap 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 <ListFilterChips\n fields={SUPPRESSION_LIST_QUERY.fields}\n headers={SUPPRESSION_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={SUPPRESSION_FILTER_OPTIONS}\n />\n <ListQueryNotices\n refused={refusals}\n notices={[...plan.notices, ...columnSort.notices]}\n />\n <ListTable\n aria-label=\"Suppressed addresses\"\n rows={columnSort.rows}\n columnSort={columnSort}\n columns={listFilterGridColumns(\n columns as GridColDef[],\n SUPPRESSION_LIST_QUERY.fields,\n SUPPRESSION_FILTER_OPTIONS,\n SUPPRESSION_FILTER_HEADERS,\n )}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel, the search and the headers go to the query; the\n // grid neither filters nor sorts the page it holds.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n noRowsLabel=\"No suppressions match these filters\"\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={entries.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\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. A filtered list's size is unknown.\n count={\n totals && !filtering\n ? Object.values(totals).reduce((a, b) => a + b, 0)\n : undefined\n }\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":["TransferResumeImport","pluginDocsHelp","useTransferLauncher","ICON_VARIANT_CLOSE","mdiEmailCheckOutline","mdiTrayArrowDown","mdiTrayArrowUp","CardDisplay","Container","MdiIcon","SrOnly","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","NavigationDrawerComponent","useSnackbar","Alert","Chip","IconButton","Stack","TextField","Tooltip","Typography","Button","collection","deleteDoc","doc","useCallback","useEffect","useMemo","useState","useFirestore","useUser","useListQuery","useListColumnSort","authorizedFetch","SUPPRESSION_FILTER_HEADERS","SUPPRESSION_LIST_QUERY","describeSuppressionReason","describeReason","readSuppressionTotals","SUPPRESSION_REASONS","orgSiteName","useEmailOrgMount","SUPPRESSIONS_RESOURCE","SUPPRESSION_FILTER_OPTIONS","reason","Object","entries","map","value","described","label","SUPPRESSION_SELECT_FIELDS","onDate","row","seconds","createdAt","suppressedAt","Date","toISOString","slice","SuppressionsCard","props","hostId","firestore","data","user","enqueueSnackbar","confirm","orgMount","adding","setAdding","addInput","setAddInput","addNote","setAddNote","busy","setBusy","gridFilter","selectFields","askedSort","setAskedSort","sorts","rows","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","clauses","search","searchWords","sort","deps","idField","columnSort","defaultSort","onSortChange","orderBy","filtering","length","some","word","trim","transfer","exportFilter","filters","refusals","refused","fields","headers","options","totalsEpoch","setTotalsEpoch","totals","setTotals","active","then","read","catch","handleAdd","typed","response","method","body","JSON","stringify","emails","note","payload","json","ok","error","variant","results","added","Number","rejected","filter","result","refusal","input","join","persist","epoch","console","isBlockedPlatformWide","email","platform","handleRemove","toLowerCase","alsoPlatform","accepted","title","description","confirmationText","confirmationButtonProps","color","$id","columns","field","headerName","flex","minWidth","renderCell","width","valueGetter","_value","String","direction","spacing","sx","alignItems","height","size","carriedFromHostId","items","key","icon","path","destructive","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","can","resource","scope","startIcon","openExport","target","onFinished","openImport","useFlexGap","flexWrap","total","onChange","setClauses","notices","aria-label","rowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","noRowsLabel","rowCount","onPageChange","onPageSizeChange","count","values","reduce","a","b","undefined","open","onClose","AppBarProps","appBarLeft","edge","mr","component","appBarRight","gutterY","event","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 { TransferResumeImport } from '@aglyn/aglyn/app-utils/transfer-resume-import'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { useTransferLauncher } from '@aglyn/aglyn/app-utils/transfer-launcher-context'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport {\n mdiEmailCheckOutline,\n mdiTrayArrowDown,\n mdiTrayArrowUp,\n} from '@aglyn/shared-data-mdi'\nimport {\n CardDisplay,\n Container,\n MdiIcon,\n SrOnly,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\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 Tooltip,\n Typography,\n} from '@mui/material'\nimport Button from '@mui/material/Button'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, deleteDoc, doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { useListColumnSort } from '@aglyn/shared-ui-jsx/hooks/use-list-column-sort'\nimport type { ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n SUPPRESSION_FILTER_HEADERS,\n SUPPRESSION_LIST_QUERY,\n} from '../constants/list-queries'\nimport {\n describeSuppressionReason as describeReason,\n readSuppressionTotals,\n SUPPRESSION_REASONS,\n type SuppressionTotals,\n} from './suppression-totals'\nimport { orgSiteName, useEmailOrgMount } from './email-org-mount'\nimport { SUPPRESSIONS_RESOURCE } from '../transfer/email-transfer-catalog'\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 * The site this entry was copied from when the two stopped being one\n * sender (AGL-3320). The copy keeps the original's reason and dates.\n */\n carriedFromHostId?: string\n}\n\n/*\n * What the suppressions grid's Filters panel offers, every clause and the\n * search on the list's query (AGL-3321): see `SUPPRESSION_LIST_QUERY`. The\n * Reason choices are the stored reasons, by the words the column uses.\n */\nconst SUPPRESSION_FILTER_OPTIONS = {\n reason: Object.entries(SUPPRESSION_REASONS).map(([value, described]) => ({\n value,\n label: described.label,\n })),\n}\nconst SUPPRESSION_SELECT_FIELDS = ['reason']\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 // Present on the organization's page, which can name the other site.\n const orgMount = useEmailOrgMount()\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 page IS the query, ordered by the server (AGL-2501, AGL-2292), and\n * every filter and search word is a predicate on it (AGL-3321).\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 gridFilter = useListGridFilter({ selectFields: SUPPRESSION_SELECT_FIELDS })\n /*\n * EVERY HEADER SORTS ON THE QUERY (AGL-3680): Since, Address and Reason\n * are stored on every entry (see `SUPPRESSION_LIST_QUERY`). Newest first\n * until a header is clicked.\n */\n const [askedSort, setAskedSort] = useState<ListQuerySort | null>(\n SUPPRESSION_LIST_QUERY.sorts[0],\n )\n const {\n rows: entries,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<SuppressionRow>({\n collection: collection(firestore, 'hosts', hostId, 'suppressions'),\n declaration: SUPPRESSION_LIST_QUERY,\n request: {\n clauses: gridFilter.clauses,\n search: gridFilter.searchWords,\n sort: askedSort,\n },\n deps: [firestore, hostId],\n idField: '$id',\n })\n const columnSort = useListColumnSort<SuppressionRow>({\n sorts: SUPPRESSION_LIST_QUERY.sorts,\n defaultSort: SUPPRESSION_LIST_QUERY.sorts[0],\n sort: askedSort,\n onSortChange: setAskedSort,\n orderBy: plan.orderBy,\n rows: entries,\n })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n /*\n * IMPORT AND EXPORT (AGL-3529) open the console's transfer wizard and\n * export dialog on this site's list. An import only ever adds: an address\n * already here keeps its entry, and nothing removes one. The export's\n * \"current filter\" is this table's own query.\n */\n const transfer = useTransferLauncher()\n const exportFilter = filtering\n ? {\n label: 'Entries matching the table’s filters',\n value: { filters: plan.filters },\n }\n : null\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: SUPPRESSION_LIST_QUERY.fields,\n headers: SUPPRESSION_FILTER_HEADERS,\n options: SUPPRESSION_FILTER_OPTIONS,\n }),\n [plan.refused],\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: 230,\n // An absent reason is an unsubscribe, as the chip says.\n valueGetter: (_value, row) => String(row.reason ?? 'unsubscribe'),\n renderCell: ({ row }) => {\n const described = describeReason(row.reason)\n return (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%' }}\n >\n <Chip\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={described.label}\n />\n {/*\n A copy made when sites stopped being one sender. It is an\n entry like any other; the marker says why an address nobody\n on this site acted on is on its list.\n */}\n {row.carriedFromHostId ? (\n <Tooltip\n title={`Copied from ${\n orgMount\n ? orgSiteName(orgMount, row.carriedFromHostId)\n : 'another site'\n } when the two stopped being one sender`}\n >\n <Chip size=\"small\" variant=\"outlined\" label=\"Copied\" />\n </Tooltip>\n ) : null}\n </Stack>\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: '#suppressions' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n {/* Each only for whom the route takes it (AGL-3554). */}\n {transfer?.can('export', { resource: SUPPRESSIONS_RESOURCE, scope: 'host', hostId }) ? (\n <Button\n size=\"small\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiTrayArrowDown.path} size={0.8} />}\n onClick={() =>\n transfer.openExport({\n resource: SUPPRESSIONS_RESOURCE,\n scope: 'host',\n hostId,\n ...(exportFilter ? { filter: exportFilter } : {}),\n })\n }\n >\n {'Export'}\n </Button>\n ) : null}\n {/* An import left unfinished, reopened where it stopped (AGL-3549). */}\n <TransferResumeImport\n target={{ resource: SUPPRESSIONS_RESOURCE, scope: 'host', hostId }}\n onFinished={() => setTotalsEpoch((epoch) => epoch + 1)}\n />\n {transfer?.can('import', { resource: SUPPRESSIONS_RESOURCE, scope: 'host', hostId }) ? (\n <Button\n size=\"small\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiTrayArrowUp.path} size={0.8} />}\n onClick={() =>\n transfer.openImport({\n resource: SUPPRESSIONS_RESOURCE,\n scope: 'host',\n hostId,\n onFinished: () => setTotalsEpoch((epoch) => epoch + 1),\n })\n }\n >\n {'Import'}\n </Button>\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setAdding(true)}\n >\n {'Add'}\n </Button>\n </Stack>\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 && !hasMore && !filtering ? (\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 useFlexGap 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 <ListFilterChips\n fields={SUPPRESSION_LIST_QUERY.fields}\n headers={SUPPRESSION_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={SUPPRESSION_FILTER_OPTIONS}\n />\n <ListQueryNotices\n refused={refusals}\n notices={[...plan.notices, ...columnSort.notices]}\n />\n <ListTable\n aria-label=\"Suppressed addresses\"\n rows={columnSort.rows}\n columnSort={columnSort}\n columns={listFilterGridColumns(\n columns as GridColDef[],\n SUPPRESSION_LIST_QUERY.fields,\n SUPPRESSION_FILTER_OPTIONS,\n SUPPRESSION_FILTER_HEADERS,\n )}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel, the search and the headers go to the query; the\n // grid neither filters nor sorts the page it holds.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n noRowsLabel=\"No suppressions match these filters\"\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={entries.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\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. A filtered list's size is unknown.\n count={\n totals && !filtering\n ? Object.values(totals).reduce((a, b) => a + b, 0)\n : undefined\n }\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":["TransferResumeImport","pluginDocsHelp","useTransferLauncher","ICON_VARIANT_CLOSE","mdiEmailCheckOutline","mdiTrayArrowDown","mdiTrayArrowUp","CardDisplay","Container","MdiIcon","SrOnly","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","NavigationDrawerComponent","useSnackbar","Alert","Chip","IconButton","Stack","TextField","Tooltip","Typography","Button","collection","deleteDoc","doc","useCallback","useEffect","useMemo","useState","useFirestore","useUser","useListQuery","useListColumnSort","authorizedFetch","SUPPRESSION_FILTER_HEADERS","SUPPRESSION_LIST_QUERY","describeSuppressionReason","describeReason","readSuppressionTotals","SUPPRESSION_REASONS","orgSiteName","useEmailOrgMount","SUPPRESSIONS_RESOURCE","SUPPRESSION_FILTER_OPTIONS","reason","Object","entries","map","value","described","label","SUPPRESSION_SELECT_FIELDS","onDate","row","seconds","createdAt","suppressedAt","Date","toISOString","slice","SuppressionsCard","props","hostId","firestore","data","user","enqueueSnackbar","confirm","orgMount","adding","setAdding","addInput","setAddInput","addNote","setAddNote","busy","setBusy","gridFilter","selectFields","askedSort","setAskedSort","sorts","rows","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","clauses","search","searchWords","sort","deps","idField","columnSort","defaultSort","onSortChange","orderBy","filtering","length","some","word","trim","transfer","exportFilter","filters","refusals","refused","fields","headers","options","totalsEpoch","setTotalsEpoch","totals","setTotals","active","then","read","catch","handleAdd","typed","response","method","body","JSON","stringify","emails","note","payload","json","ok","error","variant","results","added","Number","rejected","filter","result","refusal","input","join","persist","epoch","console","isBlockedPlatformWide","email","platform","handleRemove","toLowerCase","alsoPlatform","accepted","title","description","confirmationText","confirmationButtonProps","color","$id","columns","field","headerName","flex","minWidth","renderCell","width","valueGetter","_value","String","direction","spacing","sx","alignItems","height","size","carriedFromHostId","items","key","icon","path","destructive","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","can","resource","scope","startIcon","openExport","target","onFinished","openImport","useFlexGap","flexWrap","total","onChange","setClauses","notices","aria-label","rowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","noRowsLabel","rowCount","onPageChange","onPageSizeChange","count","values","reduce","a","b","undefined","open","onClose","AppBarProps","appBarLeft","edge","mr","component","appBarRight","gutterY","event","multiline","minRows","fullWidth","autoFocus","helperText","slotProps","htmlInput","maxLength","severity","justifyContent","disabled","displayName"],"mappings":"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"}
|