@aglyn/plugins-outreach 1.0.0-beta.219 → 1.0.0-beta.220

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 (59) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/compliance-section.d.ts +5 -0
  3. package/src/lib/components/compliance-section.js +233 -149
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enrollment-detail.js +2 -2
  6. package/src/lib/components/enrollment-detail.js.map +1 -1
  7. package/src/lib/components/sequence-editor.d.ts +13 -0
  8. package/src/lib/components/sequence-editor.js +508 -302
  9. package/src/lib/components/sequence-editor.js.map +1 -1
  10. package/src/lib/components/sequence-preview.js +25 -10
  11. package/src/lib/components/sequence-preview.js.map +1 -1
  12. package/src/lib/components/sequence-step-card.d.ts +10 -0
  13. package/src/lib/components/sequence-step-card.js +320 -202
  14. package/src/lib/components/sequence-step-card.js.map +1 -1
  15. package/src/lib/engine/compose.js +2 -2
  16. package/src/lib/engine/compose.js.map +1 -1
  17. package/src/lib/engine/delivery-status.js +1 -1
  18. package/src/lib/engine/delivery-status.js.map +1 -1
  19. package/src/lib/engine/enrollment-state.js +2 -2
  20. package/src/lib/engine/enrollment-state.js.map +1 -1
  21. package/src/lib/engine/gates.d.ts +2 -2
  22. package/src/lib/engine/gates.js.map +1 -1
  23. package/src/lib/engine/opt-out-intent.js +1 -1
  24. package/src/lib/engine/opt-out-intent.js.map +1 -1
  25. package/src/lib/engine/sequence-validation.js +2 -2
  26. package/src/lib/engine/sequence-validation.js.map +1 -1
  27. package/src/lib/engine/thread-classification.js +3 -3
  28. package/src/lib/engine/thread-classification.js.map +1 -1
  29. package/src/lib/enrollment/gate-lookups.js +5 -4
  30. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  31. package/src/lib/model/outreach.types.d.ts +1 -1
  32. package/src/lib/model/outreach.types.js.map +1 -1
  33. package/src/lib/model/sales-topic.d.ts +27 -0
  34. package/src/lib/model/sales-topic.js +27 -0
  35. package/src/lib/model/sales-topic.js.map +1 -0
  36. package/src/lib/model/sequence-draft.d.ts +1 -1
  37. package/src/lib/model/sequence-draft.js +2 -2
  38. package/src/lib/model/sequence-draft.js.map +1 -1
  39. package/src/lib/model/stored-records.js +2 -2
  40. package/src/lib/model/stored-records.js.map +1 -1
  41. package/src/lib/routes/curate-routes.js +7 -7
  42. package/src/lib/routes/curate-routes.js.map +1 -1
  43. package/src/lib/routes/enroll-routes.js +10 -13
  44. package/src/lib/routes/enroll-routes.js.map +1 -1
  45. package/src/lib/routes/sequence-routes.js +9 -9
  46. package/src/lib/routes/sequence-routes.js.map +1 -1
  47. package/src/lib/runtime/campaign-credit.js +2 -2
  48. package/src/lib/runtime/campaign-credit.js.map +1 -1
  49. package/src/lib/runtime/campaign-detach.js +3 -2
  50. package/src/lib/runtime/campaign-detach.js.map +1 -1
  51. package/src/lib/runtime/lead-records.d.ts +6 -1
  52. package/src/lib/runtime/lead-records.js +25 -6
  53. package/src/lib/runtime/lead-records.js.map +1 -1
  54. package/src/lib/runtime/platform-runtime-deps.js +3 -3
  55. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +20 -4
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/runtime/sync-job.js +3 -3
  59. package/src/lib/runtime/sync-job.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-step-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n mdiArrowDown,\n mdiArrowUp,\n mdiCodeBraces,\n mdiEmailFastOutline,\n mdiTrashCanOutline,\n} from '@aglyn/shared-data-mdi'\nimport { MdiIcon } from '@aglyn/shared-ui-jsx'\nimport {\n Autocomplete,\n Button,\n Card,\n CardContent,\n FormControlLabel,\n IconButton,\n ListSubheader,\n Menu,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { useRef, useState } from 'react'\nimport { OUTREACH_MERGE_FIELDS } from '../engine/sequence-validation'\nimport {\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_TASK_KIND_LABELS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachSequenceStep,\n type OutreachTaskKind,\n type OutreachTaskStep,\n} from '../model/outreach.types'\nimport type { OutreachTemplateOption } from './use-outreach-crm'\n\nexport interface OutreachStepCardProps {\n step: OutreachSequenceStep\n /** Its position, from 0. */\n index: number\n /** How many steps the sequence has, for the move controls. */\n count: number\n /** Whether it is the sequence's first email, which always starts the thread. */\n firstEmail: boolean\n /** Whether it is sent as a reply in the thread an earlier email started. */\n inThread: boolean\n /** The subject the thread was started with, for an in-thread email's note. */\n threadSubject: string\n templates: readonly OutreachTemplateOption[]\n /** The issue to show beside each field, by the field's own name. */\n issues: Partial<Record<string, string>>\n disabled?: boolean\n onChange(step: OutreachSequenceStep): void\n onMove(direction: -1 | 1): void\n onRemove(): void\n /**\n * Opens the test-send dialog for this email step (AGL-3325). Absent for a\n * step that cannot be tested — a task, or an archived sequence.\n */\n onSendTest?(): void\n /** Why the test cannot be sent yet, shown on the disabled button; `null` when it can. */\n sendTestDisabledReason?: string | null\n}\n\n/** The merge fields, grouped the way the picker heads them. */\nconst FIELD_GROUPS = [\n ...new Set(OUTREACH_MERGE_FIELDS.map((field) => field.group)),\n]\n\n/**\n * One step of the sequence editor (AGL-2980): an email — subject, body or a\n * CRM template, merge fields, the wait before it — or a task for the rep.\n */\nexport function OutreachStepCard(props: OutreachStepCardProps) {\n const { step, index, count, disabled } = props\n const title =\n step.kind === 'email'\n ? `Step ${index + 1} · Email`\n : `Step ${index + 1} · ${OUTREACH_TASK_KIND_LABELS[(step as OutreachTaskStep).taskKind] ?? 'Task'}`\n return (\n <Card variant=\"outlined\" component=\"section\" aria-label={title}>\n <CardContent>\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Typography variant=\"subtitle1\" sx={{ flexGrow: 1 }}>\n {title}\n </Typography>\n <Tooltip title=\"Move up\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Move step ${index + 1} up`}\n disabled={disabled || index === 0}\n onClick={() => props.onMove(-1)}\n >\n <MdiIcon path={mdiArrowUp.path} fontSize=\"small\" />\n </IconButton>\n </span>\n </Tooltip>\n <Tooltip title=\"Move down\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Move step ${index + 1} down`}\n disabled={disabled || index === count - 1}\n onClick={() => props.onMove(1)}\n >\n <MdiIcon path={mdiArrowDown.path} fontSize=\"small\" />\n </IconButton>\n </span>\n </Tooltip>\n <Tooltip title=\"Remove\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Remove step ${index + 1}`}\n disabled={disabled || count === 1}\n onClick={props.onRemove}\n >\n <MdiIcon path={mdiTrashCanOutline.path} fontSize=\"small\" />\n </IconButton>\n </span>\n </Tooltip>\n </Stack>\n <DelayField {...props} />\n {step.kind === 'email' ? (\n <EmailStepFields {...props} step={step} />\n ) : step.kind === 'task' ? (\n <TaskStepFields {...props} step={step} />\n ) : (\n <Typography variant=\"body2\" color=\"error\">\n {props.issues['step'] ?? 'A step is an email or a task.'}\n </Typography>\n )}\n </Stack>\n </CardContent>\n </Card>\n )\n}\nOutreachStepCard.displayName = 'OutreachStepCard'\n\nfunction DelayField(props: OutreachStepCardProps) {\n const { step, index, disabled } = props\n const first = index === 0\n const issue = props.issues['delayBusinessDays']\n return (\n <TextField\n type=\"number\"\n label={\n first\n ? 'Wait after enrolling (business days)'\n : 'Wait after the step before (business days)'\n }\n value={\n Number.isFinite(step.delayBusinessDays) ? step.delayBusinessDays : ''\n }\n onChange={(event) =>\n props.onChange({\n ...step,\n delayBusinessDays:\n event.target.value === '' ? Number.NaN : Number(event.target.value),\n })\n }\n disabled={disabled}\n error={Boolean(issue)}\n helperText={\n issue ??\n (first\n ? '0 sends at the next opening of the sending hours.'\n : 'Counted in the mailbox’s timezone, skipping weekends.')\n }\n slotProps={{\n htmlInput: {\n min: 0,\n max: OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n step: 1,\n },\n }}\n sx={{ maxWidth: { sm: 360 } }}\n />\n )\n}\n\nfunction EmailStepFields(\n props: OutreachStepCardProps & { step: OutreachEmailStep },\n) {\n const { step, disabled, issues } = props\n const subjectRef = useRef<HTMLInputElement | null>(null)\n const bodyRef = useRef<HTMLTextAreaElement | null>(null)\n const lastFocused = useRef<'subject' | 'body'>('body')\n const [fieldsAnchor, setFieldsAnchor] = useState<HTMLElement | null>(null)\n const template =\n props.templates.find((option) => option.id === step.templateId) ?? null\n const showSubject = !props.inThread\n\n /** Puts `{{key}}` where the caret was in the field last focused. */\n const insertField = (key: string) => {\n setFieldsAnchor(null)\n const token = `{{${key}}}`\n const target =\n lastFocused.current === 'subject' && showSubject\n ? 'subject'\n : template\n ? null\n : 'body'\n if (!target) return\n const element = target === 'subject' ? subjectRef.current : bodyRef.current\n const value = step[target]\n const start = element?.selectionStart ?? value.length\n const end = element?.selectionEnd ?? value.length\n props.onChange({\n ...step,\n [target]: `${value.slice(0, start)}${token}${value.slice(end)}`,\n })\n requestAnimationFrame(() => {\n element?.focus()\n element?.setSelectionRange(start + token.length, start + token.length)\n })\n }\n\n return (\n <Stack spacing={2}>\n {props.firstEmail ? null : (\n <FormControlLabel\n control={\n <Switch\n checked={step.replyInThread !== false}\n onChange={(event) =>\n props.onChange({ ...step, replyInThread: event.target.checked })\n }\n disabled={disabled}\n />\n }\n label=\"Reply in the same thread\"\n />\n )}\n {showSubject ? (\n <TextField\n label=\"Subject\"\n value={step.subject}\n inputRef={subjectRef}\n onFocus={() => (lastFocused.current = 'subject')}\n onChange={(event) =>\n props.onChange({ ...step, subject: event.target.value })\n }\n disabled={disabled}\n error={Boolean(issues['subject'])}\n helperText={issues['subject']}\n fullWidth\n />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {props.threadSubject\n ? `Sent as a reply: “Re: ${props.threadSubject}”.`\n : 'Sent as a reply in the thread the earlier email started.'}\n </Typography>\n )}\n <Autocomplete<OutreachTemplateOption>\n options={[...props.templates]}\n value={template}\n getOptionLabel={(option) => option.name}\n isOptionEqualToValue={(option, value) => option.id === value.id}\n onChange={(_event, next) =>\n props.onChange({\n ...step,\n templateId: next?.id ?? null,\n body: next ? '' : step.body,\n })\n }\n disabled={disabled}\n renderInput={(params) => (\n <TextField\n {...params}\n label=\"CRM email template\"\n helperText={\n template\n ? 'The template’s body is sent; this step’s own subject is the one used.'\n : 'Optional. Pick one to send its body instead of writing it here.'\n }\n />\n )}\n />\n {template ? (\n <TextField\n label=\"Body (from the template)\"\n value={template.body}\n multiline\n minRows={4}\n fullWidth\n disabled\n />\n ) : (\n <TextField\n label=\"Body\"\n value={step.body}\n inputRef={bodyRef}\n onFocus={() => (lastFocused.current = 'body')}\n onChange={(event) =>\n props.onChange({ ...step, body: event.target.value })\n }\n disabled={disabled}\n error={Boolean(issues['body'])}\n helperText={issues['body']}\n multiline\n minRows={6}\n fullWidth\n />\n )}\n <Stack direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap', rowGap: 1 }}>\n <Button\n size=\"small\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiCodeBraces.path} fontSize=\"small\" />}\n onClick={(event) => setFieldsAnchor(event.currentTarget)}\n disabled={disabled || (Boolean(template) && !showSubject)}\n aria-haspopup=\"menu\"\n >\n Insert field\n </Button>\n {props.onSendTest ? (\n <Tooltip title={props.sendTestDisabledReason ?? ''}>\n <span>\n <Button\n size=\"small\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiEmailFastOutline.path} fontSize=\"small\" />}\n onClick={props.onSendTest}\n disabled={Boolean(props.sendTestDisabledReason)}\n aria-label={`Send a test of step ${props.index + 1}`}\n >\n Send a test\n </Button>\n </span>\n </Tooltip>\n ) : null}\n </Stack>\n <Menu\n anchorEl={fieldsAnchor}\n open={Boolean(fieldsAnchor)}\n onClose={() => setFieldsAnchor(null)}\n >\n {FIELD_GROUPS.flatMap((group) => [\n <ListSubheader key={`group-${group}`}>{group}</ListSubheader>,\n ...OUTREACH_MERGE_FIELDS.filter((field) => field.group === group).map(\n (field) => (\n <MenuItem key={field.key} onClick={() => insertField(field.key)}>\n {field.label}\n </MenuItem>\n ),\n ),\n ])}\n </Menu>\n </Stack>\n )\n}\n\nfunction TaskStepFields(\n props: OutreachStepCardProps & { step: OutreachTaskStep },\n) {\n const { step, disabled, issues } = props\n return (\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>\n <TextField\n select\n label=\"Task\"\n value={OUTREACH_TASK_KINDS.includes(step.taskKind) ? step.taskKind : ''}\n onChange={(event) =>\n props.onChange({\n ...step,\n taskKind: event.target.value as OutreachTaskKind,\n })\n }\n disabled={disabled}\n error={Boolean(issues['taskKind'])}\n helperText={issues['taskKind']}\n sx={{ minWidth: { sm: 180 } }}\n >\n {OUTREACH_TASK_KINDS.map((kind) => (\n <MenuItem key={kind} value={kind}>\n {OUTREACH_TASK_KIND_LABELS[kind]}\n </MenuItem>\n ))}\n </TextField>\n <TextField\n label=\"Title\"\n value={step.title}\n onChange={(event) =>\n props.onChange({ ...step, title: event.target.value })\n }\n disabled={disabled}\n error={Boolean(issues['title'])}\n helperText={\n issues['title'] ??\n 'What the task says, for example “Connect on LinkedIn”.'\n }\n fullWidth\n />\n </Stack>\n )\n}\n\nexport default OutreachStepCard\n"],"names":["mdiArrowDown","mdiArrowUp","mdiCodeBraces","mdiEmailFastOutline","mdiTrashCanOutline","MdiIcon","Autocomplete","Button","Card","CardContent","FormControlLabel","IconButton","ListSubheader","Menu","MenuItem","Stack","Switch","TextField","Tooltip","Typography","useRef","useState","OUTREACH_MERGE_FIELDS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_TASK_KIND_LABELS","OUTREACH_TASK_KINDS","FIELD_GROUPS","Set","map","field","group","OutreachStepCard","props","step","index","count","disabled","title","kind","taskKind","variant","component","aria-label","spacing","direction","sx","alignItems","flexGrow","span","size","onClick","onMove","path","fontSize","onRemove","DelayField","EmailStepFields","TaskStepFields","color","issues","displayName","first","issue","type","label","value","Number","isFinite","delayBusinessDays","onChange","event","target","NaN","error","Boolean","helperText","slotProps","htmlInput","min","max","maxWidth","sm","subjectRef","bodyRef","lastFocused","fieldsAnchor","setFieldsAnchor","template","templates","find","option","id","templateId","showSubject","inThread","insertField","key","token","current","element","start","selectionStart","length","end","selectionEnd","slice","requestAnimationFrame","focus","setSelectionRange","firstEmail","control","checked","replyInThread","subject","inputRef","onFocus","fullWidth","threadSubject","options","getOptionLabel","name","isOptionEqualToValue","_event","next","body","renderInput","params","multiline","minRows","flexWrap","rowGap","startIcon","currentTarget","aria-haspopup","onSendTest","sendTestDisabledReason","anchorEl","open","onClose","flatMap","filter","xs","select","includes","minWidth"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-step-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n mdiArrowDown,\n mdiArrowUp,\n mdiCodeBraces,\n mdiEmailFastOutline,\n mdiTrashCanOutline,\n} from '@aglyn/shared-data-mdi'\nimport { MdiIcon } from '@aglyn/shared-ui-jsx'\nimport {\n Autocomplete,\n Button,\n Card,\n CardContent,\n FormControlLabel,\n IconButton,\n ListSubheader,\n Menu,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { memo, useCallback, useRef, useState } from 'react'\nimport { OUTREACH_MERGE_FIELDS } from '../engine/sequence-validation'\nimport {\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_TASK_KIND_LABELS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachSequenceStep,\n type OutreachTaskKind,\n type OutreachTaskStep,\n} from '../model/outreach.types'\nimport type { OutreachTemplateOption } from './use-outreach-crm'\n\nexport interface OutreachStepCardProps {\n step: OutreachSequenceStep\n /** Its position, from 0. */\n index: number\n /** How many steps the sequence has, for the move controls. */\n count: number\n /** Whether it is the sequence's first email, which always starts the thread. */\n firstEmail: boolean\n /** Whether it is sent as a reply in the thread an earlier email started. */\n inThread: boolean\n /** The subject the thread was started with, for an in-thread email's note. */\n threadSubject: string\n templates: readonly OutreachTemplateOption[]\n /** The issue to show beside each field, by the field's own name. */\n issues: Partial<Record<string, string>>\n disabled?: boolean\n onChange(step: OutreachSequenceStep): void\n onMove(direction: -1 | 1): void\n onRemove(): void\n /**\n * Opens the test-send dialog for this email step (AGL-3325). Absent for a\n * step that cannot be tested — a task, or an archived sequence.\n */\n onSendTest?(): void\n /** Why the test cannot be sent yet, shown on the disabled button; `null` when it can. */\n sendTestDisabledReason?: string | null\n}\n\n/** The merge fields, grouped the way the picker heads them. */\nconst FIELD_GROUPS = [\n ...new Set(OUTREACH_MERGE_FIELDS.map((field) => field.group)),\n]\n\n/** A text field that redraws only when what it shows changes (AGL-3423). */\nconst MemoTextField = memo(TextField) as typeof TextField\n\ntype FieldChange = { target: { value: string } }\ntype SwitchChange = { target: { checked: boolean } }\n\nconst HEADER_ROW = { alignItems: 'center' } as const\nconst TITLE = { flexGrow: 1 } as const\nconst DELAY_FIELD = { maxWidth: { sm: 360 } } as const\nconst DELAY_SLOT_PROPS = {\n htmlInput: {\n min: 0,\n max: OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n step: 1,\n },\n}\nconst ACTIONS_ROW = { flexWrap: 'wrap', rowGap: 1 } as const\nconst TASK_ROW = { xs: 'column', sm: 'row' } as const\nconst TASK_KIND_FIELD = { minWidth: { sm: 180 } } as const\n\nconst templateName = (option: OutreachTemplateOption): string => option.name\nconst sameTemplate = (\n option: OutreachTemplateOption,\n value: OutreachTemplateOption,\n): boolean => option.id === value.id\n\n/** Every task kind, as the Task select lists them, built once. */\nconst TASK_KIND_MENU = OUTREACH_TASK_KINDS.map((kind) => (\n <MenuItem key={kind} value={kind}>\n {OUTREACH_TASK_KIND_LABELS[kind]}\n </MenuItem>\n))\n\n/**\n * One step of the sequence editor (AGL-2980): an email — subject, body or a\n * CRM template, merge fields, the wait before it — or a task for the rep.\n *\n * ## Each field redraws on its own (AGL-3423)\n *\n * The editor keeps the whole sequence in one draft, so a letter typed into a\n * step hands its card a new step. The card's fields are memoized, with\n * handlers that keep their identity and write the step as last drawn, so the\n * letter redraws the field it lands in rather than the wait, the subject, the\n * template picker and the body together. A keystroke that costs one field\n * leaves typed input nothing to queue behind, which is what kept the\n * countries chips from being redrawn by fifty keystrokes in a row (#185).\n */\nexport function OutreachStepCard(props: OutreachStepCardProps) {\n const { step, index, count, disabled } = props\n // The card as last drawn, which every handler below writes from.\n const latest = useRef(props)\n latest.current = props\n const moveUp = useCallback(() => latest.current.onMove(-1), [])\n const moveDown = useCallback(() => latest.current.onMove(1), [])\n const remove = useCallback(() => latest.current.onRemove(), [])\n const setDelay = useCallback(\n (event: FieldChange) =>\n latest.current.onChange({\n ...latest.current.step,\n delayBusinessDays:\n event.target.value === '' ? Number.NaN : Number(event.target.value),\n }),\n [],\n )\n const title =\n step.kind === 'email'\n ? `Step ${index + 1} · Email`\n : `Step ${index + 1} · ${OUTREACH_TASK_KIND_LABELS[(step as OutreachTaskStep).taskKind] ?? 'Task'}`\n return (\n <Card variant=\"outlined\" component=\"section\" aria-label={title}>\n <CardContent>\n <Stack spacing={2}>\n <StepHeader\n title={title}\n index={index}\n count={count}\n disabled={disabled}\n onMoveUp={moveUp}\n onMoveDown={moveDown}\n onRemove={remove}\n />\n <DelayField\n first={index === 0}\n value={step.delayBusinessDays}\n issue={props.issues['delayBusinessDays']}\n disabled={disabled}\n onChange={setDelay}\n />\n {step.kind === 'email' ? (\n <EmailStepFields {...props} step={step} />\n ) : step.kind === 'task' ? (\n <TaskStepFields {...props} step={step} />\n ) : (\n <Typography variant=\"body2\" color=\"error\">\n {props.issues['step'] ?? 'A step is an email or a task.'}\n </Typography>\n )}\n </Stack>\n </CardContent>\n </Card>\n )\n}\nOutreachStepCard.displayName = 'OutreachStepCard'\n\n/** The step's title and the controls that move or remove it. */\nconst StepHeader = memo(function StepHeader(props: {\n title: string\n index: number\n count: number\n disabled?: boolean\n onMoveUp(): void\n onMoveDown(): void\n onRemove(): void\n}) {\n const { index, count, disabled } = props\n return (\n <Stack direction=\"row\" spacing={1} sx={HEADER_ROW}>\n <Typography variant=\"subtitle1\" sx={TITLE}>\n {props.title}\n </Typography>\n <Tooltip title=\"Move up\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Move step ${index + 1} up`}\n disabled={disabled || index === 0}\n onClick={props.onMoveUp}\n >\n <MdiIcon path={mdiArrowUp.path} fontSize=\"small\" />\n </IconButton>\n </span>\n </Tooltip>\n <Tooltip title=\"Move down\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Move step ${index + 1} down`}\n disabled={disabled || index === count - 1}\n onClick={props.onMoveDown}\n >\n <MdiIcon path={mdiArrowDown.path} fontSize=\"small\" />\n </IconButton>\n </span>\n </Tooltip>\n <Tooltip title=\"Remove\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Remove step ${index + 1}`}\n disabled={disabled || count === 1}\n onClick={props.onRemove}\n >\n <MdiIcon path={mdiTrashCanOutline.path} fontSize=\"small\" />\n </IconButton>\n </span>\n </Tooltip>\n </Stack>\n )\n})\n\nconst DelayField = memo(function DelayField(props: {\n /** Whether it is the first step, whose wait starts at enrollment. */\n first: boolean\n value: number\n issue?: string\n disabled?: boolean\n onChange(event: FieldChange): void\n}) {\n const { first, value, issue } = props\n return (\n <TextField\n type=\"number\"\n label={\n first\n ? 'Wait after enrolling (business days)'\n : 'Wait after the step before (business days)'\n }\n value={Number.isFinite(value) ? value : ''}\n onChange={props.onChange}\n disabled={props.disabled}\n error={Boolean(issue)}\n helperText={\n issue ??\n (first\n ? '0 sends at the next opening of the sending hours.'\n : 'Counted in the mailbox’s timezone, skipping weekends.')\n }\n slotProps={DELAY_SLOT_PROPS}\n sx={DELAY_FIELD}\n />\n )\n})\n\nfunction EmailStepFields(\n props: OutreachStepCardProps & { step: OutreachEmailStep },\n) {\n const { step, disabled, issues } = props\n // The step as last drawn, which every handler below writes from.\n const latest = useRef(props)\n latest.current = props\n const subjectRef = useRef<HTMLInputElement | null>(null)\n const bodyRef = useRef<HTMLTextAreaElement | null>(null)\n const lastFocused = useRef<'subject' | 'body'>('body')\n const [fieldsAnchor, setFieldsAnchor] = useState<HTMLElement | null>(null)\n const template =\n props.templates.find((option) => option.id === step.templateId) ?? null\n const showSubject = !props.inThread\n\n const write = useCallback(\n (patch: Partial<OutreachEmailStep>) =>\n latest.current.onChange({ ...latest.current.step, ...patch }),\n [],\n )\n const setReply = useCallback(\n (event: SwitchChange) => write({ replyInThread: event.target.checked }),\n [write],\n )\n const setSubject = useCallback(\n (event: FieldChange) => write({ subject: event.target.value }),\n [write],\n )\n const setBody = useCallback(\n (event: FieldChange) => write({ body: event.target.value }),\n [write],\n )\n const pickTemplate = useCallback(\n (next: OutreachTemplateOption | null) =>\n write({\n templateId: next?.id ?? null,\n body: next ? '' : latest.current.step.body,\n }),\n [write],\n )\n const focusSubject = useCallback(() => {\n lastFocused.current = 'subject'\n }, [])\n const focusBody = useCallback(() => {\n lastFocused.current = 'body'\n }, [])\n const openFields = useCallback(\n (event: { currentTarget: HTMLElement }) =>\n setFieldsAnchor(event.currentTarget),\n [],\n )\n const closeFields = useCallback(() => setFieldsAnchor(null), [])\n const sendTest = useCallback(() => latest.current.onSendTest?.(), [])\n\n /** Puts `{{key}}` where the caret was in the field last focused. */\n const insertField = useCallback((key: string) => {\n setFieldsAnchor(null)\n const current = latest.current\n const token = `{{${key}}}`\n const target =\n lastFocused.current === 'subject' && !current.inThread\n ? 'subject'\n : current.templates.some(\n (option) => option.id === current.step.templateId,\n )\n ? null\n : 'body'\n if (!target) return\n const element = target === 'subject' ? subjectRef.current : bodyRef.current\n const value = current.step[target]\n const start = element?.selectionStart ?? value.length\n const end = element?.selectionEnd ?? value.length\n current.onChange({\n ...current.step,\n [target]: `${value.slice(0, start)}${token}${value.slice(end)}`,\n })\n requestAnimationFrame(() => {\n element?.focus()\n element?.setSelectionRange(start + token.length, start + token.length)\n })\n }, [])\n\n return (\n <Stack spacing={2}>\n {props.firstEmail ? null : (\n <ReplyInThreadField\n checked={step.replyInThread !== false}\n disabled={disabled}\n onChange={setReply}\n />\n )}\n {showSubject ? (\n <MemoTextField\n label=\"Subject\"\n value={step.subject}\n inputRef={subjectRef}\n onFocus={focusSubject}\n onChange={setSubject}\n disabled={disabled}\n error={Boolean(issues['subject'])}\n helperText={issues['subject']}\n fullWidth\n />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {props.threadSubject\n ? `Sent as a reply: “Re: ${props.threadSubject}”.`\n : 'Sent as a reply in the thread the earlier email started.'}\n </Typography>\n )}\n <TemplateField\n templates={props.templates}\n template={template}\n disabled={disabled}\n onChange={pickTemplate}\n />\n {template ? (\n <MemoTextField\n label=\"Body (from the template)\"\n value={template.body}\n multiline\n minRows={4}\n fullWidth\n disabled\n />\n ) : (\n <MemoTextField\n label=\"Body\"\n value={step.body}\n inputRef={bodyRef}\n onFocus={focusBody}\n onChange={setBody}\n disabled={disabled}\n error={Boolean(issues['body'])}\n helperText={issues['body']}\n multiline\n minRows={6}\n fullWidth\n />\n )}\n <EmailStepActions\n stepNumber={props.index + 1}\n insertDisabled={Boolean(disabled || (template && !showSubject))}\n canSendTest={Boolean(props.onSendTest)}\n sendTestDisabledReason={props.sendTestDisabledReason}\n onInsertField={openFields}\n onSendTest={sendTest}\n />\n <MergeFieldMenu\n anchorEl={fieldsAnchor}\n onClose={closeFields}\n onPick={insertField}\n />\n </Stack>\n )\n}\n\nconst ReplyInThreadField = memo(function ReplyInThreadField(props: {\n checked: boolean\n disabled?: boolean\n onChange(event: SwitchChange): void\n}) {\n return (\n <FormControlLabel\n control={\n <Switch\n checked={props.checked}\n onChange={props.onChange}\n disabled={props.disabled}\n />\n }\n label=\"Reply in the same thread\"\n />\n )\n})\n\nconst TemplateField = memo(function TemplateField(props: {\n templates: readonly OutreachTemplateOption[]\n template: OutreachTemplateOption | null\n disabled?: boolean\n onChange(next: OutreachTemplateOption | null): void\n}) {\n const { template, onChange } = props\n return (\n <Autocomplete<OutreachTemplateOption>\n options={[...props.templates]}\n value={template}\n getOptionLabel={templateName}\n isOptionEqualToValue={sameTemplate}\n onChange={(_event, next) => onChange(next)}\n disabled={props.disabled}\n renderInput={(params) => (\n <TextField\n {...params}\n label=\"CRM email template\"\n helperText={\n template\n ? 'The template’s body is sent; this step’s own subject is the one used.'\n : 'Optional. Pick one to send its body instead of writing it here.'\n }\n />\n )}\n />\n )\n})\n\nconst EmailStepActions = memo(function EmailStepActions(props: {\n stepNumber: number\n insertDisabled: boolean\n canSendTest: boolean\n sendTestDisabledReason?: string | null\n onInsertField(event: { currentTarget: HTMLElement }): void\n onSendTest(): void\n}) {\n return (\n <Stack direction=\"row\" spacing={1} sx={ACTIONS_ROW}>\n <Button\n size=\"small\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiCodeBraces.path} fontSize=\"small\" />}\n onClick={props.onInsertField}\n disabled={props.insertDisabled}\n aria-haspopup=\"menu\"\n >\n Insert field\n </Button>\n {props.canSendTest ? (\n <Tooltip title={props.sendTestDisabledReason ?? ''}>\n <span>\n <Button\n size=\"small\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiEmailFastOutline.path} fontSize=\"small\" />}\n onClick={props.onSendTest}\n disabled={Boolean(props.sendTestDisabledReason)}\n aria-label={`Send a test of step ${props.stepNumber}`}\n >\n Send a test\n </Button>\n </span>\n </Tooltip>\n ) : null}\n </Stack>\n )\n})\n\nconst MergeFieldMenu = memo(function MergeFieldMenu(props: {\n anchorEl: HTMLElement | null\n onClose(): void\n onPick(key: string): void\n}) {\n const { onPick } = props\n return (\n <Menu\n anchorEl={props.anchorEl}\n open={Boolean(props.anchorEl)}\n onClose={props.onClose}\n >\n {FIELD_GROUPS.flatMap((group) => [\n <ListSubheader key={`group-${group}`}>{group}</ListSubheader>,\n ...OUTREACH_MERGE_FIELDS.filter((field) => field.group === group).map(\n (field) => (\n <MenuItem key={field.key} onClick={() => onPick(field.key)}>\n {field.label}\n </MenuItem>\n ),\n ),\n ])}\n </Menu>\n )\n})\n\nfunction TaskStepFields(\n props: OutreachStepCardProps & { step: OutreachTaskStep },\n) {\n const { step, disabled, issues } = props\n // The step as last drawn, which both handlers write from.\n const latest = useRef(props)\n latest.current = props\n const setKind = useCallback(\n (event: FieldChange) =>\n latest.current.onChange({\n ...latest.current.step,\n taskKind: event.target.value as OutreachTaskKind,\n }),\n [],\n )\n const setTitle = useCallback(\n (event: FieldChange) =>\n latest.current.onChange({\n ...latest.current.step,\n title: event.target.value,\n }),\n [],\n )\n return (\n <Stack direction={TASK_ROW} spacing={2}>\n <TaskKindField\n value={OUTREACH_TASK_KINDS.includes(step.taskKind) ? step.taskKind : ''}\n issue={issues['taskKind']}\n disabled={disabled}\n onChange={setKind}\n />\n <MemoTextField\n label=\"Title\"\n value={step.title}\n onChange={setTitle}\n disabled={disabled}\n error={Boolean(issues['title'])}\n helperText={\n issues['title'] ??\n 'What the task says, for example “Connect on LinkedIn”.'\n }\n fullWidth\n />\n </Stack>\n )\n}\n\nconst TaskKindField = memo(function TaskKindField(props: {\n value: string\n issue?: string\n disabled?: boolean\n onChange(event: FieldChange): void\n}) {\n return (\n <TextField\n select\n label=\"Task\"\n value={props.value}\n onChange={props.onChange}\n disabled={props.disabled}\n error={Boolean(props.issue)}\n helperText={props.issue}\n sx={TASK_KIND_FIELD}\n >\n {TASK_KIND_MENU}\n </TextField>\n )\n})\n\nexport default OutreachStepCard\n"],"names":["mdiArrowDown","mdiArrowUp","mdiCodeBraces","mdiEmailFastOutline","mdiTrashCanOutline","MdiIcon","Autocomplete","Button","Card","CardContent","FormControlLabel","IconButton","ListSubheader","Menu","MenuItem","Stack","Switch","TextField","Tooltip","Typography","memo","useCallback","useRef","useState","OUTREACH_MERGE_FIELDS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_TASK_KIND_LABELS","OUTREACH_TASK_KINDS","FIELD_GROUPS","Set","map","field","group","MemoTextField","HEADER_ROW","alignItems","TITLE","flexGrow","DELAY_FIELD","maxWidth","sm","DELAY_SLOT_PROPS","htmlInput","min","max","step","ACTIONS_ROW","flexWrap","rowGap","TASK_ROW","xs","TASK_KIND_FIELD","minWidth","templateName","option","name","sameTemplate","value","id","TASK_KIND_MENU","kind","OutreachStepCard","props","index","count","disabled","latest","current","moveUp","onMove","moveDown","remove","onRemove","setDelay","event","onChange","delayBusinessDays","target","Number","NaN","title","taskKind","variant","component","aria-label","spacing","StepHeader","onMoveUp","onMoveDown","DelayField","first","issue","issues","EmailStepFields","TaskStepFields","color","displayName","direction","sx","span","size","onClick","path","fontSize","type","label","isFinite","error","Boolean","helperText","slotProps","subjectRef","bodyRef","lastFocused","fieldsAnchor","setFieldsAnchor","template","templates","find","templateId","showSubject","inThread","write","patch","setReply","replyInThread","checked","setSubject","subject","setBody","body","pickTemplate","next","focusSubject","focusBody","openFields","currentTarget","closeFields","sendTest","onSendTest","insertField","key","token","some","element","start","selectionStart","length","end","selectionEnd","slice","requestAnimationFrame","focus","setSelectionRange","firstEmail","ReplyInThreadField","inputRef","onFocus","fullWidth","threadSubject","TemplateField","multiline","minRows","EmailStepActions","stepNumber","insertDisabled","canSendTest","sendTestDisabledReason","onInsertField","MergeFieldMenu","anchorEl","onClose","onPick","control","options","getOptionLabel","isOptionEqualToValue","_event","renderInput","params","startIcon","aria-haspopup","open","flatMap","filter","setKind","setTitle","TaskKindField","includes","select"],"mappings":"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"}
@@ -61,7 +61,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
61
61
  *==========================================*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
62
62
  import { CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
63
63
  import { resolveCrmMergeFields } from "@aglyn/aglyn/app-utils/crm-email-templates";
64
- import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
64
+ import { threadSubject as threadSubjectOf } from "@aglyn/aglyn/app-utils/email-text";
65
65
  import { rewriteOutreachBodyLinks } from "./click-tracking.js";
66
66
  import { outreachOpenTrackedHtml } from "./open-tracking.js";
67
67
  import { normalizeOutreachPersonalLine } from "./gates.js";
@@ -207,7 +207,7 @@ const refuse = (code, message)=>({
207
207
  const startStepIndex = Number.isInteger(input.enrollment.startStepIndex) ? Math.max(0, Number(input.enrollment.startStepIndex)) : 0;
208
208
  if (isInThreadEmailStep(steps, stepIndex, startStepIndex)) {
209
209
  var _input_enrollment_messageIds, _input_enrollment_gmailThreadId;
210
- const threadSubject = crmThreadSubject(input.enrollment.threadSubject);
210
+ const threadSubject = threadSubjectOf(input.enrollment.threadSubject);
211
211
  const messageIds = ((_input_enrollment_messageIds = input.enrollment.messageIds) != null ? _input_enrollment_messageIds : []).map(outreachMessageIdHeader).filter((id)=>id !== null);
212
212
  const threadId = String((_input_enrollment_gmailThreadId = input.enrollment.gmailThreadId) != null ? _input_enrollment_gmailThreadId : '').trim();
213
213
  if (!threadSubject || !messageIds.length || !threadId) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *\n * ## A curated step\n *\n * An enrollment may carry its own copy of a step (AGL-3324): a subject and\n * a body drafted for that one person. The composer reads the copy in place\n * of the step's subject and body — and of the template the step names —\n * and then does everything it does to a step: the merge, the threading, the\n * click tracking, the footer. A curated in-thread email still goes out as\n * `Re:` the thread's subject, since it answers the same thread.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n OutreachStepOverride,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { outreachOpenTrackedHtml } from './open-tracking'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n outreachThreadStartSubject,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /**\n * The same body as HTML with a tracking image (AGL-3395), sent beside\n * `text` as `multipart/alternative`. Absent on every email of a sequence\n * that does not count opens, which goes out as plain text alone.\n */\n html?: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n /**\n * The destinations whose links were rewritten for click tracking, in the\n * order a click token indexes them (AGL-3239). Empty when the sequence\n * does not track clicks, when the body carries no links, and when no\n * tracking link could be minted.\n */\n trackedLinks: string[]\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n > &\n Partial<Pick<OutreachEnrollment, 'stepOverrides' | 'startStepIndex'>>\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n /**\n * Answers the tracking link for one link in the body, or `null` to leave\n * that one as it was written (AGL-3239).\n *\n * Absent leaves every link alone, which is what a sequence with\n * `trackClicks` off passes and what every caller that is not the sending\n * runtime passes — a PREVIEW must show the rep the body their recipient\n * reads, and minting a live tracking link for a preview would count the\n * rep's own click against the sequence.\n *\n * It runs on the step's rendered body and never on the footer, so the\n * organization's identification and the way out are never rewritten.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n /**\n * The tracking image's address, minted by the runtime for a sequence that\n * counts opens (AGL-3395). Given, the email gains an HTML part — the same\n * text, footer included, and the image; absent or `null`, it stays plain\n * text, which is what a preview and every other caller pass.\n */\n openPixelUrl?: string | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n /**\n * The person's own copy of the step the email was written from\n * (AGL-3324), or `null` when the step was sent as the sequence wrote it.\n */\n override: OutreachStepOverride | null\n}\n\n/**\n * The enrollment's own copy of the step at `stepIndex` (AGL-3324), or\n * `null`. A copy with neither a subject nor a body is no copy.\n */\nexport function outreachStepOverrideFor(\n enrollment: Partial<Pick<OutreachEnrollment, 'stepOverrides'>> | null | undefined,\n stepIndex: number,\n): OutreachStepOverride | null {\n const override = enrollment?.stepOverrides?.[String(stepIndex)]\n if (!override || typeof override !== 'object') return null\n const hasSubject = typeof override.subject === 'string'\n const hasBody = typeof override.body === 'string'\n return hasSubject || hasBody ? override : null\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [], override: null })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [], override: null }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n // This person's own copy of the step (AGL-3324), read in place of the\n // step's own words — and of its template — from here on.\n const override = outreachStepOverrideFor(input.enrollment, stepIndex)\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '', trackedLinks: [] }\n // An enrollment that began past the first step (AGL-3228) starts its\n // thread with the first email it sends, under the subject the person\n // already had by hand when the step was written as a reply.\n const startStepIndex = Number.isInteger(input.enrollment.startStepIndex)\n ? Math.max(0, Number(input.enrollment.startStepIndex))\n : 0\n if (isInThreadEmailStep(steps, stepIndex, startStepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(override?.subject ?? outreachThreadStartSubject(steps, stepIndex))))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source =\n typeof override?.body === 'string'\n ? override.body\n : step.templateId\n ? String(input.templateBody ?? '')\n : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n /*\n * Click tracking rewrites the STEP'S links and nothing else (AGL-3239):\n * it runs here, on the rendered body, before the footer is appended. The\n * footer's opt-out `mailto:` and the organization's own identification are\n * never rewritten, and the one-click unsubscribe link never appears in a\n * body at all — it rides in `List-Unsubscribe`.\n */\n const tracked = input.rewriteLink\n ? rewriteOutreachBodyLinks(body, input.rewriteLink)\n : { text: body, links: [] }\n email.trackedLinks = tracked.links\n email.text = `${tracked.text}\\n\\n${footer}`\n // Built from the finished text, so the HTML part says exactly what the\n // plain-text part says — the footer and any rewritten link included.\n if (input.openPixelUrl) email.html = outreachOpenTrackedHtml(email.text, input.openPixelUrl)\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved, override }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","rewriteOutreachBodyLinks","outreachOpenTrackedHtml","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","outreachThreadStartSubject","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","outreachStepOverrideFor","enrollment","stepIndex","override","stepOverrides","String","hasSubject","subject","hasBody","body","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","trackedLinks","startStepIndex","Number","isInteger","Math","max","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","tracked","rewriteLink","links","openPixelUrl","html","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA4C4C,GAE5C,SAASA,qBAAqB,QAAQ,kCAAiC;AACvE,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAEEC,qBAAqB,QAChB,6CAA4C;AACnD,SAASC,gBAAgB,QAAQ,qCAAoC;AAOrE,SAASC,wBAAwB,QAAQ,sBAAkB;AAC3D,SAASC,uBAAuB,QAAQ,qBAAiB;AACzD,SAASC,6BAA6B,QAAQ,aAAS;AACvD,SACEC,mBAAmB,EACnBC,4BAA4B,EAC5BC,6BAA6B,EAC7BC,0BAA0B,QACrB,2BAAuB;AAE9B,mDAAmD,GACnD,OAAO,MAAMC,wBAAwB,uDAAsD;AAE3F;;;;;CAKC,GACD,OAAO,SAASC,8BAA8BC,UAAkB;IAC9D,OAAO,CAAC,2BAA2B,EAAEA,WAAW,CAAC,CAAC;AACpD;AA8GA;;;CAGC,GACD,OAAO,SAASC,wBACdC,UAAiF,EACjFC,SAAiB;QAEAD;IAAjB,MAAME,WAAWF,+BAAAA,4BAAAA,WAAYG,aAAa,qBAAzBH,yBAA2B,CAACI,OAAOH,WAAW;IAC/D,IAAI,CAACC,YAAY,OAAOA,aAAa,UAAU,OAAO;IACtD,MAAMG,aAAa,OAAOH,SAASI,OAAO,KAAK;IAC/C,MAAMC,UAAU,OAAOL,SAASM,IAAI,KAAK;IACzC,OAAOH,cAAcE,UAAUL,WAAW;AAC5C;AAEA,MAAMO,UAAU,CAACC,QACf,OAAOA,UAAU,WAAWA,MAAMC,OAAO,CAAC,QAAQ,KAAKC,IAAI,KAAK;AAElE;;;;;CAKC,GACD,OAAO,SAASC,sBACdC,QAAmG;;IAEnG,MAAMC,gBAAgBX,eAAOU,4BAAAA,SAAUC,aAAa,mBAAI,IACrDC,KAAK,CAAC,cACNC,GAAG,CAAC,CAACC,OAAST,QAAQS,MAAMP,OAAO,CAAC,OAAO,KAC3CQ,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,IAAI,CAACN,eAAe;QAClB,OAAO;YACLO,QAAQ;YACRC,OAAO;gBACLC,MAAM;gBACNC,SACE;YACJ;QACF;IACF;IACA,MAAMC,YAAYjB,QAAQK,4BAAAA,SAAUY,SAAS;IAC7C,IAAI,CAACA,WAAW;QACd,OAAO;YACLJ,QAAQ;YACRC,OAAO;gBACLC,MAAM;gBACNC,SACE;YACJ;QACF;IACF;IACA,MAAM3B,aAAaW,QAAQK,4BAAAA,SAAUa,SAAS,KAAKD;IACnD,OAAO;QACLJ,QAAQ,GAAGI,UAAU,GAAG,EAAEX,cAAc,EAAE,EAAElB,8BAA8BC,YAAY,CAAC,EAAEF,uBAAuB;QAChH2B,OAAO;IACT;AACF;AAEA,qFAAqF,GACrF,OAAO,SAASK,wBAAwBlB,KAAc;IACpD,MAAMmB,OAAOzB,OAAOM,gBAAAA,QAAS,IAAIE,IAAI,GAAGD,OAAO,CAAC,OAAO,IAAIA,OAAO,CAAC,OAAO;IAC1E,IAAI,CAACkB,QAAQ,SAASC,IAAI,CAACD,OAAO,OAAO;IACzC,OAAO,CAAC,CAAC,EAAEA,KAAK,CAAC,CAAC;AACpB;AAEA,SAASE,eAAerB,KAAc;IACpC,MAAMsB,MAAM5B,OAAOM,gBAAAA,QAAS,IAAIE,IAAI;IACpC,IAAI,CAACoB,KAAK,OAAOC;IACjB,IAAI,SAASH,IAAI,CAACE,MAAM,OAAO;IAC/B,IAAI;QACF,MAAME,MAAM,IAAIC,IAAIH;QACpB,OAAOE,IAAIE,QAAQ,KAAK,WAAWF,IAAIG,IAAI,GAAG;IAChD,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,SAASC,kBAAkB5B,KAAc;IACvC,MAAMsB,MAAM5B,OAAOM,gBAAAA,QAAS,IAAIE,IAAI;IACpC,IAAI,CAACoB,KAAK,OAAOC;IACjB,IAAI,SAASH,IAAI,CAACE,MAAM,OAAO;IAC/B,IAAI,CAAC,YAAYF,IAAI,CAACE,MAAM;QAC1B,MAAMO,UAAUtD,sBAAsB+C;QACtC,OAAOO,UAAU,CAAC,OAAO,EAAEA,QAAQ,oBAAoB,CAAC,GAAG;IAC7D;IACA,IAAIC,SAASR,IAAIS,KAAK,CAAC,UAAUC,MAAM,EAAE1B,KAAK,CAAC,IAAI,CAAC,EAAE;IACtD,IAAI;QACFwB,SAASG,mBAAmBH;IAC9B,EAAE,eAAM;QACN,OAAO;IACT;IACA,OAAOvD,sBAAsBuD,UAAUR,MAAM;AAC/C;AAEA,MAAMY,SAAS,CACbpB,MACAC,UAC2B,CAAA;QAAEoB,OAAO;QAAMtB,OAAO;YAAEC;YAAMC;QAAQ;QAAGqB,kBAAkB,EAAE;QAAE5C,UAAU;IAAK,CAAA;AAE3G,kEAAkE,GAClE,OAAO,SAAS6C,qBAAqBC,KAAgC;cAgB7DA,qBAwDSA,qBACAC;QArEDD,iBACIA,mBAaVA;IAjBR,MAAM,EAAE1B,MAAM,EAAEC,KAAK,EAAE,GAAGV,sBAAsBmC,MAAME,WAAW;IACjE,IAAI3B,OAAO,OAAO;QAAEsB,OAAO;QAAMtB;QAAOuB,kBAAkB,EAAE;QAAE5C,UAAU;IAAK;IAE7E,MAAMiD,iBAAQH,kBAAAA,MAAMI,QAAQ,qBAAdJ,gBAAgBG,KAAK,mBAAI,EAAE;IACzC,MAAMlD,aAAY+C,oBAAAA,MAAMhD,UAAU,qBAAhBgD,kBAAkB/C,SAAS;IAC7C,MAAMgD,OAAOE,KAAK,CAAClD,UAAU;IAC7B,IAAIgD,CAAAA,wBAAAA,KAAMI,IAAI,MAAK,SAAS;QAC1B,OAAOT,OAAO,qBAAqB;IACrC;IACA,MAAMU,KAAKrE,sBAAsB+D,MAAMhD,UAAU,CAAC6C,KAAK;IACvD,IAAI,CAACS,IAAI;QACP,OAAOV,OAAO,qBAAqB;IACrC;IAEA,MAAMW,UAA2B,cAC3BP,eAAAA,MAAMQ,KAAK,YAAXR,eAAe,CAAC;QACpBS,OAAO,uBACDT,gBAAAA,MAAMQ,KAAK,qBAAXR,cAAaS,KAAK,oBAAI,CAAC;YAC3B,CAAChE,6BAA6B,EAAEF,8BAA8ByD,MAAMhD,UAAU,CAAC0D,YAAY;;;IAG/F,MAAMC,aAAuB,EAAE;IAC/B,MAAMC,SAAS,CAACC;QACd,MAAMC,SAAS3E,sBAAsB0E,MAAMN;QAC3C,KAAK,MAAMQ,OAAOD,OAAOH,UAAU,CAAE,IAAI,CAACA,WAAWK,QAAQ,CAACD,MAAMJ,WAAWM,IAAI,CAACF;QACpF,OAAOD,OAAOD,IAAI;IACpB;IAEA,sEAAsE;IACtE,yDAAyD;IACzD,MAAM3D,WAAWH,wBAAwBiD,MAAMhD,UAAU,EAAEC;IAE3D,MAAM4C,QAA+B;QAAES;QAAIhD,SAAS;QAAIuD,MAAM;QAAIK,cAAc,EAAE;IAAC;IACnF,qEAAqE;IACrE,qEAAqE;IACrE,4DAA4D;IAC5D,MAAMC,iBAAiBC,OAAOC,SAAS,CAACrB,MAAMhD,UAAU,CAACmE,cAAc,IACnEG,KAAKC,GAAG,CAAC,GAAGH,OAAOpB,MAAMhD,UAAU,CAACmE,cAAc,KAClD;IACJ,IAAI3E,oBAAoB2D,OAAOlD,WAAWkE,iBAAiB;YAErCnB,8BAGIA;QAJxB,MAAMwB,gBAAgBpF,iBAAiB4D,MAAMhD,UAAU,CAACwE,aAAa;QACrE,MAAMC,aAAa,EAACzB,+BAAAA,MAAMhD,UAAU,CAACyE,UAAU,YAA3BzB,+BAA+B,EAAE,EAClD/B,GAAG,CAACW,yBACJT,MAAM,CAAC,CAACuD,KAAqBA,OAAO;QACvC,MAAMC,WAAWvE,QAAO4C,kCAAAA,MAAMhD,UAAU,CAAC4E,aAAa,YAA9B5B,kCAAkC,IAAIpC,IAAI;QAClE,IAAI,CAAC4D,iBAAiB,CAACC,WAAW/B,MAAM,IAAI,CAACiC,UAAU;YACrD,OAAO/B,OACL,kBACA;QAEJ;QACAC,MAAMvC,OAAO,GAAG,CAAC,IAAI,EAAEkE,eAAe,CAAC/B,KAAK,CAAC,GAAGvD;QAChD2D,MAAM8B,QAAQ,GAAGA;QACjB9B,MAAMgC,SAAS,GAAGJ,UAAU,CAACA,WAAW/B,MAAM,GAAG,EAAE;QACnDG,MAAMiC,UAAU,GAAGL,WAAWpD,IAAI,CAAC;IACrC,OAAO;;QACL,MAAMf,UAAUG,QAAQmD,OAAOxD,gBAAOF,4BAAAA,SAAUI,OAAO,oBAAIX,2BAA2BwD,OAAOlD;QAC7F,IAAI,CAACK,SAAS,OAAOsC,OAAO,mBAAmB;QAC/C,IAAIlD,8BAA8BY,UAAU;YAC1C,OAAOsC,OACL,wBACA;QAEJ;QACAC,MAAMvC,OAAO,GAAGA,QAAQmC,KAAK,CAAC,GAAGvD;IACnC;IAEA,MAAM6F,SACJ,QAAO7E,4BAAAA,SAAUM,IAAI,MAAK,WACtBN,SAASM,IAAI,GACbyC,KAAK+B,UAAU,GACb5E,QAAO4C,sBAAAA,MAAMiC,YAAY,YAAlBjC,sBAAsB,MAC7B5C,QAAO6C,aAAAA,KAAKzC,IAAI,YAATyC,aAAa;IAC5B,MAAMzC,OAAOoD,OAAOmB,QACjBpE,OAAO,CAAC,UAAU,MAClBK,KAAK,CAAC,MACNC,GAAG,CAAC,CAACC,OAASA,KAAKP,OAAO,CAAC,WAAW,KACtCU,IAAI,CAAC,MACLV,OAAO,CAAC,cAAc;IACzB,IAAI,CAACH,KAAKI,IAAI,IAAI,OAAOgC,OAAO,gBAAgB;IAChD;;;;;;GAMC,GACD,MAAMsC,UAAUlC,MAAMmC,WAAW,GAC7B9F,yBAAyBmB,MAAMwC,MAAMmC,WAAW,IAChD;QAAEtB,MAAMrD;QAAM4E,OAAO,EAAE;IAAC;IAC5BvC,MAAMqB,YAAY,GAAGgB,QAAQE,KAAK;IAClCvC,MAAMgB,IAAI,GAAG,GAAGqB,QAAQrB,IAAI,CAAC,IAAI,EAAEvC,QAAQ;IAC3C,uEAAuE;IACvE,qEAAqE;IACrE,IAAI0B,MAAMqC,YAAY,EAAExC,MAAMyC,IAAI,GAAGhG,wBAAwBuD,MAAMgB,IAAI,EAAEb,MAAMqC,YAAY;IAE3F,MAAMnD,MAAMH,eAAeiB,MAAMuC,kBAAkB;IACnD,IAAIrD,QAAQ,MAAM;QAChB,OAAOU,OAAO,2BAA2B;IAC3C;IACA,IAAIV,KAAKW,MAAM0C,kBAAkB,GAAGrD;IACpC,MAAMsD,SAASlD,kBAAkBU,MAAMyC,qBAAqB;IAC5D,IAAID,WAAW,MAAM;QACnB,OAAO5C,OAAO,8BAA8B;IAC9C;IACA,IAAI4C,QAAQ3C,MAAM4C,qBAAqB,GAAGD;IAE1C,OAAO;QAAE3C;QAAOtB,OAAO;QAAMuB,kBAAkBa;QAAYzD;IAAS;AACtE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *\n * ## A curated step\n *\n * An enrollment may carry its own copy of a step (AGL-3324): a subject and\n * a body drafted for that one person. The composer reads the copy in place\n * of the step's subject and body — and of the template the step names —\n * and then does everything it does to a step: the merge, the threading, the\n * click tracking, the footer. A curated in-thread email still goes out as\n * `Re:` the thread's subject, since it answers the same thread.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { threadSubject as threadSubjectOf } from '@aglyn/aglyn/app-utils/email-text'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n OutreachStepOverride,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { outreachOpenTrackedHtml } from './open-tracking'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n outreachThreadStartSubject,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /**\n * The same body as HTML with a tracking image (AGL-3395), sent beside\n * `text` as `multipart/alternative`. Absent on every email of a sequence\n * that does not count opens, which goes out as plain text alone.\n */\n html?: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n /**\n * The destinations whose links were rewritten for click tracking, in the\n * order a click token indexes them (AGL-3239). Empty when the sequence\n * does not track clicks, when the body carries no links, and when no\n * tracking link could be minted.\n */\n trackedLinks: string[]\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n > &\n Partial<Pick<OutreachEnrollment, 'stepOverrides' | 'startStepIndex'>>\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n /**\n * Answers the tracking link for one link in the body, or `null` to leave\n * that one as it was written (AGL-3239).\n *\n * Absent leaves every link alone, which is what a sequence with\n * `trackClicks` off passes and what every caller that is not the sending\n * runtime passes — a PREVIEW must show the rep the body their recipient\n * reads, and minting a live tracking link for a preview would count the\n * rep's own click against the sequence.\n *\n * It runs on the step's rendered body and never on the footer, so the\n * organization's identification and the way out are never rewritten.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n /**\n * The tracking image's address, minted by the runtime for a sequence that\n * counts opens (AGL-3395). Given, the email gains an HTML part — the same\n * text, footer included, and the image; absent or `null`, it stays plain\n * text, which is what a preview and every other caller pass.\n */\n openPixelUrl?: string | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n /**\n * The person's own copy of the step the email was written from\n * (AGL-3324), or `null` when the step was sent as the sequence wrote it.\n */\n override: OutreachStepOverride | null\n}\n\n/**\n * The enrollment's own copy of the step at `stepIndex` (AGL-3324), or\n * `null`. A copy with neither a subject nor a body is no copy.\n */\nexport function outreachStepOverrideFor(\n enrollment: Partial<Pick<OutreachEnrollment, 'stepOverrides'>> | null | undefined,\n stepIndex: number,\n): OutreachStepOverride | null {\n const override = enrollment?.stepOverrides?.[String(stepIndex)]\n if (!override || typeof override !== 'object') return null\n const hasSubject = typeof override.subject === 'string'\n const hasBody = typeof override.body === 'string'\n return hasSubject || hasBody ? override : null\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [], override: null })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [], override: null }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n // This person's own copy of the step (AGL-3324), read in place of the\n // step's own words — and of its template — from here on.\n const override = outreachStepOverrideFor(input.enrollment, stepIndex)\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '', trackedLinks: [] }\n // An enrollment that began past the first step (AGL-3228) starts its\n // thread with the first email it sends, under the subject the person\n // already had by hand when the step was written as a reply.\n const startStepIndex = Number.isInteger(input.enrollment.startStepIndex)\n ? Math.max(0, Number(input.enrollment.startStepIndex))\n : 0\n if (isInThreadEmailStep(steps, stepIndex, startStepIndex)) {\n const threadSubject = threadSubjectOf(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(override?.subject ?? outreachThreadStartSubject(steps, stepIndex))))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source =\n typeof override?.body === 'string'\n ? override.body\n : step.templateId\n ? String(input.templateBody ?? '')\n : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n /*\n * Click tracking rewrites the STEP'S links and nothing else (AGL-3239):\n * it runs here, on the rendered body, before the footer is appended. The\n * footer's opt-out `mailto:` and the organization's own identification are\n * never rewritten, and the one-click unsubscribe link never appears in a\n * body at all — it rides in `List-Unsubscribe`.\n */\n const tracked = input.rewriteLink\n ? rewriteOutreachBodyLinks(body, input.rewriteLink)\n : { text: body, links: [] }\n email.trackedLinks = tracked.links\n email.text = `${tracked.text}\\n\\n${footer}`\n // Built from the finished text, so the HTML part says exactly what the\n // plain-text part says — the footer and any rewritten link included.\n if (input.openPixelUrl) email.html = outreachOpenTrackedHtml(email.text, input.openPixelUrl)\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved, override }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","threadSubject","threadSubjectOf","rewriteOutreachBodyLinks","outreachOpenTrackedHtml","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","outreachThreadStartSubject","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","outreachStepOverrideFor","enrollment","stepIndex","override","stepOverrides","String","hasSubject","subject","hasBody","body","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","trackedLinks","startStepIndex","Number","isInteger","Math","max","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","tracked","rewriteLink","links","openPixelUrl","html","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
@@ -40,7 +40,7 @@
40
40
  * whose subject or text reads like one is read for the status code the prose
41
41
  * quotes (`550 5.1.1`, `421 4.7.0`), and failing that for the words that say
42
42
  * permanent or temporary — soft when it says it will retry, hard otherwise.
43
- *==========================================*/ import { emailAddressOf } from "@aglyn/aglyn/app-utils/crm-inbound";
43
+ *==========================================*/ import { emailAddressOf } from "@aglyn/aglyn/app-utils/email-text";
44
44
  import { outreachHeader } from "./thread-message.js";
45
45
  const DAEMON_LOCAL_PART = /^(mailer-daemon|postmaster|mail-daemon|maildaemon)$/i;
46
46
  const DAEMON_NAME = /\bmail delivery (subsystem|system|service)\b/i;
@@ -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 /**\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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"}
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/email-text'\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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA2B4C,GAE5C,SAASA,cAAc,QAAQ,oCAAmC;AAClE,SAASC,cAAc,QAAoC,sBAAkB;AA0C7E,MAAMC,oBAAoB;AAC1B,MAAMC,cAAc;AACpB,MAAMC,eACJ;AAEF,0EAA0E,GAC1E,OAAO,SAASC,sBAAsBC,OAA4C;;IAChF,MAAMC,MAAMC,eAAOF,2BAAAA,QAASG,IAAI,mBAAI;IACpC,MAAMC,UAAUV,eAAeO;IAC/B,MAAMI,QAAQD,UAAUA,QAAQE,KAAK,CAAC,GAAGF,QAAQG,WAAW,CAAC,QAAQ;IACrE,OAAOX,kBAAkBY,IAAI,CAACH,UAAUR,YAAYW,IAAI,CAACP;AAC3D;AAEA,SAASQ,uBAAuBT,OAA8B;QAQpDA;IAPR,MAAMU,cAAcf,eAAeK,SAAS,gBAAgBW,WAAW;IACvE,IACED,YAAYE,QAAQ,CAAC,uBACrB,wCAAwCJ,IAAI,CAACE,cAC7C;QACA,OAAO;IACT;IACA,OAAO,EAACV,iBAAAA,QAAQa,KAAK,YAAbb,iBAAiB,EAAE,EAAEc,IAAI,CAC/B,CAACC,OACCA,KAAKC,QAAQ,KAAK,6BAClBD,KAAKC,QAAQ,KAAK;AAExB;AAEA,uFAAuF,GACvF,SAASC,YAAYC,IAAY;IAC/B,MAAMC,SAAwC,EAAE;IAChD,IAAIC,UAAkC,CAAC;IACvC,IAAIC,OAAsB;IAC1B,MAAMC,QAAQ;QACZ,IAAIC,OAAOC,IAAI,CAACJ,SAASK,MAAM,EAAEN,OAAOO,IAAI,CAACN;QAC7CA,UAAU,CAAC;QACXC,OAAO;IACT;IACA,KAAK,MAAMM,QAAQT,KAAKU,OAAO,CAAC,UAAU,MAAMC,KAAK,CAAC,MAAO;QAC3D,IAAI,CAACF,KAAKG,IAAI,IAAI;YAChBR;YACA;QACF;QACA,IAAI,SAASd,IAAI,CAACmB,SAASN,MAAM;YAC/BD,OAAO,CAACC,KAAK,GAAG,GAAGD,OAAO,CAACC,KAAK,CAAC,CAAC,EAAEM,KAAKG,IAAI,IAAI;YACjD;QACF;QACA,MAAMC,QAAQ,uCAAuCC,IAAI,CAACL;QAC1D,IAAII,OAAO;YACTV,OAAOU,KAAK,CAAC,EAAE,CAACpB,WAAW;YAC3B,IAAI,CAAEU,CAAAA,QAAQD,OAAM,GAAIA,OAAO,CAACC,KAAK,GAAGU,KAAK,CAAC,EAAE,CAACD,IAAI;QACvD;IACF;IACAR;IACA,OAAOH;AACT;AAEA,iDAAiD,GACjD,SAASc,aAAaC,KAAyB;IAC7C,IAAI,CAACA,OAAO,OAAO;IACnB,MAAMC,QAAQ,2BAA2BH,IAAI,CAACE;IAC9C,OAAOxC,eAAeyC,QAAQA,KAAK,CAAC,EAAE,GAAGD;AAC3C;AAEA,qEAAqE,GACrE,MAAME,cAAc;AACpB,iEAAiE,GACjE,MAAMC,sBAAsB;AAC5B,oFAAoF,GACpF,MAAMC,qBAAqB;AAE3B,SAASC,QAAQC,MAAqB,EAAEC,MAAqB;IAC3D,IAAID,WAAW,eAAeA,WAAW,aAAaA,WAAW,YAAY,OAAO;IACpF,MAAME,cAAcD,SAASA,OAAOE,MAAM,CAAC,KAAK;IAChD,IAAID,gBAAgB,OAAOF,WAAW,WAAW,OAAO;IACxD,IAAIE,gBAAgB,KAAK,OAAOF,WAAW,QAAQA,WAAW,WAAW,SAAS;IAClF,IAAIA,WAAW,UAAU,OAAO;IAChC,OAAO;AACT;AAEA,MAAMI,WAA+C;IAAEC,MAAM;IAAGC,MAAM;AAAE;AAExE,SAASC,uBAAuB/C,OAA8B;IAC5D,OAAOL,eAAeK,SAAS,uBAC5B6B,KAAK,CAAC,UACNmB,GAAG,CAAC,CAACd,QAAUxC,eAAewC,QAC9Be,MAAM,CAAC,CAACf,QAA2BA,UAAU;AAClD;AAEA,SAASgB,MAAMlD,OAA8B;IAC3C,OAAO;QAACA,QAAQmD,OAAO;QAAEnD,QAAQoD,QAAQ;QAAEpD,QAAQqD,OAAO;KAAC,CACxDJ,MAAM,CAAC,CAACf,QAAU,OAAOA,UAAU,YAAYA,OAC/CoB,IAAI,CAAC;AACV;AAEA;;;CAGC,GACD,OAAO,SAASC,2BACdvD,OAA8B;QAMVA;IAJpB,MAAMwD,WAAW/C,uBAAuBT;IACxC,MAAMyD,SAAS1D,sBAAsBC;IACrC,IAAI,CAACwD,YAAY,CAAEC,CAAAA,UAAU3D,aAAaU,IAAI,CAAC0C,MAAMlD,SAAQ,GAAI,OAAO;IAExE,MAAM0D,aAAa,EAAC1D,iBAAAA,QAAQa,KAAK,YAAbb,iBAAiB,EAAE,EACpCiD,MAAM,CACL,CAAClC,OACCA,KAAKC,QAAQ,KAAK,6BAClBD,KAAKC,QAAQ,KAAK,kCAErBgC,GAAG,CAAC,CAACjC;YAASA;gBAAAA,aAAAA,KAAKG,IAAI,YAATH,aAAa;OAC3BuC,IAAI,CAAC;IACR,gEAAgE;IAChE,yDAAyD;IACzD,MAAMK,aAA0C1C,YAAYyC,YACzDT,MAAM,CACL,CAACW,QACCA,KAAK,CAAC,kBAAkB,IAAIA,KAAK,CAAC,qBAAqB,IAAIA,KAAK,CAAC,SAAS,IAAIA,KAAK,CAAC,SAAS,EAEhGZ,GAAG,CAAC,CAACY;YAEiCA,eAU1B3B;QAXX,MAAMO,SAASoB,KAAK,CAAC,SAAS,GAAGA,KAAK,CAAC,SAAS,CAACjD,WAAW,GAAGkB,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG;QAChF,MAAMgC,cAAczB,YAAYJ,IAAI,EAAC4B,gBAAAA,KAAK,CAAC,SAAS,YAAfA,gBAAmB;QACxD,MAAMnB,SAASoB,cAAcA,WAAW,CAAC,EAAE,GAAG;QAC9C,MAAMC,aAAaF,KAAK,CAAC,kBAAkB,GACvCA,KAAK,CAAC,kBAAkB,CAAChC,OAAO,CAAC,uBAAuB,MACxD;QACJ,6DAA6D;QAC7D,MAAMmC,YAAYH,KAAK,CAAC,aAAa,GACjCA,KAAK,CAAC,aAAa,CAAChC,OAAO,CAAC,uBAAuB,IAAIE,IAAI,GAAGD,KAAK,CAAC,MAAM,CAAC,EAAE,CAAClB,WAAW,GAAGiB,OAAO,CAAC,OAAO,OAAO,OAClH;QACJ,OAAO;YACLxB,OAAO,GAAE6B,gBAAAA,aAAa2B,KAAK,CAAC,kBAAkB,aAArC3B,gBAA0CA,aAAa2B,KAAK,CAAC,qBAAqB;YAC3FpB;YACAC;YACAqB;YACAC;YACAC,MAAMzB,QAAQC,QAAQC;QACxB;IACF;IAEF,IAAIwB,QAA0C;IAC9C,KAAK,MAAMC,aAAaP,WAAY;QAClC,IAAIO,UAAUF,IAAI,IAAK,CAAA,CAACC,SAASrB,QAAQ,CAACsB,UAAUF,IAAI,CAAC,GAAGpB,QAAQ,CAACqB,MAAMD,IAAI,CAAC,AAAD,GAAI;YACjFC,QAAQC;QACV;IACF;IAEA,IAAI,CAACD,SAAS,CAACN,WAAWlC,MAAM,EAAE;YAMZ0C;QALpB,sEAAsE;QACtE,wCAAwC;QACxC,MAAMjD,OAAOgC,MAAMlD;QACnB,MAAMmE,OAAO9B,oBAAoBL,IAAI,CAACd;QACtC,MAAMkD,QAAQ9B,mBAAmBN,IAAI,CAACd;QACtC,MAAMwB,eAAcyB,iBAAAA,wBAAAA,IAAM,CAAC,EAAE,oBAAIC,yBAAAA,KAAO,CAAC,EAAE,YAAvBD,QAA2B;QAC/C,MAAME,WACJ,iFAAiF7D,IAAI,CAACU;QACxF,MAAM8C,OACJtB,gBAAgB,MAAM,SAASA,gBAAgB,OAAO2B,WAAW,SAAS;QAC5E,OAAO;YACLL;YACAL,YAAY,EAAE;YACdW,iBAAiBvB,uBAAuB/C;YACxCyC,QAAQ0B,OAAOA,IAAI,CAAC,EAAE,GAAG;YACzBL,YAAY;YACZC,WAAW;QACb;IACF;IAEA,mEAAmE;IACnE,uEAAuE;IACvE,MAAMO,kBAAkBL,QACpBN,WACGV,MAAM,CAAC,CAACiB,YAAcA,UAAUF,IAAI,KAAKC,MAAMD,IAAI,IAAIE,UAAU9D,OAAO,EACxE4C,GAAG,CAAC,CAACkB,YAAcA,UAAU9D,OAAO,IACvC,EAAE;IACN,OAAO;QACL4D,IAAI,UAAEC,yBAAAA,MAAOD,IAAI,mBAAI;QACrBL;QACAW,iBACEA,gBAAgB7C,MAAM,IAAI,CAACwC,QAAQK,kBAAkBvB,uBAAuB/C;QAC9EyC,MAAM,WAAEwB,yBAAAA,MAAOxB,MAAM,oBAAI;QACzBqB,UAAU,WAAEG,yBAAAA,MAAOH,UAAU,oBAAI;QACjCC,SAAS,WAAEE,yBAAAA,MAAOF,SAAS,oBAAI;IACjC;AACF"}
@@ -37,7 +37,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
37
37
  * whole document, so a writer applies it with its own SDK and its own
38
38
  * timestamps.
39
39
  *==========================================*/ import { OUTREACH_ATTESTATION_KINDS, OUTREACH_STOP_REASONS_BY_STATUS } from "../model/outreach.types.js";
40
- import { normalizeCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
40
+ import { normalizeContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
41
41
  import { normalizeOutreachPersonalLine } from "./gates.js";
42
42
  import { effectiveOutreachWindow, scheduleOutreachDue } from "./schedule.js";
43
43
  import { isInThreadEmailStep } from "./sequence-validation.js";
@@ -267,7 +267,7 @@ const detailOf = (value)=>{
267
267
  lastSentAtMs: null,
268
268
  // The sequence's campaigns as they stand now (AGL-3254): what this
269
269
  // enrollment's outcomes are credited to, whatever the sequence joins later.
270
- campaignIds: normalizeCampaignIds(draft.sequence.campaignIds),
270
+ campaignIds: normalizeContainerIds(draft.sequence.campaignIds),
271
271
  createdAtMs: draft.nowMs,
272
272
  updatedAtMs: draft.nowMs
273
273
  };
@@ -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 | {\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/**\n * The step an enrollment begins at (AGL-3228), read from a request: the\n * first when the request names none, or the zero-based index it names —\n * or, as `refusal`, why that is not a step of this sequence.\n */\nexport function readOutreachStartStepIndex(\n raw: unknown,\n steps: readonly unknown[] | null | undefined,\n): { startStepIndex: number; refusal: null } | { startStepIndex: null; refusal: string } {\n if (raw === undefined || raw === null) return { startStepIndex: 0, refusal: null }\n const count = steps?.length ?? 0\n const index = typeof raw === 'number' ? raw : Number.NaN\n if (!Number.isInteger(index) || index < 0) {\n return { startStepIndex: null, refusal: 'Name the step to start at by its number in the sequence.' }\n }\n if (index >= count) {\n return {\n startStepIndex: null,\n refusal: `This sequence has ${count} ${count === 1 ? 'step' : 'steps'}, so enrolling can't start at step ${index + 1}.`,\n }\n }\n return { startStepIndex: index, refusal: null }\n}\n\n/**\n * When a new enrollment's first step comes due, or `null` when it cannot be\n * placed: the step it begins at (AGL-3228; the first unless it says so),\n * that step's own delay counted from the moment of enrollment.\n */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n startStepIndex?: number\n}): number | null {\n const first = input.sequence.steps?.[input.startStepIndex ?? 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 * The step to begin at (AGL-3228), zero-based; the first when absent.\n * The caller has checked it names a step — {@link readOutreachStartStepIndex}.\n */\n startStepIndex?: number\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 *\n * An enrollment that begins past the first step (AGL-3228) is for someone\n * who already had the earlier steps by hand: those are marked skipped, the\n * step it begins at is scheduled by its own delay from now, and\n * `lastSentAtMs` is the enrollment — the moment the earlier steps are\n * accounted for — so the sync watches this mailbox for the person's reply,\n * bounce or opt-out from the start, as it does after a send.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n const start = draft.startStepIndex ?? 0\n const steps = draft.sequence.steps ?? []\n const enrollment: OutreachEnrollment = {\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: start,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n startStepIndex: start,\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 if (start > 0) {\n enrollment.startStepIndex = start\n enrollment.skippedSteps = steps.slice(0, start).map((step, stepIndex) => ({\n stepIndex,\n stepId: step.id,\n kind: step.kind,\n atMs: draft.nowMs,\n }))\n enrollment.lastSentAtMs = draft.nowMs\n }\n return enrollment\n}\n\n/** The step an enrollment began at (AGL-3228): the first, unless it began later. */\nexport function outreachEnrollmentStartStepIndex(\n enrollment: Pick<OutreachEnrollment, 'startStepIndex'> | null | undefined,\n): number {\n const start = Number(enrollment?.startStepIndex)\n return Number.isInteger(start) && start > 0 ? start : 0\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 Partial<Pick<OutreachEnrollment, 'startStepIndex'>>\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 (\n isInThreadEmailStep(steps, enrollment.stepIndex, outreachEnrollmentStartStepIndex(enrollment)) &&\n enrollment.threadSubject\n ) {\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","readOutreachStartStepIndex","raw","steps","undefined","startStepIndex","refusal","count","length","index","Number","NaN","isInteger","planOutreachFirstDue","input","first","sequence","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","start","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","createdAtMs","updatedAtMs","skippedSteps","map","step","stepId","outreachEnrollmentStartStepIndex","planOutreachStepCompletion","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 { normalizeContainerIds } from '@aglyn/aglyn/app-utils/container-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/**\n * The step an enrollment begins at (AGL-3228), read from a request: the\n * first when the request names none, or the zero-based index it names —\n * or, as `refusal`, why that is not a step of this sequence.\n */\nexport function readOutreachStartStepIndex(\n raw: unknown,\n steps: readonly unknown[] | null | undefined,\n): { startStepIndex: number; refusal: null } | { startStepIndex: null; refusal: string } {\n if (raw === undefined || raw === null) return { startStepIndex: 0, refusal: null }\n const count = steps?.length ?? 0\n const index = typeof raw === 'number' ? raw : Number.NaN\n if (!Number.isInteger(index) || index < 0) {\n return { startStepIndex: null, refusal: 'Name the step to start at by its number in the sequence.' }\n }\n if (index >= count) {\n return {\n startStepIndex: null,\n refusal: `This sequence has ${count} ${count === 1 ? 'step' : 'steps'}, so enrolling can't start at step ${index + 1}.`,\n }\n }\n return { startStepIndex: index, refusal: null }\n}\n\n/**\n * When a new enrollment's first step comes due, or `null` when it cannot be\n * placed: the step it begins at (AGL-3228; the first unless it says so),\n * that step's own delay counted from the moment of enrollment.\n */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n startStepIndex?: number\n}): number | null {\n const first = input.sequence.steps?.[input.startStepIndex ?? 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 * The step to begin at (AGL-3228), zero-based; the first when absent.\n * The caller has checked it names a step — {@link readOutreachStartStepIndex}.\n */\n startStepIndex?: number\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 *\n * An enrollment that begins past the first step (AGL-3228) is for someone\n * who already had the earlier steps by hand: those are marked skipped, the\n * step it begins at is scheduled by its own delay from now, and\n * `lastSentAtMs` is the enrollment — the moment the earlier steps are\n * accounted for — so the sync watches this mailbox for the person's reply,\n * bounce or opt-out from the start, as it does after a send.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n const start = draft.startStepIndex ?? 0\n const steps = draft.sequence.steps ?? []\n const enrollment: OutreachEnrollment = {\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: start,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n startStepIndex: start,\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: normalizeContainerIds(draft.sequence.campaignIds),\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n if (start > 0) {\n enrollment.startStepIndex = start\n enrollment.skippedSteps = steps.slice(0, start).map((step, stepIndex) => ({\n stepIndex,\n stepId: step.id,\n kind: step.kind,\n atMs: draft.nowMs,\n }))\n enrollment.lastSentAtMs = draft.nowMs\n }\n return enrollment\n}\n\n/** The step an enrollment began at (AGL-3228): the first, unless it began later. */\nexport function outreachEnrollmentStartStepIndex(\n enrollment: Pick<OutreachEnrollment, 'startStepIndex'> | null | undefined,\n): number {\n const start = Number(enrollment?.startStepIndex)\n return Number.isInteger(start) && start > 0 ? start : 0\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 Partial<Pick<OutreachEnrollment, 'startStepIndex'>>\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 (\n isInThreadEmailStep(steps, enrollment.stepIndex, outreachEnrollmentStartStepIndex(enrollment)) &&\n enrollment.threadSubject\n ) {\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","normalizeContainerIds","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","readOutreachStartStepIndex","raw","steps","undefined","startStepIndex","refusal","count","length","index","Number","NaN","isInteger","planOutreachFirstDue","input","first","sequence","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","start","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","createdAtMs","updatedAtMs","skippedSteps","map","step","stepId","outreachEnrollmentStartStepIndex","planOutreachStepCompletion","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type ContactSource } from '@aglyn/aglyn/app-utils/contacts';
18
- import type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics';
18
+ import type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/subscription-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
21
  import { type OutreachGatewayStanding } from './mail-gateway';
@@ -59,7 +59,7 @@ export interface OutreachGateLookups {
59
59
  doNotContactDomain: boolean | null;
60
60
  /**
61
61
  * The organization's roster: each member's sign-in address and confirmed
62
- * aliases, as `crmInboundRoster` builds it.
62
+ * aliases, as `memberAddressRoster` builds it.
63
63
  */
64
64
  workspaceMembers: readonly MemberAddresses[] | null;
65
65
  /**