@aglyn/plugins-outreach 1.0.0-beta.197 → 1.0.0-beta.198

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.
Files changed (63) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/compliance-section.d.ts +3 -1
  3. package/src/lib/components/compliance-section.js +110 -45
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.d.ts +7 -1
  6. package/src/lib/components/enroll-dialog.js +47 -3
  7. package/src/lib/components/enroll-dialog.js.map +1 -1
  8. package/src/lib/components/outreach-ui.js +1 -0
  9. package/src/lib/components/outreach-ui.js.map +1 -1
  10. package/src/lib/components/sequence-editor.js +25 -24
  11. package/src/lib/components/sequence-editor.js.map +1 -1
  12. package/src/lib/engine/delivery-status.d.ts +8 -0
  13. package/src/lib/engine/delivery-status.js +10 -5
  14. package/src/lib/engine/delivery-status.js.map +1 -1
  15. package/src/lib/engine/enrollment-state.d.ts +2 -0
  16. package/src/lib/engine/enrollment-state.js +2 -2
  17. package/src/lib/engine/enrollment-state.js.map +1 -1
  18. package/src/lib/engine/gates.d.ts +28 -1
  19. package/src/lib/engine/gates.js +28 -1
  20. package/src/lib/engine/gates.js.map +1 -1
  21. package/src/lib/engine/mail-gateway.d.ts +97 -0
  22. package/src/lib/engine/mail-gateway.js +226 -0
  23. package/src/lib/engine/mail-gateway.js.map +1 -0
  24. package/src/lib/engine/mailbox-health.d.ts +3 -3
  25. package/src/lib/engine/mailbox-health.js +17 -11
  26. package/src/lib/engine/mailbox-health.js.map +1 -1
  27. package/src/lib/engine/thread-classification.d.ts +2 -0
  28. package/src/lib/engine/thread-classification.js +2 -1
  29. package/src/lib/engine/thread-classification.js.map +1 -1
  30. package/src/lib/enrollment/enroll-people.d.ts +6 -1
  31. package/src/lib/enrollment/enroll-people.js +18 -4
  32. package/src/lib/enrollment/enroll-people.js.map +1 -1
  33. package/src/lib/enrollment/gate-lookups.d.ts +7 -0
  34. package/src/lib/enrollment/gate-lookups.js +11 -2
  35. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  36. package/src/lib/model/outreach-api.d.ts +14 -0
  37. package/src/lib/model/outreach-api.js.map +1 -1
  38. package/src/lib/model/outreach.types.d.ts +79 -3
  39. package/src/lib/model/outreach.types.js +21 -2
  40. package/src/lib/model/outreach.types.js.map +1 -1
  41. package/src/lib/routes/enroll-routes.js +23 -4
  42. package/src/lib/routes/enroll-routes.js.map +1 -1
  43. package/src/lib/routes/enrollment-routes.js +15 -1
  44. package/src/lib/routes/enrollment-routes.js.map +1 -1
  45. package/src/lib/routes/register-routes.js +3 -0
  46. package/src/lib/routes/register-routes.js.map +1 -1
  47. package/src/lib/routes/route-deps.d.ts +6 -0
  48. package/src/lib/routes/route-deps.js.map +1 -1
  49. package/src/lib/runtime/gateway-ledger.d.ts +46 -0
  50. package/src/lib/runtime/gateway-ledger.js +113 -0
  51. package/src/lib/runtime/gateway-ledger.js.map +1 -0
  52. package/src/lib/runtime/platform-runtime-deps.js +3 -0
  53. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  54. package/src/lib/runtime/runtime-deps.d.ts +6 -0
  55. package/src/lib/runtime/runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +48 -0
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/runtime/sync-job.d.ts +2 -0
  59. package/src/lib/runtime/sync-job.js +40 -3
  60. package/src/lib/runtime/sync-job.js.map +1 -1
  61. package/src/lib/storage/domain-intel-store.d.ts +107 -0
  62. package/src/lib/storage/domain-intel-store.js +320 -0
  63. package/src/lib/storage/domain-intel-store.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-editor.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp, type ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { mdiPlus } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useOrgCampaigns, useUser } from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Autocomplete,\n Box,\n Button,\n Chip,\n FormControlLabel,\n Grid,\n Menu,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n isInThreadEmailStep,\n OUTREACH_SEQUENCE_NAME_MAX,\n validateOutreachSequence,\n} from '../engine/sequence-validation'\nimport { outreachCountryLabel } from '../model/compliance-settings'\nimport {\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEPS,\n OUTREACH_TASK_KIND_LABELS,\n OUTREACH_TASK_KINDS,\n type OutreachSequence,\n type OutreachSequenceStep,\n type OutreachTaskKind,\n} from '../model/outreach.types'\nimport {\n emptyOutreachSequenceDraft,\n newOutreachEmailStep,\n newOutreachTaskStep,\n outreachSequenceDraftOf,\n type OutreachSequenceDraft,\n type OutreachSequenceIssue,\n} from '../model/sequence-draft'\nimport { OutreachSendWindowFields } from './send-window-fields'\nimport { OutreachSequenceMailboxPicker } from './sequence-mailbox-picker'\nimport { OutreachSequencePreview } from './sequence-preview'\nimport { OutreachStepCard } from './sequence-step-card'\nimport { OutreachStepTestDialog } from './step-test-dialog'\nimport { OutreachRouteError, useOutreachApi } from './use-outreach-api'\nimport { useOutreachEmailTemplates } from './use-outreach-crm'\nimport type { OutreachMailboxesResult } from './use-outreach-mailboxes'\nimport type { OutreachSettingsLoad } from './use-outreach-settings'\n\nexport interface OutreachSequenceEditorProps {\n orgId: string\n orgMount?: ConsolePluginOrgMount\n /** The organization document, for the consent group a test send's person picker searches (AGL-3325). */\n org?: Record<string, unknown> | null\n /** The stored sequence, or `null` for a new one. */\n sequence: OutreachSequence | null\n /** The organization's compliance settings: its countries, and the preview's footer. */\n settings: OutreachSettingsLoad\n /**\n * The organization's mailboxes, from the Mailboxes section's listener:\n * what the picker offers, and who the preview's emails are from.\n */\n mailboxes: OutreachMailboxesResult\n /** The Mailboxes section, where the picker sends a member to connect one. */\n mailboxesPath: string\n onSaved(sequence: OutreachSequence): void\n onCancel?(): void\n}\n\n/** The step a dotted issue path is about, and the field within it. */\nfunction stepIssueKey(path: string): { index: number; field: string } | null {\n const match = /^steps\\.(\\d+)(?:\\.(.+))?$/.exec(path)\n if (!match) return null\n const field = match[2] ?? 'step'\n return { index: Number(match[1]), field: field === 'id' ? 'step' : field }\n}\n\n/**\n * The sequence editor (AGL-2980): the name, the site and mailbox, the steps,\n * who it sends to and when, beside a live preview of each email.\n *\n * Every issue the engine's validator finds is shown beside its field — the\n * same validator the save route refuses with — errors once a save was\n * attempted, warnings as soon as they appear. What only the server can\n * judge (whose mailbox, which countries the organization allows, what may\n * no longer change once people are enrolled) comes back from the save and\n * is shown in the same places.\n */\nexport function OutreachSequenceEditor(props: OutreachSequenceEditorProps) {\n const { orgId, orgMount, sequence, settings } = props\n const api = useOutreachApi(orgId)\n const { enqueueSnackbar } = useSnackbar()\n const { data: user } = useUser()\n const uid = user?.uid ?? null\n const mountedHosts = orgMount?.hosts\n const hosts = useMemo(() => mountedHosts ?? [], [mountedHosts])\n const orgCountries = settings.settings?.allowedCountries ?? ['US']\n\n const [draft, setDraft] = useState<OutreachSequenceDraft>(() =>\n sequence\n ? outreachSequenceDraftOf(sequence)\n : emptyOutreachSequenceDraft({\n hostId: hosts.length === 1 ? hosts[0].id : '',\n allowedCountries: orgCountries,\n }),\n )\n const [attempted, setAttempted] = useState(false)\n const [serverIssues, setServerIssues] = useState<OutreachSequenceIssue[]>([])\n const [saving, setSaving] = useState(false)\n const [taskAnchor, setTaskAnchor] = useState<HTMLElement | null>(null)\n // The step whose test-send dialog is open (AGL-3325), by index.\n const [testing, setTesting] = useState<number | null>(null)\n\n // A stored sequence the listener updated (a save, a status change) is the\n // new starting point.\n useEffect(() => {\n if (sequence) setDraft(outreachSequenceDraftOf(sequence))\n }, [sequence])\n // A new sequence starts on the organization's countries once they are read.\n const settingsCountries = settings.settings?.allowedCountries.join(',')\n useEffect(() => {\n if (!sequence && settingsCountries) {\n setDraft((previous) => ({\n ...previous,\n settings: {\n ...previous.settings,\n allowedCountries: settingsCountries.split(','),\n },\n }))\n }\n }, [sequence, settingsCountries])\n useEffect(() => {\n if (!sequence && !draft.hostId && hosts.length === 1)\n setDraft((previous) => ({ ...previous, hostId: hosts[0].id }))\n }, [sequence, draft.hostId, hosts])\n\n // A new sequence starts on the member's own mailbox when they have exactly\n // one that is sending.\n const ownSending = props.mailboxes.mailboxes.filter(\n (entry) => entry.connectedByUid === uid && entry.status === 'connected',\n )\n const onlyOwnMailbox = ownSending.length === 1 ? ownSending[0].id : ''\n useEffect(() => {\n if (!sequence && !draft.mailboxId && onlyOwnMailbox)\n setDraft((previous) => ({ ...previous, mailboxId: onlyOwnMailbox }))\n }, [sequence, draft.mailboxId, onlyOwnMailbox])\n\n const templates = useOutreachEmailTemplates(orgId, draft.hostId || null, uid)\n const mailbox =\n props.mailboxes.mailboxes.find((entry) => entry.id === draft.mailboxId) ??\n null\n const archived = sequence?.status === 'archived'\n /*\n * The org's campaigns, for the picker (AGL-3254): read while the editor\n * is open. A sequence is the organization's record and so is a campaign,\n * so the choice is every campaign in the org, whichever site the\n * sequence sends as.\n */\n const campaigns = useOrgCampaigns(orgId, { enabled: true })\n\n const issues = useMemo(() => {\n const judged = [\n ...validateOutreachSequence(draft),\n ...serverIssues,\n ] as OutreachSequenceIssue[]\n return judged.filter(\n (issue) =>\n issue.severity === 'warning' ||\n attempted ||\n serverIssues.includes(issue),\n )\n }, [draft, serverIssues, attempted])\n const issueAt = (path: string) =>\n issues.find((issue) => issue.path === path)?.message\n const stepIssues = (index: number) => {\n const found: Record<string, string> = {}\n for (const issue of issues) {\n const key = stepIssueKey(issue.path)\n if (key?.index === index && !found[key.field])\n found[key.field] = issue.message\n }\n return found\n }\n const listIssue = issueAt('steps')\n const countriesIssue = issues.find((issue) =>\n issue.path.startsWith('settings.allowedCountries'),\n )?.message\n\n const update = (next: Partial<OutreachSequenceDraft>) => {\n setServerIssues([])\n setDraft((previous) => ({ ...previous, ...next }))\n }\n const setStep = (index: number, step: OutreachSequenceStep) =>\n update({\n steps: draft.steps.map((entry, position) =>\n position === index ? step : entry,\n ),\n })\n const moveStep = (index: number, direction: -1 | 1) => {\n const steps = [...draft.steps]\n const [moved] = steps.splice(index, 1)\n steps.splice(index + direction, 0, moved)\n update({ steps })\n }\n const emailCount = draft.steps.filter((step) => step.kind === 'email').length\n const full = draft.steps.length >= OUTREACH_MAX_STEPS\n const threadSubject = (index: number) => {\n for (let position = index - 1; position >= 0; position -= 1) {\n const step = draft.steps[position]\n if (step.kind === 'email' && !isInThreadEmailStep(draft.steps, position))\n return step.subject\n }\n return ''\n }\n\n const save = async () => {\n setAttempted(true)\n if (\n validateOutreachSequence(draft).some(\n (issue) => issue.severity === 'error',\n )\n )\n return\n setSaving(true)\n try {\n const answer = await api.saveSequence(sequence?.id ?? null, draft)\n setServerIssues([])\n setAttempted(false)\n enqueueSnackbar(\n answer.created ? 'Sequence created.' : 'Sequence saved.',\n { variant: 'success' },\n )\n props.onSaved(answer.sequence)\n } catch (error) {\n if (error instanceof OutreachRouteError && error.issues.length) {\n setServerIssues(\n error.issues.filter(\n (issue): issue is OutreachSequenceIssue => 'path' in issue,\n ),\n )\n }\n enqueueSnackbar((error as Error).message, {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setSaving(false)\n }\n }\n\n const siteName = hosts.find((host) => host.id === draft.hostId)?.name ?? ''\n const countryOptions = [\n ...new Set([...orgCountries, ...draft.settings.allowedCountries]),\n ]\n /*\n * A test sends the step as STORED (AGL-3325): the route reads the saved\n * sequence, so a new one has nothing to send yet and an edited one sends\n * its last save — said on the button and in the dialog rather than found\n * out in the inbox.\n */\n const unsaved =\n sequence !== null &&\n JSON.stringify(draft) !== JSON.stringify(outreachSequenceDraftOf(sequence))\n const sendTestDisabledReason = !sequence\n ? 'Create the sequence to send a test of it.'\n : !draft.mailboxId\n ? 'Choose a mailbox to send the test from.'\n : null\n\n return (\n <Grid container spacing={2}>\n <Grid size={{ xs: 12, md: 7 }}>\n <Stack spacing={2}>\n {archived ? (\n <Alert severity=\"info\">\n This sequence is archived, so it can’t be edited.\n </Alert>\n ) : null}\n <CardDisplay\n header=\"Sequence\"\n help={pluginDocsHelp('sequences', { anchor: '#build-a-sequence' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <TextField\n label=\"Name\"\n value={draft.name}\n onChange={(event) => update({ name: event.target.value })}\n disabled={archived}\n error={Boolean(issueAt('name'))}\n helperText={issueAt('name')}\n slotProps={{\n htmlInput: { maxLength: OUTREACH_SEQUENCE_NAME_MAX + 20 },\n }}\n fullWidth\n />\n <TextField\n select\n label=\"Site\"\n value={\n hosts.some((host) => host.id === draft.hostId)\n ? draft.hostId\n : ''\n }\n onChange={(event) => update({ hostId: event.target.value })}\n disabled={archived || !hosts.length}\n error={Boolean(issueAt('hostId'))}\n helperText={\n issueAt('hostId') ??\n 'The site whose contacts this sequence emails.'\n }\n fullWidth\n >\n {hosts.map((host) => (\n <MenuItem key={host.id} value={host.id}>\n {host.name}\n </MenuItem>\n ))}\n </TextField>\n <OutreachSequenceMailboxPicker\n orgId={orgId}\n uid={uid}\n mailboxes={props.mailboxes}\n mailboxesPath={props.mailboxesPath}\n value={draft.mailboxId}\n onChange={(mailboxId) => update({ mailboxId })}\n error={issueAt('mailboxId')}\n disabled={archived}\n />\n {/*\n The campaigns this sequence is part of (AGL-3254), picked the\n way a form's page picks them. Everyone enrolled joins them,\n and what the sequence produces is reported under them; it\n does not change who is enrolled or what is sent.\n */}\n <CampaignPicker\n options={campaigns.options}\n value={draft.campaignIds}\n onChange={(campaignIds) => update({ campaignIds })}\n label=\"Campaigns\"\n helperText={\n issueAt('campaignIds') ??\n 'The campaigns this sequence is part of. Everyone enrolled joins them, and its sends, replies, meetings and conversions count on their pages.'\n }\n disabled={archived}\n empty={campaigns.ready && !campaigns.options.length}\n emptyText=\"There are no campaigns yet. Create one from Marketing to file this sequence under it.\"\n />\n </Stack>\n </CardDisplay>\n\n {listIssue ? <Alert severity=\"error\">{listIssue}</Alert> : null}\n {draft.steps.map((step, index) => (\n <OutreachStepCard\n key={step.id || `step-${index}`}\n step={step}\n index={index}\n count={draft.steps.length}\n firstEmail={\n draft.steps.findIndex((entry) => entry.kind === 'email') ===\n index\n }\n inThread={isInThreadEmailStep(draft.steps, index)}\n threadSubject={threadSubject(index)}\n templates={templates.data}\n issues={stepIssues(index)}\n disabled={archived}\n onChange={(next) => setStep(index, next)}\n onMove={(direction) => moveStep(index, direction)}\n onRemove={() =>\n update({\n steps: draft.steps.filter(\n (_entry, position) => position !== index,\n ),\n })\n }\n onSendTest={\n step.kind === 'email' && !archived\n ? () => setTesting(index)\n : undefined\n }\n sendTestDisabledReason={sendTestDisabledReason}\n />\n ))}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPlus.path} fontSize=\"small\" />}\n disabled={\n archived || full || emailCount >= OUTREACH_MAX_EMAIL_STEPS\n }\n onClick={() =>\n update({\n steps: [...draft.steps, newOutreachEmailStep(draft.steps)],\n })\n }\n >\n Add email\n </Button>\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPlus.path} fontSize=\"small\" />}\n disabled={archived || full}\n onClick={(event) => setTaskAnchor(event.currentTarget)}\n aria-haspopup=\"menu\"\n >\n Add task\n </Button>\n <Menu\n anchorEl={taskAnchor}\n open={Boolean(taskAnchor)}\n onClose={() => setTaskAnchor(null)}\n >\n {OUTREACH_TASK_KINDS.map((kind: OutreachTaskKind) => (\n <MenuItem\n key={kind}\n onClick={() => {\n setTaskAnchor(null)\n update({\n steps: [\n ...draft.steps,\n newOutreachTaskStep(draft.steps, kind),\n ],\n })\n }}\n >\n {OUTREACH_TASK_KIND_LABELS[kind]}\n </MenuItem>\n ))}\n </Menu>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Up to ${OUTREACH_MAX_STEPS} steps, ${OUTREACH_MAX_EMAIL_STEPS} of them emails.`}\n </Typography>\n\n <CardDisplay\n header=\"Who it sends to, and when\"\n help={pluginDocsHelp('sequences', {\n anchor: '#build-a-sequence',\n excerpt:\n 'A sequence sends only to the countries both it and your organization allow, and in its mailbox’s sending hours unless it has its own.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Autocomplete<string, true>\n multiple\n options={countryOptions}\n value={draft.settings.allowedCountries}\n getOptionLabel={outreachCountryLabel}\n onChange={(_event, next) =>\n update({\n settings: { ...draft.settings, allowedCountries: next },\n })\n }\n disabled={archived}\n renderValue={(value, getItemProps) =>\n value.map((code, index) => {\n const { key, ...item } = getItemProps({ index })\n return (\n <Chip\n key={key}\n size=\"small\"\n label={outreachCountryLabel(code)}\n {...item}\n />\n )\n })\n }\n renderInput={(params) => (\n <TextField\n {...params}\n label=\"Countries\"\n error={Boolean(countriesIssue)}\n helperText={\n countriesIssue ??\n 'Among the countries your organization allows. Cold contacts are emailed only in the United States.'\n }\n />\n )}\n />\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.allowCustomers}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n allowCustomers: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Include customers\"\n />\n {/*\n * Click tracking (AGL-3239), with its cost stated where it is\n * chosen rather than in a document nobody opens. Off by\n * default: it changes the link a cold recipient sees, and the\n * emails stay plain text either way — there is no open\n * tracking to turn on, because a tracking image needs an HTML\n * email and these are not.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.trackClicks}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n trackClicks: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Count link clicks\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Links in these emails are replaced with links of ours that\n forward to the same page, so clicks can be counted. The\n recipient sees the replacement, not your address. Opens\n aren’t counted either way: these emails are plain text, and\n counting an open needs a tracking image.\n </Typography>\n </Stack>\n {/*\n * The mail-client unsubscribe button (AGL-3296), off by\n * default: the header is what makes Apple Mail, Gmail and\n * Outlook present a one-to-one email as a mailing list. The\n * footer's \"reply 'no'\" line is the way out either way.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.listUnsubscribe}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n listUnsubscribe: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Add a mail-client unsubscribe button\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Adds the List-Unsubscribe header, so the recipient’s mail\n app shows its own Unsubscribe button. It’s the easiest way\n out for them, and fewer people mark you as spam when they\n have it, but mail apps then label the email as coming from\n a mailing list. Off, the footer’s “reply ‘no’” line is the\n way out, and it’s always there.\n </Typography>\n </Stack>\n <OutreachSendWindowFields\n window={draft.settings.window}\n onChange={(window) =>\n update({ settings: { ...draft.settings, window } })\n }\n daysIssue={issueAt('settings.window.days')}\n hoursIssue={issueAt('settings.window')}\n disabled={archived}\n />\n </Stack>\n </CardDisplay>\n\n {archived ? null : (\n <Stack\n direction={{ xs: 'column', sm: 'row' }}\n spacing={1}\n sx={{ justifyContent: 'flex-end' }}\n >\n {props.onCancel ? (\n <Button\n variant=\"text\"\n onClick={props.onCancel}\n disabled={saving}\n >\n Cancel\n </Button>\n ) : null}\n <Button\n variant=\"contained\"\n onClick={() => void save()}\n disabled={saving}\n >\n {saving ? 'Saving…' : sequence ? 'Save' : 'Create sequence'}\n </Button>\n </Stack>\n )}\n </Stack>\n </Grid>\n {sequence && testing !== null ? (\n <OutreachStepTestDialog\n open\n onClose={() => setTesting(null)}\n orgId={orgId}\n sequence={sequence}\n stepIndex={testing}\n contactGroupId={\n sequence.hostId\n ? consentGroupForHost(props.org ?? null, sequence.hostId).groupId\n : ''\n }\n selfEmail={user?.email ?? null}\n unsaved={unsaved}\n api={api}\n />\n ) : null}\n <Grid size={{ xs: 12, md: 5 }}>\n <Box sx={{ position: { md: 'sticky' }, top: { md: 16 } }}>\n <OutreachSequencePreview\n steps={draft.steps}\n orgSettings={settings.settings}\n orgSettingsStatus={settings.status}\n templates={templates.data}\n sender={\n mailbox\n ? {\n name: mailbox.displayName,\n email: mailbox.sendAs || mailbox.email,\n }\n : null\n }\n siteName={siteName}\n />\n </Box>\n </Grid>\n </Grid>\n )\n}\nOutreachSequenceEditor.displayName = 'OutreachSequenceEditor'\n\nexport default OutreachSequenceEditor\n"],"names":["pluginDocsHelp","consentGroupForHost","mdiPlus","CardDisplay","MdiIcon","CampaignPicker","useSnackbar","useOrgCampaigns","useUser","Alert","Autocomplete","Box","Button","Chip","FormControlLabel","Grid","Menu","MenuItem","Stack","Switch","TextField","Typography","useEffect","useMemo","useState","isInThreadEmailStep","OUTREACH_SEQUENCE_NAME_MAX","validateOutreachSequence","outreachCountryLabel","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEPS","OUTREACH_TASK_KIND_LABELS","OUTREACH_TASK_KINDS","emptyOutreachSequenceDraft","newOutreachEmailStep","newOutreachTaskStep","outreachSequenceDraftOf","OutreachSendWindowFields","OutreachSequenceMailboxPicker","OutreachSequencePreview","OutreachStepCard","OutreachStepTestDialog","OutreachRouteError","useOutreachApi","useOutreachEmailTemplates","stepIssueKey","path","match","exec","field","index","Number","OutreachSequenceEditor","props","issueAt","settings","issues","hosts","orgId","orgMount","sequence","api","enqueueSnackbar","data","user","uid","mountedHosts","orgCountries","allowedCountries","draft","setDraft","hostId","length","id","attempted","setAttempted","serverIssues","setServerIssues","saving","setSaving","taskAnchor","setTaskAnchor","testing","setTesting","settingsCountries","join","previous","split","ownSending","mailboxes","filter","entry","connectedByUid","status","onlyOwnMailbox","mailboxId","templates","mailbox","find","archived","campaigns","enabled","judged","issue","severity","includes","message","stepIssues","found","key","listIssue","countriesIssue","startsWith","update","next","setStep","step","steps","map","position","moveStep","direction","moved","splice","emailCount","kind","full","threadSubject","subject","save","some","answer","saveSequence","created","variant","onSaved","error","allowDuplicate","siteName","host","name","countryOptions","Set","unsaved","JSON","stringify","sendTestDisabledReason","container","spacing","size","xs","md","header","help","anchor","contentGutterX","contentGutterY","label","value","onChange","event","target","disabled","Boolean","helperText","slotProps","htmlInput","maxLength","fullWidth","select","mailboxesPath","options","campaignIds","empty","ready","emptyText","count","firstEmail","findIndex","inThread","onMove","onRemove","_entry","onSendTest","undefined","sx","flexWrap","rowGap","startIcon","fontSize","onClick","currentTarget","aria-haspopup","anchorEl","open","onClose","color","excerpt","multiple","getOptionLabel","_event","renderValue","getItemProps","code","item","renderInput","params","control","checked","allowCustomers","trackClicks","listUnsubscribe","window","daysIssue","hoursIssue","sm","justifyContent","onCancel","stepIndex","contactGroupId","org","groupId","selfEmail","email","top","orgSettings","orgSettingsStatus","sender","displayName","sendAs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;;AAEA,SAASA,cAAc,QAAoC,eAAc;AACzE,SAASC,mBAAmB,QAAQ,wCAAuC;AAC3E,SAASC,OAAO,QAAQ,yBAAwB;AAChD,SAASC,WAAW,EAAEC,OAAO,QAAQ,uBAAsB;AAC3D,OAAOC,oBAAoB,wEAAuE;AAClG,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAASC,eAAe,EAAEC,OAAO,QAAQ,iCAAgC;AACzE,SACEC,KAAK,EACLC,YAAY,EACZC,GAAG,EACHC,MAAM,EACNC,IAAI,EACJC,gBAAgB,EAChBC,IAAI,EACJC,IAAI,EACJC,QAAQ,EACRC,KAAK,EACLC,MAAM,EACNC,SAAS,EACTC,UAAU,QACL,gBAAe;AACtB,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACpD,SACEC,mBAAmB,EACnBC,0BAA0B,EAC1BC,wBAAwB,QACnB,mCAA+B;AACtC,SAASC,oBAAoB,QAAQ,kCAA8B;AACnE,SACEC,wBAAwB,EACxBC,kBAAkB,EAClBC,yBAAyB,EACzBC,mBAAmB,QAId,6BAAyB;AAChC,SACEC,0BAA0B,EAC1BC,oBAAoB,EACpBC,mBAAmB,EACnBC,uBAAuB,QAGlB,6BAAyB;AAChC,SAASC,wBAAwB,QAAQ,0BAAsB;AAC/D,SAASC,6BAA6B,QAAQ,+BAA2B;AACzE,SAASC,uBAAuB,QAAQ,wBAAoB;AAC5D,SAASC,gBAAgB,QAAQ,0BAAsB;AACvD,SAASC,sBAAsB,QAAQ,wBAAoB;AAC3D,SAASC,kBAAkB,EAAEC,cAAc,QAAQ,wBAAoB;AACvE,SAASC,yBAAyB,QAAQ,wBAAoB;AAwB9D,oEAAoE,GACpE,SAASC,aAAaC,IAAY;QAGlBC;IAFd,MAAMA,QAAQ,4BAA4BC,IAAI,CAACF;IAC/C,IAAI,CAACC,OAAO,OAAO;IACnB,MAAME,SAAQF,UAAAA,KAAK,CAAC,EAAE,YAARA,UAAY;IAC1B,OAAO;QAAEG,OAAOC,OAAOJ,KAAK,CAAC,EAAE;QAAGE,OAAOA,UAAU,OAAO,SAASA;IAAM;AAC3E;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASG,uBAAuBC,KAAkC;qBA6DrEA,wCAgKcC,UAiCAA,WAoRkBD;QA1gBbE,oBAuBKA,qBAiEHC,cAkENC;IAjKjB,MAAM,EAAEC,KAAK,EAAEC,QAAQ,EAAEC,QAAQ,EAAEL,QAAQ,EAAE,GAAGF;IAChD,MAAMQ,MAAMlB,eAAee;IAC3B,MAAM,EAAEI,eAAe,EAAE,GAAGxD;IAC5B,MAAM,EAAEyD,MAAMC,IAAI,EAAE,GAAGxD;IACvB,MAAMyD,cAAMD,wBAAAA,KAAMC,GAAG,mBAAI;IACzB,MAAMC,eAAeP,4BAAAA,SAAUF,KAAK;IACpC,MAAMA,QAAQlC,QAAQ,IAAM2C,uBAAAA,eAAgB,EAAE,EAAE;QAACA;KAAa;IAC9D,MAAMC,yBAAeZ,qBAAAA,SAASA,QAAQ,qBAAjBA,mBAAmBa,gBAAgB,oBAAI;QAAC;KAAK;IAElE,MAAM,CAACC,OAAOC,SAAS,GAAG9C,SAAgC,IACxDoC,WACIxB,wBAAwBwB,YACxB3B,2BAA2B;YACzBsC,QAAQd,MAAMe,MAAM,KAAK,IAAIf,KAAK,CAAC,EAAE,CAACgB,EAAE,GAAG;YAC3CL,kBAAkBD;QACpB;IAEN,MAAM,CAACO,WAAWC,aAAa,GAAGnD,SAAS;IAC3C,MAAM,CAACoD,cAAcC,gBAAgB,GAAGrD,SAAkC,EAAE;IAC5E,MAAM,CAACsD,QAAQC,UAAU,GAAGvD,SAAS;IACrC,MAAM,CAACwD,YAAYC,cAAc,GAAGzD,SAA6B;IACjE,gEAAgE;IAChE,MAAM,CAAC0D,SAASC,WAAW,GAAG3D,SAAwB;IAEtD,0EAA0E;IAC1E,sBAAsB;IACtBF,UAAU;QACR,IAAIsC,UAAUU,SAASlC,wBAAwBwB;IACjD,GAAG;QAACA;KAAS;IACb,4EAA4E;IAC5E,MAAMwB,qBAAoB7B,sBAAAA,SAASA,QAAQ,qBAAjBA,oBAAmBa,gBAAgB,CAACiB,IAAI,CAAC;IACnE/D,UAAU;QACR,IAAI,CAACsC,YAAYwB,mBAAmB;YAClCd,SAAS,CAACgB,WAAc,aACnBA;oBACH/B,UAAU,aACL+B,SAAS/B,QAAQ;wBACpBa,kBAAkBgB,kBAAkBG,KAAK,CAAC;;;QAGhD;IACF,GAAG;QAAC3B;QAAUwB;KAAkB;IAChC9D,UAAU;QACR,IAAI,CAACsC,YAAY,CAACS,MAAME,MAAM,IAAId,MAAMe,MAAM,KAAK,GACjDF,SAAS,CAACgB,WAAc,aAAKA;gBAAUf,QAAQd,KAAK,CAAC,EAAE,CAACgB,EAAE;;IAC9D,GAAG;QAACb;QAAUS,MAAME,MAAM;QAAEd;KAAM;IAElC,2EAA2E;IAC3E,uBAAuB;IACvB,MAAM+B,aAAanC,MAAMoC,SAAS,CAACA,SAAS,CAACC,MAAM,CACjD,CAACC,QAAUA,MAAMC,cAAc,KAAK3B,OAAO0B,MAAME,MAAM,KAAK;IAE9D,MAAMC,iBAAiBN,WAAWhB,MAAM,KAAK,IAAIgB,UAAU,CAAC,EAAE,CAACf,EAAE,GAAG;IACpEnD,UAAU;QACR,IAAI,CAACsC,YAAY,CAACS,MAAM0B,SAAS,IAAID,gBACnCxB,SAAS,CAACgB,WAAc,aAAKA;gBAAUS,WAAWD;;IACtD,GAAG;QAAClC;QAAUS,MAAM0B,SAAS;QAAED;KAAe;IAE9C,MAAME,YAAYpD,0BAA0Bc,OAAOW,MAAME,MAAM,IAAI,MAAMN;IACzE,MAAMgC,WACJ5C,kCAAAA,MAAMoC,SAAS,CAACA,SAAS,CAACS,IAAI,CAAC,CAACP,QAAUA,MAAMlB,EAAE,KAAKJ,MAAM0B,SAAS,aAAtE1C,kCACA;IACF,MAAM8C,WAAWvC,CAAAA,4BAAAA,SAAUiC,MAAM,MAAK;IACtC;;;;;GAKC,GACD,MAAMO,YAAY7F,gBAAgBmD,OAAO;QAAE2C,SAAS;IAAK;IAEzD,MAAM7C,SAASjC,QAAQ;QACrB,MAAM+E,SAAS;eACV3E,yBAAyB0C;eACzBO;SACJ;QACD,OAAO0B,OAAOZ,MAAM,CAClB,CAACa,QACCA,MAAMC,QAAQ,KAAK,aACnB9B,aACAE,aAAa6B,QAAQ,CAACF;IAE5B,GAAG;QAAClC;QAAOO;QAAcF;KAAU;IACnC,MAAMpB,UAAU,CAACR;YACfU;gBAAAA,eAAAA,OAAO0C,IAAI,CAAC,CAACK,QAAUA,MAAMzD,IAAI,KAAKA,0BAAtCU,aAA6CkD,OAAO;;IACtD,MAAMC,aAAa,CAACzD;QAClB,MAAM0D,QAAgC,CAAC;QACvC,KAAK,MAAML,SAAS/C,OAAQ;YAC1B,MAAMqD,MAAMhE,aAAa0D,MAAMzD,IAAI;YACnC,IAAI+D,CAAAA,uBAAAA,IAAK3D,KAAK,MAAKA,SAAS,CAAC0D,KAAK,CAACC,IAAI5D,KAAK,CAAC,EAC3C2D,KAAK,CAACC,IAAI5D,KAAK,CAAC,GAAGsD,MAAMG,OAAO;QACpC;QACA,OAAOE;IACT;IACA,MAAME,YAAYxD,QAAQ;IAC1B,MAAMyD,kBAAiBvD,eAAAA,OAAO0C,IAAI,CAAC,CAACK,QAClCA,MAAMzD,IAAI,CAACkE,UAAU,CAAC,kDADDxD,aAEpBkD,OAAO;IAEV,MAAMO,SAAS,CAACC;QACdrC,gBAAgB,EAAE;QAClBP,SAAS,CAACgB,WAAc,aAAKA,UAAa4B;IAC5C;IACA,MAAMC,UAAU,CAACjE,OAAekE,OAC9BH,OAAO;YACLI,OAAOhD,MAAMgD,KAAK,CAACC,GAAG,CAAC,CAAC3B,OAAO4B,WAC7BA,aAAarE,QAAQkE,OAAOzB;QAEhC;IACF,MAAM6B,WAAW,CAACtE,OAAeuE;QAC/B,MAAMJ,QAAQ;eAAIhD,MAAMgD,KAAK;SAAC;QAC9B,MAAM,CAACK,MAAM,GAAGL,MAAMM,MAAM,CAACzE,OAAO;QACpCmE,MAAMM,MAAM,CAACzE,QAAQuE,WAAW,GAAGC;QACnCT,OAAO;YAAEI;QAAM;IACjB;IACA,MAAMO,aAAavD,MAAMgD,KAAK,CAAC3B,MAAM,CAAC,CAAC0B,OAASA,KAAKS,IAAI,KAAK,SAASrD,MAAM;IAC7E,MAAMsD,OAAOzD,MAAMgD,KAAK,CAAC7C,MAAM,IAAI1C;IACnC,MAAMiG,gBAAgB,CAAC7E;QACrB,IAAK,IAAIqE,WAAWrE,QAAQ,GAAGqE,YAAY,GAAGA,YAAY,EAAG;YAC3D,MAAMH,OAAO/C,MAAMgD,KAAK,CAACE,SAAS;YAClC,IAAIH,KAAKS,IAAI,KAAK,WAAW,CAACpG,oBAAoB4C,MAAMgD,KAAK,EAAEE,WAC7D,OAAOH,KAAKY,OAAO;QACvB;QACA,OAAO;IACT;IAEA,MAAMC,OAAO;QACXtD,aAAa;QACb,IACEhD,yBAAyB0C,OAAO6D,IAAI,CAClC,CAAC3B,QAAUA,MAAMC,QAAQ,KAAK,UAGhC;QACFzB,UAAU;QACV,IAAI;;YACF,MAAMoD,SAAS,MAAMtE,IAAIuE,YAAY,SAACxE,4BAAAA,SAAUa,EAAE,mBAAI,MAAMJ;YAC5DQ,gBAAgB,EAAE;YAClBF,aAAa;YACbb,gBACEqE,OAAOE,OAAO,GAAG,sBAAsB,mBACvC;gBAAEC,SAAS;YAAU;YAEvBjF,MAAMkF,OAAO,CAACJ,OAAOvE,QAAQ;QAC/B,EAAE,OAAO4E,OAAO;YACd,IAAIA,iBAAiB9F,sBAAsB8F,MAAMhF,MAAM,CAACgB,MAAM,EAAE;gBAC9DK,gBACE2D,MAAMhF,MAAM,CAACkC,MAAM,CACjB,CAACa,QAA0C,UAAUA;YAG3D;YACAzC,gBAAgB,AAAC0E,MAAgB9B,OAAO,EAAE;gBACxC4B,SAAS;gBACTG,gBAAgB;YAClB;QACF,SAAU;YACR1D,UAAU;QACZ;IACF;IAEA,MAAM2D,qBAAWjF,cAAAA,MAAMyC,IAAI,CAAC,CAACyC,OAASA,KAAKlE,EAAE,KAAKJ,MAAME,MAAM,sBAA7Cd,YAAgDmF,IAAI,oBAAI;IACzE,MAAMC,iBAAiB;WAClB,IAAIC,IAAI;eAAI3E;eAAiBE,MAAMd,QAAQ,CAACa,gBAAgB;SAAC;KACjE;IACD;;;;;GAKC,GACD,MAAM2E,UACJnF,aAAa,QACboF,KAAKC,SAAS,CAAC5E,WAAW2E,KAAKC,SAAS,CAAC7G,wBAAwBwB;IACnE,MAAMsF,yBAAyB,CAACtF,WAC5B,8CACA,CAACS,MAAM0B,SAAS,GACd,4CACA;IAEN,qBACE,MAAChF;QAAKoI,SAAS;QAACC,SAAS;;0BACvB,KAACrI;gBAAKsI,MAAM;oBAAEC,IAAI;oBAAIC,IAAI;gBAAE;0BAC1B,cAAA,MAACrI;oBAAMkI,SAAS;;wBACbjD,yBACC,KAAC1F;4BAAM+F,UAAS;sCAAO;6BAGrB;sCACJ,KAACrG;4BACCqJ,QAAO;4BACPC,MAAMzJ,eAAe,aAAa;gCAAE0J,QAAQ;4BAAoB;4BAChEC,cAAc;4BACdC,cAAc;sCAEd,cAAA,MAAC1I;gCAAMkI,SAAS;;kDACd,KAAChI;wCACCyI,OAAM;wCACNC,OAAOzF,MAAMuE,IAAI;wCACjBmB,UAAU,CAACC,QAAU/C,OAAO;gDAAE2B,MAAMoB,MAAMC,MAAM,CAACH,KAAK;4CAAC;wCACvDI,UAAU/D;wCACVqC,OAAO2B,QAAQ7G,QAAQ;wCACvB8G,YAAY9G,QAAQ;wCACpB+G,WAAW;4CACTC,WAAW;gDAAEC,WAAW7I,6BAA6B;4CAAG;wCAC1D;wCACA8I,SAAS;;kDAEX,KAACpJ;wCACCqJ,MAAM;wCACNZ,OAAM;wCACNC,OACErG,MAAMyE,IAAI,CAAC,CAACS,OAASA,KAAKlE,EAAE,KAAKJ,MAAME,MAAM,IACzCF,MAAME,MAAM,GACZ;wCAENwF,UAAU,CAACC,QAAU/C,OAAO;gDAAE1C,QAAQyF,MAAMC,MAAM,CAACH,KAAK;4CAAC;wCACzDI,UAAU/D,YAAY,CAAC1C,MAAMe,MAAM;wCACnCgE,OAAO2B,QAAQ7G,QAAQ;wCACvB8G,UAAU,GACR9G,WAAAA,QAAQ,qBAARA,WACA;wCAEFkH,SAAS;kDAER/G,MAAM6D,GAAG,CAAC,CAACqB,qBACV,KAAC1H;gDAAuB6I,OAAOnB,KAAKlE,EAAE;0DACnCkE,KAAKC,IAAI;+CADGD,KAAKlE,EAAE;;kDAK1B,KAACnC;wCACCoB,OAAOA;wCACPO,KAAKA;wCACLwB,WAAWpC,MAAMoC,SAAS;wCAC1BiF,eAAerH,MAAMqH,aAAa;wCAClCZ,OAAOzF,MAAM0B,SAAS;wCACtBgE,UAAU,CAAChE,YAAckB,OAAO;gDAAElB;4CAAU;wCAC5CyC,OAAOlF,QAAQ;wCACf4G,UAAU/D;;kDAQZ,KAAC9F;wCACCsK,SAASvE,UAAUuE,OAAO;wCAC1Bb,OAAOzF,MAAMuG,WAAW;wCACxBb,UAAU,CAACa,cAAgB3D,OAAO;gDAAE2D;4CAAY;wCAChDf,OAAM;wCACNO,UAAU,GACR9G,YAAAA,QAAQ,0BAARA,YACA;wCAEF4G,UAAU/D;wCACV0E,OAAOzE,UAAU0E,KAAK,IAAI,CAAC1E,UAAUuE,OAAO,CAACnG,MAAM;wCACnDuG,WAAU;;;;;wBAKfjE,0BAAY,KAACrG;4BAAM+F,UAAS;sCAASM;6BAAqB;wBAC1DzC,MAAMgD,KAAK,CAACC,GAAG,CAAC,CAACF,MAAMlE,sBACtB,KAACV;gCAEC4E,MAAMA;gCACNlE,OAAOA;gCACP8H,OAAO3G,MAAMgD,KAAK,CAAC7C,MAAM;gCACzByG,YACE5G,MAAMgD,KAAK,CAAC6D,SAAS,CAAC,CAACvF,QAAUA,MAAMkC,IAAI,KAAK,aAChD3E;gCAEFiI,UAAU1J,oBAAoB4C,MAAMgD,KAAK,EAAEnE;gCAC3C6E,eAAeA,cAAc7E;gCAC7B8C,WAAWA,UAAUjC,IAAI;gCACzBP,QAAQmD,WAAWzD;gCACnBgH,UAAU/D;gCACV4D,UAAU,CAAC7C,OAASC,QAAQjE,OAAOgE;gCACnCkE,QAAQ,CAAC3D,YAAcD,SAAStE,OAAOuE;gCACvC4D,UAAU,IACRpE,OAAO;wCACLI,OAAOhD,MAAMgD,KAAK,CAAC3B,MAAM,CACvB,CAAC4F,QAAQ/D,WAAaA,aAAarE;oCAEvC;gCAEFqI,YACEnE,KAAKS,IAAI,KAAK,WAAW,CAAC1B,WACtB,IAAMhB,WAAWjC,SACjBsI;gCAENtC,wBAAwBA;+BA3BnB9B,KAAK3C,EAAE,IAAI,CAAC,KAAK,EAAEvB,OAAO;sCA8BnC,MAAChC;4BACCuG,WAAU;4BACV2B,SAAS;4BACTqC,IAAI;gCAAEC,UAAU;gCAAQC,QAAQ;4BAAE;;8CAElC,KAAC/K;oCACC0H,SAAQ;oCACRsD,yBAAW,KAACxL;wCAAQ0C,MAAM5C,QAAQ4C,IAAI;wCAAE+I,UAAS;;oCACjD3B,UACE/D,YAAY2B,QAAQF,cAAc/F;oCAEpCiK,SAAS,IACP7E,OAAO;4CACLI,OAAO;mDAAIhD,MAAMgD,KAAK;gDAAEnF,qBAAqBmC,MAAMgD,KAAK;6CAAE;wCAC5D;8CAEH;;8CAGD,KAACzG;oCACC0H,SAAQ;oCACRsD,yBAAW,KAACxL;wCAAQ0C,MAAM5C,QAAQ4C,IAAI;wCAAE+I,UAAS;;oCACjD3B,UAAU/D,YAAY2B;oCACtBgE,SAAS,CAAC9B,QAAU/E,cAAc+E,MAAM+B,aAAa;oCACrDC,iBAAc;8CACf;;8CAGD,KAAChL;oCACCiL,UAAUjH;oCACVkH,MAAM/B,QAAQnF;oCACdmH,SAAS,IAAMlH,cAAc;8CAE5BjD,oBAAoBsF,GAAG,CAAC,CAACO,qBACxB,KAAC5G;4CAEC6K,SAAS;gDACP7G,cAAc;gDACdgC,OAAO;oDACLI,OAAO;2DACFhD,MAAMgD,KAAK;wDACdlF,oBAAoBkC,MAAMgD,KAAK,EAAEQ;qDAClC;gDACH;4CACF;sDAEC9F,yBAAyB,CAAC8F,KAAK;2CAX3BA;;;;sCAgBb,KAACxG;4BAAWiH,SAAQ;4BAAU8D,OAAM;sCACjC,CAAC,MAAM,EAAEtK,mBAAmB,QAAQ,EAAED,yBAAyB,gBAAgB,CAAC;;sCAGnF,KAAC1B;4BACCqJ,QAAO;4BACPC,MAAMzJ,eAAe,aAAa;gCAChC0J,QAAQ;gCACR2C,SACE;4BACJ;4BACA1C,cAAc;4BACdC,cAAc;sCAEd,cAAA,MAAC1I;gCAAMkI,SAAS;;kDACd,KAAC1I;wCACC4L,QAAQ;wCACR3B,SAAS9B;wCACTiB,OAAOzF,MAAMd,QAAQ,CAACa,gBAAgB;wCACtCmI,gBAAgB3K;wCAChBmI,UAAU,CAACyC,QAAQtF,OACjBD,OAAO;gDACL1D,UAAU,aAAKc,MAAMd,QAAQ;oDAAEa,kBAAkB8C;;4CACnD;wCAEFgD,UAAU/D;wCACVsG,aAAa,CAAC3C,OAAO4C,eACnB5C,MAAMxC,GAAG,CAAC,CAACqF,MAAMzJ;gDACf,MAAyBwJ,gBAAAA,aAAa;oDAAExJ;gDAAM,IAAxC,EAAE2D,GAAG,EAAW,GAAG6F,eAATE,wCAASF;;;gDACzB,qBACE,KAAC7L;oDAECwI,MAAK;oDACLQ,OAAOjI,qBAAqB+K;mDACxBC,OAHC/F;4CAMX;wCAEFgG,aAAa,CAACC,uBACZ,KAAC1L,wBACK0L;gDACJjD,OAAM;gDACNrB,OAAO2B,QAAQpD;gDACfqD,UAAU,EACRrD,yBAAAA,iBACA;;;kDAKR,KAACjG;wCACCiM,uBACE,KAAC5L;4CACC6L,SAAS3I,MAAMd,QAAQ,CAAC0J,cAAc;4CACtClD,UAAU,CAACC,QACT/C,OAAO;oDACL1D,UAAU,aACLc,MAAMd,QAAQ;wDACjB0J,gBAAgBjD,MAAMC,MAAM,CAAC+C,OAAO;;gDAExC;4CAEF9C,UAAU/D;;wCAGd0D,OAAM;;kDAUR,MAAC3I;wCAAMkI,SAAS;;0DACd,KAACtI;gDACCiM,uBACE,KAAC5L;oDACC6L,SAAS3I,MAAMd,QAAQ,CAAC2J,WAAW;oDACnCnD,UAAU,CAACC,QACT/C,OAAO;4DACL1D,UAAU,aACLc,MAAMd,QAAQ;gEACjB2J,aAAalD,MAAMC,MAAM,CAAC+C,OAAO;;wDAErC;oDAEF9C,UAAU/D;;gDAGd0D,OAAM;;0DAER,KAACxI;gDAAWiH,SAAQ;gDAAU8D,OAAM;0DAAiB;;;;kDAcvD,MAAClL;wCAAMkI,SAAS;;0DACd,KAACtI;gDACCiM,uBACE,KAAC5L;oDACC6L,SAAS3I,MAAMd,QAAQ,CAAC4J,eAAe;oDACvCpD,UAAU,CAACC,QACT/C,OAAO;4DACL1D,UAAU,aACLc,MAAMd,QAAQ;gEACjB4J,iBAAiBnD,MAAMC,MAAM,CAAC+C,OAAO;;wDAEzC;oDAEF9C,UAAU/D;;gDAGd0D,OAAM;;0DAER,KAACxI;gDAAWiH,SAAQ;gDAAU8D,OAAM;0DAAiB;;;;kDASvD,KAAC/J;wCACC+K,QAAQ/I,MAAMd,QAAQ,CAAC6J,MAAM;wCAC7BrD,UAAU,CAACqD,SACTnG,OAAO;gDAAE1D,UAAU,aAAKc,MAAMd,QAAQ;oDAAE6J;;4CAAS;wCAEnDC,WAAW/J,QAAQ;wCACnBgK,YAAYhK,QAAQ;wCACpB4G,UAAU/D;;;;;wBAKfA,WAAW,qBACV,MAACjF;4BACCuG,WAAW;gCAAE6B,IAAI;gCAAUiE,IAAI;4BAAM;4BACrCnE,SAAS;4BACTqC,IAAI;gCAAE+B,gBAAgB;4BAAW;;gCAEhCnK,MAAMoK,QAAQ,iBACb,KAAC7M;oCACC0H,SAAQ;oCACRwD,SAASzI,MAAMoK,QAAQ;oCACvBvD,UAAUpF;8CACX;qCAGC;8CACJ,KAAClE;oCACC0H,SAAQ;oCACRwD,SAAS,IAAM,KAAK7D;oCACpBiC,UAAUpF;8CAETA,SAAS,YAAYlB,WAAW,SAAS;;;;;;;YAMnDA,YAAYsB,YAAY,qBACvB,KAACzC;gBACCyJ,IAAI;gBACJC,SAAS,IAAMhH,WAAW;gBAC1BzB,OAAOA;gBACPE,UAAUA;gBACV8J,WAAWxI;gBACXyI,gBACE/J,SAASW,MAAM,GACXtE,qBAAoBoD,aAAAA,MAAMuK,GAAG,YAATvK,aAAa,MAAMO,SAASW,MAAM,EAAEsJ,OAAO,GAC/D;gBAENC,SAAS,WAAE9J,wBAAAA,KAAM+J,KAAK,oBAAI;gBAC1BhF,SAASA;gBACTlF,KAAKA;iBAEL;0BACJ,KAAC9C;gBAAKsI,MAAM;oBAAEC,IAAI;oBAAIC,IAAI;gBAAE;0BAC1B,cAAA,KAAC5I;oBAAI8K,IAAI;wBAAElE,UAAU;4BAAEgC,IAAI;wBAAS;wBAAGyE,KAAK;4BAAEzE,IAAI;wBAAG;oBAAE;8BACrD,cAAA,KAAChH;wBACC8E,OAAOhD,MAAMgD,KAAK;wBAClB4G,aAAa1K,SAASA,QAAQ;wBAC9B2K,mBAAmB3K,SAASsC,MAAM;wBAClCG,WAAWA,UAAUjC,IAAI;wBACzBoK,QACElI,UACI;4BACE2C,MAAM3C,QAAQmI,WAAW;4BACzBL,OAAO9H,QAAQoI,MAAM,IAAIpI,QAAQ8H,KAAK;wBACxC,IACA;wBAENrF,UAAUA;;;;;;AAMtB;AACAtF,uBAAuBgL,WAAW,GAAG;AAErC,eAAehL,uBAAsB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-editor.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp, type ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { mdiPlus } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useOrgCampaigns, useUser } from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Autocomplete,\n Box,\n Button,\n Chip,\n FormControlLabel,\n Grid,\n Menu,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n isInThreadEmailStep,\n OUTREACH_SEQUENCE_NAME_MAX,\n validateOutreachSequence,\n} from '../engine/sequence-validation'\nimport { outreachCountryLabel } from '../model/compliance-settings'\nimport {\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEPS,\n OUTREACH_TASK_KIND_LABELS,\n OUTREACH_TASK_KINDS,\n type OutreachSequence,\n type OutreachSequenceStep,\n type OutreachTaskKind,\n} from '../model/outreach.types'\nimport {\n emptyOutreachSequenceDraft,\n newOutreachEmailStep,\n newOutreachTaskStep,\n outreachSequenceDraftOf,\n type OutreachSequenceDraft,\n type OutreachSequenceIssue,\n} from '../model/sequence-draft'\nimport { OutreachSendWindowFields } from './send-window-fields'\nimport { OutreachSequenceMailboxPicker } from './sequence-mailbox-picker'\nimport { OutreachSequencePreview } from './sequence-preview'\nimport { OutreachStepCard } from './sequence-step-card'\nimport { OutreachStepTestDialog } from './step-test-dialog'\nimport { OutreachRouteError, useOutreachApi } from './use-outreach-api'\nimport { useOutreachEmailTemplates } from './use-outreach-crm'\nimport type { OutreachMailboxesResult } from './use-outreach-mailboxes'\nimport type { OutreachSettingsLoad } from './use-outreach-settings'\n\nexport interface OutreachSequenceEditorProps {\n orgId: string\n orgMount?: ConsolePluginOrgMount\n /** The organization document, for the consent group a test send's person picker searches (AGL-3325). */\n org?: Record<string, unknown> | null\n /** The stored sequence, or `null` for a new one. */\n sequence: OutreachSequence | null\n /** The organization's compliance settings: its countries, and the preview's footer. */\n settings: OutreachSettingsLoad\n /**\n * The organization's mailboxes, from the Mailboxes section's listener:\n * what the picker offers, and who the preview's emails are from.\n */\n mailboxes: OutreachMailboxesResult\n /** The Mailboxes section, where the picker sends a member to connect one. */\n mailboxesPath: string\n onSaved(sequence: OutreachSequence): void\n onCancel?(): void\n}\n\n/** The step a dotted issue path is about, and the field within it. */\nfunction stepIssueKey(path: string): { index: number; field: string } | null {\n const match = /^steps\\.(\\d+)(?:\\.(.+))?$/.exec(path)\n if (!match) return null\n const field = match[2] ?? 'step'\n return { index: Number(match[1]), field: field === 'id' ? 'step' : field }\n}\n\n/**\n * The sequence editor (AGL-2980): the name, the site and mailbox, the steps,\n * who it sends to and when, beside a live preview of each email.\n *\n * Every issue the engine's validator finds is shown beside its field — the\n * same validator the save route refuses with — errors once a save was\n * attempted, warnings as soon as they appear. What only the server can\n * judge (whose mailbox, which countries the organization allows, what may\n * no longer change once people are enrolled) comes back from the save and\n * is shown in the same places.\n */\nexport function OutreachSequenceEditor(props: OutreachSequenceEditorProps) {\n const { orgId, orgMount, sequence, settings } = props\n const api = useOutreachApi(orgId)\n const { enqueueSnackbar } = useSnackbar()\n const { data: user } = useUser()\n const uid = user?.uid ?? null\n const mountedHosts = orgMount?.hosts\n const hosts = useMemo(() => mountedHosts ?? [], [mountedHosts])\n const orgCountries = settings.settings?.allowedCountries ?? ['US']\n\n const [draft, setDraft] = useState<OutreachSequenceDraft>(() =>\n sequence\n ? outreachSequenceDraftOf(sequence)\n : emptyOutreachSequenceDraft({\n hostId: hosts.length === 1 ? hosts[0].id : '',\n allowedCountries: orgCountries,\n }),\n )\n const [attempted, setAttempted] = useState(false)\n const [serverIssues, setServerIssues] = useState<OutreachSequenceIssue[]>([])\n const [saving, setSaving] = useState(false)\n const [taskAnchor, setTaskAnchor] = useState<HTMLElement | null>(null)\n // The step whose test-send dialog is open (AGL-3325), by index.\n const [testing, setTesting] = useState<number | null>(null)\n\n // A stored sequence the listener updated (a save, a status change) is the\n // new starting point.\n useEffect(() => {\n if (sequence) setDraft(outreachSequenceDraftOf(sequence))\n }, [sequence])\n // A new sequence starts on the organization's countries once they are read.\n const settingsCountries = settings.settings?.allowedCountries.join(',')\n useEffect(() => {\n if (!sequence && settingsCountries) {\n setDraft((previous) => ({\n ...previous,\n settings: {\n ...previous.settings,\n allowedCountries: settingsCountries.split(','),\n },\n }))\n }\n }, [sequence, settingsCountries])\n useEffect(() => {\n if (!sequence && !draft.hostId && hosts.length === 1)\n setDraft((previous) => ({ ...previous, hostId: hosts[0].id }))\n }, [sequence, draft.hostId, hosts])\n\n // A new sequence starts on the member's own mailbox when they have exactly\n // one that is sending.\n const ownSending = props.mailboxes.mailboxes.filter(\n (entry) => entry.connectedByUid === uid && entry.status === 'connected',\n )\n const onlyOwnMailbox = ownSending.length === 1 ? ownSending[0].id : ''\n useEffect(() => {\n if (!sequence && !draft.mailboxId && onlyOwnMailbox)\n setDraft((previous) => ({ ...previous, mailboxId: onlyOwnMailbox }))\n }, [sequence, draft.mailboxId, onlyOwnMailbox])\n\n const templates = useOutreachEmailTemplates(orgId, draft.hostId || null, uid)\n const mailbox =\n props.mailboxes.mailboxes.find((entry) => entry.id === draft.mailboxId) ??\n null\n const archived = sequence?.status === 'archived'\n /*\n * The org's campaigns, for the picker (AGL-3254): read while the editor\n * is open. A sequence is the organization's record and so is a campaign,\n * so the choice is every campaign in the org, whichever site the\n * sequence sends as.\n */\n const campaigns = useOrgCampaigns(orgId, { enabled: true })\n\n const issues = useMemo(() => {\n const judged = [\n ...validateOutreachSequence(draft),\n ...serverIssues,\n ] as OutreachSequenceIssue[]\n return judged.filter(\n (issue) =>\n issue.severity === 'warning' ||\n attempted ||\n serverIssues.includes(issue),\n )\n }, [draft, serverIssues, attempted])\n const issueAt = (path: string) =>\n issues.find((issue) => issue.path === path)?.message\n const stepIssues = (index: number) => {\n const found: Record<string, string> = {}\n for (const issue of issues) {\n const key = stepIssueKey(issue.path)\n if (key?.index === index && !found[key.field])\n found[key.field] = issue.message\n }\n return found\n }\n const listIssue = issueAt('steps')\n const countriesIssue = issues.find((issue) =>\n issue.path.startsWith('settings.allowedCountries'),\n )?.message\n\n const update = (next: Partial<OutreachSequenceDraft>) => {\n setServerIssues([])\n setDraft((previous) => ({ ...previous, ...next }))\n }\n const setStep = (index: number, step: OutreachSequenceStep) =>\n update({\n steps: draft.steps.map((entry, position) =>\n position === index ? step : entry,\n ),\n })\n const moveStep = (index: number, direction: -1 | 1) => {\n const steps = [...draft.steps]\n const [moved] = steps.splice(index, 1)\n steps.splice(index + direction, 0, moved)\n update({ steps })\n }\n const emailCount = draft.steps.filter((step) => step.kind === 'email').length\n const full = draft.steps.length >= OUTREACH_MAX_STEPS\n const threadSubject = (index: number) => {\n for (let position = index - 1; position >= 0; position -= 1) {\n const step = draft.steps[position]\n if (step.kind === 'email' && !isInThreadEmailStep(draft.steps, position))\n return step.subject\n }\n return ''\n }\n\n const save = async () => {\n setAttempted(true)\n if (\n validateOutreachSequence(draft).some(\n (issue) => issue.severity === 'error',\n )\n )\n return\n setSaving(true)\n try {\n const answer = await api.saveSequence(sequence?.id ?? null, draft)\n setServerIssues([])\n setAttempted(false)\n enqueueSnackbar(\n answer.created ? 'Sequence created.' : 'Sequence saved.',\n { variant: 'success' },\n )\n props.onSaved(answer.sequence)\n } catch (error) {\n if (error instanceof OutreachRouteError && error.issues.length) {\n setServerIssues(\n error.issues.filter(\n (issue): issue is OutreachSequenceIssue => 'path' in issue,\n ),\n )\n }\n enqueueSnackbar((error as Error).message, {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setSaving(false)\n }\n }\n\n const siteName = hosts.find((host) => host.id === draft.hostId)?.name ?? ''\n const countryOptions = [\n ...new Set([...orgCountries, ...draft.settings.allowedCountries]),\n ]\n /*\n * A test sends the step as STORED (AGL-3325): the route reads the saved\n * sequence, so a new one has nothing to send yet and an edited one sends\n * its last save — said on the button and in the dialog rather than found\n * out in the inbox.\n */\n const unsaved =\n sequence !== null &&\n JSON.stringify(draft) !== JSON.stringify(outreachSequenceDraftOf(sequence))\n const sendTestDisabledReason = !sequence\n ? 'Create the sequence to send a test of it.'\n : !draft.mailboxId\n ? 'Choose a mailbox to send the test from.'\n : null\n\n return (\n <Grid container spacing={2}>\n <Grid size={{ xs: 12, md: 7 }}>\n <Stack spacing={2}>\n {archived ? (\n <Alert severity=\"info\">\n This sequence is archived, so it can’t be edited.\n </Alert>\n ) : null}\n <CardDisplay\n header=\"Sequence\"\n help={pluginDocsHelp('sequences', { anchor: '#build-a-sequence' })}\n // The whole sequence (steps and sending rules included) saves\n // from its first card's header, where the console puts a card's\n // actions (AGL-3333).\n HeaderProps={{\n action: archived ? undefined : (\n <Stack direction=\"row\" spacing={1}>\n {props.onCancel ? (\n <Button\n size=\"small\"\n variant=\"text\"\n onClick={props.onCancel}\n disabled={saving}\n >\n Cancel\n </Button>\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void save()}\n disabled={saving}\n >\n {saving ? 'Saving…' : sequence ? 'Save' : 'Create sequence'}\n </Button>\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <TextField\n label=\"Name\"\n value={draft.name}\n onChange={(event) => update({ name: event.target.value })}\n disabled={archived}\n error={Boolean(issueAt('name'))}\n helperText={issueAt('name')}\n slotProps={{\n htmlInput: { maxLength: OUTREACH_SEQUENCE_NAME_MAX + 20 },\n }}\n fullWidth\n />\n <TextField\n select\n label=\"Site\"\n value={\n hosts.some((host) => host.id === draft.hostId)\n ? draft.hostId\n : ''\n }\n onChange={(event) => update({ hostId: event.target.value })}\n disabled={archived || !hosts.length}\n error={Boolean(issueAt('hostId'))}\n helperText={\n issueAt('hostId') ??\n 'The site whose contacts this sequence emails.'\n }\n fullWidth\n >\n {hosts.map((host) => (\n <MenuItem key={host.id} value={host.id}>\n {host.name}\n </MenuItem>\n ))}\n </TextField>\n <OutreachSequenceMailboxPicker\n orgId={orgId}\n uid={uid}\n mailboxes={props.mailboxes}\n mailboxesPath={props.mailboxesPath}\n value={draft.mailboxId}\n onChange={(mailboxId) => update({ mailboxId })}\n error={issueAt('mailboxId')}\n disabled={archived}\n />\n {/*\n The campaigns this sequence is part of (AGL-3254), picked the\n way a form's page picks them. Everyone enrolled joins them,\n and what the sequence produces is reported under them; it\n does not change who is enrolled or what is sent.\n */}\n <CampaignPicker\n options={campaigns.options}\n value={draft.campaignIds}\n onChange={(campaignIds) => update({ campaignIds })}\n label=\"Campaigns\"\n helperText={\n issueAt('campaignIds') ??\n 'The campaigns this sequence is part of. Everyone enrolled joins them, and its sends, replies, meetings and conversions count on their pages.'\n }\n disabled={archived}\n empty={campaigns.ready && !campaigns.options.length}\n emptyText=\"There are no campaigns yet. Create one from Marketing to file this sequence under it.\"\n />\n </Stack>\n </CardDisplay>\n\n {listIssue ? <Alert severity=\"error\">{listIssue}</Alert> : null}\n {draft.steps.map((step, index) => (\n <OutreachStepCard\n key={step.id || `step-${index}`}\n step={step}\n index={index}\n count={draft.steps.length}\n firstEmail={\n draft.steps.findIndex((entry) => entry.kind === 'email') ===\n index\n }\n inThread={isInThreadEmailStep(draft.steps, index)}\n threadSubject={threadSubject(index)}\n templates={templates.data}\n issues={stepIssues(index)}\n disabled={archived}\n onChange={(next) => setStep(index, next)}\n onMove={(direction) => moveStep(index, direction)}\n onRemove={() =>\n update({\n steps: draft.steps.filter(\n (_entry, position) => position !== index,\n ),\n })\n }\n onSendTest={\n step.kind === 'email' && !archived\n ? () => setTesting(index)\n : undefined\n }\n sendTestDisabledReason={sendTestDisabledReason}\n />\n ))}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPlus.path} fontSize=\"small\" />}\n disabled={\n archived || full || emailCount >= OUTREACH_MAX_EMAIL_STEPS\n }\n onClick={() =>\n update({\n steps: [...draft.steps, newOutreachEmailStep(draft.steps)],\n })\n }\n >\n Add email\n </Button>\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPlus.path} fontSize=\"small\" />}\n disabled={archived || full}\n onClick={(event) => setTaskAnchor(event.currentTarget)}\n aria-haspopup=\"menu\"\n >\n Add task\n </Button>\n <Menu\n anchorEl={taskAnchor}\n open={Boolean(taskAnchor)}\n onClose={() => setTaskAnchor(null)}\n >\n {OUTREACH_TASK_KINDS.map((kind: OutreachTaskKind) => (\n <MenuItem\n key={kind}\n onClick={() => {\n setTaskAnchor(null)\n update({\n steps: [\n ...draft.steps,\n newOutreachTaskStep(draft.steps, kind),\n ],\n })\n }}\n >\n {OUTREACH_TASK_KIND_LABELS[kind]}\n </MenuItem>\n ))}\n </Menu>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Up to ${OUTREACH_MAX_STEPS} steps, ${OUTREACH_MAX_EMAIL_STEPS} of them emails.`}\n </Typography>\n\n <CardDisplay\n header=\"Who it sends to, and when\"\n help={pluginDocsHelp('sequences', {\n anchor: '#build-a-sequence',\n excerpt:\n 'A sequence sends only to the countries both it and your organization allow, and in its mailbox’s sending hours unless it has its own.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Autocomplete<string, true>\n multiple\n options={countryOptions}\n value={draft.settings.allowedCountries}\n getOptionLabel={outreachCountryLabel}\n onChange={(_event, next) =>\n update({\n settings: { ...draft.settings, allowedCountries: next },\n })\n }\n disabled={archived}\n renderValue={(value, getItemProps) =>\n value.map((code, index) => {\n const { key, ...item } = getItemProps({ index })\n return (\n <Chip\n key={key}\n size=\"small\"\n label={outreachCountryLabel(code)}\n {...item}\n />\n )\n })\n }\n renderInput={(params) => (\n <TextField\n {...params}\n label=\"Countries\"\n error={Boolean(countriesIssue)}\n helperText={\n countriesIssue ??\n 'Among the countries your organization allows. Cold contacts are emailed only in the United States.'\n }\n />\n )}\n />\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.allowCustomers}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n allowCustomers: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Include customers\"\n />\n {/*\n * Click tracking (AGL-3239), with its cost stated where it is\n * chosen rather than in a document nobody opens. Off by\n * default: it changes the link a cold recipient sees, and the\n * emails stay plain text either way — there is no open\n * tracking to turn on, because a tracking image needs an HTML\n * email and these are not.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.trackClicks}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n trackClicks: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Count link clicks\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Links in these emails are replaced with links of ours that\n forward to the same page, so clicks can be counted. The\n recipient sees the replacement, not your address. Opens\n aren’t counted either way: these emails are plain text, and\n counting an open needs a tracking image.\n </Typography>\n </Stack>\n {/*\n * The mail-client unsubscribe button (AGL-3296), off by\n * default: the header is what makes Apple Mail, Gmail and\n * Outlook present a one-to-one email as a mailing list. The\n * footer's \"reply 'no'\" line is the way out either way.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.listUnsubscribe}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n listUnsubscribe: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Add a mail-client unsubscribe button\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Adds the List-Unsubscribe header, so the recipient’s mail\n app shows its own Unsubscribe button. It’s the easiest way\n out for them, and fewer people mark you as spam when they\n have it, but mail apps then label the email as coming from\n a mailing list. Off, the footer’s “reply ‘no’” line is the\n way out, and it’s always there.\n </Typography>\n </Stack>\n <OutreachSendWindowFields\n window={draft.settings.window}\n onChange={(window) =>\n update({ settings: { ...draft.settings, window } })\n }\n daysIssue={issueAt('settings.window.days')}\n hoursIssue={issueAt('settings.window')}\n disabled={archived}\n />\n </Stack>\n </CardDisplay>\n\n </Stack>\n </Grid>\n {sequence && testing !== null ? (\n <OutreachStepTestDialog\n open\n onClose={() => setTesting(null)}\n orgId={orgId}\n sequence={sequence}\n stepIndex={testing}\n contactGroupId={\n sequence.hostId\n ? consentGroupForHost(props.org ?? null, sequence.hostId).groupId\n : ''\n }\n selfEmail={user?.email ?? null}\n unsaved={unsaved}\n api={api}\n />\n ) : null}\n <Grid size={{ xs: 12, md: 5 }}>\n <Box sx={{ position: { md: 'sticky' }, top: { md: 16 } }}>\n <OutreachSequencePreview\n steps={draft.steps}\n orgSettings={settings.settings}\n orgSettingsStatus={settings.status}\n templates={templates.data}\n sender={\n mailbox\n ? {\n name: mailbox.displayName,\n email: mailbox.sendAs || mailbox.email,\n }\n : null\n }\n siteName={siteName}\n />\n </Box>\n </Grid>\n </Grid>\n )\n}\nOutreachSequenceEditor.displayName = 'OutreachSequenceEditor'\n\nexport default OutreachSequenceEditor\n"],"names":["pluginDocsHelp","consentGroupForHost","mdiPlus","CardDisplay","MdiIcon","CampaignPicker","useSnackbar","useOrgCampaigns","useUser","Alert","Autocomplete","Box","Button","Chip","FormControlLabel","Grid","Menu","MenuItem","Stack","Switch","TextField","Typography","useEffect","useMemo","useState","isInThreadEmailStep","OUTREACH_SEQUENCE_NAME_MAX","validateOutreachSequence","outreachCountryLabel","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEPS","OUTREACH_TASK_KIND_LABELS","OUTREACH_TASK_KINDS","emptyOutreachSequenceDraft","newOutreachEmailStep","newOutreachTaskStep","outreachSequenceDraftOf","OutreachSendWindowFields","OutreachSequenceMailboxPicker","OutreachSequencePreview","OutreachStepCard","OutreachStepTestDialog","OutreachRouteError","useOutreachApi","useOutreachEmailTemplates","stepIssueKey","path","match","exec","field","index","Number","OutreachSequenceEditor","props","issueAt","settings","issues","hosts","orgId","orgMount","sequence","api","enqueueSnackbar","data","user","uid","mountedHosts","orgCountries","allowedCountries","draft","setDraft","hostId","length","id","attempted","setAttempted","serverIssues","setServerIssues","saving","setSaving","taskAnchor","setTaskAnchor","testing","setTesting","settingsCountries","join","previous","split","ownSending","mailboxes","filter","entry","connectedByUid","status","onlyOwnMailbox","mailboxId","templates","mailbox","find","archived","campaigns","enabled","judged","issue","severity","includes","message","stepIssues","found","key","listIssue","countriesIssue","startsWith","update","next","setStep","step","steps","map","position","moveStep","direction","moved","splice","emailCount","kind","full","threadSubject","subject","save","some","answer","saveSequence","created","variant","onSaved","error","allowDuplicate","siteName","host","name","countryOptions","Set","unsaved","JSON","stringify","sendTestDisabledReason","container","spacing","size","xs","md","header","help","anchor","HeaderProps","action","undefined","onCancel","onClick","disabled","contentGutterX","contentGutterY","label","value","onChange","event","target","Boolean","helperText","slotProps","htmlInput","maxLength","fullWidth","select","mailboxesPath","options","campaignIds","empty","ready","emptyText","count","firstEmail","findIndex","inThread","onMove","onRemove","_entry","onSendTest","sx","flexWrap","rowGap","startIcon","fontSize","currentTarget","aria-haspopup","anchorEl","open","onClose","color","excerpt","multiple","getOptionLabel","_event","renderValue","getItemProps","code","item","renderInput","params","control","checked","allowCustomers","trackClicks","listUnsubscribe","window","daysIssue","hoursIssue","stepIndex","contactGroupId","org","groupId","selfEmail","email","top","orgSettings","orgSettingsStatus","sender","displayName","sendAs"],"mappings":"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"}
@@ -26,6 +26,12 @@ export interface OutreachDeliveryRecipient {
26
26
  status: string | null;
27
27
  /** `Diagnostic-Code`, without its type prefix. */
28
28
  diagnostic: string | null;
29
+ /**
30
+ * `Remote-MTA`, without its type prefix and lowercased: the receiving
31
+ * server that answered — which names the gateway in front of the domain
32
+ * when it is one (AGL-3326).
33
+ */
34
+ remoteMta: string | null;
29
35
  /** `hard`, `soft`, or `null` for a block that reports no failure. */
30
36
  kind: OutreachBounceKind | null;
31
37
  }
@@ -43,6 +49,8 @@ export interface OutreachDeliveryReport {
43
49
  status: string | null;
44
50
  /** The diagnostic of the worst failure, when one was given. */
45
51
  diagnostic: string | null;
52
+ /** The `Remote-MTA` of the worst failure, when the report named one. */
53
+ remoteMta: string | null;
46
54
  }
47
55
  /** Whether a message comes from a mail system rather than from a person. */
48
56
  export declare function isMailerDaemonMessage(message: Pick<OutreachThreadMessage, 'from'>): boolean;
@@ -121,7 +121,7 @@ function prose(message) {
121
121
  * What a delivery report says, or `null` when the message is not one — see
122
122
  * the module note for how a standard report and a prose bounce are read.
123
123
  */ export function readOutreachDeliveryReport(message) {
124
- var _message_parts, _ref, _ref1, _ref2;
124
+ var _message_parts, _ref, _ref1, _ref2, _ref3;
125
125
  const standard = isDeliveryStatusReport(message);
126
126
  const daemon = isMailerDaemonMessage(message);
127
127
  if (!standard && !(daemon && BOUNCE_WORDS.test(prose(message)))) return null;
@@ -137,11 +137,14 @@ function prose(message) {
137
137
  const statusMatch = STATUS_CODE.exec((_block_status = block['status']) != null ? _block_status : '');
138
138
  const status = statusMatch ? statusMatch[0] : null;
139
139
  const diagnostic = block['diagnostic-code'] ? block['diagnostic-code'].replace(/^[a-z0-9-]+\s*;\s*/i, '') : null;
140
+ // `dns; mx.example.com. (192.0.2.10, ...)` → the host alone.
141
+ const remoteMta = block['remote-mta'] ? block['remote-mta'].replace(/^[a-z0-9-]+\s*;\s*/i, '').trim().split(/\s+/)[0].toLowerCase().replace(/\.$/, '') || null : null;
140
142
  return {
141
143
  address: (_typedAddress = typedAddress(block['final-recipient'])) != null ? _typedAddress : typedAddress(block['original-recipient']),
142
144
  action,
143
145
  status,
144
146
  diagnostic,
147
+ remoteMta,
145
148
  kind: kindFor(action, status)
146
149
  };
147
150
  });
@@ -152,13 +155,13 @@ function prose(message) {
152
155
  }
153
156
  }
154
157
  if (!worst && !recipients.length) {
155
- var _ref3, _ref4;
158
+ var _ref4, _ref5;
156
159
  // A report without readable blocks, or a bounce written as prose: the
157
160
  // code the text quotes, else the words.
158
161
  const text = prose(message);
159
162
  const code = FAILURE_STATUS_CODE.exec(text);
160
163
  const reply = FAILURE_REPLY_CODE.exec(text);
161
- const statusClass = (_ref3 = (_ref4 = code == null ? void 0 : code[1]) != null ? _ref4 : reply == null ? void 0 : reply[1]) != null ? _ref3 : null;
164
+ const statusClass = (_ref4 = (_ref5 = code == null ? void 0 : code[1]) != null ? _ref5 : reply == null ? void 0 : reply[1]) != null ? _ref4 : null;
162
165
  const retrying = /\b(delay(ed)?|will (keep )?retry|not (been )?delivered yet|temporar(y|ily))\b/i.test(text);
163
166
  const kind = statusClass === '5' ? 'hard' : statusClass === '4' || retrying ? 'soft' : 'hard';
164
167
  return {
@@ -166,7 +169,8 @@ function prose(message) {
166
169
  recipients: [],
167
170
  failedAddresses: failedRecipientsHeader(message),
168
171
  status: code ? code[0] : null,
169
- diagnostic: null
172
+ diagnostic: null,
173
+ remoteMta: null
170
174
  };
171
175
  }
172
176
  // Only the addresses the report's own kind is about: a report that
@@ -177,7 +181,8 @@ function prose(message) {
177
181
  recipients,
178
182
  failedAddresses: failedAddresses.length || !worst ? failedAddresses : failedRecipientsHeader(message),
179
183
  status: (_ref1 = worst == null ? void 0 : worst.status) != null ? _ref1 : null,
180
- diagnostic: (_ref2 = worst == null ? void 0 : worst.diagnostic) != null ? _ref2 : null
184
+ diagnostic: (_ref2 = worst == null ? void 0 : worst.diagnostic) != null ? _ref2 : null,
185
+ remoteMta: (_ref3 = worst == null ? void 0 : worst.remoteMta) != null ? _ref3 : null
181
186
  };
182
187
  }
183
188
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/delivery-status.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 * BOUNCES (AGL-2979).\n *\n * A mail server that cannot deliver says so in a DELIVERY STATUS\n * NOTIFICATION (RFC 3464): a `multipart/report; report-type=delivery-status`\n * message, usually from `mailer-daemon@` or `postmaster@`, whose\n * `message/delivery-status` part carries one block per recipient —\n * `Final-Recipient`, `Action` and `Status`, an enhanced code like `5.1.1`.\n * Gmail writes one into the sender's own thread when a message it relayed is\n * refused, and a receiving server that accepted a message and then gave up\n * sends one of its own.\n *\n * ## Hard and soft\n *\n * A `5.x.x` status on a `failed` action is HARD: the address does not\n * exist, the domain refuses the sender, the mailbox is closed — trying again\n * will not help, and the enrollment stops. A `4.x.x` status, or a `delayed`\n * action, is SOFT: the server is busy or the mailbox is full, the sending\n * server is still retrying, and nothing changes. A report whose action is\n * `delivered`, `relayed` or `expanded` is not a bounce at all.\n *\n * ## Reports that are not standard\n *\n * Some servers send a bounce as plain prose. A message from a mailer daemon\n * whose subject or text reads like one is read for the status code the prose\n * quotes (`550 5.1.1`, `421 4.7.0`), and failing that for the words that say\n * permanent or temporary — soft when it says it will retry, hard otherwise.\n *==========================================*/\n\nimport { emailAddressOf } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachBounceKind = 'hard' | 'soft'\n\n/** What a delivery report says about one recipient. */\nexport interface OutreachDeliveryRecipient {\n /** `Final-Recipient`, or `Original-Recipient` when that is all there is; normalized. */\n address: string | null\n /** `Action`, lowercased: `failed`, `delayed`, `delivered`, `relayed`, `expanded`. */\n action: string | null\n /** `Status`: the enhanced code, `5.1.1`. */\n status: string | null\n /** `Diagnostic-Code`, without its type prefix. */\n diagnostic: string | null\n /** `hard`, `soft`, or `null` for a block that reports no failure. */\n kind: OutreachBounceKind | null\n}\n\nexport interface OutreachDeliveryReport {\n /** The worst failure the report carries, or `null` when it reports none. */\n kind: OutreachBounceKind | null\n /** Every recipient the report names, in order. */\n recipients: OutreachDeliveryRecipient[]\n /**\n * The addresses `kind` is about, normalized — `X-Failed-Recipients` when no\n * block names one. Empty when nothing says which recipient failed.\n */\n failedAddresses: string[]\n /** The status code of the worst failure, when one was found. */\n status: string | null\n /** The diagnostic of the worst failure, when one was given. */\n diagnostic: string | null\n}\n\nconst DAEMON_LOCAL_PART = /^(mailer-daemon|postmaster|mail-daemon|maildaemon)$/i\nconst DAEMON_NAME = /\\bmail delivery (subsystem|system|service)\\b/i\nconst BOUNCE_WORDS =\n /undeliver|delivery status notification|delivery (has )?failed|delivery failure|failure notice|returned mail|mail delivery failed|could not be delivered|wasn't delivered|was not delivered|not delivered|address not found|message blocked/i\n\n/** Whether a message comes from a mail system rather than from a person. */\nexport function isMailerDaemonMessage(message: Pick<OutreachThreadMessage, 'from'>): boolean {\n const raw = String(message?.from ?? '')\n const address = emailAddressOf(raw)\n const local = address ? address.slice(0, address.lastIndexOf('@')) : ''\n return DAEMON_LOCAL_PART.test(local) || DAEMON_NAME.test(raw)\n}\n\nfunction isDeliveryStatusReport(message: OutreachThreadMessage): boolean {\n const contentType = outreachHeader(message, 'Content-Type').toLowerCase()\n if (\n contentType.includes('multipart/report') &&\n /report-type\\s*=\\s*\"?delivery-status\"?/.test(contentType)\n ) {\n return true\n }\n return (message.parts ?? []).some(\n (part) =>\n part.mimeType === 'message/delivery-status' ||\n part.mimeType === 'message/global-delivery-status',\n )\n}\n\n/** `Name: value` fields, continuation lines folded in, one list per blank-line block. */\nfunction fieldBlocks(text: string): Array<Record<string, string>> {\n const blocks: Array<Record<string, string>> = []\n let current: Record<string, string> = {}\n let last: string | null = null\n const close = () => {\n if (Object.keys(current).length) blocks.push(current)\n current = {}\n last = null\n }\n for (const line of text.replace(/\\r\\n?/g, '\\n').split('\\n')) {\n if (!line.trim()) {\n close()\n continue\n }\n if (/^[ \\t]/.test(line) && last) {\n current[last] = `${current[last]} ${line.trim()}`\n continue\n }\n const field = /^([A-Za-z][A-Za-z0-9-]*)\\s*:\\s*(.*)$/.exec(line)\n if (field) {\n last = field[1].toLowerCase()\n if (!(last in current)) current[last] = field[2].trim()\n }\n }\n close()\n return blocks\n}\n\n/** `rfc822; someone@example.com` → the address. */\nfunction typedAddress(value: string | undefined): string | null {\n if (!value) return null\n const typed = /^[a-z0-9-]+\\s*;\\s*(.*)$/i.exec(value)\n return emailAddressOf(typed ? typed[1] : value)\n}\n\n/** An enhanced status code, any class: what a `Status` field holds. */\nconst STATUS_CODE = /\\b([245])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n/** A failure's enhanced status code, quoted somewhere in prose. */\nconst FAILURE_STATUS_CODE = /\\b([45])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n/** A failure's basic SMTP reply code, as a transcript quotes it: `550-` or `421 `. */\nconst FAILURE_REPLY_CODE = /\\b([45])[0-5]\\d(?=[ -])/\n\nfunction kindFor(action: string | null, status: string | null): OutreachBounceKind | null {\n if (action === 'delivered' || action === 'relayed' || action === 'expanded') return null\n const statusClass = status ? status.charAt(0) : null\n if (statusClass === '4' || action === 'delayed') return 'soft'\n if (statusClass === '5') return action === null || action === 'failed' ? 'hard' : 'soft'\n if (action === 'failed') return 'hard'\n return null\n}\n\nconst SEVERITY: Record<OutreachBounceKind, number> = { soft: 1, hard: 2 }\n\nfunction failedRecipientsHeader(message: OutreachThreadMessage): string[] {\n return outreachHeader(message, 'X-Failed-Recipients')\n .split(/[,\\s]+/)\n .map((value) => emailAddressOf(value))\n .filter((value): value is string => value !== null)\n}\n\nfunction prose(message: OutreachThreadMessage): string {\n return [message.subject, message.textBody, message.snippet]\n .filter((value) => typeof value === 'string' && value)\n .join('\\n')\n}\n\n/**\n * What a delivery report says, or `null` when the message is not one — see\n * the module note for how a standard report and a prose bounce are read.\n */\nexport function readOutreachDeliveryReport(\n message: OutreachThreadMessage,\n): OutreachDeliveryReport | null {\n const standard = isDeliveryStatusReport(message)\n const daemon = isMailerDaemonMessage(message)\n if (!standard && !(daemon && BOUNCE_WORDS.test(prose(message)))) return null\n\n const statusText = (message.parts ?? [])\n .filter(\n (part) =>\n part.mimeType === 'message/delivery-status' ||\n part.mimeType === 'message/global-delivery-status',\n )\n .map((part) => part.text ?? '')\n .join('\\n\\n')\n // The first block describes the report itself (`Reporting-MTA`,\n // `Arrival-Date`); each later one describes a recipient.\n const recipients: OutreachDeliveryRecipient[] = fieldBlocks(statusText)\n .filter(\n (block) =>\n block['final-recipient'] || block['original-recipient'] || block['action'] || block['status'],\n )\n .map((block) => {\n const action = block['action'] ? block['action'].toLowerCase().split(/\\s/)[0] : null\n const statusMatch = STATUS_CODE.exec(block['status'] ?? '')\n const status = statusMatch ? statusMatch[0] : null\n const diagnostic = block['diagnostic-code']\n ? block['diagnostic-code'].replace(/^[a-z0-9-]+\\s*;\\s*/i, '')\n : null\n return {\n address: typedAddress(block['final-recipient']) ?? typedAddress(block['original-recipient']),\n action,\n status,\n diagnostic,\n kind: kindFor(action, status),\n }\n })\n\n let worst: OutreachDeliveryRecipient | null = null\n for (const recipient of recipients) {\n if (recipient.kind && (!worst || SEVERITY[recipient.kind] > SEVERITY[worst.kind])) {\n worst = recipient\n }\n }\n\n if (!worst && !recipients.length) {\n // A report without readable blocks, or a bounce written as prose: the\n // code the text quotes, else the words.\n const text = prose(message)\n const code = FAILURE_STATUS_CODE.exec(text)\n const reply = FAILURE_REPLY_CODE.exec(text)\n const statusClass = code?.[1] ?? reply?.[1] ?? null\n const retrying =\n /\\b(delay(ed)?|will (keep )?retry|not (been )?delivered yet|temporar(y|ily))\\b/i.test(text)\n const kind: OutreachBounceKind =\n statusClass === '5' ? 'hard' : statusClass === '4' || retrying ? 'soft' : 'hard'\n return {\n kind,\n recipients: [],\n failedAddresses: failedRecipientsHeader(message),\n status: code ? code[0] : null,\n diagnostic: null,\n }\n }\n\n // Only the addresses the report's own kind is about: a report that\n // hard-bounced one recipient and delayed another does not bounce both.\n const failedAddresses = worst\n ? recipients\n .filter((recipient) => recipient.kind === worst.kind && recipient.address)\n .map((recipient) => recipient.address as string)\n : []\n return {\n kind: worst?.kind ?? null,\n recipients,\n failedAddresses:\n failedAddresses.length || !worst ? failedAddresses : failedRecipientsHeader(message),\n status: worst?.status ?? null,\n diagnostic: worst?.diagnostic ?? null,\n }\n}\n"],"names":["emailAddressOf","outreachHeader","DAEMON_LOCAL_PART","DAEMON_NAME","BOUNCE_WORDS","isMailerDaemonMessage","message","raw","String","from","address","local","slice","lastIndexOf","test","isDeliveryStatusReport","contentType","toLowerCase","includes","parts","some","part","mimeType","fieldBlocks","text","blocks","current","last","close","Object","keys","length","push","line","replace","split","trim","field","exec","typedAddress","value","typed","STATUS_CODE","FAILURE_STATUS_CODE","FAILURE_REPLY_CODE","kindFor","action","status","statusClass","charAt","SEVERITY","soft","hard","failedRecipientsHeader","map","filter","prose","subject","textBody","snippet","join","readOutreachDeliveryReport","standard","daemon","statusText","recipients","block","statusMatch","diagnostic","kind","worst","recipient","code","reply","retrying","failedAddresses"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/delivery-status.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 * BOUNCES (AGL-2979).\n *\n * A mail server that cannot deliver says so in a DELIVERY STATUS\n * NOTIFICATION (RFC 3464): a `multipart/report; report-type=delivery-status`\n * message, usually from `mailer-daemon@` or `postmaster@`, whose\n * `message/delivery-status` part carries one block per recipient —\n * `Final-Recipient`, `Action` and `Status`, an enhanced code like `5.1.1`.\n * Gmail writes one into the sender's own thread when a message it relayed is\n * refused, and a receiving server that accepted a message and then gave up\n * sends one of its own.\n *\n * ## Hard and soft\n *\n * A `5.x.x` status on a `failed` action is HARD: the address does not\n * exist, the domain refuses the sender, the mailbox is closed — trying again\n * will not help, and the enrollment stops. A `4.x.x` status, or a `delayed`\n * action, is SOFT: the server is busy or the mailbox is full, the sending\n * server is still retrying, and nothing changes. A report whose action is\n * `delivered`, `relayed` or `expanded` is not a bounce at all.\n *\n * ## Reports that are not standard\n *\n * Some servers send a bounce as plain prose. A message from a mailer daemon\n * whose subject or text reads like one is read for the status code the prose\n * quotes (`550 5.1.1`, `421 4.7.0`), and failing that for the words that say\n * permanent or temporary — soft when it says it will retry, hard otherwise.\n *==========================================*/\n\nimport { emailAddressOf } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachBounceKind = 'hard' | 'soft'\n\n/** What a delivery report says about one recipient. */\nexport interface OutreachDeliveryRecipient {\n /** `Final-Recipient`, or `Original-Recipient` when that is all there is; normalized. */\n address: string | null\n /** `Action`, lowercased: `failed`, `delayed`, `delivered`, `relayed`, `expanded`. */\n action: string | null\n /** `Status`: the enhanced code, `5.1.1`. */\n status: string | null\n /** `Diagnostic-Code`, without its type prefix. */\n diagnostic: string | null\n /**\n * `Remote-MTA`, without its type prefix and lowercased: the receiving\n * server that answered — which names the gateway in front of the domain\n * when it is one (AGL-3326).\n */\n remoteMta: string | null\n /** `hard`, `soft`, or `null` for a block that reports no failure. */\n kind: OutreachBounceKind | null\n}\n\nexport interface OutreachDeliveryReport {\n /** The worst failure the report carries, or `null` when it reports none. */\n kind: OutreachBounceKind | null\n /** Every recipient the report names, in order. */\n recipients: OutreachDeliveryRecipient[]\n /**\n * The addresses `kind` is about, normalized — `X-Failed-Recipients` when no\n * block names one. Empty when nothing says which recipient failed.\n */\n failedAddresses: string[]\n /** The status code of the worst failure, when one was found. */\n status: string | null\n /** The diagnostic of the worst failure, when one was given. */\n diagnostic: string | null\n /** The `Remote-MTA` of the worst failure, when the report named one. */\n remoteMta: string | null\n}\n\nconst DAEMON_LOCAL_PART = /^(mailer-daemon|postmaster|mail-daemon|maildaemon)$/i\nconst DAEMON_NAME = /\\bmail delivery (subsystem|system|service)\\b/i\nconst BOUNCE_WORDS =\n /undeliver|delivery status notification|delivery (has )?failed|delivery failure|failure notice|returned mail|mail delivery failed|could not be delivered|wasn't delivered|was not delivered|not delivered|address not found|message blocked/i\n\n/** Whether a message comes from a mail system rather than from a person. */\nexport function isMailerDaemonMessage(message: Pick<OutreachThreadMessage, 'from'>): boolean {\n const raw = String(message?.from ?? '')\n const address = emailAddressOf(raw)\n const local = address ? address.slice(0, address.lastIndexOf('@')) : ''\n return DAEMON_LOCAL_PART.test(local) || DAEMON_NAME.test(raw)\n}\n\nfunction isDeliveryStatusReport(message: OutreachThreadMessage): boolean {\n const contentType = outreachHeader(message, 'Content-Type').toLowerCase()\n if (\n contentType.includes('multipart/report') &&\n /report-type\\s*=\\s*\"?delivery-status\"?/.test(contentType)\n ) {\n return true\n }\n return (message.parts ?? []).some(\n (part) =>\n part.mimeType === 'message/delivery-status' ||\n part.mimeType === 'message/global-delivery-status',\n )\n}\n\n/** `Name: value` fields, continuation lines folded in, one list per blank-line block. */\nfunction fieldBlocks(text: string): Array<Record<string, string>> {\n const blocks: Array<Record<string, string>> = []\n let current: Record<string, string> = {}\n let last: string | null = null\n const close = () => {\n if (Object.keys(current).length) blocks.push(current)\n current = {}\n last = null\n }\n for (const line of text.replace(/\\r\\n?/g, '\\n').split('\\n')) {\n if (!line.trim()) {\n close()\n continue\n }\n if (/^[ \\t]/.test(line) && last) {\n current[last] = `${current[last]} ${line.trim()}`\n continue\n }\n const field = /^([A-Za-z][A-Za-z0-9-]*)\\s*:\\s*(.*)$/.exec(line)\n if (field) {\n last = field[1].toLowerCase()\n if (!(last in current)) current[last] = field[2].trim()\n }\n }\n close()\n return blocks\n}\n\n/** `rfc822; someone@example.com` → the address. */\nfunction typedAddress(value: string | undefined): string | null {\n if (!value) return null\n const typed = /^[a-z0-9-]+\\s*;\\s*(.*)$/i.exec(value)\n return emailAddressOf(typed ? typed[1] : value)\n}\n\n/** An enhanced status code, any class: what a `Status` field holds. */\nconst STATUS_CODE = /\\b([245])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n/** A failure's enhanced status code, quoted somewhere in prose. */\nconst FAILURE_STATUS_CODE = /\\b([45])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n/** A failure's basic SMTP reply code, as a transcript quotes it: `550-` or `421 `. */\nconst FAILURE_REPLY_CODE = /\\b([45])[0-5]\\d(?=[ -])/\n\nfunction kindFor(action: string | null, status: string | null): OutreachBounceKind | null {\n if (action === 'delivered' || action === 'relayed' || action === 'expanded') return null\n const statusClass = status ? status.charAt(0) : null\n if (statusClass === '4' || action === 'delayed') return 'soft'\n if (statusClass === '5') return action === null || action === 'failed' ? 'hard' : 'soft'\n if (action === 'failed') return 'hard'\n return null\n}\n\nconst SEVERITY: Record<OutreachBounceKind, number> = { soft: 1, hard: 2 }\n\nfunction failedRecipientsHeader(message: OutreachThreadMessage): string[] {\n return outreachHeader(message, 'X-Failed-Recipients')\n .split(/[,\\s]+/)\n .map((value) => emailAddressOf(value))\n .filter((value): value is string => value !== null)\n}\n\nfunction prose(message: OutreachThreadMessage): string {\n return [message.subject, message.textBody, message.snippet]\n .filter((value) => typeof value === 'string' && value)\n .join('\\n')\n}\n\n/**\n * What a delivery report says, or `null` when the message is not one — see\n * the module note for how a standard report and a prose bounce are read.\n */\nexport function readOutreachDeliveryReport(\n message: OutreachThreadMessage,\n): OutreachDeliveryReport | null {\n const standard = isDeliveryStatusReport(message)\n const daemon = isMailerDaemonMessage(message)\n if (!standard && !(daemon && BOUNCE_WORDS.test(prose(message)))) return null\n\n const statusText = (message.parts ?? [])\n .filter(\n (part) =>\n part.mimeType === 'message/delivery-status' ||\n part.mimeType === 'message/global-delivery-status',\n )\n .map((part) => part.text ?? '')\n .join('\\n\\n')\n // The first block describes the report itself (`Reporting-MTA`,\n // `Arrival-Date`); each later one describes a recipient.\n const recipients: OutreachDeliveryRecipient[] = fieldBlocks(statusText)\n .filter(\n (block) =>\n block['final-recipient'] || block['original-recipient'] || block['action'] || block['status'],\n )\n .map((block) => {\n const action = block['action'] ? block['action'].toLowerCase().split(/\\s/)[0] : null\n const statusMatch = STATUS_CODE.exec(block['status'] ?? '')\n const status = statusMatch ? statusMatch[0] : null\n const diagnostic = block['diagnostic-code']\n ? block['diagnostic-code'].replace(/^[a-z0-9-]+\\s*;\\s*/i, '')\n : null\n // `dns; mx.example.com. (192.0.2.10, ...)` → the host alone.\n const remoteMta = block['remote-mta']\n ? block['remote-mta'].replace(/^[a-z0-9-]+\\s*;\\s*/i, '').trim().split(/\\s+/)[0].toLowerCase().replace(/\\.$/, '') || null\n : null\n return {\n address: typedAddress(block['final-recipient']) ?? typedAddress(block['original-recipient']),\n action,\n status,\n diagnostic,\n remoteMta,\n kind: kindFor(action, status),\n }\n })\n\n let worst: OutreachDeliveryRecipient | null = null\n for (const recipient of recipients) {\n if (recipient.kind && (!worst || SEVERITY[recipient.kind] > SEVERITY[worst.kind])) {\n worst = recipient\n }\n }\n\n if (!worst && !recipients.length) {\n // A report without readable blocks, or a bounce written as prose: the\n // code the text quotes, else the words.\n const text = prose(message)\n const code = FAILURE_STATUS_CODE.exec(text)\n const reply = FAILURE_REPLY_CODE.exec(text)\n const statusClass = code?.[1] ?? reply?.[1] ?? null\n const retrying =\n /\\b(delay(ed)?|will (keep )?retry|not (been )?delivered yet|temporar(y|ily))\\b/i.test(text)\n const kind: OutreachBounceKind =\n statusClass === '5' ? 'hard' : statusClass === '4' || retrying ? 'soft' : 'hard'\n return {\n kind,\n recipients: [],\n failedAddresses: failedRecipientsHeader(message),\n status: code ? code[0] : null,\n diagnostic: null,\n remoteMta: null,\n }\n }\n\n // Only the addresses the report's own kind is about: a report that\n // hard-bounced one recipient and delayed another does not bounce both.\n const failedAddresses = worst\n ? recipients\n .filter((recipient) => recipient.kind === worst.kind && recipient.address)\n .map((recipient) => recipient.address as string)\n : []\n return {\n kind: worst?.kind ?? null,\n recipients,\n failedAddresses:\n failedAddresses.length || !worst ? failedAddresses : failedRecipientsHeader(message),\n status: worst?.status ?? null,\n diagnostic: worst?.diagnostic ?? null,\n remoteMta: worst?.remoteMta ?? null,\n }\n}\n"],"names":["emailAddressOf","outreachHeader","DAEMON_LOCAL_PART","DAEMON_NAME","BOUNCE_WORDS","isMailerDaemonMessage","message","raw","String","from","address","local","slice","lastIndexOf","test","isDeliveryStatusReport","contentType","toLowerCase","includes","parts","some","part","mimeType","fieldBlocks","text","blocks","current","last","close","Object","keys","length","push","line","replace","split","trim","field","exec","typedAddress","value","typed","STATUS_CODE","FAILURE_STATUS_CODE","FAILURE_REPLY_CODE","kindFor","action","status","statusClass","charAt","SEVERITY","soft","hard","failedRecipientsHeader","map","filter","prose","subject","textBody","snippet","join","readOutreachDeliveryReport","standard","daemon","statusText","recipients","block","statusMatch","diagnostic","remoteMta","kind","worst","recipient","code","reply","retrying","failedAddresses"],"mappings":"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"}
@@ -24,6 +24,8 @@ export type OutreachEnrollmentEvent = {
24
24
  type: 'pause';
25
25
  atMs: number;
26
26
  byUid: string | null;
27
+ /** `manual` unless the engine held the send on its gateway (AGL-3326). */
28
+ reason?: Extract<OutreachStopReason, 'manual' | 'gateway_blocked_here'>;
27
29
  detail?: string | null;
28
30
  } | {
29
31
  type: 'resume';
@@ -107,7 +107,7 @@ const detailOf = (value)=>{
107
107
  return text ? text.slice(0, 500) : null;
108
108
  };
109
109
  /** What an event does to an enrollment's status — see the module note. */ export function applyOutreachEnrollmentEvent(enrollment, event) {
110
- var _event_reason;
110
+ var _event_reason, _event_reason1;
111
111
  const from = enrollment.status;
112
112
  const to = EVENT_TARGET[event.type];
113
113
  if (!to) return {
@@ -137,7 +137,7 @@ const detailOf = (value)=>{
137
137
  error: null
138
138
  };
139
139
  }
140
- const reason = event.type === 'pause' ? 'manual' : event.type === 'stop' ? (_event_reason = event.reason) != null ? _event_reason : 'manual' : event.type === 'reply' ? 'reply' : event.type === 'opt_out' ? event.reason : event.type === 'bounce' ? 'hard_bounce' : 'send_failed';
140
+ const reason = event.type === 'pause' ? (_event_reason = event.reason) != null ? _event_reason : 'manual' : event.type === 'stop' ? (_event_reason1 = event.reason) != null ? _event_reason1 : 'manual' : event.type === 'reply' ? 'reply' : event.type === 'opt_out' ? event.reason : event.type === 'bounce' ? 'hard_bounce' : 'send_failed';
141
141
  const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to];
142
142
  if (!(allowed == null ? void 0 : allowed.includes(reason))) {
143
143
  return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.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 * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | { type: 'pause'; atMs: number; byUid: string | null; detail?: string | null }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? 'manual'\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings' | 'campaignIds'>\n mailbox: ScheduleMailbox\n /** The record the person is (AGL-3234); a contact when absent. */\n target?: OutreachEnrollmentTarget\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key, for an enrollment made on a lead. */\n leadId?: string | null\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n target: draft.target ?? 'contact',\n contactId: draft.contactId,\n leadId: draft.leadId ?? null,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n // The sequence's campaigns as they stand now (AGL-3254): what this\n // enrollment's outcomes are credited to, whatever the sequence joins later.\n campaignIds: normalizeCampaignIds(draft.sequence.campaignIds),\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeCampaignIds","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.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 * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | {\n type: 'pause'\n atMs: number\n byUid: string | null\n /** `manual` unless the engine held the send on its gateway (AGL-3326). */\n reason?: Extract<OutreachStopReason, 'manual' | 'gateway_blocked_here'>\n detail?: string | null\n }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? (event.reason ?? 'manual')\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings' | 'campaignIds'>\n mailbox: ScheduleMailbox\n /** The record the person is (AGL-3234); a contact when absent. */\n target?: OutreachEnrollmentTarget\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key, for an enrollment made on a lead. */\n leadId?: string | null\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n target: draft.target ?? 'contact',\n contactId: draft.contactId,\n leadId: draft.leadId ?? null,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n // The sequence's campaigns as they stand now (AGL-3254): what this\n // enrollment's outcomes are credited to, whatever the sequence joins later.\n campaignIds: normalizeCampaignIds(draft.sequence.campaignIds),\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeCampaignIds","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
@@ -18,6 +18,7 @@ import { type ContactSource } from '@aglyn/aglyn/app-utils/contacts';
18
18
  import type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics';
19
19
  import { type MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases';
20
20
  import { type OutreachAttestationKind, type OutreachAttestations, type OutreachEnrollmentStatus, type OutreachSequenceSettings } from '../model/outreach.types';
21
+ import { type OutreachGatewayStanding } from './mail-gateway';
21
22
  import { type OutreachRecipientCountry } from './recipient-country';
22
23
  /**
23
24
  * The capture doors that mean a person came to the business: a form, a
@@ -77,6 +78,13 @@ export interface OutreachGateLookups {
77
78
  * the rules stricter.
78
79
  */
79
80
  hasInboundEmail: boolean | null;
81
+ /**
82
+ * The mail gateway in front of the address's domain (AGL-3326), as its MX
83
+ * names it — `outreachDomainIntel/{domain}` — and what that gateway did
84
+ * with this organization's mail lately, from `outreachGatewayStats`.
85
+ * `none` blocks; a gateway that holds is answered in `hold`.
86
+ */
87
+ gateway: OutreachGatewayStanding | null;
80
88
  }
81
89
  export interface OutreachGateInput {
82
90
  /** The address the steps go to. */
@@ -94,14 +102,27 @@ export interface OutreachGateInput {
94
102
  attestations: OutreachAttestations | null | undefined;
95
103
  /** The enrollment being re-checked before a send, so it does not count as another. */
96
104
  enrollmentId?: string | null;
105
+ /**
106
+ * A member already released the gateway hold on this enrollment
107
+ * (AGL-3326) — resumed it, or ticked the person past the red chip — so
108
+ * the gateway's history does not hold it again.
109
+ */
110
+ gatewayHoldReleased?: boolean;
97
111
  lookups: OutreachGateLookups;
98
112
  }
99
- export type OutreachGateCode = 'contact_missing' | 'invalid_email' | 'email_not_on_contact' | 'free_mail' | 'country_not_allowed' | 'country_unknown' | 'platform_suppressed' | 'host_suppressed' | 'email_state' | 'sales_opted_out' | 'do_not_contact' | 'do_not_contact_domain' | 'workspace_member' | 'customer' | 'already_enrolled' | 'attestations_missing' | 'personal_line_missing' | 'personal_line_too_long';
113
+ export type OutreachGateCode = 'contact_missing' | 'invalid_email' | 'email_not_on_contact' | 'free_mail' | 'country_not_allowed' | 'country_unknown' | 'platform_suppressed' | 'host_suppressed' | 'email_state' | 'sales_opted_out' | 'do_not_contact' | 'do_not_contact_domain' | 'no_mx' | 'workspace_member' | 'customer' | 'already_enrolled' | 'attestations_missing' | 'personal_line_missing' | 'personal_line_too_long';
100
114
  export interface OutreachGateBlock {
101
115
  code: OutreachGateCode;
102
116
  /** A sentence a rep can act on. */
103
117
  reason: string;
104
118
  }
119
+ /** The one verdict that holds a send rather than refusing it — see the module note. */
120
+ export interface OutreachGateHold {
121
+ code: 'gateway_blocked_here';
122
+ gateway: OutreachGatewayStanding['gateway'];
123
+ /** A sentence the enrollment row shows. */
124
+ reason: string;
125
+ }
105
126
  export interface OutreachGateResult {
106
127
  allowed: boolean;
107
128
  /** The address, normalized, or `null` when it is not one. */
@@ -110,6 +131,12 @@ export interface OutreachGateResult {
110
131
  country: OutreachRecipientCountry;
111
132
  /** Every gate that refuses, in gate order. */
112
133
  blocks: OutreachGateBlock[];
134
+ /**
135
+ * The gateway hold (AGL-3326), when the address's gateway refused this
136
+ * organization twice lately and nobody released it; `null` otherwise. An
137
+ * allowed result can carry one: the send waits for a member.
138
+ */
139
+ hold: OutreachGateHold | null;
113
140
  /** The attestations a cold contact still needs, in the order they are asked. */
114
141
  missingAttestations: OutreachAttestationKind[];
115
142
  }