@aglyn/plugins-inbox 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +51 -0
- package/src/index.d.ts +18 -0
- package/src/index.js +19 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/contacts-card.component.d.ts +16 -0
- package/src/lib/components/contacts-card.component.js +351 -0
- package/src/lib/components/contacts-card.component.js.map +1 -0
- package/src/lib/components/inbox-attribution-zone.d.ts +52 -0
- package/src/lib/components/inbox-attribution-zone.js +53 -0
- package/src/lib/components/inbox-attribution-zone.js.map +1 -0
- package/src/lib/components/inbox-console-page.d.ts +28 -0
- package/src/lib/components/inbox-console-page.js +219 -0
- package/src/lib/components/inbox-console-page.js.map +1 -0
- package/src/lib/components/inbox-console-sections.d.ts +40 -0
- package/src/lib/components/inbox-console-sections.js +66 -0
- package/src/lib/components/inbox-console-sections.js.map +1 -0
- package/src/lib/components/inbox-glance-card.component.d.ts +26 -0
- package/src/lib/components/inbox-glance-card.component.js +215 -0
- package/src/lib/components/inbox-glance-card.component.js.map +1 -0
- package/src/lib/components/submission-list-assignment.component.d.ts +29 -0
- package/src/lib/components/submission-list-assignment.component.js +257 -0
- package/src/lib/components/submission-list-assignment.component.js.map +1 -0
- package/src/lib/components/submission-reply.component.d.ts +30 -0
- package/src/lib/components/submission-reply.component.js +249 -0
- package/src/lib/components/submission-reply.component.js.map +1 -0
- package/src/lib/components/submissions-card.component.d.ts +35 -0
- package/src/lib/components/submissions-card.component.js +577 -0
- package/src/lib/components/submissions-card.component.js.map +1 -0
- package/src/lib/components/use-record-route-context.d.ts +15 -0
- package/src/lib/components/use-record-route-context.js +41 -0
- package/src/lib/components/use-record-route-context.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/reply-policy.d.ts +134 -0
- package/src/lib/model/reply-policy.js +141 -0
- package/src/lib/model/reply-policy.js.map +1 -0
- package/src/lib/model/submission-presenter.d.ts +81 -0
- package/src/lib/model/submission-presenter.js +152 -0
- package/src/lib/model/submission-presenter.js.map +1 -0
- package/src/lib/plugin.d.ts +25 -0
- package/src/lib/plugin.js +102 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server.d.ts +179 -0
- package/src/lib/server.js +666 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/submissions-card.component.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 { FORMS_MAX_PER_HOST, INBOX_SUBMISSION_PARAM, pluginDocsHelp } from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, for the reason above: the barrel\n// is the plugin's site entry point.\nimport {\n pluginRecordByEmailHref,\n pluginRecordListHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiDeleteOutline,\n mdiEmailOpenOutline,\n mdiEmailOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } 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 type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Avatar,\n Box,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Divider,\n MenuItem,\n Stack,\n TextField,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n getDoc,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useSearchParams } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport {\n relativeTime,\n routingChips,\n senderHue,\n submissionSender,\n} from '../model/submission-presenter'\nimport SubmissionListAssignment from './submission-list-assignment.component'\nimport SubmissionReply from './submission-reply.component'\nimport { useRecordRouteContext } from './use-record-route-context'\n\n/**\n * The Submissions section of the Inbox (AGL-77/104/109 → AGL-395): the form\n * messages a site collected, newest first, and the reader that opens one.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n */\nexport interface SubmissionsCardProps {\n hostId: string\n /**\n * Narrow this card to ONE form, permanently.\n *\n * What the forms plugin's detail surface renders instead of a second\n * submissions table. The reader has already chosen the subject by being on\n * that page, so scoping here does three things a copy would have had to\n * re-derive: the form picker is not rendered (there is nothing to pick),\n * the site's `forms` collection is not read at all (the picker was its only\n * consumer), and the empty state names the form rather than the site.\n *\n * Everything else — the paged walk, the reader dialog, read/unread,\n * delete, reply, list assignment, attribution — is the same code answering\n * a narrower query. That is the reason this is a prop rather than a second\n * component: a per-form table written separately would be a second reader\n * to keep in step, and it would have been the one to reintroduce the\n * unordered `limit()` this card's own comment exists to warn about.\n */\n formId?: string\n}\n\nexport function SubmissionsCard({ hostId, formId }: SubmissionsCardProps) {\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n /** Scoped to one form: the subject is fixed and nothing may widen it. */\n const scoped = Boolean(formId)\n /*\n * Where the sender's CONTACT is (AGL-2612). A submission that carried an\n * address updated a contact in the CRM at stage Lead; the row links to\n * it by that address, and the Contacts list — which holds the id nothing\n * here does — opens the record. The same hub path the Members & leads\n * rows use to open a lead.\n */\n const routeContext = useRecordRouteContext()\n\n /*\n * The site's forms, for the Submissions filter.\n *\n * `FORMS_MAX_PER_HOST` is a read WINDOW, not a cap on the collection — how\n * many forms a site may hold is `formsPerHost`, enforced at the create in\n * `/api/hosts/resources`, and a staff-set per-org override can raise it\n * past this window. So the window can be smaller than the catalog, and one\n * more document than fits is read on purpose: a filter that quietly listed\n * the first N would report \"this form does not exist\" and \"this form is\n * past the window\" as the same empty answer.\n *\n * Ordered by `__name__`. `displayName` would be the nicer order and is the\n * wrong instrument: `orderBy` on a data field DROPS every document missing\n * it, invisibly, so a form saved without a name would vanish from its own\n * filter. The list is sorted for display below, where a missing name is\n * merely ugly.\n */\n const { data: formDocs } = useFirestoreCollection<any>(\n () =>\n scoped\n ? null\n : query(\n collection(firestore, 'hosts', hostId, 'forms'),\n orderBy('__name__'),\n limit(FORMS_MAX_PER_HOST + 1),\n ),\n [firestore, hostId, scoped],\n { idField: '$id' },\n )\n /** More forms exist than the window shows; the filter has to say so. */\n const formsTruncated = (formDocs?.length ?? 0) > FORMS_MAX_PER_HOST\n const forms = useMemo(\n () =>\n [...(formDocs ?? [])]\n .slice(0, FORMS_MAX_PER_HOST)\n .sort((left: any, right: any) =>\n String(left.displayName ?? left.$id).localeCompare(\n String(right.displayName ?? right.$id),\n ),\n ),\n [formDocs],\n )\n /*\n * `null` is \"All forms\"; a form id narrows to one form's submissions.\n *\n * A PRIMITIVE, deliberately: `usePagedCollection` reopens its listener when\n * a dep changes, and an object identity would tear down and reopen on every\n * render. It also resets the reader to page 1, which is what switching\n * subjects should do.\n */\n const [formFilter, setFormFilter] = useState<string | null>(null)\n /**\n * The form the query is actually narrowed to.\n *\n * The scope wins over the picker rather than seeding it. A scoped card\n * renders no picker, so a `formFilter` that could outrank `formId` would be\n * a filter with no control — reachable only by a state change nothing on\n * screen can cause, and unclearable if one ever could.\n */\n const activeForm = formId ?? formFilter\n\n /*\n * The inbox WALKS its submissions instead of sampling them (AGL-2501,\n * AGL-2292).\n *\n * `limit(200)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids `add()` generates — an arbitrary two hundred\n * of the site's messages, which the client sort then arranged newest-first\n * so the result looked like a feed. A site past two hundred submissions\n * could not reach the rest, and the messages missing left no gap: the row\n * dates on screen simply skipped, which reads as a quiet week rather than\n * as an unreachable inbox.\n *\n * `createdAt` is safe to order on, checked against the writer rather than\n * assumed: `apps/tenant/app/api/forms/submit/route.ts` is the only path\n * that creates one and stamps `createdAt: serverTimestamp()` on every add,\n * the v1 API only ever reads and deletes, and `formSubmissions` is absent\n * from `IMPORTABLE_FIELDS`, so no restore path can make one without it.\n */\n const {\n rows: submissions,\n hasMore: hasMoreSubmissions,\n page: submissionPage,\n setPage: setSubmissionPage,\n pageSize: submissionPageSize,\n setPageSize: setSubmissionPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'formSubmissions'),\n // Served by the `formId ASC, createdAt DESC` composite index in\n // `cloud/firebase-firestore.indexes.json`, which must be deployed\n // before this ships — without it Firestore refuses the query rather\n // than answering it slowly.\n ...(activeForm ? [where('formId', '==', activeForm)] : []),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId, activeForm],\n { idField: '$id' },\n )\n\n // Mail reader (AGL-104): opening a submission shows the full message and\n // marks it read.\n const [reader, setReader] = useState<any | null>(null)\n const handleOpenReader = useCallback(\n (submission: any) => () => {\n setReader(submission)\n if (!submission.read) {\n void updateDoc(\n doc(firestore, 'hosts', hostId, 'formSubmissions', submission.$id),\n { read: true },\n )\n }\n },\n [firestore, hostId],\n )\n\n const handleToggleRead = useCallback(\n (submission: any) => () => {\n void updateDoc(\n doc(firestore, 'hosts', hostId, 'formSubmissions', submission.$id),\n { read: !submission.read },\n )\n },\n [firestore, hostId],\n )\n\n /*\n * A submission named in the URL opens in the reader on arrival (AGL-2622).\n *\n * A contact's timeline names the submission that captured the person, and\n * the address it links to is this tab with `?submission={id}`. The\n * document is read once, by id, rather than looked for in the paged\n * window: the window is the newest page of a walk that may not reach a\n * submission from months back, and one document read is what the URL\n * asked for. Opening it marks it read, as a click on the row would. A\n * submission that is gone says so, because a reader that silently stays\n * shut reads as the link having done nothing.\n *\n * Once per id: the ref keeps a re-render — and the same URL re-settling —\n * from reopening a reader the person has since closed. The landing read\n * is judged against that ref rather than an effect cleanup: a listener\n * settling while the read is in flight re-renders this card, and a\n * cleanup keyed on any dep would cancel the answer to the question the\n * URL just asked.\n */\n const searchParams = useSearchParams()\n const seededSubmissionId = searchParams?.get(INBOX_SUBMISSION_PARAM) ?? null\n const seededOpened = useRef<string | null>(null)\n useEffect(() => {\n if (!seededSubmissionId || seededOpened.current === seededSubmissionId) return\n seededOpened.current = seededSubmissionId\n void getDoc(doc(firestore, 'hosts', hostId, 'formSubmissions', seededSubmissionId))\n .then((snapshot) => {\n if (seededOpened.current !== snapshot.id) return\n if (!snapshot.exists()) {\n enqueueSnackbar('That submission is no longer in the Inbox.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n handleOpenReader({ ...snapshot.data(), $id: snapshot.id })()\n })\n .catch(() => undefined)\n }, [seededSubmissionId, firestore, hostId, handleOpenReader, enqueueSnackbar])\n\n const handleDelete = useCallback(\n (submission: any) => async () => {\n const confirmed = await confirm({\n title: 'Delete this submission?',\n description: 'The submission is removed permanently.',\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await deleteDoc(\n doc(firestore, 'hosts', hostId, 'formSubmissions', submission.$id),\n )\n enqueueSnackbar('Submission deleted', {\n variant: 'success',\n persist: false,\n })\n },\n [confirm, firestore, hostId, enqueueSnackbar],\n )\n\n /*\n * One row per submission, newest first: who sent it, what it said and how\n * long ago. The row opens the reader, which also marks it read.\n */\n const submissionActions = (submission: any): RowActionsMenuItem[] => {\n const senderEmail = submissionSender(submission.fields).email\n // Where the sender's contact is read, asked of whichever plugin keeps\n // people; with none loaded there is no such page, and no menu item.\n const contactsHref = routeContext\n ? pluginRecordListHref('contact', routeContext)\n : null\n return [\n ...(routeContext && contactsHref\n ? [\n {\n key: 'crm',\n label: 'Open contact in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n ...(senderEmail\n ? {\n href:\n pluginRecordByEmailHref(\n 'contact',\n routeContext,\n senderEmail,\n ) ?? contactsHref,\n }\n : {\n disabled: true,\n disabledReason:\n 'This submission carried no email address, so no contact was updated.',\n }),\n },\n ]\n : []),\n {\n key: 'read',\n label: submission.read ? 'Mark unread' : 'Mark read',\n icon: (\n <MdiIcon\n path={submission.read ? mdiEmailOutline.path : mdiEmailOpenOutline.path}\n size={0.8}\n />\n ),\n onClick: handleToggleRead(submission),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: handleDelete(submission),\n },\n ]\n }\n const submissionColumns: GridColDef[] = [\n {\n /*\n The mockup's list is people, not forms (AGL-2168): an initials avatar,\n the sender, and the form name beneath it. The unread DOT replaces the\n `New` chip — the row is already bold, and a chip that says \"New\" beside\n bold text is the same fact twice.\n */\n field: 'from',\n headerName: 'From',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, submission) => submissionSender(submission.fields).label,\n renderCell: ({ row: submission }) => {\n const sender = submissionSender(submission.fields)\n const hue = senderHue(sender.label)\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n {!submission.read ? (\n <Box\n aria-label=\"Unread\"\n sx={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: 'primary.main',\n flexShrink: 0,\n }}\n />\n ) : (\n <Box sx={{ width: 8, flexShrink: 0 }} />\n )}\n <Avatar\n sx={{\n width: 28,\n height: 28,\n fontSize: 13,\n bgcolor: `hsl(${hue} 55% 45%)`,\n }}\n >\n {sender.initials}\n </Avatar>\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {sender.label}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {submission.formName ?? 'Form'}\n </Typography>\n </Stack>\n </Stack>\n )\n },\n },\n {\n field: 'message',\n headerName: 'Message',\n flex: 2,\n minWidth: 260,\n valueGetter: (_value, submission) =>\n Object.entries(submission.fields ?? {})\n .map(([key, value]) => `${key}: ${value}`)\n .join(' · '),\n renderCell: ({ value }) => (\n <Typography variant=\"body2\" noWrap>\n {value}\n </Typography>\n ),\n },\n {\n /*\n Relative, as the mockup shows it — an inbox is scanned for recency and a\n locale timestamp makes the reader do the subtraction. The absolute time\n stays on the detail dialog, where it is the fact you actually want.\n */\n field: 'createdAt',\n headerName: 'Received',\n width: 130,\n valueGetter: (_value, submission) =>\n submission.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: submission }) => (\n <Tooltip title={submission.createdAt?.toDate?.().toLocaleString() ?? ''}>\n <span>{relativeTime(submission.createdAt?.toDate?.().getTime())}</span>\n </Tooltip>\n ),\n },\n /*\n One overflow menu, as the Members & leads rows have: two inline buttons\n had no room for a third, and the contact link is the one a reader\n reaches for after reading. Present but disabled for a submission with no\n address, with the reason — a row that simply lacked the item would read\n as the contact not existing.\n */\n listActionsColumn(\n (submission) => (\n <ListRowActions\n label={String(submissionSender(submission.fields).email ?? submission.$id)}\n items={submissionActions(submission)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <>\n <CardDisplay\n header={scoped ? 'Submissions to this form' : 'Form Submissions'}\n help={pluginDocsHelp('forms', {\n anchor: '#the-inbox',\n excerpt:\n 'Messages your forms collected, newest first, showing who sent ' +\n 'each one and where it was routed.',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {/*\n * The Inbox stays the site-wide answer to \"who is waiting for a\n * reply\" — that question does not decompose by form. This narrows\n * it on request; it does not turn the page into a per-form view.\n *\n * Rendered only when the site HAS forms, so a site that has not\n * adopted any sees the page exactly as it was.\n */}\n {/*\n * Withheld when the card is scoped: the subject is settled by the\n * surface this is rendered on, and a picker offering to widen it\n * would be a control that navigates away from the page it is on.\n */}\n {!scoped && forms.length > 0 ? (\n <TextField\n select\n size=\"small\"\n label={'Form'}\n value={formFilter ?? ''}\n onChange={(event) => setFormFilter(event.target.value || null)}\n helperText={\n formsTruncated\n ? `Showing the first ${FORMS_MAX_PER_HOST.toLocaleString()} ` +\n 'forms. Narrow by form is incomplete; All forms still ' +\n 'covers every submission.'\n : undefined\n }\n sx={{ mb: 2, minWidth: 240 }}\n >\n <MenuItem value=\"\">{'All forms'}</MenuItem>\n {forms.map((form: any) => (\n <MenuItem key={form.$id} value={form.$id}>\n {form.displayName || form.$id}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n {submissions.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {scoped\n ? 'No submissions carry this form’s id yet. Messages this ' +\n 'form’s design collected before it became a form entity are ' +\n 'in the Inbox, filed under the name they were sent with.'\n : activeForm\n ? 'No submissions for this form yet. Submissions sent before ' +\n 'the form was created stay under All forms.'\n : 'No form submissions yet. Add a Contact Form element to a ' +\n 'screen — visitor messages arrive here.'}\n </Typography>\n ) : (\n <>\n <ListTable\n aria-label={scoped ? 'Submissions to this form' : 'Form submissions'}\n rows={submissions}\n columns={submissionColumns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, submission) => handleOpenReader(submission)()}\n // An unread submission reads bold across its row, which is why\n // the list needs no `New` chip.\n getRowClassName={({ row }) => (row.read ? '' : 'submission-unread')}\n sx={{\n '& .submission-unread .MuiDataGrid-cell, & .submission-unread .MuiTypography-root':\n { fontWeight: 'fontWeightBold' },\n }}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={submissionPage}\n pageSize={submissionPageSize}\n rowCount={submissions.length}\n hasMore={hasMoreSubmissions}\n onPageChange={setSubmissionPage}\n onPageSizeChange={setSubmissionPageSize}\n />\n </>\n )}\n </CardDisplay>\n <Dialog\n open={Boolean(reader)}\n onClose={() => setReader(null)}\n maxWidth=\"sm\"\n fullWidth\n >\n <DialogTitle>{reader?.formName ?? 'Form submission'}</DialogTitle>\n <DialogContent>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Received ${reader?.createdAt?.toDate?.().toLocaleString() ?? ''}` +\n (reader?.screenId ? ` · screen ${reader.screenId}` : '')}\n {/*\n THE PAGE THE FORM WAS ON. Stored by the submit route since the\n form existed and rendered by nothing, which is also the field\n the marketing console's landing-page grouping joins on — a\n reader who wants to check one row against that grouping has to\n be able to see the row's own page.\n */}\n {reader?.path ? ` · ${String(reader.path)}` : ''}\n </Typography>\n <Divider sx={{ my: 1.5 }} />\n <Stack spacing={1.5}>\n {Object.entries(reader?.fields ?? {}).map(([key, value]) => (\n <Stack key={key} spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {key}\n </Typography>\n <Typography\n variant=\"body2\"\n sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}\n >\n {String(value)}\n </Typography>\n </Stack>\n ))}\n </Stack>\n {/*\n What happened to this submission (AGL-2168). The mockup puts\n these under the fields: `Saved to Inbox` and `Added to \"Leads\"\n dataset`. The second is stamped by the submit route only when a\n record was really appended — a form bound to a deleted dataset,\n or one whose record quota is full, shows no chip rather than a\n chip for a row that does not exist. Both are failures the route\n already swallows silently, and a chip that lied about them\n would be worse than the silence.\n */}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ mt: 2, flexWrap: 'wrap', rowGap: 1 }}\n >\n {routingChips(reader?.routing).map((chip) => (\n <Chip\n key={chip.label}\n size=\"small\"\n label={chip.label}\n color={chip.color}\n variant=\"outlined\"\n />\n ))}\n </Stack>\n {/*\n WHERE THIS SUBMISSION CAME FROM.\n\n One keyed document read — the attribution's id is `form:{id}` and\n the submission's id is that `{id}` — paid when a merchant opens a\n submission rather than once per row of the list. A submission that\n was credited to nobody renders the sentence saying so; it never\n renders a campaign with a zero beside it.\n */}\n {reader?.$id ? (\n <Box sx={{ mt: 2 }}>\n <InboxRecordAttributionZone\n hostId={hostId}\n recordKind=\"form\"\n recordId={String(reader.$id)}\n />\n </Box>\n ) : null}\n {/*\n Answering the person is the act the Inbox exists for, and it is\n inside the reader rather than on the row because a reply written\n without the message in front of you is the reply that answers the\n wrong question. Mounted with the dialog, so its reads — the site\n name and the replies already sent — are paid when a merchant opens\n a submission and not once per visit to this page.\n */}\n {reader ? (\n <SubmissionReply hostId={hostId} submission={reader} />\n ) : null}\n {/*\n Enrolling the sender in a marketing list — a SEPARATE act from\n answering them, and a separate card, because the person asked to\n be answered and did not ask to be marketed to. Its reads are paid\n only when a merchant presses its own button, so opening a\n submission to read it costs nothing extra.\n */}\n {reader ? (\n <SubmissionListAssignment hostId={hostId} submission={reader} />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button\n color=\"error\"\n onClick={async () => {\n const target = reader\n setReader(null)\n if (target) await handleDelete(target)()\n }}\n >\n {'Delete'}\n </Button>\n <Button\n onClick={() => {\n if (reader) void handleToggleRead({ ...reader, read: true })()\n setReader(null)\n }}\n >\n {'Mark unread'}\n </Button>\n <Button variant=\"contained\" onClick={() => setReader(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nSubmissionsCard.displayName = 'SubmissionsCard'\n\nexport default SubmissionsCard\n"],"names":["FORMS_MAX_PER_HOST","INBOX_SUBMISSION_PARAM","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordByEmailHref","pluginRecordListHref","mdiAccountArrowRight","mdiDeleteOutline","mdiEmailOpenOutline","mdiEmailOutline","CardDisplay","MdiIcon","useConfirmationContext","ListPagination","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","useSnackbar","useFirestore","useFirestoreCollection","usePagedCollection","Avatar","Box","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Divider","MenuItem","Stack","TextField","Tooltip","Typography","collection","deleteDoc","doc","getDoc","limit","orderBy","query","updateDoc","where","useSearchParams","useCallback","useEffect","useMemo","useRef","useState","relativeTime","routingChips","senderHue","submissionSender","SubmissionListAssignment","SubmissionReply","useRecordRouteContext","SubmissionsCard","hostId","formId","reader","firestore","enqueueSnackbar","confirm","scoped","Boolean","routeContext","data","formDocs","idField","formsTruncated","length","forms","slice","sort","left","right","String","displayName","$id","localeCompare","formFilter","setFormFilter","activeForm","rows","submissions","hasMore","hasMoreSubmissions","page","submissionPage","setPage","setSubmissionPage","pageSize","submissionPageSize","setPageSize","setSubmissionPageSize","pageLimit","setReader","handleOpenReader","submission","read","handleToggleRead","searchParams","seededSubmissionId","get","seededOpened","current","then","snapshot","id","exists","variant","persist","catch","undefined","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","submissionActions","senderEmail","fields","email","contactsHref","key","label","icon","path","size","href","disabled","disabledReason","onClick","destructive","submissionColumns","field","headerName","flex","minWidth","valueGetter","_value","renderCell","row","sender","hue","direction","spacing","sx","alignItems","aria-label","width","height","borderRadius","bgcolor","flexShrink","fontSize","initials","noWrap","formName","Object","entries","map","value","join","createdAt","toDate","getTime","toLocaleString","span","items","header","help","anchor","excerpt","contentGutterX","contentGutterY","contentBordered","select","onChange","event","target","helperText","mb","form","columns","rowHeight","onOpen","_id","getRowClassName","fontWeight","hideFooter","rowCount","onPageChange","onPageSizeChange","open","onClose","maxWidth","fullWidth","screenId","my","whiteSpace","wordBreak","mt","flexWrap","rowGap","routing","chip","recordKind","recordId"],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PluginRecordRouteContext } from '@aglyn/aglyn/plugin-manager/plugin-record-routes';
|
|
2
|
+
/**
|
|
3
|
+
* Where this console is — the organization and the site — for asking the
|
|
4
|
+
* record-route registry for a link, or `null` until the route params settle
|
|
5
|
+
* (AGL-2608).
|
|
6
|
+
*
|
|
7
|
+
* Both are already in the URL, so no document is read to build a link. What
|
|
8
|
+
* the Inbox does NOT know is the address itself: the plugin that keeps people
|
|
9
|
+
* publishes where a lead or a contact is read, and the Inbox asks
|
|
10
|
+
* `pluginRecordHref('lead', context, id)`. It lists leads; wherever they are
|
|
11
|
+
* worked is that plugin's to say, and a workspace without one gets text
|
|
12
|
+
* instead of a link.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useRecordRouteContext(): PluginRecordRouteContext | null;
|
|
15
|
+
export default useRecordRouteContext;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { useParams } from "next/navigation";
|
|
18
|
+
/**
|
|
19
|
+
* Where this console is — the organization and the site — for asking the
|
|
20
|
+
* record-route registry for a link, or `null` until the route params settle
|
|
21
|
+
* (AGL-2608).
|
|
22
|
+
*
|
|
23
|
+
* Both are already in the URL, so no document is read to build a link. What
|
|
24
|
+
* the Inbox does NOT know is the address itself: the plugin that keeps people
|
|
25
|
+
* publishes where a lead or a contact is read, and the Inbox asks
|
|
26
|
+
* `pluginRecordHref('lead', context, id)`. It lists leads; wherever they are
|
|
27
|
+
* worked is that plugin's to say, and a workspace without one gets text
|
|
28
|
+
* instead of a link.
|
|
29
|
+
*/ export function useRecordRouteContext() {
|
|
30
|
+
const params = useParams();
|
|
31
|
+
const orgSlug = params == null ? void 0 : params.orgSlug;
|
|
32
|
+
const host = params == null ? void 0 : params.host;
|
|
33
|
+
if (!orgSlug || !host) return null;
|
|
34
|
+
return {
|
|
35
|
+
orgSlug,
|
|
36
|
+
host
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export default useRecordRouteContext;
|
|
40
|
+
|
|
41
|
+
//# sourceMappingURL=use-record-route-context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/use-record-route-context.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { PluginRecordRouteContext } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { useParams } from 'next/navigation'\n\n/**\n * Where this console is — the organization and the site — for asking the\n * record-route registry for a link, or `null` until the route params settle\n * (AGL-2608).\n *\n * Both are already in the URL, so no document is read to build a link. What\n * the Inbox does NOT know is the address itself: the plugin that keeps people\n * publishes where a lead or a contact is read, and the Inbox asks\n * `pluginRecordHref('lead', context, id)`. It lists leads; wherever they are\n * worked is that plugin's to say, and a workspace without one gets text\n * instead of a link.\n */\nexport function useRecordRouteContext(): PluginRecordRouteContext | null {\n const params = useParams<{ orgSlug: string; host: string }>()\n const orgSlug = params?.orgSlug\n const host = params?.host\n if (!orgSlug || !host) return null\n return { orgSlug, host }\n}\n\nexport default useRecordRouteContext\n"],"names":["useParams","useRecordRouteContext","params","orgSlug","host"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAGA,SAASA,SAAS,QAAQ,kBAAiB;AAE3C;;;;;;;;;;;CAWC,GACD,OAAO,SAASC;IACd,MAAMC,SAASF;IACf,MAAMG,UAAUD,0BAAAA,OAAQC,OAAO;IAC/B,MAAMC,OAAOF,0BAAAA,OAAQE,IAAI;IACzB,IAAI,CAACD,WAAW,CAACC,MAAM,OAAO;IAC9B,OAAO;QAAED;QAASC;IAAK;AACzB;AAEA,eAAeH,sBAAqB"}
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
*/ export const BUNDLE_ID = 'inbox';
|
|
8
|
+
|
|
9
|
+
//# sourceMappingURL=bundle-common.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/constants/bundle-common.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 */\n\nexport const BUNDLE_ID = 'inbox'\n"],"names":["BUNDLE_ID"],"mappings":"AAAA;;;;;;CAMC,GAED,OAAO,MAAMA,YAAY,QAAO"}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* What may be replied to, and what the reply says on the envelope.
|
|
19
|
+
*
|
|
20
|
+
* Pure policy, no Firestore and no network, so the rules that decide whether
|
|
21
|
+
* a message leaves the building can be asserted without a send path. The
|
|
22
|
+
* handler in `../server.ts` is the only caller.
|
|
23
|
+
*
|
|
24
|
+
* ## A reply is transactional, and that is a consent decision
|
|
25
|
+
*
|
|
26
|
+
* Someone filled in a form and asked to be contacted. Answering them is the
|
|
27
|
+
* transaction they initiated, so no marketing-consent record is required and
|
|
28
|
+
* none is read here. The opposite direction does not follow: replying is not
|
|
29
|
+
* permission to market to them, so nothing in this module enrolls anybody in
|
|
30
|
+
* anything. Enrollment is a separate act with a separate basis and it is
|
|
31
|
+
* deliberately not built — see `docs/specs/inbox-replies-and-list-assignment.md`.
|
|
32
|
+
*
|
|
33
|
+
* ## Suppression is not waived by that
|
|
34
|
+
*
|
|
35
|
+
* "Transactional" excuses the absence of consent, never a suppression. A hard
|
|
36
|
+
* bounce means the mailbox does not exist, and a spam complaint means the
|
|
37
|
+
* person asked this platform to stop; both answers are about the address
|
|
38
|
+
* rather than the campaign that learned them, and both apply to a reply.
|
|
39
|
+
* The suppression read itself needs Firestore, so it lives in the handler;
|
|
40
|
+
* what lives here is the ordering it must follow.
|
|
41
|
+
*/
|
|
42
|
+
import type { AddressRefusal } from '@aglyn/aglyn/server';
|
|
43
|
+
/**
|
|
44
|
+
* Why an address cannot be replied to. Each maps to one refusal message.
|
|
45
|
+
*
|
|
46
|
+
* The framework's `AddressRefusal` under a reply-shaped name, not a second
|
|
47
|
+
* union that happens to match. These four are facts about the ADDRESS — no
|
|
48
|
+
* email field, an unroutable value, and the two suppression lists — so the
|
|
49
|
+
* enrollment policy refuses on exactly the same set, and two declarations
|
|
50
|
+
* would drift the moment either side gained a fifth. A `type` import: erased
|
|
51
|
+
* at compile time, so the client component that reads this file takes on
|
|
52
|
+
* nothing from `@aglyn/aglyn/server`.
|
|
53
|
+
*/
|
|
54
|
+
export type ReplyRefusal = AddressRefusal;
|
|
55
|
+
/** The longest reply the composer accepts, matching the campaign composer. */
|
|
56
|
+
export declare const REPLY_BODY_MAX = 20000;
|
|
57
|
+
/** Subjects longer than this are truncated by most clients anyway. */
|
|
58
|
+
export declare const REPLY_SUBJECT_MAX = 150;
|
|
59
|
+
/**
|
|
60
|
+
* The address a reply to this submission would go to, or why there is none.
|
|
61
|
+
*
|
|
62
|
+
* Resolved from the submission's own `fields`, never from the request body.
|
|
63
|
+
* The recipient of a message this platform sends must be a property of the
|
|
64
|
+
* stored record, not an argument a caller supplies: a site editor who could
|
|
65
|
+
* name the recipient would have a send surface pointed at any address, on a
|
|
66
|
+
* verified domain, in the platform's own name.
|
|
67
|
+
*
|
|
68
|
+
* It resolves through `submissionSender`, which is what the Inbox row already
|
|
69
|
+
* displays, so the address the merchant is shown is the address that is
|
|
70
|
+
* mailed. A second resolver here would be a second answer to "who is this
|
|
71
|
+
* from", and the two would disagree on some form eventually.
|
|
72
|
+
*/
|
|
73
|
+
export declare function replyRecipient(fields: Record<string, unknown> | undefined): {
|
|
74
|
+
email: string;
|
|
75
|
+
} | {
|
|
76
|
+
refusal: ReplyRefusal;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* A deliberately narrow shape test: one `@`, something either side, a dot in
|
|
80
|
+
* the domain, and no whitespace.
|
|
81
|
+
*
|
|
82
|
+
* It is not RFC 5322 and does not try to be. The question being answered is
|
|
83
|
+
* "would handing this to the provider produce a rejected send", and the
|
|
84
|
+
* addresses that reach here came out of a public form's free-text field, so
|
|
85
|
+
* the common failures are a stray comma, a pasted `mailto:` and a name in
|
|
86
|
+
* angle brackets — all of which this refuses.
|
|
87
|
+
*
|
|
88
|
+
* The colon is in the excluded set for the `mailto:` case specifically: it is
|
|
89
|
+
* not valid in an unquoted local part, and without it `mailto:priya@lumen.co`
|
|
90
|
+
* matches with `mailto:priya` as the local part and is handed to the provider
|
|
91
|
+
* whole.
|
|
92
|
+
*/
|
|
93
|
+
export declare function isRoutableAddress(value: string): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* The default subject, which the merchant may then edit.
|
|
96
|
+
*
|
|
97
|
+
* `Re:` is a small lie and it is the right one: the recipient never received
|
|
98
|
+
* a message from us, because the submission arrived over HTTP, so there is
|
|
99
|
+
* nothing to be `Re:` to. But the prefix is what a person scanning an inbox
|
|
100
|
+
* reads as "this answers the thing I sent", and it is what every mail client
|
|
101
|
+
* groups on when the recipient replies again. The alternative — a bare
|
|
102
|
+
* subject — reads as an unsolicited message from a domain they do not know.
|
|
103
|
+
*
|
|
104
|
+
* The site name leads because that is what the recipient recognizes. They
|
|
105
|
+
* filled in a form on a site; they do not know the form's internal name and
|
|
106
|
+
* they have never heard of this platform.
|
|
107
|
+
*/
|
|
108
|
+
export declare function defaultReplySubject(siteName: string | undefined, formName: string | undefined): string;
|
|
109
|
+
/**
|
|
110
|
+
* The reply body as it goes on the wire.
|
|
111
|
+
*
|
|
112
|
+
* Plain text only. `sendEmail` synthesizes the HTML part from it, which is
|
|
113
|
+
* the one place that guarantee lives — a caller that built its own HTML here
|
|
114
|
+
* would be a caller that can get it wrong, and an empty HTML part is a
|
|
115
|
+
* message whose links cannot be clicked or counted.
|
|
116
|
+
*
|
|
117
|
+
* The quoted original is appended rather than left to the merchant, because
|
|
118
|
+
* a recipient who wrote a week ago has no idea which of their messages this
|
|
119
|
+
* answers, and the merchant retyping it is how a support thread loses the
|
|
120
|
+
* question it was about.
|
|
121
|
+
*/
|
|
122
|
+
export declare function composeReplyBody(options: {
|
|
123
|
+
message: string;
|
|
124
|
+
fields?: Record<string, unknown>;
|
|
125
|
+
siteName?: string;
|
|
126
|
+
}): string;
|
|
127
|
+
/**
|
|
128
|
+
* The original submission, rendered as `Label: value` lines.
|
|
129
|
+
*
|
|
130
|
+
* Field order is the author's, which is the order the visitor filled them in
|
|
131
|
+
* and therefore the order they will recognize. Empty values are dropped: a
|
|
132
|
+
* quote full of `Phone:` with nothing after it reads as a broken template.
|
|
133
|
+
*/
|
|
134
|
+
export declare function quoteSubmission(fields: Record<string, unknown> | undefined): string;
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
* What may be replied to, and what the reply says on the envelope.
|
|
18
|
+
*
|
|
19
|
+
* Pure policy, no Firestore and no network, so the rules that decide whether
|
|
20
|
+
* a message leaves the building can be asserted without a send path. The
|
|
21
|
+
* handler in `../server.ts` is the only caller.
|
|
22
|
+
*
|
|
23
|
+
* ## A reply is transactional, and that is a consent decision
|
|
24
|
+
*
|
|
25
|
+
* Someone filled in a form and asked to be contacted. Answering them is the
|
|
26
|
+
* transaction they initiated, so no marketing-consent record is required and
|
|
27
|
+
* none is read here. The opposite direction does not follow: replying is not
|
|
28
|
+
* permission to market to them, so nothing in this module enrolls anybody in
|
|
29
|
+
* anything. Enrollment is a separate act with a separate basis and it is
|
|
30
|
+
* deliberately not built — see `docs/specs/inbox-replies-and-list-assignment.md`.
|
|
31
|
+
*
|
|
32
|
+
* ## Suppression is not waived by that
|
|
33
|
+
*
|
|
34
|
+
* "Transactional" excuses the absence of consent, never a suppression. A hard
|
|
35
|
+
* bounce means the mailbox does not exist, and a spam complaint means the
|
|
36
|
+
* person asked this platform to stop; both answers are about the address
|
|
37
|
+
* rather than the campaign that learned them, and both apply to a reply.
|
|
38
|
+
* The suppression read itself needs Firestore, so it lives in the handler;
|
|
39
|
+
* what lives here is the ordering it must follow.
|
|
40
|
+
*/ import { submissionSender } from "./submission-presenter.js";
|
|
41
|
+
/** The longest reply the composer accepts, matching the campaign composer. */ export const REPLY_BODY_MAX = 20000;
|
|
42
|
+
/** Subjects longer than this are truncated by most clients anyway. */ export const REPLY_SUBJECT_MAX = 150;
|
|
43
|
+
/**
|
|
44
|
+
* The address a reply to this submission would go to, or why there is none.
|
|
45
|
+
*
|
|
46
|
+
* Resolved from the submission's own `fields`, never from the request body.
|
|
47
|
+
* The recipient of a message this platform sends must be a property of the
|
|
48
|
+
* stored record, not an argument a caller supplies: a site editor who could
|
|
49
|
+
* name the recipient would have a send surface pointed at any address, on a
|
|
50
|
+
* verified domain, in the platform's own name.
|
|
51
|
+
*
|
|
52
|
+
* It resolves through `submissionSender`, which is what the Inbox row already
|
|
53
|
+
* displays, so the address the merchant is shown is the address that is
|
|
54
|
+
* mailed. A second resolver here would be a second answer to "who is this
|
|
55
|
+
* from", and the two would disagree on some form eventually.
|
|
56
|
+
*/ export function replyRecipient(fields) {
|
|
57
|
+
const email = submissionSender(fields).email;
|
|
58
|
+
if (!email) return {
|
|
59
|
+
refusal: 'no-address'
|
|
60
|
+
};
|
|
61
|
+
const normalized = email.trim().toLowerCase();
|
|
62
|
+
if (!isRoutableAddress(normalized)) return {
|
|
63
|
+
refusal: 'unroutable-address'
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
email: normalized
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A deliberately narrow shape test: one `@`, something either side, a dot in
|
|
71
|
+
* the domain, and no whitespace.
|
|
72
|
+
*
|
|
73
|
+
* It is not RFC 5322 and does not try to be. The question being answered is
|
|
74
|
+
* "would handing this to the provider produce a rejected send", and the
|
|
75
|
+
* addresses that reach here came out of a public form's free-text field, so
|
|
76
|
+
* the common failures are a stray comma, a pasted `mailto:` and a name in
|
|
77
|
+
* angle brackets — all of which this refuses.
|
|
78
|
+
*
|
|
79
|
+
* The colon is in the excluded set for the `mailto:` case specifically: it is
|
|
80
|
+
* not valid in an unquoted local part, and without it `mailto:priya@lumen.co`
|
|
81
|
+
* matches with `mailto:priya` as the local part and is handed to the provider
|
|
82
|
+
* whole.
|
|
83
|
+
*/ export function isRoutableAddress(value) {
|
|
84
|
+
return /^[^\s@,;:<>]+@[^\s@,;:<>]+\.[^\s@,;:<>]+$/.test(String(value != null ? value : ''));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The default subject, which the merchant may then edit.
|
|
88
|
+
*
|
|
89
|
+
* `Re:` is a small lie and it is the right one: the recipient never received
|
|
90
|
+
* a message from us, because the submission arrived over HTTP, so there is
|
|
91
|
+
* nothing to be `Re:` to. But the prefix is what a person scanning an inbox
|
|
92
|
+
* reads as "this answers the thing I sent", and it is what every mail client
|
|
93
|
+
* groups on when the recipient replies again. The alternative — a bare
|
|
94
|
+
* subject — reads as an unsolicited message from a domain they do not know.
|
|
95
|
+
*
|
|
96
|
+
* The site name leads because that is what the recipient recognizes. They
|
|
97
|
+
* filled in a form on a site; they do not know the form's internal name and
|
|
98
|
+
* they have never heard of this platform.
|
|
99
|
+
*/ export function defaultReplySubject(siteName, formName) {
|
|
100
|
+
const site = String(siteName != null ? siteName : '').trim();
|
|
101
|
+
const form = String(formName != null ? formName : '').trim();
|
|
102
|
+
const topic = site || form || 'your message';
|
|
103
|
+
return `Re: your message to ${topic}`.slice(0, REPLY_SUBJECT_MAX);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The reply body as it goes on the wire.
|
|
107
|
+
*
|
|
108
|
+
* Plain text only. `sendEmail` synthesizes the HTML part from it, which is
|
|
109
|
+
* the one place that guarantee lives — a caller that built its own HTML here
|
|
110
|
+
* would be a caller that can get it wrong, and an empty HTML part is a
|
|
111
|
+
* message whose links cannot be clicked or counted.
|
|
112
|
+
*
|
|
113
|
+
* The quoted original is appended rather than left to the merchant, because
|
|
114
|
+
* a recipient who wrote a week ago has no idea which of their messages this
|
|
115
|
+
* answers, and the merchant retyping it is how a support thread loses the
|
|
116
|
+
* question it was about.
|
|
117
|
+
*/ export function composeReplyBody(options) {
|
|
118
|
+
var _options_message, _options_siteName;
|
|
119
|
+
const message = String((_options_message = options.message) != null ? _options_message : '').trim().slice(0, REPLY_BODY_MAX);
|
|
120
|
+
const quoted = quoteSubmission(options.fields);
|
|
121
|
+
const site = String((_options_siteName = options.siteName) != null ? _options_siteName : '').trim();
|
|
122
|
+
const attribution = site ? `This is a reply to the message you sent through ${site}.` : 'This is a reply to the message you sent through our website.';
|
|
123
|
+
return quoted ? `${message}\n\n---\n${attribution}\n\n${quoted}` : `${message}\n\n---\n${attribution}`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The original submission, rendered as `Label: value` lines.
|
|
127
|
+
*
|
|
128
|
+
* Field order is the author's, which is the order the visitor filled them in
|
|
129
|
+
* and therefore the order they will recognize. Empty values are dropped: a
|
|
130
|
+
* quote full of `Phone:` with nothing after it reads as a broken template.
|
|
131
|
+
*/ export function quoteSubmission(fields) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
for (const [key, value] of Object.entries(fields != null ? fields : {})){
|
|
134
|
+
const text = String(value != null ? value : '').trim();
|
|
135
|
+
if (!text) continue;
|
|
136
|
+
lines.push(`> ${key}: ${text}`);
|
|
137
|
+
}
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//# sourceMappingURL=reply-policy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/model/reply-policy.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What may be replied to, and what the reply says on the envelope.\n *\n * Pure policy, no Firestore and no network, so the rules that decide whether\n * a message leaves the building can be asserted without a send path. The\n * handler in `../server.ts` is the only caller.\n *\n * ## A reply is transactional, and that is a consent decision\n *\n * Someone filled in a form and asked to be contacted. Answering them is the\n * transaction they initiated, so no marketing-consent record is required and\n * none is read here. The opposite direction does not follow: replying is not\n * permission to market to them, so nothing in this module enrolls anybody in\n * anything. Enrollment is a separate act with a separate basis and it is\n * deliberately not built — see `docs/specs/inbox-replies-and-list-assignment.md`.\n *\n * ## Suppression is not waived by that\n *\n * \"Transactional\" excuses the absence of consent, never a suppression. A hard\n * bounce means the mailbox does not exist, and a spam complaint means the\n * person asked this platform to stop; both answers are about the address\n * rather than the campaign that learned them, and both apply to a reply.\n * The suppression read itself needs Firestore, so it lives in the handler;\n * what lives here is the ordering it must follow.\n */\n\nimport type { AddressRefusal } from '@aglyn/aglyn/server'\nimport { submissionSender } from './submission-presenter'\n\n/**\n * Why an address cannot be replied to. Each maps to one refusal message.\n *\n * The framework's `AddressRefusal` under a reply-shaped name, not a second\n * union that happens to match. These four are facts about the ADDRESS — no\n * email field, an unroutable value, and the two suppression lists — so the\n * enrollment policy refuses on exactly the same set, and two declarations\n * would drift the moment either side gained a fifth. A `type` import: erased\n * at compile time, so the client component that reads this file takes on\n * nothing from `@aglyn/aglyn/server`.\n */\nexport type ReplyRefusal = AddressRefusal\n\n/** The longest reply the composer accepts, matching the campaign composer. */\nexport const REPLY_BODY_MAX = 20000\n\n/** Subjects longer than this are truncated by most clients anyway. */\nexport const REPLY_SUBJECT_MAX = 150\n\n/**\n * The address a reply to this submission would go to, or why there is none.\n *\n * Resolved from the submission's own `fields`, never from the request body.\n * The recipient of a message this platform sends must be a property of the\n * stored record, not an argument a caller supplies: a site editor who could\n * name the recipient would have a send surface pointed at any address, on a\n * verified domain, in the platform's own name.\n *\n * It resolves through `submissionSender`, which is what the Inbox row already\n * displays, so the address the merchant is shown is the address that is\n * mailed. A second resolver here would be a second answer to \"who is this\n * from\", and the two would disagree on some form eventually.\n */\nexport function replyRecipient(\n fields: Record<string, unknown> | undefined,\n): { email: string } | { refusal: ReplyRefusal } {\n const email = submissionSender(fields).email\n if (!email) return { refusal: 'no-address' }\n const normalized = email.trim().toLowerCase()\n if (!isRoutableAddress(normalized)) return { refusal: 'unroutable-address' }\n return { email: normalized }\n}\n\n/**\n * A deliberately narrow shape test: one `@`, something either side, a dot in\n * the domain, and no whitespace.\n *\n * It is not RFC 5322 and does not try to be. The question being answered is\n * \"would handing this to the provider produce a rejected send\", and the\n * addresses that reach here came out of a public form's free-text field, so\n * the common failures are a stray comma, a pasted `mailto:` and a name in\n * angle brackets — all of which this refuses.\n *\n * The colon is in the excluded set for the `mailto:` case specifically: it is\n * not valid in an unquoted local part, and without it `mailto:priya@lumen.co`\n * matches with `mailto:priya` as the local part and is handed to the provider\n * whole.\n */\nexport function isRoutableAddress(value: string): boolean {\n return /^[^\\s@,;:<>]+@[^\\s@,;:<>]+\\.[^\\s@,;:<>]+$/.test(String(value ?? ''))\n}\n\n/**\n * The default subject, which the merchant may then edit.\n *\n * `Re:` is a small lie and it is the right one: the recipient never received\n * a message from us, because the submission arrived over HTTP, so there is\n * nothing to be `Re:` to. But the prefix is what a person scanning an inbox\n * reads as \"this answers the thing I sent\", and it is what every mail client\n * groups on when the recipient replies again. The alternative — a bare\n * subject — reads as an unsolicited message from a domain they do not know.\n *\n * The site name leads because that is what the recipient recognizes. They\n * filled in a form on a site; they do not know the form's internal name and\n * they have never heard of this platform.\n */\nexport function defaultReplySubject(\n siteName: string | undefined,\n formName: string | undefined,\n): string {\n const site = String(siteName ?? '').trim()\n const form = String(formName ?? '').trim()\n const topic = site || form || 'your message'\n return `Re: your message to ${topic}`.slice(0, REPLY_SUBJECT_MAX)\n}\n\n/**\n * The reply body as it goes on the wire.\n *\n * Plain text only. `sendEmail` synthesizes the HTML part from it, which is\n * the one place that guarantee lives — a caller that built its own HTML here\n * would be a caller that can get it wrong, and an empty HTML part is a\n * message whose links cannot be clicked or counted.\n *\n * The quoted original is appended rather than left to the merchant, because\n * a recipient who wrote a week ago has no idea which of their messages this\n * answers, and the merchant retyping it is how a support thread loses the\n * question it was about.\n */\nexport function composeReplyBody(options: {\n message: string\n fields?: Record<string, unknown>\n siteName?: string\n}): string {\n const message = String(options.message ?? '')\n .trim()\n .slice(0, REPLY_BODY_MAX)\n const quoted = quoteSubmission(options.fields)\n const site = String(options.siteName ?? '').trim()\n const attribution = site\n ? `This is a reply to the message you sent through ${site}.`\n : 'This is a reply to the message you sent through our website.'\n return quoted\n ? `${message}\\n\\n---\\n${attribution}\\n\\n${quoted}`\n : `${message}\\n\\n---\\n${attribution}`\n}\n\n/**\n * The original submission, rendered as `Label: value` lines.\n *\n * Field order is the author's, which is the order the visitor filled them in\n * and therefore the order they will recognize. Empty values are dropped: a\n * quote full of `Phone:` with nothing after it reads as a broken template.\n */\nexport function quoteSubmission(\n fields: Record<string, unknown> | undefined,\n): string {\n const lines: string[] = []\n for (const [key, value] of Object.entries(fields ?? {})) {\n const text = String(value ?? '').trim()\n if (!text) continue\n lines.push(`> ${key}: ${text}`)\n }\n return lines.join('\\n')\n}\n"],"names":["submissionSender","REPLY_BODY_MAX","REPLY_SUBJECT_MAX","replyRecipient","fields","email","refusal","normalized","trim","toLowerCase","isRoutableAddress","value","test","String","defaultReplySubject","siteName","formName","site","form","topic","slice","composeReplyBody","options","message","quoted","quoteSubmission","attribution","lines","key","Object","entries","text","push","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GAGD,SAASA,gBAAgB,QAAQ,4BAAwB;AAezD,4EAA4E,GAC5E,OAAO,MAAMC,iBAAiB,MAAK;AAEnC,oEAAoE,GACpE,OAAO,MAAMC,oBAAoB,IAAG;AAEpC;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,eACdC,MAA2C;IAE3C,MAAMC,QAAQL,iBAAiBI,QAAQC,KAAK;IAC5C,IAAI,CAACA,OAAO,OAAO;QAAEC,SAAS;IAAa;IAC3C,MAAMC,aAAaF,MAAMG,IAAI,GAAGC,WAAW;IAC3C,IAAI,CAACC,kBAAkBH,aAAa,OAAO;QAAED,SAAS;IAAqB;IAC3E,OAAO;QAAED,OAAOE;IAAW;AAC7B;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASG,kBAAkBC,KAAa;IAC7C,OAAO,4CAA4CC,IAAI,CAACC,OAAOF,gBAAAA,QAAS;AAC1E;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASG,oBACdC,QAA4B,EAC5BC,QAA4B;IAE5B,MAAMC,OAAOJ,OAAOE,mBAAAA,WAAY,IAAIP,IAAI;IACxC,MAAMU,OAAOL,OAAOG,mBAAAA,WAAY,IAAIR,IAAI;IACxC,MAAMW,QAAQF,QAAQC,QAAQ;IAC9B,OAAO,CAAC,oBAAoB,EAAEC,OAAO,CAACC,KAAK,CAAC,GAAGlB;AACjD;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASmB,iBAAiBC,OAIhC;QACwBA,kBAIHA;IAJpB,MAAMC,UAAUV,QAAOS,mBAAAA,QAAQC,OAAO,YAAfD,mBAAmB,IACvCd,IAAI,GACJY,KAAK,CAAC,GAAGnB;IACZ,MAAMuB,SAASC,gBAAgBH,QAAQlB,MAAM;IAC7C,MAAMa,OAAOJ,QAAOS,oBAAAA,QAAQP,QAAQ,YAAhBO,oBAAoB,IAAId,IAAI;IAChD,MAAMkB,cAAcT,OAChB,CAAC,gDAAgD,EAAEA,KAAK,CAAC,CAAC,GAC1D;IACJ,OAAOO,SACH,GAAGD,QAAQ,SAAS,EAAEG,YAAY,IAAI,EAAEF,QAAQ,GAChD,GAAGD,QAAQ,SAAS,EAAEG,aAAa;AACzC;AAEA;;;;;;CAMC,GACD,OAAO,SAASD,gBACdrB,MAA2C;IAE3C,MAAMuB,QAAkB,EAAE;IAC1B,KAAK,MAAM,CAACC,KAAKjB,MAAM,IAAIkB,OAAOC,OAAO,CAAC1B,iBAAAA,SAAU,CAAC,GAAI;QACvD,MAAM2B,OAAOlB,OAAOF,gBAAAA,QAAS,IAAIH,IAAI;QACrC,IAAI,CAACuB,MAAM;QACXJ,MAAMK,IAAI,CAAC,CAAC,EAAE,EAAEJ,IAAI,EAAE,EAAEG,MAAM;IAChC;IACA,OAAOJ,MAAMM,IAAI,CAAC;AACpB"}
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
export interface SubmissionSender {
|
|
18
|
+
/** Best available display name — a name, else an email, else a fallback. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** The email, when one was submitted. */
|
|
21
|
+
email?: string;
|
|
22
|
+
/** One or two letters for the avatar. */
|
|
23
|
+
initials: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Who sent it.
|
|
27
|
+
*
|
|
28
|
+
* A form is author-defined, so there is no guaranteed name field — the
|
|
29
|
+
* lookup is by convention and falls back to the email, then to the form's
|
|
30
|
+
* own name. It never returns an empty string: an avatar with no letters in
|
|
31
|
+
* it is worse than a generic one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function submissionSender(fields: Record<string, unknown> | undefined, fallback?: string): SubmissionSender;
|
|
34
|
+
/**
|
|
35
|
+
* Up to two initials. An email falls back to its local part, so
|
|
36
|
+
* `priya@lumen.co` gives `P` rather than a `@`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function initialsOf(label: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* A deterministic hue for the avatar, so one sender keeps one colour
|
|
41
|
+
* across sessions and machines. The mockup's avatars are coloured, and a
|
|
42
|
+
* random palette index would make the same person a different colour on
|
|
43
|
+
* every render.
|
|
44
|
+
*/
|
|
45
|
+
export declare function senderHue(label: string): number;
|
|
46
|
+
/**
|
|
47
|
+
* `2m`, `18m`, `1h`, `3d` — the relative times the mockup's list shows.
|
|
48
|
+
*
|
|
49
|
+
* The console rendered `toLocaleString()`, which is the right answer to a
|
|
50
|
+
* different question: an inbox is scanned for recency, and "8/18/2026,
|
|
51
|
+
* 2:04:31 PM" makes the reader do the subtraction. The absolute time stays
|
|
52
|
+
* on the detail pane, where it is the fact you actually want.
|
|
53
|
+
*/
|
|
54
|
+
export declare function relativeTime(atMs: number | undefined, nowMs?: number): string;
|
|
55
|
+
export interface SubmissionRouting {
|
|
56
|
+
/** The dataset a record was appended to, when one was. */
|
|
57
|
+
dataset?: {
|
|
58
|
+
id?: string;
|
|
59
|
+
name?: string;
|
|
60
|
+
recordId?: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export interface RoutingChip {
|
|
64
|
+
label: string;
|
|
65
|
+
color: 'success' | 'info' | 'default';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The status chips the mockup's detail pane carries under the fields:
|
|
69
|
+
* `Saved to Inbox` (green) and `Added to "Leads" dataset` (blue).
|
|
70
|
+
*
|
|
71
|
+
* The first is unconditional and free — every submission that exists is in
|
|
72
|
+
* the Inbox, which is exactly why the mockup shows it: it is the
|
|
73
|
+
* reassurance, not a status.
|
|
74
|
+
*
|
|
75
|
+
* The second reports what the submit route actually DID. It is stamped on
|
|
76
|
+
* the document only when a record was really appended, so a form bound to
|
|
77
|
+
* a dataset that was deleted, or whose record quota was full, shows no chip
|
|
78
|
+
* rather than a chip for a row that does not exist — the two failure modes
|
|
79
|
+
* the route already swallows silently.
|
|
80
|
+
*/
|
|
81
|
+
export declare function routingChips(routing: SubmissionRouting | undefined): RoutingChip[];
|