@aglyn/plugins-email 1.0.0-beta.189 → 1.0.0-beta.191

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.
@@ -14,14 +14,18 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
17
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
19
  import { pluginDocsHelp } from "@aglyn/aglyn";
19
20
  import { ICON_VARIANT_CLOSE } from "@aglyn/shared-data-enums";
20
21
  import { mdiEmailCheckOutline } from "@aglyn/shared-data-mdi";
21
22
  import { CardDisplay, Container, MdiIcon, SrOnly, useConfirmationContext } from "@aglyn/shared-ui-jsx";
23
+ import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
22
24
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
23
25
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
26
+ import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
24
27
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
28
+ import { usePagedRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-paged-rows-filter";
25
29
  /*
26
30
  * The shared drawer, reached by its own path.
27
31
  *
@@ -37,10 +41,39 @@ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
37
41
  import { Alert, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
38
42
  import Button from "@mui/material/Button";
39
43
  import { collection, deleteDoc, doc, limit, orderBy, query } from "firebase/firestore";
40
- import { useCallback, useEffect, useState } from "react";
44
+ import { useCallback, useEffect, useMemo, useState } from "react";
41
45
  import { useFirestore, usePagedCollection, useUser } from "@aglyn/tenant-feature-instance";
42
46
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
43
- import { describeSuppressionReason as describeReason, readSuppressionTotals } from "./suppression-totals.js";
47
+ import { describeSuppressionReason as describeReason, readSuppressionTotals, SUPPRESSION_REASONS } from "./suppression-totals.js";
48
+ /*
49
+ * What the suppressions grid's Filters panel offers (AGL-3317). The list is a
50
+ * paged listener, so a filter matches over what its window has read, which
51
+ * the first filter widens (`usePagedRowsFilter`). Reason reads `reasonKey`,
52
+ * the stored reason with an absent one read as an unsubscribe, which is how
53
+ * the column and the breakdown describe it.
54
+ */ const SUPPRESSION_FILTER_FIELDS = [
55
+ inMemoryListField('email', 'text'),
56
+ inMemoryListField('reason', 'select', 'reasonKey')
57
+ ];
58
+ const SUPPRESSION_FILTER_HEADERS = {
59
+ email: 'Address',
60
+ reason: 'Reason'
61
+ };
62
+ const SUPPRESSION_FILTER_OPTIONS = {
63
+ reason: Object.entries(SUPPRESSION_REASONS).map(([value, described])=>({
64
+ value,
65
+ label: described.label
66
+ }))
67
+ };
68
+ const SUPPRESSION_SEARCH_FIELDS = [
69
+ 'email'
70
+ ];
71
+ const withReasonKey = (row)=>{
72
+ var _row_reason;
73
+ return _extends({}, row, {
74
+ reasonKey: String((_row_reason = row.reason) != null ? _row_reason : 'unsubscribe')
75
+ });
76
+ };
44
77
  /**
45
78
  * `YYYY-MM-DD` from a Firestore timestamp shape, or an em dash.
46
79
  *
@@ -127,12 +160,34 @@ import { describeSuppressionReason as describeReason, readSuppressionTotals } fr
127
160
  * one. `suppressedAt` would NOT be safe: it is absent on every entry
128
161
  * written before AGL-1918, and `orderBy` drops documents that lack the
129
162
  * field rather than mis-sorting them.
130
- */ const { rows: entries, hasMore, page, setPage, pageSize, setPageSize } = usePagedCollection((pageLimit)=>query(collection(firestore, 'hosts', hostId, 'suppressions'), orderBy('createdAt', 'desc'), limit(pageLimit)), [
163
+ */ const { data: entryData, rows: entryRows, hasMore, page, setPage, pageSize, setPageSize } = usePagedCollection((pageLimit)=>query(collection(firestore, 'hosts', hostId, 'suppressions'), orderBy('createdAt', 'desc'), limit(pageLimit)), [
131
164
  firestore,
132
165
  hostId
133
166
  ], {
134
167
  idField: '$id'
135
168
  });
169
+ const entryWindow = useMemo(()=>entryData == null ? void 0 : entryData.map(withReasonKey), [
170
+ entryData
171
+ ]);
172
+ const entryPage = useMemo(()=>entryRows.map(withReasonKey), [
173
+ entryRows
174
+ ]);
175
+ const entryFilter = usePagedRowsFilter({
176
+ data: entryWindow,
177
+ rows: entryPage,
178
+ hasMore,
179
+ page,
180
+ setPage,
181
+ pageSize,
182
+ setPageSize
183
+ }, {
184
+ fields: SUPPRESSION_FILTER_FIELDS,
185
+ options: SUPPRESSION_FILTER_OPTIONS,
186
+ headers: SUPPRESSION_FILTER_HEADERS,
187
+ search: SUPPRESSION_SEARCH_FIELDS
188
+ });
189
+ // The rows on screen: the page, or the page of the matches.
190
+ const entries = entryFilter.rows;
136
191
  /*==========================================
137
192
  * THE BREAKDOWN IS A SERVER AGGREGATE, not a tally of the page.
138
193
  *
@@ -345,7 +400,7 @@ import { describeSuppressionReason as describeReason, readSuppressionTotals } fr
345
400
  field: 'reason',
346
401
  headerName: 'Reason',
347
402
  width: 170,
348
- valueGetter: (_value, row)=>describeReason(row.reason).label,
403
+ valueGetter: (_value, row)=>row.reasonKey,
349
404
  renderCell: ({ row })=>{
350
405
  const described = describeReason(row.reason);
351
406
  return /*#__PURE__*/ _jsx(Chip, {
@@ -412,7 +467,7 @@ import { describeSuppressionReason as describeReason, readSuppressionTotals } fr
412
467
  color: "text.secondary",
413
468
  children: 'Addresses this site’s marketing email skips. Someone lands here ' + 'by clicking unsubscribe, by bouncing permanently, by marking a ' + 'message as spam, or because you added them — this is where the ' + 'gap between a campaign’s recipient count and what it actually ' + 'sent comes from.'
414
469
  }),
415
- entries.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
470
+ entryRows.length === 0 && !hasMore && !entryFilter.filtering ? /*#__PURE__*/ _jsx(Typography, {
416
471
  variant: "body2",
417
472
  color: "text.secondary",
418
473
  children: 'Nobody is suppressed. Every address in your audiences is ' + 'currently mailable.'
@@ -440,27 +495,29 @@ import { describeSuppressionReason as describeReason, readSuppressionTotals } fr
440
495
  }, reason);
441
496
  })
442
497
  }),
