@aglyn/plugins-email 1.0.0-beta.179 → 1.0.0-beta.180
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/lib/components/email-org-mount.d.ts +116 -0
- package/src/lib/components/email-org-mount.js +211 -0
- package/src/lib/components/email-org-mount.js.map +1 -0
- package/src/lib/components/email-topic-detail.d.ts +2 -1
- package/src/lib/components/email-topic-detail.js +7 -1
- package/src/lib/components/email-topic-detail.js.map +1 -1
- package/src/lib/components/email-topics-card.d.ts +2 -1
- package/src/lib/components/email-topics-card.js +7 -1
- package/src/lib/components/email-topics-card.js.map +1 -1
- package/src/lib/components/email-zones.d.ts +28 -1
- package/src/lib/components/email-zones.js.map +1 -1
- package/src/lib/components/emails-console-page.d.ts +4 -0
- package/src/lib/components/emails-console-page.js +107 -5
- package/src/lib/components/emails-console-page.js.map +1 -1
- package/src/lib/components/list-detail-card.d.ts +19 -3
- package/src/lib/components/list-detail-card.js +52 -13
- package/src/lib/components/list-detail-card.js.map +1 -1
- package/src/lib/components/list-edit-card.d.ts +2 -1
- package/src/lib/components/list-edit-card.js +56 -17
- package/src/lib/components/list-edit-card.js.map +1 -1
- package/src/lib/components/lists-card.d.ts +2 -1
- package/src/lib/components/lists-card.js +5 -5
- package/src/lib/components/lists-card.js.map +1 -1
- package/src/lib/components/org-email-templates-card.d.ts +34 -0
- package/src/lib/components/org-email-templates-card.js +470 -0
- package/src/lib/components/org-email-templates-card.js.map +1 -0
- package/src/lib/components/org-sending-card.d.ts +30 -0
- package/src/lib/components/org-sending-card.js +680 -0
- package/src/lib/components/org-sending-card.js.map +1 -0
- package/src/lib/components/org-sending-domain-detail.d.ts +28 -0
- package/src/lib/components/org-sending-domain-detail.js +497 -0
- package/src/lib/components/org-sending-domain-detail.js.map +1 -0
- package/src/lib/components/org-site-suppressions.d.ts +20 -0
- package/src/lib/components/org-site-suppressions.js +99 -0
- package/src/lib/components/org-site-suppressions.js.map +1 -0
- package/src/lib/components/org-suppressions-card.d.ts +22 -0
- package/src/lib/components/org-suppressions-card.js +196 -0
- package/src/lib/components/org-suppressions-card.js.map +1 -0
- package/src/lib/components/sending-domain-detail.d.ts +12 -0
- package/src/lib/components/sending-domain-detail.js +4 -1
- package/src/lib/components/sending-domain-detail.js.map +1 -1
- package/src/lib/components/suppression-totals.d.ts +63 -0
- package/src/lib/components/suppression-totals.js +105 -0
- package/src/lib/components/suppression-totals.js.map +1 -0
- package/src/lib/components/suppressions-card.js +7 -80
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/components/use-org-email-topics.d.ts +9 -1
- package/src/lib/components/use-org-email-topics.js +4 -3
- package/src/lib/components/use-org-email-topics.js.map +1 -1
- package/src/lib/components/use-org-sending-views.d.ts +39 -0
- package/src/lib/components/use-org-sending-views.js +124 -0
- package/src/lib/components/use-org-sending-views.js.map +1 -0
- package/src/lib/model/sending-domain-status.d.ts +36 -0
- package/src/lib/model/sending-domain-status.js +46 -0
- package/src/lib/model/sending-domain-status.js.map +1 -1
- package/src/lib/plugin.js +29 -1
- package/src/lib/plugin.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/sending-domain-detail.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiDeleteOutline, mdiEmailCheckOutline } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n Typography,\n} from '@mui/material'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport {\n describeSendingDomain,\n describeSendingDomainRemoval,\n INCONCLUSIVE_CHECK,\n} from '../model/sending-domain-status'\nimport {\n useSendingApi,\n type SendingDnsRecordView,\n type SendingDomainView,\n type SendingIdentityView,\n} from '@aglyn/tenant-feature-instance/hooks/use-sending-identity-api'\n\nexport interface SendingDomainDetailProps {\n hostId: string\n domain: string\n basePath: string\n}\n\n/** The record block, in the shape a registrar's own form asks for. */\nfunction RecordRow(props: { record: SendingDnsRecordView; missing: boolean }) {\n const { record, missing } = props\n const target = record.priority\n ? `${record.priority} ${record.value}`\n : record.value\n return (\n <Stack spacing={0.5}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip size=\"small\" label={record.type} variant=\"outlined\" />\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {record.purpose === 'spf'\n ? 'Authorization'\n : record.purpose === 'dkim'\n ? 'Signing key'\n : record.purpose === 'return-path'\n ? 'Bounce routing'\n : 'DMARC policy'}\n </Typography>\n {record.required ? null : (\n <Chip size=\"small\" label=\"Optional\" variant=\"outlined\" />\n )}\n {missing ? <Chip size=\"small\" label=\"Not seen yet\" color=\"warning\" /> : null}\n </Stack>\n <Typography\n variant=\"body2\"\n component=\"code\"\n sx={{\n p: 1,\n bgcolor: 'action.hover',\n borderRadius: 1,\n fontFamily: 'monospace',\n whiteSpace: 'pre-wrap',\n overflowWrap: 'anywhere',\n }}\n >\n {`${record.type} ${record.name} → ${target || '(not issued yet)'}`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {record.note}\n </Typography>\n </Stack>\n )\n}\nRecordRow.displayName = 'RecordRow'\n\n/**\n * ONE SENDING DOMAIN: what to publish, whether we can see it, and whether\n * this site sends as it.\n *\n * ## `inconclusive` is not a failure, and this page is where that is felt\n *\n * Pressing Check DNS has three outcomes, not two. The third is that nobody\n * answered — a resolver outage, a timeout — and the route says so with a\n * `503` rather than with `verified: false`. It is held HERE, in the surface's\n * own state, next to a status the record still holds, because the record was\n * not changed and saying otherwise would send a customer whose DNS is perfect\n * to go and edit a zone that has nothing wrong with it.\n *\n * That is why `lastCheck` is a separate piece of state from `record.status`\n * and never assigned into it.\n */\nexport function SendingDomainDetail(props: SendingDomainDetailProps) {\n const { hostId, domain, basePath } = props\n const call = useSendingApi()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n /*\n * The snackbar held in a REF, for the reason `use-campaign-send-api` holds\n * the user in one: `load` is a dependency of the effect that runs it, so\n * anything whose identity changes per render re-runs the fetch — and a\n * hook returning a fresh object each render turns that into a loop that\n * never settles. Reading it through a ref makes the effect depend on the\n * things that actually identify the request.\n */\n const notifyRef = useRef(enqueueSnackbar)\n notifyRef.current = enqueueSnackbar\n const { confirm } = useConfirmationContext()\n\n const [identity, setIdentity] = useState<SendingIdentityView | null>(null)\n const [record, setRecord] = useState<SendingDomainView | null>(null)\n const [loading, setLoading] = useState(true)\n const [busy, setBusy] = useState(false)\n /**\n * The outcome of the last check the reader ran, and ONLY when it was\n * inconclusive. Never merged into the record: an unanswered lookup is\n * evidence of nothing, and the record's own status is still the last thing\n * we actually established.\n */\n const [unreachable, setUnreachable] = useState(false)\n\n const load = useCallback(async () => {\n setLoading(true)\n const view = await call({\n path: 'sending-identity',\n method: 'GET',\n query: { hostId },\n })\n if (!view.response.ok) {\n setLoading(false)\n return void notifyRef.current(\n view.payload?.error ?? 'Could not read the sending identity',\n { variant: 'warning' },\n )\n }\n setIdentity(view.payload as SendingIdentityView)\n const orgId = String(view.payload?.orgId ?? '')\n const domains = orgId\n ? await call({\n path: 'sending-domains',\n method: 'GET',\n query: { orgId },\n })\n : null\n setLoading(false)\n const found = (domains?.payload?.domains ?? []).find(\n (one: SendingDomainView) => one.domain === domain,\n )\n setRecord(found ?? null)\n }, [call, hostId, domain])\n\n useEffect(() => {\n void load()\n }, [load])\n\n const handleVerify = useCallback(async () => {\n if (busy || !identity?.orgId) return\n setBusy(true)\n setUnreachable(false)\n const { response, payload } = await call({\n path: 'sending-domains',\n method: 'POST',\n body: { orgId: identity.orgId, domain, action: 'verify' },\n })\n setBusy(false)\n /*\n * THE THIRD OUTCOME.\n *\n * A `503` here means the lookup got no answer. Nothing about the domain\n * changed, so nothing on screen changes either except a notice saying the\n * question could not be asked. Treating it as a failed check — which is\n * what a plain `!response.ok` branch would do — is how a customer ends up\n * rewriting records that were already correct.\n */\n if (response.status === 503) {\n setUnreachable(true)\n return\n }\n if (!response.ok) {\n return void notifyRef.current(payload?.error ?? 'The check could not run', {\n variant: 'warning',\n })\n }\n await load()\n notifyRef.current(\n payload?.verified\n ? 'Verified — this domain can send'\n : 'Checked. Some records are still missing.',\n { variant: payload?.verified ? 'success' : 'info' },\n )\n }, [busy, call, identity?.orgId, domain, load])\n\n const handleRequestRecords = useCallback(async () => {\n if (busy || !identity?.orgId) return\n setBusy(true)\n const { response, payload } = await call({\n path: 'sending-domains',\n method: 'POST',\n body: { orgId: identity.orgId, domain, action: 'request' },\n })\n setBusy(false)\n if (!response.ok) {\n return void notifyRef.current(payload?.error ?? 'Could not request records', {\n variant: 'warning',\n })\n }\n await load()\n }, [busy, call, identity?.orgId, domain, load])\n\n const handleUse = useCallback(\n async (next: string) => {\n if (busy) return\n setBusy(true)\n const { response, payload } = await call({\n path: 'sending-identity',\n method: 'POST',\n body: { hostId, domain: next },\n })\n setBusy(false)\n if (!response.ok) {\n return void notifyRef.current(payload?.error ?? 'Could not change the identity', {\n variant: 'warning',\n })\n }\n await load()\n /*\n * The address the ROUTE settled on, not the one this click asked for.\n *\n * Clearing the selection does not name a domain — it hands the site back\n * to the one provisioned for it — so only the response knows what the\n * `From:` line now says. A message composed from `next` would announce\n * an address chosen here rather than the one recipients will see.\n */\n notifyRef.current(\n payload?.from\n ? `This site now sends as ${payload.from}`\n : 'This site’s sending address was changed',\n { variant: 'success' },\n )\n },\n [busy, call, hostId, load],\n )\n\n const handleRelease = useCallback(async () => {\n if (!identity?.orgId) return\n /*\n * The consequence stated rather than implied, and it is not one\n * consequence — see `describeSendingDomainRemoval`, which holds all three.\n *\n * Taken from the model rather than written here because the LIST asks to\n * remove the same record from its row menu. Two confirmations describing\n * one action differently is how a merchant learns to dismiss the harsher\n * of them.\n */\n const ok = await confirm({\n ...describeSendingDomainRemoval({\n domain,\n selected: identity.selected,\n platformDomain: identity.platformDomain,\n }),\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so the\n // resolved value alone can never gate this.\n .then(() => true)\n .catch(() => false)\n if (!ok) return\n const { response, payload } = await call({\n path: 'sending-domains',\n method: 'DELETE',\n query: { orgId: identity.orgId, domain },\n })\n if (!response.ok) {\n return void notifyRef.current(payload?.error ?? 'Could not remove the domain', {\n variant: 'warning',\n })\n }\n router.push(`${basePath}/sending`)\n }, [confirm, call, identity, domain, router, basePath])\n\n const state = describeSendingDomain({\n status: record?.status ?? 'requested',\n pendingProvider: record?.pendingProvider ?? record?.status === 'requested',\n issueError: record?.lastIssueError ?? null,\n missing: record?.lastMissing ?? null,\n })\n const missing = new Set(record?.lastMissing ?? [])\n const canManage = identity?.canManage === true\n const isSelected = identity?.selected === domain\n\n const actions: RowActionsMenuItem[] = canManage\n ? [\n {\n key: 'release',\n label: 'Remove domain',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRelease(),\n },\n ]\n : []\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Sending domain'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: '#sending-domains',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: actions.length ? <RowActionsMenu items={actions} /> : null,\n }}\n >\n {loading ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : !record ? (\n <Alert severity=\"warning\">\n {`${domain} is not claimed by this workspace. It may have been ` +\n `removed — go back and add it again if you still want it.`}\n </Alert>\n ) : (\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip\n label={state.label}\n color={state.color}\n variant={state.sending ? 'filled' : 'outlined'}\n />\n {isSelected ? (\n <Chip label=\"This site sends as this\" color=\"primary\" size=\"small\" />\n ) : null}\n </Stack>\n\n <Alert severity={state.severity}>{state.text}</Alert>\n\n {/*\n The unanswered check, BESIDE the state above and never instead of\n it. Both are true at once: the record still says what it said, and\n the last thing we tried could not be completed.\n */}\n {unreachable ? (\n <Alert severity={INCONCLUSIVE_CHECK.severity} icon={false}>\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {INCONCLUSIVE_CHECK.label}\n </Typography>\n <Typography variant=\"body2\">{INCONCLUSIVE_CHECK.text}</Typography>\n </Alert>\n ) : null}\n\n {/*\n The customer's DMARC policy, read and never written. It decides\n whether unauthenticated mail from this domain is filed as spam or\n refused outright, which is something a person publishing these\n records should know before they finish rather than after.\n */}\n {record.dmarc ? (\n <Alert\n severity={\n record.dmarc.policy === 'reject' && record.status !== 'verified'\n ? 'error'\n : 'info'\n }\n >\n {record.dmarc.consequence}\n </Alert>\n ) : null}\n\n {record.records?.length &&\n record.records.some((entry) => entry.value) ? (\n <>\n <Divider />\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {'Publish these records'}\n </Typography>\n <Stack spacing={2}>\n {record.records\n .filter((entry) => entry.value)\n .map((entry) => (\n <RecordRow\n key={`${entry.type}:${entry.name}`}\n record={entry}\n missing={missing.has(`${entry.type}:${entry.name}`)}\n />\n ))}\n {record.dmarcSuggestion &&\n record.dmarc?.policy === 'absent' ? (\n <RecordRow record={record.dmarcSuggestion} missing={false} />\n ) : null}\n </Stack>\n </>\n ) : null}\n\n <Divider />\n <Stack direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>\n {record.status === 'requested' && canManage ? (\n <Button\n variant=\"contained\"\n disabled={busy}\n onClick={() => void handleRequestRecords()}\n >\n {busy ? 'Working…' : 'Request records'}\n </Button>\n ) : null}\n {record.status !== 'requested' ? (\n <Button\n variant=\"contained\"\n disabled={busy}\n onClick={() => void handleVerify()}\n >\n {busy ? 'Checking…' : 'Check DNS'}\n </Button>\n ) : null}\n {canManage && record.status === 'verified' && !isSelected ? (\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiEmailCheckOutline.path} size={0.8} />}\n disabled={busy}\n onClick={() => void handleUse(domain)}\n >\n {'Send this site’s email as this domain'}\n </Button>\n ) : null}\n {canManage && isSelected ? (\n /*\n * Clearing the selection returns the site to the domain\n * PROVISIONED for it — `{label}.mail.aglyn.app` — not to the\n * shared pool, and the label says so. A site whose plan carries\n * no such domain is refused here with the reason, which is why\n * the control is worded as leaving this domain rather than\n * promising a destination it may not reach.\n */\n <Button\n variant=\"outlined\"\n disabled={busy}\n onClick={() => void handleUse('platform')}\n >\n {'Stop sending this site’s email as this domain'}\n </Button>\n ) : null}\n </Stack>\n </Stack>\n )}\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the domain; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={domain} />\n {card}\n </>\n )\n}\nSendingDomainDetail.displayName = 'SendingDomainDetail'\n\nexport default SendingDomainDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","mdiDeleteOutline","mdiEmailCheckOutline","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","useSnackbar","Alert","Button","Chip","Divider","Stack","Typography","useRouter","useCallback","useEffect","useRef","useState","describeSendingDomain","describeSendingDomainRemoval","INCONCLUSIVE_CHECK","useSendingApi","RecordRow","props","record","missing","target","priority","value","spacing","direction","sx","alignItems","size","label","type","variant","fontWeight","purpose","required","color","component","p","bgcolor","borderRadius","fontFamily","whiteSpace","overflowWrap","name","note","displayName","SendingDomainDetail","hostId","domain","basePath","call","router","enqueueSnackbar","notifyRef","current","confirm","identity","setIdentity","setRecord","loading","setLoading","busy","setBusy","unreachable","setUnreachable","load","view","domains","path","method","query","response","ok","payload","error","orgId","String","found","find","one","handleVerify","body","action","status","verified","handleRequestRecords","handleUse","next","from","handleRelease","selected","platformDomain","confirmationButtonProps","then","catch","push","state","pendingProvider","issueError","lastIssueError","lastMissing","Set","canManage","isSelected","actions","key","icon","destructive","onClick","card","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","length","items","severity","sending","text","dmarc","policy","consequence","records","some","entry","filter","map","has","dmarcSuggestion","flexWrap","gap","disabled","startIcon","title"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/sending-domain-detail.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiDeleteOutline, mdiEmailCheckOutline } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n Typography,\n} from '@mui/material'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport {\n describeSendingDomain,\n describeSendingDomainRemoval,\n INCONCLUSIVE_CHECK,\n} from '../model/sending-domain-status'\nimport {\n useSendingApi,\n type SendingDnsRecordView,\n type SendingDomainView,\n type SendingIdentityView,\n} from '@aglyn/tenant-feature-instance/hooks/use-sending-identity-api'\n\nexport interface SendingDomainDetailProps {\n hostId: string\n domain: string\n basePath: string\n}\n\n/**\n * The record block, in the shape a registrar's own form asks for. Shared with\n * the organization's page for a domain, which prints the same records.\n */\nexport function RecordRow(props: {\n record: SendingDnsRecordView\n missing: boolean\n}) {\n const { record, missing } = props\n const target = record.priority\n ? `${record.priority} ${record.value}`\n : record.value\n return (\n <Stack spacing={0.5}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip size=\"small\" label={record.type} variant=\"outlined\" />\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {record.purpose === 'spf'\n ? 'Authorization'\n : record.purpose === 'dkim'\n ? 'Signing key'\n : record.purpose === 'return-path'\n ? 'Bounce routing'\n : 'DMARC policy'}\n </Typography>\n {record.required ? null : (\n <Chip size=\"small\" label=\"Optional\" variant=\"outlined\" />\n )}\n {missing ? <Chip size=\"small\" label=\"Not seen yet\" color=\"warning\" /> : null}\n </Stack>\n <Typography\n variant=\"body2\"\n component=\"code\"\n sx={{\n p: 1,\n bgcolor: 'action.hover',\n borderRadius: 1,\n fontFamily: 'monospace',\n whiteSpace: 'pre-wrap',\n overflowWrap: 'anywhere',\n }}\n >\n {`${record.type} ${record.name} → ${target || '(not issued yet)'}`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {record.note}\n </Typography>\n </Stack>\n )\n}\nRecordRow.displayName = 'RecordRow'\n\n/**\n * ONE SENDING DOMAIN: what to publish, whether we can see it, and whether\n * this site sends as it.\n *\n * ## `inconclusive` is not a failure, and this page is where that is felt\n *\n * Pressing Check DNS has three outcomes, not two. The third is that nobody\n * answered — a resolver outage, a timeout — and the route says so with a\n * `503` rather than with `verified: false`. It is held HERE, in the surface's\n * own state, next to a status the record still holds, because the record was\n * not changed and saying otherwise would send a customer whose DNS is perfect\n * to go and edit a zone that has nothing wrong with it.\n *\n * That is why `lastCheck` is a separate piece of state from `record.status`\n * and never assigned into it.\n */\nexport function SendingDomainDetail(props: SendingDomainDetailProps) {\n const { hostId, domain, basePath } = props\n const call = useSendingApi()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n /*\n * The snackbar held in a REF, for the reason `use-campaign-send-api` holds\n * the user in one: `load` is a dependency of the effect that runs it, so\n * anything whose identity changes per render re-runs the fetch — and a\n * hook returning a fresh object each render turns that into a loop that\n * never settles. Reading it through a ref makes the effect depend on the\n * things that actually identify the request.\n */\n const notifyRef = useRef(enqueueSnackbar)\n notifyRef.current = enqueueSnackbar\n const { confirm } = useConfirmationContext()\n\n const [identity, setIdentity] = useState<SendingIdentityView | null>(null)\n const [record, setRecord] = useState<SendingDomainView | null>(null)\n const [loading, setLoading] = useState(true)\n const [busy, setBusy] = useState(false)\n /**\n * The outcome of the last check the reader ran, and ONLY when it was\n * inconclusive. Never merged into the record: an unanswered lookup is\n * evidence of nothing, and the record's own status is still the last thing\n * we actually established.\n */\n const [unreachable, setUnreachable] = useState(false)\n\n const load = useCallback(async () => {\n setLoading(true)\n const view = await call({\n path: 'sending-identity',\n method: 'GET',\n query: { hostId },\n })\n if (!view.response.ok) {\n setLoading(false)\n return void notifyRef.current(\n view.payload?.error ?? 'Could not read the sending identity',\n { variant: 'warning' },\n )\n }\n setIdentity(view.payload as SendingIdentityView)\n const orgId = String(view.payload?.orgId ?? '')\n const domains = orgId\n ? await call({\n path: 'sending-domains',\n method: 'GET',\n query: { orgId },\n })\n : null\n setLoading(false)\n const found = (domains?.payload?.domains ?? []).find(\n (one: SendingDomainView) => one.domain === domain,\n )\n setRecord(found ?? null)\n }, [call, hostId, domain])\n\n useEffect(() => {\n void load()\n }, [load])\n\n const handleVerify = useCallback(async () => {\n if (busy || !identity?.orgId) return\n setBusy(true)\n setUnreachable(false)\n const { response, payload } = await call({\n path: 'sending-domains',\n method: 'POST',\n body: { orgId: identity.orgId, domain, action: 'verify' },\n })\n setBusy(false)\n /*\n * THE THIRD OUTCOME.\n *\n * A `503` here means the lookup got no answer. Nothing about the domain\n * changed, so nothing on screen changes either except a notice saying the\n * question could not be asked. Treating it as a failed check — which is\n * what a plain `!response.ok` branch would do — is how a customer ends up\n * rewriting records that were already correct.\n */\n if (response.status === 503) {\n setUnreachable(true)\n return\n }\n if (!response.ok) {\n return void notifyRef.current(payload?.error ?? 'The check could not run', {\n variant: 'warning',\n })\n }\n await load()\n notifyRef.current(\n payload?.verified\n ? 'Verified — this domain can send'\n : 'Checked. Some records are still missing.',\n { variant: payload?.verified ? 'success' : 'info' },\n )\n }, [busy, call, identity?.orgId, domain, load])\n\n const handleRequestRecords = useCallback(async () => {\n if (busy || !identity?.orgId) return\n setBusy(true)\n const { response, payload } = await call({\n path: 'sending-domains',\n method: 'POST',\n body: { orgId: identity.orgId, domain, action: 'request' },\n })\n setBusy(false)\n if (!response.ok) {\n return void notifyRef.current(payload?.error ?? 'Could not request records', {\n variant: 'warning',\n })\n }\n await load()\n }, [busy, call, identity?.orgId, domain, load])\n\n const handleUse = useCallback(\n async (next: string) => {\n if (busy) return\n setBusy(true)\n const { response, payload } = await call({\n path: 'sending-identity',\n method: 'POST',\n body: { hostId, domain: next },\n })\n setBusy(false)\n if (!response.ok) {\n return void notifyRef.current(payload?.error ?? 'Could not change the identity', {\n variant: 'warning',\n })\n }\n await load()\n /*\n * The address the ROUTE settled on, not the one this click asked for.\n *\n * Clearing the selection does not name a domain — it hands the site back\n * to the one provisioned for it — so only the response knows what the\n * `From:` line now says. A message composed from `next` would announce\n * an address chosen here rather than the one recipients will see.\n */\n notifyRef.current(\n payload?.from\n ? `This site now sends as ${payload.from}`\n : 'This site’s sending address was changed',\n { variant: 'success' },\n )\n },\n [busy, call, hostId, load],\n )\n\n const handleRelease = useCallback(async () => {\n if (!identity?.orgId) return\n /*\n * The consequence stated rather than implied, and it is not one\n * consequence — see `describeSendingDomainRemoval`, which holds all three.\n *\n * Taken from the model rather than written here because the LIST asks to\n * remove the same record from its row menu. Two confirmations describing\n * one action differently is how a merchant learns to dismiss the harsher\n * of them.\n */\n const ok = await confirm({\n ...describeSendingDomainRemoval({\n domain,\n selected: identity.selected,\n platformDomain: identity.platformDomain,\n }),\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so the\n // resolved value alone can never gate this.\n .then(() => true)\n .catch(() => false)\n if (!ok) return\n const { response, payload } = await call({\n path: 'sending-domains',\n method: 'DELETE',\n query: { orgId: identity.orgId, domain },\n })\n if (!response.ok) {\n return void notifyRef.current(payload?.error ?? 'Could not remove the domain', {\n variant: 'warning',\n })\n }\n router.push(`${basePath}/sending`)\n }, [confirm, call, identity, domain, router, basePath])\n\n const state = describeSendingDomain({\n status: record?.status ?? 'requested',\n pendingProvider: record?.pendingProvider ?? record?.status === 'requested',\n issueError: record?.lastIssueError ?? null,\n missing: record?.lastMissing ?? null,\n })\n const missing = new Set(record?.lastMissing ?? [])\n const canManage = identity?.canManage === true\n const isSelected = identity?.selected === domain\n\n const actions: RowActionsMenuItem[] = canManage\n ? [\n {\n key: 'release',\n label: 'Remove domain',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRelease(),\n },\n ]\n : []\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Sending domain'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: '#sending-domains',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: actions.length ? <RowActionsMenu items={actions} /> : null,\n }}\n >\n {loading ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : !record ? (\n <Alert severity=\"warning\">\n {`${domain} is not claimed by this workspace. It may have been ` +\n `removed — go back and add it again if you still want it.`}\n </Alert>\n ) : (\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip\n label={state.label}\n color={state.color}\n variant={state.sending ? 'filled' : 'outlined'}\n />\n {isSelected ? (\n <Chip label=\"This site sends as this\" color=\"primary\" size=\"small\" />\n ) : null}\n </Stack>\n\n <Alert severity={state.severity}>{state.text}</Alert>\n\n {/*\n The unanswered check, BESIDE the state above and never instead of\n it. Both are true at once: the record still says what it said, and\n the last thing we tried could not be completed.\n */}\n {unreachable ? (\n <Alert severity={INCONCLUSIVE_CHECK.severity} icon={false}>\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {INCONCLUSIVE_CHECK.label}\n </Typography>\n <Typography variant=\"body2\">{INCONCLUSIVE_CHECK.text}</Typography>\n </Alert>\n ) : null}\n\n {/*\n The customer's DMARC policy, read and never written. It decides\n whether unauthenticated mail from this domain is filed as spam or\n refused outright, which is something a person publishing these\n records should know before they finish rather than after.\n */}\n {record.dmarc ? (\n <Alert\n severity={\n record.dmarc.policy === 'reject' && record.status !== 'verified'\n ? 'error'\n : 'info'\n }\n >\n {record.dmarc.consequence}\n </Alert>\n ) : null}\n\n {record.records?.length &&\n record.records.some((entry) => entry.value) ? (\n <>\n <Divider />\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {'Publish these records'}\n </Typography>\n <Stack spacing={2}>\n {record.records\n .filter((entry) => entry.value)\n .map((entry) => (\n <RecordRow\n key={`${entry.type}:${entry.name}`}\n record={entry}\n missing={missing.has(`${entry.type}:${entry.name}`)}\n />\n ))}\n {record.dmarcSuggestion &&\n record.dmarc?.policy === 'absent' ? (\n <RecordRow record={record.dmarcSuggestion} missing={false} />\n ) : null}\n </Stack>\n </>\n ) : null}\n\n <Divider />\n <Stack direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>\n {record.status === 'requested' && canManage ? (\n <Button\n variant=\"contained\"\n disabled={busy}\n onClick={() => void handleRequestRecords()}\n >\n {busy ? 'Working…' : 'Request records'}\n </Button>\n ) : null}\n {record.status !== 'requested' ? (\n <Button\n variant=\"contained\"\n disabled={busy}\n onClick={() => void handleVerify()}\n >\n {busy ? 'Checking…' : 'Check DNS'}\n </Button>\n ) : null}\n {canManage && record.status === 'verified' && !isSelected ? (\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiEmailCheckOutline.path} size={0.8} />}\n disabled={busy}\n onClick={() => void handleUse(domain)}\n >\n {'Send this site’s email as this domain'}\n </Button>\n ) : null}\n {canManage && isSelected ? (\n /*\n * Clearing the selection returns the site to the domain\n * PROVISIONED for it — `{label}.mail.aglyn.app` — not to the\n * shared pool, and the label says so. A site whose plan carries\n * no such domain is refused here with the reason, which is why\n * the control is worded as leaving this domain rather than\n * promising a destination it may not reach.\n */\n <Button\n variant=\"outlined\"\n disabled={busy}\n onClick={() => void handleUse('platform')}\n >\n {'Stop sending this site’s email as this domain'}\n </Button>\n ) : null}\n </Stack>\n </Stack>\n )}\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the domain; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={domain} />\n {card}\n </>\n )\n}\nSendingDomainDetail.displayName = 'SendingDomainDetail'\n\nexport default SendingDomainDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","mdiDeleteOutline","mdiEmailCheckOutline","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","useSnackbar","Alert","Button","Chip","Divider","Stack","Typography","useRouter","useCallback","useEffect","useRef","useState","describeSendingDomain","describeSendingDomainRemoval","INCONCLUSIVE_CHECK","useSendingApi","RecordRow","props","record","missing","target","priority","value","spacing","direction","sx","alignItems","size","label","type","variant","fontWeight","purpose","required","color","component","p","bgcolor","borderRadius","fontFamily","whiteSpace","overflowWrap","name","note","displayName","SendingDomainDetail","hostId","domain","basePath","call","router","enqueueSnackbar","notifyRef","current","confirm","identity","setIdentity","setRecord","loading","setLoading","busy","setBusy","unreachable","setUnreachable","load","view","domains","path","method","query","response","ok","payload","error","orgId","String","found","find","one","handleVerify","body","action","status","verified","handleRequestRecords","handleUse","next","from","handleRelease","selected","platformDomain","confirmationButtonProps","then","catch","push","state","pendingProvider","issueError","lastIssueError","lastMissing","Set","canManage","isSelected","actions","key","icon","destructive","onClick","card","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","length","items","severity","sending","text","dmarc","policy","consequence","records","some","entry","filter","map","has","dmarcSuggestion","flexWrap","gap","disabled","startIcon","title"],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type Firestore } from 'firebase/firestore';
|
|
18
|
+
/**
|
|
19
|
+
* What a reason means to a merchant, and how much it should worry them.
|
|
20
|
+
*
|
|
21
|
+
* An ABSENT reason reads as "Unsubscribed", and that is a compatibility rule
|
|
22
|
+
* rather than a guess: until AGL-2408 the unsubscribe handler wrote
|
|
23
|
+
* `{ email, createdAt }` and nothing else, while the Resend webhook has
|
|
24
|
+
* stamped `'bounce'`/`'complaint'` since AGL-1918 — so an entry with no reason
|
|
25
|
+
* can only have come from somebody clicking the link. New unsubscribes write
|
|
26
|
+
* the reason explicitly, so this fallback covers history and nothing else.
|
|
27
|
+
*/
|
|
28
|
+
export declare const SUPPRESSION_REASONS: Record<string, {
|
|
29
|
+
label: string;
|
|
30
|
+
color: 'default' | 'warning' | 'error';
|
|
31
|
+
}>;
|
|
32
|
+
/** One stored reason, as {@link SUPPRESSION_REASONS} describes it. */
|
|
33
|
+
export declare const describeSuppressionReason: (reason: unknown) => {
|
|
34
|
+
label: string;
|
|
35
|
+
color: "default" | "warning" | "error";
|
|
36
|
+
};
|
|
37
|
+
/** One site's suppression list, broken down by why each address is on it. */
|
|
38
|
+
export interface SuppressionTotals {
|
|
39
|
+
unsubscribe: number;
|
|
40
|
+
bounce: number;
|
|
41
|
+
complaint: number;
|
|
42
|
+
manual: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* THE BREAKDOWN OF ONE SITE'S SUPPRESSION LIST, as server aggregates.
|
|
46
|
+
*
|
|
47
|
+
* One reader for both the site's own suppression card and the organization's
|
|
48
|
+
* summary of every site's, so the two can never count differently.
|
|
49
|
+
*
|
|
50
|
+
* Four reads, not one per reason. `where('reason','==','unsubscribe')` cannot
|
|
51
|
+
* be asked, because an entry written before AGL-2408 carries no `reason` at
|
|
52
|
+
* all and an equality filter excludes it — the field-presence trap the list's
|
|
53
|
+
* ordering also avoids. Unsubscribes are therefore the REMAINDER: the total
|
|
54
|
+
* minus every reason that is always written explicitly, which is exactly the
|
|
55
|
+
* compatibility rule the card's `describeReason` applies row by row. A reason
|
|
56
|
+
* counted explicitly and left out of the subtraction would be reported as
|
|
57
|
+
* somebody who clicked unsubscribe.
|
|
58
|
+
*
|
|
59
|
+
* Rejects when any read fails; a caller holds its figure at "unknown" rather
|
|
60
|
+
* than zeroing it, because "Bounced: 0" is a confident wrong number in the
|
|
61
|
+
* reassuring direction.
|
|
62
|
+
*/
|
|
63
|
+
export declare function readSuppressionTotals(firestore: Firestore, hostId: string): Promise<SuppressionTotals>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { collection, count, getAggregateFromServer, query, where } from "firebase/firestore";
|
|
17
|
+
/**
|
|
18
|
+
* What a reason means to a merchant, and how much it should worry them.
|
|
19
|
+
*
|
|
20
|
+
* An ABSENT reason reads as "Unsubscribed", and that is a compatibility rule
|
|
21
|
+
* rather than a guess: until AGL-2408 the unsubscribe handler wrote
|
|
22
|
+
* `{ email, createdAt }` and nothing else, while the Resend webhook has
|
|
23
|
+
* stamped `'bounce'`/`'complaint'` since AGL-1918 — so an entry with no reason
|
|
24
|
+
* can only have come from somebody clicking the link. New unsubscribes write
|
|
25
|
+
* the reason explicitly, so this fallback covers history and nothing else.
|
|
26
|
+
*/ export const SUPPRESSION_REASONS = {
|
|
27
|
+
unsubscribe: {
|
|
28
|
+
label: 'Unsubscribed',
|
|
29
|
+
color: 'default'
|
|
30
|
+
},
|
|
31
|
+
bounce: {
|
|
32
|
+
label: 'Bounced',
|
|
33
|
+
color: 'warning'
|
|
34
|
+
},
|
|
35
|
+
complaint: {
|
|
36
|
+
label: 'Marked as spam',
|
|
37
|
+
color: 'error'
|
|
38
|
+
},
|
|
39
|
+
/*
|
|
40
|
+
* Recorded by a person, through the Add control.
|
|
41
|
+
*
|
|
42
|
+
* Its OWN value rather than a reuse of `unsubscribe`: an opt-out arriving
|
|
43
|
+
* by reply, phone or in person is not somebody clicking a link, and the
|
|
44
|
+
* difference is exactly what a merchant asked to prove the request was
|
|
45
|
+
* honored has to be able to show.
|
|
46
|
+
*/ manual: {
|
|
47
|
+
label: 'Added by hand',
|
|
48
|
+
color: 'default'
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** One stored reason, as {@link SUPPRESSION_REASONS} describes it. */ export const describeSuppressionReason = (reason)=>{
|
|
52
|
+
var _SUPPRESSION_REASONS_String;
|
|
53
|
+
return (_SUPPRESSION_REASONS_String = SUPPRESSION_REASONS[String(reason != null ? reason : 'unsubscribe')]) != null ? _SUPPRESSION_REASONS_String : {
|
|
54
|
+
label: String(reason),
|
|
55
|
+
color: 'default'
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* THE BREAKDOWN OF ONE SITE'S SUPPRESSION LIST, as server aggregates.
|
|
60
|
+
*
|
|
61
|
+
* One reader for both the site's own suppression card and the organization's
|
|
62
|
+
* summary of every site's, so the two can never count differently.
|
|
63
|
+
*
|
|
64
|
+
* Four reads, not one per reason. `where('reason','==','unsubscribe')` cannot
|
|
65
|
+
* be asked, because an entry written before AGL-2408 carries no `reason` at
|
|
66
|
+
* all and an equality filter excludes it — the field-presence trap the list's
|
|
67
|
+
* ordering also avoids. Unsubscribes are therefore the REMAINDER: the total
|
|
68
|
+
* minus every reason that is always written explicitly, which is exactly the
|
|
69
|
+
* compatibility rule the card's `describeReason` applies row by row. A reason
|
|
70
|
+
* counted explicitly and left out of the subtraction would be reported as
|
|
71
|
+
* somebody who clicked unsubscribe.
|
|
72
|
+
*
|
|
73
|
+
* Rejects when any read fails; a caller holds its figure at "unknown" rather
|
|
74
|
+
* than zeroing it, because "Bounced: 0" is a confident wrong number in the
|
|
75
|
+
* reassuring direction.
|
|
76
|
+
*/ export async function readSuppressionTotals(firestore, hostId) {
|
|
77
|
+
var _all_data_total, _bounced_data_total, _complained_data_total, _added_data_total;
|
|
78
|
+
const suppressionsRef = collection(firestore, 'hosts', hostId, 'suppressions');
|
|
79
|
+
const [all, bounced, complained, added] = await Promise.all([
|
|
80
|
+
getAggregateFromServer(suppressionsRef, {
|
|
81
|
+
total: count()
|
|
82
|
+
}),
|
|
83
|
+
getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'bounce')), {
|
|
84
|
+
total: count()
|
|
85
|
+
}),
|
|
86
|
+
getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'complaint')), {
|
|
87
|
+
total: count()
|
|
88
|
+
}),
|
|
89
|
+
getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'manual')), {
|
|
90
|
+
total: count()
|
|
91
|
+
})
|
|
92
|
+
]);
|
|
93
|
+
const total = Number((_all_data_total = all.data().total) != null ? _all_data_total : 0);
|
|
94
|
+
const bounce = Number((_bounced_data_total = bounced.data().total) != null ? _bounced_data_total : 0);
|
|
95
|
+
const complaint = Number((_complained_data_total = complained.data().total) != null ? _complained_data_total : 0);
|
|
96
|
+
const manual = Number((_added_data_total = added.data().total) != null ? _added_data_total : 0);
|
|
97
|
+
return {
|
|
98
|
+
unsubscribe: Math.max(0, total - bounce - complaint - manual),
|
|
99
|
+
bounce,
|
|
100
|
+
complaint,
|
|
101
|
+
manual
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
//# sourceMappingURL=suppression-totals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/suppression-totals.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n collection,\n count,\n getAggregateFromServer,\n query,\n where,\n type Firestore,\n} from 'firebase/firestore'\n\n/**\n * What a reason means to a merchant, and how much it should worry them.\n *\n * An ABSENT reason reads as \"Unsubscribed\", and that is a compatibility rule\n * rather than a guess: until AGL-2408 the unsubscribe handler wrote\n * `{ email, createdAt }` and nothing else, while the Resend webhook has\n * stamped `'bounce'`/`'complaint'` since AGL-1918 — so an entry with no reason\n * can only have come from somebody clicking the link. New unsubscribes write\n * the reason explicitly, so this fallback covers history and nothing else.\n */\nexport const SUPPRESSION_REASONS: Record<\n string,\n { label: string; color: 'default' | 'warning' | 'error' }\n> = {\n unsubscribe: { label: 'Unsubscribed', color: 'default' },\n bounce: { label: 'Bounced', color: 'warning' },\n complaint: { label: 'Marked as spam', color: 'error' },\n /*\n * Recorded by a person, through the Add control.\n *\n * Its OWN value rather than a reuse of `unsubscribe`: an opt-out arriving\n * by reply, phone or in person is not somebody clicking a link, and the\n * difference is exactly what a merchant asked to prove the request was\n * honored has to be able to show.\n */\n manual: { label: 'Added by hand', color: 'default' },\n}\n\n/** One stored reason, as {@link SUPPRESSION_REASONS} describes it. */\nexport const describeSuppressionReason = (reason: unknown) =>\n SUPPRESSION_REASONS[String(reason ?? 'unsubscribe')] ?? {\n label: String(reason),\n color: 'default' as const,\n }\n\n/** One site's suppression list, broken down by why each address is on it. */\nexport interface SuppressionTotals {\n unsubscribe: number\n bounce: number\n complaint: number\n manual: number\n}\n\n/**\n * THE BREAKDOWN OF ONE SITE'S SUPPRESSION LIST, as server aggregates.\n *\n * One reader for both the site's own suppression card and the organization's\n * summary of every site's, so the two can never count differently.\n *\n * Four reads, not one per reason. `where('reason','==','unsubscribe')` cannot\n * be asked, because an entry written before AGL-2408 carries no `reason` at\n * all and an equality filter excludes it — the field-presence trap the list's\n * ordering also avoids. Unsubscribes are therefore the REMAINDER: the total\n * minus every reason that is always written explicitly, which is exactly the\n * compatibility rule the card's `describeReason` applies row by row. A reason\n * counted explicitly and left out of the subtraction would be reported as\n * somebody who clicked unsubscribe.\n *\n * Rejects when any read fails; a caller holds its figure at \"unknown\" rather\n * than zeroing it, because \"Bounced: 0\" is a confident wrong number in the\n * reassuring direction.\n */\nexport async function readSuppressionTotals(\n firestore: Firestore,\n hostId: string,\n): Promise<SuppressionTotals> {\n const suppressionsRef = collection(firestore, 'hosts', hostId, 'suppressions')\n const [all, bounced, complained, added] = await Promise.all([\n getAggregateFromServer(suppressionsRef, { total: count() }),\n getAggregateFromServer(\n query(suppressionsRef, where('reason', '==', 'bounce')),\n { total: count() },\n ),\n getAggregateFromServer(\n query(suppressionsRef, where('reason', '==', 'complaint')),\n { total: count() },\n ),\n getAggregateFromServer(\n query(suppressionsRef, where('reason', '==', 'manual')),\n { total: count() },\n ),\n ])\n const total = Number(all.data().total ?? 0)\n const bounce = Number(bounced.data().total ?? 0)\n const complaint = Number(complained.data().total ?? 0)\n const manual = Number(added.data().total ?? 0)\n return {\n unsubscribe: Math.max(0, total - bounce - complaint - manual),\n bounce,\n complaint,\n manual,\n }\n}\n"],"names":["collection","count","getAggregateFromServer","query","where","SUPPRESSION_REASONS","unsubscribe","label","color","bounce","complaint","manual","describeSuppressionReason","reason","String","readSuppressionTotals","firestore","hostId","all","bounced","complained","added","suppressionsRef","Promise","total","Number","data","Math","max"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,UAAU,EACVC,KAAK,EACLC,sBAAsB,EACtBC,KAAK,EACLC,KAAK,QAEA,qBAAoB;AAE3B;;;;;;;;;CASC,GACD,OAAO,MAAMC,sBAGT;IACFC,aAAa;QAAEC,OAAO;QAAgBC,OAAO;IAAU;IACvDC,QAAQ;QAAEF,OAAO;QAAWC,OAAO;IAAU;IAC7CE,WAAW;QAAEH,OAAO;QAAkBC,OAAO;IAAQ;IACrD;;;;;;;GAOC,GACDG,QAAQ;QAAEJ,OAAO;QAAiBC,OAAO;IAAU;AACrD,EAAC;AAED,oEAAoE,GACpE,OAAO,MAAMI,4BAA4B,CAACC;QACxCR;YAAAA,8BAAAA,mBAAmB,CAACS,OAAOD,iBAAAA,SAAU,eAAe,YAApDR,8BAAwD;QACtDE,OAAOO,OAAOD;QACdL,OAAO;IACT;EAAC;AAUH;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,eAAeO,sBACpBC,SAAoB,EACpBC,MAAc;QAkBOC,iBACCC,qBACGC,wBACHC;IAnBtB,MAAMC,kBAAkBtB,WAAWgB,WAAW,SAASC,QAAQ;IAC/D,MAAM,CAACC,KAAKC,SAASC,YAAYC,MAAM,GAAG,MAAME,QAAQL,GAAG,CAAC;QAC1DhB,uBAAuBoB,iBAAiB;YAAEE,OAAOvB;QAAQ;QACzDC,uBACEC,MAAMmB,iBAAiBlB,MAAM,UAAU,MAAM,YAC7C;YAAEoB,OAAOvB;QAAQ;QAEnBC,uBACEC,MAAMmB,iBAAiBlB,MAAM,UAAU,MAAM,eAC7C;YAAEoB,OAAOvB;QAAQ;QAEnBC,uBACEC,MAAMmB,iBAAiBlB,MAAM,UAAU,MAAM,YAC7C;YAAEoB,OAAOvB;QAAQ;KAEpB;IACD,MAAMuB,QAAQC,QAAOP,kBAAAA,IAAIQ,IAAI,GAAGF,KAAK,YAAhBN,kBAAoB;IACzC,MAAMT,SAASgB,QAAON,sBAAAA,QAAQO,IAAI,GAAGF,KAAK,YAApBL,sBAAwB;IAC9C,MAAMT,YAAYe,QAAOL,yBAAAA,WAAWM,IAAI,GAAGF,KAAK,YAAvBJ,yBAA2B;IACpD,MAAMT,SAASc,QAAOJ,oBAAAA,MAAMK,IAAI,GAAGF,KAAK,YAAlBH,oBAAsB;IAC5C,OAAO;QACLf,aAAaqB,KAAKC,GAAG,CAAC,GAAGJ,QAAQf,SAASC,YAAYC;QACtDF;QACAC;QACAC;IACF;AACF"}
|
|
@@ -36,51 +36,11 @@ import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
|
36
36
|
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
37
37
|
import { Alert, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
|
|
38
38
|
import Button from "@mui/material/Button";
|
|
39
|
-
import { collection,
|
|
39
|
+
import { collection, deleteDoc, doc, limit, orderBy, query } from "firebase/firestore";
|
|
40
40
|
import { useCallback, useEffect, useState } from "react";
|
|
41
41
|
import { useFirestore, usePagedCollection, useUser } from "@aglyn/tenant-feature-instance";
|
|
42
42
|
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
43
|
-
|
|
44
|
-
* What a reason means to a merchant, and how much it should worry them.
|
|
45
|
-
*
|
|
46
|
-
* An ABSENT reason reads as "Unsubscribed", and that is a compatibility rule
|
|
47
|
-
* rather than a guess: until AGL-2408 the unsubscribe handler wrote
|
|
48
|
-
* `{ email, createdAt }` and nothing else, while the Resend webhook has
|
|
49
|
-
* stamped `'bounce'`/`'complaint'` since AGL-1918 — so an entry with no reason
|
|
50
|
-
* can only have come from somebody clicking the link. New unsubscribes write
|
|
51
|
-
* the reason explicitly, so this fallback covers history and nothing else.
|
|
52
|
-
*/ const REASONS = {
|
|
53
|
-
unsubscribe: {
|
|
54
|
-
label: 'Unsubscribed',
|
|
55
|
-
color: 'default'
|
|
56
|
-
},
|
|
57
|
-
bounce: {
|
|
58
|
-
label: 'Bounced',
|
|
59
|
-
color: 'warning'
|
|
60
|
-
},
|
|
61
|
-
complaint: {
|
|
62
|
-
label: 'Marked as spam',
|
|
63
|
-
color: 'error'
|
|
64
|
-
},
|
|
65
|
-
/*
|
|
66
|
-
* Recorded by a person, through the Add control.
|
|
67
|
-
*
|
|
68
|
-
* Its OWN value rather than a reuse of `unsubscribe`: an opt-out arriving
|
|
69
|
-
* by reply, phone or in person is not somebody clicking a link, and the
|
|
70
|
-
* difference is exactly what a merchant asked to prove the request was
|
|
71
|
-
* honored has to be able to show.
|
|
72
|
-
*/ manual: {
|
|
73
|
-
label: 'Added by hand',
|
|
74
|
-
color: 'default'
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
const describeReason = (reason)=>{
|
|
78
|
-
var _REASONS_String;
|
|
79
|
-
return (_REASONS_String = REASONS[String(reason != null ? reason : 'unsubscribe')]) != null ? _REASONS_String : {
|
|
80
|
-
label: String(reason),
|
|
81
|
-
color: 'default'
|
|
82
|
-
};
|
|
83
|
-
};
|
|
43
|
+
import { describeSuppressionReason as describeReason, readSuppressionTotals } from "./suppression-totals.js";
|
|
84
44
|
/**
|
|
85
45
|
* `YYYY-MM-DD` from a Firestore timestamp shape, or an em dash.
|
|
86
46
|
*
|
|
@@ -182,48 +142,15 @@ const describeReason = (reason)=>{
|
|
|
182
142
|
* a ten-row page it would have meant 140 of ten. A bounce rate computed
|
|
183
143
|
* from a sample is not a bounce rate, and nothing on screen said it was one.
|
|
184
144
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* field-presence trap as the ordering above. Unsubscribes are therefore the
|
|
189
|
-
* REMAINDER: total minus the two reasons that are always written
|
|
190
|
-
* explicitly, which is exactly the compatibility rule `describeReason`
|
|
191
|
-
* applies row by row.
|
|
145
|
+
* `readSuppressionTotals` holds the four reads and the remainder rule for
|
|
146
|
+
* unsubscribes, and the organization's summary of every site's list asks
|
|
147
|
+
* the same function, so a site's figures read the same on both pages.
|
|
192
148
|
*=========================================*/ const [totalsEpoch, setTotalsEpoch] = useState(0);
|
|
193
149
|
const [totals, setTotals] = useState(null);
|
|
194
150
|
useEffect(()=>{
|
|
195
151
|
let active = true;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
getAggregateFromServer(suppressionsRef, {
|
|
199
|
-
total: count()
|
|
200
|
-
}),
|
|
201
|
-
getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'bounce')), {
|
|
202
|
-
total: count()
|
|
203
|
-
}),
|
|
204
|
-
getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'complaint')), {
|
|
205
|
-
total: count()
|
|
206
|
-
}),
|
|
207
|
-
// A FOURTH read, and it is not optional. Unsubscribes are the
|
|
208
|
-
// REMAINDER, so every reason that is counted explicitly has to be
|
|
209
|
-
// subtracted — a hand-added entry left out of this list would be
|
|
210
|
-
// reported as somebody who clicked unsubscribe.
|
|
211
|
-
getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'manual')), {
|
|
212
|
-
total: count()
|
|
213
|
-
})
|
|
214
|
-
]).then(([all, bounced, complained, added])=>{
|
|
215
|
-
var _all_data_total, _bounced_data_total, _complained_data_total, _added_data_total;
|
|
216
|
-
if (!active) return;
|
|
217
|
-
const total = Number((_all_data_total = all.data().total) != null ? _all_data_total : 0);
|
|
218
|
-
const bounce = Number((_bounced_data_total = bounced.data().total) != null ? _bounced_data_total : 0);
|
|
219
|
-
const complaint = Number((_complained_data_total = complained.data().total) != null ? _complained_data_total : 0);
|
|
220
|
-
const manual = Number((_added_data_total = added.data().total) != null ? _added_data_total : 0);
|
|
221
|
-
setTotals({
|
|
222
|
-
unsubscribe: Math.max(0, total - bounce - complaint - manual),
|
|
223
|
-
bounce,
|
|
224
|
-
complaint,
|
|
225
|
-
manual
|
|
226
|
-
});
|
|
152
|
+
void readSuppressionTotals(firestore, hostId).then((read)=>{
|
|
153
|
+
if (active) setTotals(read);
|
|
227
154
|
}).catch(()=>{
|
|
228
155
|
// Held at null rather than zeroed. "Bounced: 0" is a confident wrong
|
|
229
156
|
// number in the reassuring direction, and this card exists to warn.
|