443
- /*#__PURE__*/ _jsx(ListTable, {
498
+ /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, entryFilter.chipsProps)),
499
+ entryFilter.filtering && hasMore ? /*#__PURE__*/ _jsx(Typography, {
500
+ variant: "caption",
501
+ color: "text.secondary",
502
+ children: `Filtering the ${entryFilter.read} suppressions read so far — the next page reads more.`
503
+ }) : null,
504
+ /*#__PURE__*/ _jsx(ListTable, _extends({
444
505
  "aria-label": "Suppressed addresses",
445
506
  rows: entries,
446
- columns: columns,
507
+ columns: entryFilter.filterColumns(columns),
447
508
  rowHeight: TABLE_ROW_HEIGHT,
448
509
  // Paged by the footer below, so the grid must not also slice.
449
510
  hideFooter: true
450
- }),
451
- /*#__PURE__*/ _jsx(ListPagination, {
452
- page: page,
453
- pageSize: pageSize,
454
- rowCount: entries.length,
455
- hasMore: hasMore,
511
+ }, entryFilter.gridProps, {
512
+ noRowsLabel: "No suppressions match these filters"
513
+ })),
514
+ /*#__PURE__*/ _jsx(ListPagination, _extends({}, entryFilter.pagination, {
456
515
  // The collection's real size, so the footer's count line says
457
516
  // "1–10 of 812" rather than "of more than 10" — the aggregate
458
517
  // above already knows it, and it is the same number the chips
459
- // are a breakdown of.
460
- count: totals ? Object.values(totals).reduce((a, b)=>a + b, 0) : undefined,
461
- onPageChange: setPage,
462
- onPageSizeChange: setPageSize
463
- })
518
+ // are a breakdown of. A filtered list's size is unknown.
519
+ count: totals && !entryFilter.filtering ? Object.values(totals).reduce((a, b)=>a + b, 0) : undefined
520
+ }))
464
521
  ]
465
522
  })
466
523
  ]
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/suppressions-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { mdiEmailCheckOutline } from '@aglyn/shared-data-mdi'\nimport {\n CardDisplay,\n Container,\n MdiIcon,\n SrOnly,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\n/*\n * The shared drawer, reached by its own path.\n *\n * `@aglyn/shared-ui-jsx`'s barrel deliberately does not re-export this one, so\n * a deep import is the supported way in rather than an escape hatch. The\n * console's `CreateArtifactDrawer` — what Screens, Components, Layouts and\n * Templates create through — is this same component with a form inside it,\n * and it lives in `apps/console`, which a plugin library may not import. So\n * the chrome is composed from the same primitive rather than duplicated from\n * the wrapper.\n */\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Chip,\n IconButton,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Button from '@mui/material/Button'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n limit,\n orderBy,\n query,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport {\n useFirestore,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n describeSuppressionReason as describeReason,\n readSuppressionTotals,\n type SuppressionTotals,\n} from './suppression-totals'\n\nexport interface SuppressionsCardProps {\n hostId: string\n}\n\n/** A stored entry. `reason` is absent on anything written before AGL-2408. */\ninterface SuppressionRow {\n $id: string\n email?: string\n reason?: string\n suppressedAt?: { seconds?: number } | null\n createdAt?: { seconds?: number } | null\n}\n\n\n/**\n * `YYYY-MM-DD` from a Firestore timestamp shape, or an em dash.\n *\n * `createdAt` first, and that ordering is the column's meaning rather than a\n * preference. Both writers restamp `suppressedAt` on every touch and write\n * `createdAt` only when the document is new, precisely so that a bounce\n * arriving after an unsubscribe does not move the date the person actually\n * unsubscribed. Reading `suppressedAt` first put the restamp on screen under a\n * heading that says \"Since\", and it is also the field the list is ordered by,\n * so a re-touched row would have sorted by one date and displayed another.\n */\nfunction onDate(row: SuppressionRow): string {\n const seconds = row.createdAt?.seconds ?? row.suppressedAt?.seconds\n if (!seconds) return '—'\n return new Date(seconds * 1000).toISOString().slice(0, 10)\n}\n\n/**\n * Suppressions (AGL-2410): who is not being emailed, and why.\n *\n * ## What was missing\n *\n * `hosts/{hostId}/suppressions` was written by two paths — the unsubscribe\n * handler and, since AGL-1918, the Resend webhook on a permanent bounce or a\n * complaint — and read by exactly one: `campaign-send.ts`, to filter an\n * audience. Nothing in the console displayed it. So a merchant could not\n * answer any of:\n *\n * - *\"My campaign says 500 recipients and 480 sent — who were the other\n * 20?\"* The send returns `{recipients, sent}` and the difference was\n * unexplained.\n * - *\"Is my list going stale?\"* A bounce rate is the single most useful\n * number about a list and there was nowhere to see it.\n * - *\"This address was suppressed by mistake.\"* There was no way to remove\n * an entry — and a link prescanner unsubscribing someone (AGL-2408 §2) was\n * therefore unrecoverable from inside the product.\n *\n * ## Why a surface and not another counter\n *\n * AGL-1918 deliberately did NOT write a `stats.bounces` counter alongside its\n * fix, because a number with no screen to show it is the written-but-never-\n * read shape this issue is about, one level up. So the fix is the READER, and\n * the breakdown here is derived from the rows on screen rather than from a\n * second stored figure that could disagree with them.\n *\n * ## Removing an entry\n *\n * A plain client `deleteDoc`, and that is a decision. The list belongs to the\n * merchant, host admins already read it through the same rules, and removing\n * a row does nothing except make an address targetable again — there is no\n * counter to launder and no money attached, which is the AGL-1367 test for\n * whether a write has to move server-side.\n *\n * The confirmation is not decoration either: for a `bounce` the address very\n * likely does not exist, and mailing it again is what a provider scores the\n * sending domain on. So the dialog says which reason is being overridden\n * rather than asking a generic \"are you sure\".\n */\nexport function SuppressionsCard(props: SuppressionsCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const [adding, setAdding] = useState(false)\n\n const [addInput, setAddInput] = useState('')\n const [addNote, setAddNote] = useState('')\n const [busy, setBusy] = useState(false)\n\n /*\n * The window IS the query, ordered by the server (AGL-2501, AGL-2292).\n *\n * This was `limit(500)` with no `orderBy`, sorted by date in the browser.\n * Firestore answers an unordered limit in DOCUMENT-ID order, and an entry\n * here is keyed by `sha256(email)` — so the window was five hundred\n * addresses chosen by the hash of the address, and the client sort dressed\n * that sample up as the newest five hundred. A list past the ceiling\n * therefore hid whoever bounced this morning behind whoever happened to\n * hash low, with no gap on screen to notice and no control asking for more.\n *\n * `createdAt` is the safe field to order on, and that is checked rather\n * than assumed: both writers — the unsubscribe handler and the Resend\n * bounce/complaint webhook — stamp it when the document is created, the\n * pre-AGL-2408 handler wrote `{ email, createdAt }`, and `suppressions` is\n * not in `IMPORTABLE_FIELDS`, so no restore path can produce a row without\n * one. `suppressedAt` would NOT be safe: it is absent on every entry\n * written before AGL-1918, and `orderBy` drops documents that lack the\n * field rather than mis-sorting them.\n */\n const {\n rows: entries,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<SuppressionRow>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'suppressions'),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE BREAKDOWN IS A SERVER AGGREGATE, not a tally of the page.\n *\n * These chips answer \"is my list going stale?\", and they were a `reduce`\n * over whatever the listener had fetched — so on a site past the old\n * ceiling \"Bounced: 140\" meant 140 of an arbitrary five hundred, and under\n * a ten-row page it would have meant 140 of ten. A bounce rate computed\n * from a sample is not a bounce rate, and nothing on screen said it was one.\n *\n * `readSuppressionTotals` holds the four reads and the remainder rule for\n * unsubscribes, and the organization's summary of every site's list asks\n * the same function, so a site's figures read the same on both pages.\n *=========================================*/\n const [totalsEpoch, setTotalsEpoch] = useState(0)\n const [totals, setTotals] = useState<SuppressionTotals | null>(null)\n useEffect(() => {\n let active = true\n void readSuppressionTotals(firestore, hostId)\n .then((read) => {\n if (active) setTotals(read)\n })\n .catch(() => {\n // Held at null rather than zeroed. \"Bounced: 0\" is a confident wrong\n // number in the reassuring direction, and this card exists to warn.\n if (active) setTotals(null)\n })\n return () => {\n active = false\n }\n // The list is a live listener and refreshes itself; an aggregate is a\n // one-shot read and would otherwise keep reporting the breakdown from\n // before the address was put back.\n }, [firestore, hostId, totalsEpoch])\n\n /*\n * The ADD, through a route rather than a client write.\n *\n * The Remove button below writes straight from the browser, and this does\n * not, which looks inconsistent until the document id is considered: an\n * entry is keyed by `sha256` of the normalized address, and a browser\n * computing that itself would be a second derivation of the key every\n * reader shares. Getting it wrong is silent and one-directional — the\n * merchant is told the person is suppressed and the mail keeps going. A\n * removal has no such hazard: it names a row that is already on screen.\n */\n const handleAdd = useCallback(async () => {\n const typed = addInput.trim()\n if (!typed || busy) return\n setBusy(true)\n try {\n const response = await authorizedFetch(\n user,\n '/api/email/suppression-add',\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, emails: typed, note: addNote.trim() }),\n },\n )\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The address could not be suppressed.',\n { variant: 'error' },\n )\n }\n const results = (payload?.results ?? []) as Array<{\n input: string\n added: boolean\n refusal?: string\n }>\n const added = Number(payload?.added ?? 0)\n const rejected = results.filter(\n (result) => result.refusal === 'not-an-address',\n )\n // The refusals are NAMED, because \"3 of 5 added\" leaves an operator to\n // work out which two, and the two that failed are the ones somebody\n // asked to stop being emailed.\n if (rejected.length) {\n enqueueSnackbar(\n `Not an email address: ${rejected\n .map((result) => result.input)\n .join(', ')}`,\n { variant: 'warning' },\n )\n }\n if (added) {\n enqueueSnackbar(\n added === 1\n ? 'Added to the suppression list'\n : `${added} addresses added to the suppression list`,\n { variant: 'success', persist: false },\n )\n } else if (!rejected.length) {\n enqueueSnackbar('Already on the suppression list', { variant: 'info' })\n }\n if (added) {\n setAddInput('')\n setAddNote('')\n setAdding(false)\n setTotalsEpoch((epoch) => epoch + 1)\n }\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n }, [addInput, addNote, busy, user, hostId, enqueueSnackbar])\n\n /**\n * Whether one address is ALSO on the platform-wide list.\n *\n * The two lists are consulted together at send time and were visible\n * separately, so a merchant who removed their own entry could still find\n * the address was never mailed, with nothing anywhere saying why. The\n * platform entry is invisible to them and cannot be lifted by them, which\n * is precisely why it has to be said before the click rather than\n * discovered from a recipient count that stays short.\n */\n const isBlockedPlatformWide = useCallback(\n async (email: string): Promise<boolean> => {\n try {\n const response = await authorizedFetch(\n user,\n '/api/email/suppression-status',\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, emails: email }),\n },\n )\n if (!response.ok) return false\n const payload = await response.json().catch(() => ({}))\n return ((payload?.platform ?? []) as string[]).length > 0\n } catch (error) {\n console.error(error)\n return false\n }\n },\n [user, hostId],\n )\n\n const handleRemove = async (row: SuppressionRow) => {\n const reason = describeReason(row.reason).label.toLowerCase()\n // The platform entry is invisible to a merchant and cannot be lifted by\n // one, so removing the site's row here changes nothing about whether the\n // address is mailed. Saying so BEFORE the click is the whole point: the\n // alternative is a merchant who removes the row, sends again, and sees a\n // recipient count that is still short with nothing explaining it.\n /*\n * ASKED, NOT MOUNTED.\n *\n * One keyed read for the one address the merchant is acting on, at the\n * moment they act. Fetching this for every visible row on mount would be\n * a request per page render for an answer that is only ever needed on a\n * click, and this card is one tab of a page a merchant opens to read.\n *\n * A failed check answers \"not blocked\", which is the ordinary case: the\n * dialog then reads exactly as it did before, and the removal still\n * works. Refusing to open the dialog because a supplementary lookup\n * failed would make an outage on an explanation into an outage on the\n * control it explains.\n */\n const alsoPlatform = row.email\n ? await isBlockedPlatformWide(row.email)\n : false\n const accepted = await confirm({\n title: alsoPlatform\n ? 'This address will still be skipped'\n : 'Put this address back on your list?',\n description: alsoPlatform\n ? `${row.email ?? 'This address'} bounced permanently or reported ` +\n 'spam somewhere else in Aglyn, so it is on the platform-wide list ' +\n 'as well as yours. Removing your entry will not start mail ' +\n 'reaching it — contact support to have the platform entry lifted.'\n : `${row.email ?? 'This address'} is suppressed because it ` +\n `${reason === 'bounced' ? 'bounced permanently' : reason === 'marked as spam' ? 'was marked as spam' : reason === 'added by hand' ? 'was added by hand' : 'unsubscribed'}. ` +\n 'Removing the entry means your next campaign will email it again.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on\n // the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n try {\n await deleteDoc(\n doc(firestore, 'hosts', hostId, 'suppressions', row.$id),\n )\n enqueueSnackbar('Removed from the suppression list', {\n variant: 'success',\n persist: false,\n })\n setTotalsEpoch((epoch) => epoch + 1)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n }\n }\n\n const columns: GridColDef<SuppressionRow>[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 240,\n /*\n Entries are keyed by `sha256(email)` because addresses are PII, and the\n address itself is stored in the document. An older row written before\n the address was stored has only its hash — which tells a merchant\n nothing, so it says so rather than displaying 64 hex characters.\n */\n renderCell: ({ row }) =>\n row.email || (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'(address not recorded)'}\n </Typography>\n ),\n },\n {\n field: 'reason',\n headerName: 'Reason',\n width: 170,\n valueGetter: (_value, row) => describeReason(row.reason).label,\n renderCell: ({ row }) => {\n const described = describeReason(row.reason)\n return (\n <Chip\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={described.label}\n />\n )\n },\n },\n {\n field: 'since',\n headerName: 'Since',\n width: 140,\n valueGetter: (_value, row) => onDate(row),\n },\n /*\n A SUPPRESSION HAS NO PAGE, so the row does not open one — this is the\n surface's one list whose rows are not a way in to anything. What it does\n have is the one act a merchant performs on an entry, and the trailing\n cluster is where every other list on this surface keeps that: a bare\n `Remove` in the row is a click that puts an address back into mailing\n range, sitting where the reader's eye is running along the row.\n */\n listActionsColumn(\n (row: SuppressionRow) => (\n <ListRowActions\n label={row.email || 'this entry'}\n items={[\n {\n key: 'remove',\n label: 'Remove from the list',\n icon: <MdiIcon path={mdiEmailCheckOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header=\"Suppressions\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#compliance' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setAdding(true)}\n >\n {'Add'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Addresses this site’s marketing email skips. Someone lands here ' +\n 'by clicking unsubscribe, by bouncing permanently, by marking a ' +\n 'message as spam, or because you added them — this is where the ' +\n 'gap between a campaign’s recipient count and what it actually ' +\n 'sent comes from.'}\n </Typography>\n {entries.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is suppressed. Every address in your audiences is ' +\n 'currently mailable.'}\n </Typography>\n ) : (\n <>\n <Stack direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap' }}>\n {totals === null ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Could not read the breakdown. This is not the same as ' +\n 'nobody having bounced.'}\n </Typography>\n ) : (\n Object.entries(totals)\n // A reason nobody has hit is not news, and three chips\n // reading zero make the two that matter harder to find.\n .filter(([, total]) => total > 0)\n .map(([reason, total]) => {\n const described = describeReason(reason)\n return (\n <Chip\n key={reason}\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={`${described.label}: ${total}`}\n />\n )\n })\n )}\n </Stack>\n <ListTable\n aria-label=\"Suppressed addresses\"\n rows={entries}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={entries.length}\n hasMore={hasMore}\n // The collection's real size, so the footer's count line says\n // \"1–10 of 812\" rather than \"of more than 10\" — the aggregate\n // above already knows it, and it is the same number the chips\n // are a breakdown of.\n count={totals ? Object.values(totals).reduce((a, b) => a + b, 0) : undefined}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n </Stack>\n {/*\n A DRAWER, not a form stacked above the table.\n Creating is a drawer and picking is a dialog, which is how Screens,\n Components, Layouts and Templates all take a name before they create\n one; the chrome here is the same `NavigationDrawerComponent` those go\n through, composed directly because the console's wrapper around it\n lives in an application a plugin library may not import.\n */}\n <NavigationDrawerComponent\n open={adding}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={() => setAdding(false)}\n AppBarProps={{ color: 'surface' }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={() => setAdding(false)}\n sx={{ mr: 2 }}\n >\n <MdiIcon path={ICON_VARIANT_CLOSE.path} />\n <SrOnly>{'close drawer'}</SrOnly>\n </IconButton>\n <Typography variant=\"h6\" component=\"div\">\n {'Stop emailing an address'}\n </Typography>\n </>\n }\n appBarRight={\n <Button\n variant=\"outlined\"\n color=\"inherit\"\n onClick={() => setAdding(false)}\n >\n {'Cancel'}\n </Button>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Use this when somebody asks you to stop emailing them by ' +\n 'reply, by phone, or in person. They stay on your audiences ' +\n 'and keep every record you hold about them — this only stops ' +\n 'this site’s marketing email reaching them.'}\n </Typography>\n <TextField\n label=\"Email addresses\"\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n multiline\n minRows={3}\n fullWidth\n autoFocus\n helperText={\n 'One per line, or separated by commas. Up to 50 at a time.'\n }\n />\n <TextField\n label=\"Note (optional)\"\n value={addNote}\n onChange={(event) => setAddNote(event.target.value)}\n fullWidth\n helperText={\n 'How the request reached you. Kept with the entry as the ' +\n 'record that it was honored.'\n }\n slotProps={{ htmlInput: { maxLength: 200 } }}\n />\n <Alert severity=\"info\">\n {'Order confirmations, booking reminders and password resets ' +\n 'are unaffected. Somebody who asked to stop hearing from ' +\n 'your marketing still gets their receipt.'}\n </Alert>\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <Button\n variant=\"contained\"\n disabled={busy || !addInput.trim()}\n onClick={() => void handleAdd()}\n >\n {'Add to suppression list'}\n </Button>\n </Stack>\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n </CardDisplay>\n )\n}\nSuppressionsCard.displayName = 'SuppressionsCard'\n\nexport default SuppressionsCard\n"],"names":["pluginDocsHelp","ICON_VARIANT_CLOSE","mdiEmailCheckOutline","CardDisplay","Container","MdiIcon","SrOnly","useConfirmationContext","ListPagination","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","NavigationDrawerComponent","useSnackbar","Alert","Chip","IconButton","Stack","TextField","Typography","Button","collection","deleteDoc","doc","limit","orderBy","query","useCallback","useEffect","useState","useFirestore","usePagedCollection","useUser","authorizedFetch","describeSuppressionReason","describeReason","readSuppressionTotals","onDate","row","seconds","createdAt","suppressedAt","Date","toISOString","slice","SuppressionsCard","props","hostId","firestore","data","user","enqueueSnackbar","confirm","adding","setAdding","addInput","setAddInput","addNote","setAddNote","busy","setBusy","rows","entries","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","totalsEpoch","setTotalsEpoch","totals","setTotals","active","then","read","catch","handleAdd","typed","trim","response","method","headers","body","JSON","stringify","emails","note","payload","json","ok","error","variant","results","added","Number","rejected","filter","result","refusal","length","map","input","join","persist","epoch","console","isBlockedPlatformWide","email","platform","handleRemove","reason","label","toLowerCase","alsoPlatform","accepted","title","description","confirmationText","confirmationButtonProps","color","$id","columns","field","headerName","flex","minWidth","renderCell","width","valueGetter","_value","described","size","items","key","icon","path","destructive","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","direction","sx","flexWrap","Object","total","aria-label","rowHeight","hideFooter","rowCount","count","values","reduce","a","b","undefined","onPageChange","onPageSizeChange","open","onClose","AppBarProps","appBarLeft","edge","mr","component","appBarRight","gutterY","value","onChange","event","target","multiline","minRows","fullWidth","autoFocus","helperText","slotProps","htmlInput","maxLength","severity","justifyContent","disabled","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/suppressions-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { mdiEmailCheckOutline } from '@aglyn/shared-data-mdi'\nimport {\n CardDisplay,\n Container,\n MdiIcon,\n SrOnly,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { inMemoryListField } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { usePagedRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-paged-rows-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 Typography,\n} from '@mui/material'\nimport Button from '@mui/material/Button'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n limit,\n orderBy,\n query,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport {\n useFirestore,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n describeSuppressionReason as describeReason,\n readSuppressionTotals,\n SUPPRESSION_REASONS,\n type SuppressionTotals,\n} from './suppression-totals'\n\nexport interface SuppressionsCardProps {\n hostId: string\n}\n\n/** A stored entry. `reason` is absent on anything written before AGL-2408. */\ninterface SuppressionRow {\n $id: string\n email?: string\n reason?: string\n suppressedAt?: { seconds?: number } | null\n createdAt?: { seconds?: number } | null\n}\n\n/*\n * What the suppressions grid's Filters panel offers (AGL-3317). The list is a\n * paged listener, so a filter matches over what its window has read, which\n * the first filter widens (`usePagedRowsFilter`). Reason reads `reasonKey`,\n * the stored reason with an absent one read as an unsubscribe, which is how\n * the column and the breakdown describe it.\n */\nconst SUPPRESSION_FILTER_FIELDS = [\n inMemoryListField('email', 'text'),\n inMemoryListField('reason', 'select', 'reasonKey'),\n]\nconst SUPPRESSION_FILTER_HEADERS: Readonly<Record<string, string>> = {\n email: 'Address',\n reason: 'Reason',\n}\nconst SUPPRESSION_FILTER_OPTIONS = {\n reason: Object.entries(SUPPRESSION_REASONS).map(([value, described]) => ({\n value,\n label: described.label,\n })),\n}\nconst SUPPRESSION_SEARCH_FIELDS = ['email'] as const\n\ntype FilterableSuppressionRow = SuppressionRow & { reasonKey: string }\n\nconst withReasonKey = (row: SuppressionRow): FilterableSuppressionRow => ({\n ...row,\n reasonKey: String(row.reason ?? 'unsubscribe'),\n})\n\n/**\n * `YYYY-MM-DD` from a Firestore timestamp shape, or an em dash.\n *\n * `createdAt` first, and that ordering is the column's meaning rather than a\n * preference. Both writers restamp `suppressedAt` on every touch and write\n * `createdAt` only when the document is new, precisely so that a bounce\n * arriving after an unsubscribe does not move the date the person actually\n * unsubscribed. Reading `suppressedAt` first put the restamp on screen under a\n * heading that says \"Since\", and it is also the field the list is ordered by,\n * so a re-touched row would have sorted by one date and displayed another.\n */\nfunction onDate(row: SuppressionRow): string {\n const seconds = row.createdAt?.seconds ?? row.suppressedAt?.seconds\n if (!seconds) return '—'\n return new Date(seconds * 1000).toISOString().slice(0, 10)\n}\n\n/**\n * Suppressions (AGL-2410): who is not being emailed, and why.\n *\n * ## What was missing\n *\n * `hosts/{hostId}/suppressions` was written by two paths — the unsubscribe\n * handler and, since AGL-1918, the Resend webhook on a permanent bounce or a\n * complaint — and read by exactly one: `campaign-send.ts`, to filter an\n * audience. Nothing in the console displayed it. So a merchant could not\n * answer any of:\n *\n * - *\"My campaign says 500 recipients and 480 sent — who were the other\n * 20?\"* The send returns `{recipients, sent}` and the difference was\n * unexplained.\n * - *\"Is my list going stale?\"* A bounce rate is the single most useful\n * number about a list and there was nowhere to see it.\n * - *\"This address was suppressed by mistake.\"* There was no way to remove\n * an entry — and a link prescanner unsubscribing someone (AGL-2408 §2) was\n * therefore unrecoverable from inside the product.\n *\n * ## Why a surface and not another counter\n *\n * AGL-1918 deliberately did NOT write a `stats.bounces` counter alongside its\n * fix, because a number with no screen to show it is the written-but-never-\n * read shape this issue is about, one level up. So the fix is the READER, and\n * the breakdown here is derived from the rows on screen rather than from a\n * second stored figure that could disagree with them.\n *\n * ## Removing an entry\n *\n * A plain client `deleteDoc`, and that is a decision. The list belongs to the\n * merchant, host admins already read it through the same rules, and removing\n * a row does nothing except make an address targetable again — there is no\n * counter to launder and no money attached, which is the AGL-1367 test for\n * whether a write has to move server-side.\n *\n * The confirmation is not decoration either: for a `bounce` the address very\n * likely does not exist, and mailing it again is what a provider scores the\n * sending domain on. So the dialog says which reason is being overridden\n * rather than asking a generic \"are you sure\".\n */\nexport function SuppressionsCard(props: SuppressionsCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const [adding, setAdding] = useState(false)\n\n const [addInput, setAddInput] = useState('')\n const [addNote, setAddNote] = useState('')\n const [busy, setBusy] = useState(false)\n\n /*\n * The window IS the query, ordered by the server (AGL-2501, AGL-2292).\n *\n * This was `limit(500)` with no `orderBy`, sorted by date in the browser.\n * Firestore answers an unordered limit in DOCUMENT-ID order, and an entry\n * here is keyed by `sha256(email)` — so the window was five hundred\n * addresses chosen by the hash of the address, and the client sort dressed\n * that sample up as the newest five hundred. A list past the ceiling\n * therefore hid whoever bounced this morning behind whoever happened to\n * hash low, with no gap on screen to notice and no control asking for more.\n *\n * `createdAt` is the safe field to order on, and that is checked rather\n * than assumed: both writers — the unsubscribe handler and the Resend\n * bounce/complaint webhook — stamp it when the document is created, the\n * pre-AGL-2408 handler wrote `{ email, createdAt }`, and `suppressions` is\n * not in `IMPORTABLE_FIELDS`, so no restore path can produce a row without\n * one. `suppressedAt` would NOT be safe: it is absent on every entry\n * written before AGL-1918, and `orderBy` drops documents that lack the\n * field rather than mis-sorting them.\n */\n const {\n data: entryData,\n rows: entryRows,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<SuppressionRow>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'suppressions'),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const entryWindow = useMemo(() => entryData?.map(withReasonKey), [entryData])\n const entryPage = useMemo(() => entryRows.map(withReasonKey), [entryRows])\n const entryFilter = usePagedRowsFilter<FilterableSuppressionRow>(\n {\n data: entryWindow,\n rows: entryPage,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n },\n {\n fields: SUPPRESSION_FILTER_FIELDS,\n options: SUPPRESSION_FILTER_OPTIONS,\n headers: SUPPRESSION_FILTER_HEADERS,\n search: SUPPRESSION_SEARCH_FIELDS,\n },\n )\n // The rows on screen: the page, or the page of the matches.\n const entries = entryFilter.rows\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<FilterableSuppressionRow>[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 240,\n /*\n Entries are keyed by `sha256(email)` because addresses are PII, and the\n address itself is stored in the document. An older row written before\n the address was stored has only its hash — which tells a merchant\n nothing, so it says so rather than displaying 64 hex characters.\n */\n renderCell: ({ row }) =>\n row.email || (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'(address not recorded)'}\n </Typography>\n ),\n },\n {\n field: 'reason',\n headerName: 'Reason',\n width: 170,\n valueGetter: (_value, row) => row.reasonKey,\n renderCell: ({ row }) => {\n const described = describeReason(row.reason)\n return (\n <Chip\n size=\"small\"\n color={described.color}\n variant=\"outlined\"\n label={described.label}\n />\n )\n },\n },\n {\n field: 'since',\n headerName: 'Since',\n width: 140,\n valueGetter: (_value, row) => onDate(row),\n },\n /*\n A SUPPRESSION HAS NO PAGE, so the row does not open one — this is the\n surface's one list whose rows are not a way in to anything. What it does\n have is the one act a merchant performs on an entry, and the trailing\n cluster is where every other list on this surface keeps that: a bare\n `Remove` in the row is a click that puts an address back into mailing\n range, sitting where the reader's eye is running along the row.\n */\n listActionsColumn(\n (row: SuppressionRow) => (\n <ListRowActions\n label={row.email || 'this entry'}\n items={[\n {\n key: 'remove',\n label: 'Remove from the list',\n icon: <MdiIcon path={mdiEmailCheckOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header=\"Suppressions\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#compliance' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setAdding(true)}\n >\n {'Add'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Addresses this site’s marketing email skips. Someone lands here ' +\n 'by clicking unsubscribe, by bouncing permanently, by marking a ' +\n 'message as spam, or because you added them — this is where the ' +\n 'gap between a campaign’s recipient count and what it actually ' +\n 'sent comes from.'}\n </Typography>\n {entryRows.length === 0 && !hasMore && !entryFilter.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 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 {...entryFilter.chipsProps} />\n {entryFilter.filtering && hasMore ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Filtering the ${entryFilter.read} suppressions read so far — the next page reads more.`}\n </Typography>\n ) : null}\n <ListTable\n aria-label=\"Suppressed addresses\"\n rows={entries}\n columns={entryFilter.filterColumns(columns as GridColDef[])}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel and the search are the grid's; the card answers\n // them over what its window read.\n {...entryFilter.gridProps}\n noRowsLabel=\"No suppressions match these filters\"\n />\n <ListPagination\n {...entryFilter.pagination}\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 && !entryFilter.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":["pluginDocsHelp","ICON_VARIANT_CLOSE","mdiEmailCheckOutline","CardDisplay","Container","MdiIcon","SrOnly","useConfirmationContext","ListFilterChips","ListPagination","ListRowActions","ListTable","listActionsColumn","inMemoryListField","TABLE_ROW_HEIGHT","usePagedRowsFilter","NavigationDrawerComponent","useSnackbar","Alert","Chip","IconButton","Stack","TextField","Typography","Button","collection","deleteDoc","doc","limit","orderBy","query","useCallback","useEffect","useMemo","useState","useFirestore","usePagedCollection","useUser","authorizedFetch","describeSuppressionReason","describeReason","readSuppressionTotals","SUPPRESSION_REASONS","SUPPRESSION_FILTER_FIELDS","SUPPRESSION_FILTER_HEADERS","email","reason","SUPPRESSION_FILTER_OPTIONS","Object","entries","map","value","described","label","SUPPRESSION_SEARCH_FIELDS","withReasonKey","row","reasonKey","String","onDate","seconds","createdAt","suppressedAt","Date","toISOString","slice","SuppressionsCard","props","hostId","firestore","data","user","enqueueSnackbar","confirm","adding","setAdding","addInput","setAddInput","addNote","setAddNote","busy","setBusy","entryData","rows","entryRows","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","entryWindow","entryPage","entryFilter","fields","options","headers","search","totalsEpoch","setTotalsEpoch","totals","setTotals","active","then","read","catch","handleAdd","typed","trim","response","method","body","JSON","stringify","emails","note","payload","json","ok","error","variant","results","added","Number","rejected","filter","result","refusal","length","input","join","persist","epoch","console","isBlockedPlatformWide","platform","handleRemove","toLowerCase","alsoPlatform","accepted","title","description","confirmationText","confirmationButtonProps","color","$id","columns","field","headerName","flex","minWidth","renderCell","width","valueGetter","_value","size","items","key","icon","path","destructive","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","filtering","direction","sx","flexWrap","total","chipsProps","aria-label","filterColumns","rowHeight","hideFooter","gridProps","noRowsLabel","pagination","count","values","reduce","a","b","undefined","open","onClose","AppBarProps","appBarLeft","edge","mr","component","appBarRight","gutterY","onChange","event","target","multiline","minRows","fullWidth","autoFocus","helperText","slotProps","htmlInput","maxLength","severity","justifyContent","disabled","displayName"],"mappings":"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"}
package/src/lib/server.js CHANGED
@@ -622,11 +622,14 @@ const unsubscribeHandler = async (req, res)=>{
622
622
  * A ticked box is a stream the person wants back FROM THE SENDER, and the
623
623
  * send paths read an opt-out on any site of the consent group — so an
624
624
  * opt-out a sibling holds is lifted too, or the box would be a choice the
625
- * send path goes on refusing. The unticked ones are written here alone:
626
- * this site's record is read across the group already.
625
+ * send path goes on refusing — and so is a sibling's pending confirmation,
626
+ * where the org said the group waits for it. The unticked ones are written
627
+ * here alone: this site's record is read across the group already.
627
628
  */ await resumeTopicsAcrossGroup(firestore, group, key, [
628
629
  ...keep
629
- ]);
630
+ ], {
631
+ confirmPending: true
632
+ });
630
633
  /*
631
634
  * HOW OFTEN, recorded from the same submit as WHAT.
632
635
  *
@@ -738,8 +741,9 @@ const unsubscribeHandler = async (req, res)=>{
738
741
  * group: a suppression standing on any of its sites, and a stream left on any
739
742
  * of them, holds this site's mail too, so both show here. The pace is the one
740
743
  * chosen most recently on any site's page. A pending confirmation is the link
741
- * site's own, as it is on the send path. A group of one is the three reads
742
- * this page always made.
744
+ * site's own, as it is on the send path — or any site's in the group, where
745
+ * the org said the group waits for the click, because then that is what holds
746
+ * this site's mail. A group of one is the three reads this page always made.
743
747
  */ async function readSubscriptionState(firestore, group, key) {
744
748
  const sites = await Promise.all(consentGroupOptOutHosts(group).map((id)=>{
745
749
  const hostRef = firestore.collection('hosts').doc(id);
@@ -779,7 +783,9 @@ const unsubscribeHandler = async (req, res)=>{
779
783
  * confirmation. Only one function knows all three states.
780
784
  */ const state = readTopicSubscriptionState(record);
781
785
  if (state === 'opted-out') optedOut.add(id);
782
- if (own && state === 'pending') pending.add(id);
786
+ if ((own || group.awaitsConfirmation === true) && state === 'pending') {
787
+ pending.add(id);
788
+ }
783
789
  }
784
790
  // The most recent choice on any site, the link site keeping a tie — the
785
791
  // rule `filterCadenceSendable` decides by.
@@ -900,12 +906,19 @@ const unsubscribeHandler = async (req, res)=>{
900
906
  *
901
907
  * Only a live opt-out is touched, and it is lifted the way
902
908
  * `writeTopicOptOuts` lifts one: `resubscribedAt` stamped onto the entry,
903
- * which stays as the evidence that the opt-out was honored while it stood. A
904
- * sibling's pending confirmation is left alone — it is that site's question,
905
- * not a refusal of the sender. Nothing is read or written for a site alone.
906
- */ async function resumeTopicsAcrossGroup(firestore, group, key, topicIds) {
909
+ * which stays as the evidence that the opt-out was honored while it stood.
910
+ *
911
+ * A sibling's pending confirmation is that site's question, and is left alone
912
+ * — unless the org said the group waits for the click AND the caller's tick
913
+ * is itself a confirmation (`confirmPending`, the preference page's). The
914
+ * sibling's question is then what holds this site's mail, and the page showed
915
+ * it as waiting on exactly this tick, so it is answered the way
916
+ * `writeTopicOptOuts` answers the link site's own: `confirmedAt` beside the
917
+ * `pendingAt` it answers. Nothing is read or written for a site alone.
918
+ */ async function resumeTopicsAcrossGroup(firestore, group, key, topicIds, options) {
907
919
  const siblings = consentGroupOptOutHosts(group).slice(1);
908
920
  if (!siblings.length || !topicIds.length) return;
921
+ const confirmsSiblings = (options == null ? void 0 : options.confirmPending) === true && group.awaitsConfirmation === true;
909
922
  await Promise.all(siblings.map(async (id)=>{
910
923
  const ref = firestore.collection('hosts').doc(id).collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key);
911
924
  await firestore.runTransaction(async (transaction)=>{
@@ -916,7 +929,14 @@ const unsubscribeHandler = async (req, res)=>{
916
929
  const lifted = {};
917
930
  for (const topicId of topicIds){
918
931
  const previous = stored[topicId];
919
- if (readTopicSubscriptionState(previous) !== 'opted-out') continue;
932
+ const state = readTopicSubscriptionState(previous);
933
+ if (state === 'pending' && confirmsSiblings) {
934
+ lifted[topicId] = _extends({}, previous, {
935
+ confirmedAt: Date.now()
936
+ });
937
+ continue;
938
+ }
939
+ if (state !== 'opted-out') continue;
920
940
  lifted[topicId] = _extends({}, previous, {
921
941
  resubscribedAt: FieldValue.serverTimestamp()
922
942
  });