@aglyn/plugins-outreach 1.0.0-beta.218 → 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.
- package/package.json +15 -15
- package/src/lib/components/compliance-section.d.ts +5 -0
- package/src/lib/components/compliance-section.js +302 -180
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/enroll-dialog.d.ts +12 -2
- package/src/lib/components/enroll-dialog.js +72 -18
- package/src/lib/components/enroll-dialog.js.map +1 -1
- package/src/lib/components/enrollment-detail.d.ts +5 -1
- package/src/lib/components/enrollment-detail.js +14 -8
- package/src/lib/components/enrollment-detail.js.map +1 -1
- package/src/lib/components/outreach-console-sections.d.ts +1 -1
- package/src/lib/components/outreach-console-sections.js +1 -1
- package/src/lib/components/outreach-console-sections.js.map +1 -1
- package/src/lib/components/sequence-editor.d.ts +13 -0
- package/src/lib/components/sequence-editor.js +586 -335
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/sequence-preview.js +25 -10
- package/src/lib/components/sequence-preview.js.map +1 -1
- package/src/lib/components/sequence-step-card.d.ts +10 -0
- package/src/lib/components/sequence-step-card.js +320 -202
- package/src/lib/components/sequence-step-card.js.map +1 -1
- package/src/lib/components/use-outreach-api.d.ts +2 -1
- package/src/lib/components/use-outreach-api.js +5 -3
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/engine/compose.d.ts +1 -1
- package/src/lib/engine/compose.js +10 -6
- package/src/lib/engine/compose.js.map +1 -1
- package/src/lib/engine/delivery-status.js +1 -1
- package/src/lib/engine/delivery-status.js.map +1 -1
- package/src/lib/engine/enrollment-activity.d.ts +9 -1
- package/src/lib/engine/enrollment-activity.js +15 -2
- package/src/lib/engine/enrollment-activity.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +33 -2
- package/src/lib/engine/enrollment-state.js +68 -9
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/engine/gates.d.ts +2 -2
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/opt-out-intent.js +1 -1
- package/src/lib/engine/opt-out-intent.js.map +1 -1
- package/src/lib/engine/sending-capacity.d.ts +1 -1
- package/src/lib/engine/sending-capacity.js +1 -1
- package/src/lib/engine/sending-capacity.js.map +1 -1
- package/src/lib/engine/sequence-validation.d.ts +20 -3
- package/src/lib/engine/sequence-validation.js +32 -6
- package/src/lib/engine/sequence-validation.js.map +1 -1
- package/src/lib/engine/thread-classification.js +3 -3
- package/src/lib/engine/thread-classification.js.map +1 -1
- package/src/lib/enrollment/enroll-people.js +6 -4
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/gate-lookups.js +5 -4
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/enrollment/step-overrides.d.ts +4 -2
- package/src/lib/enrollment/step-overrides.js +11 -4
- package/src/lib/enrollment/step-overrides.js.map +1 -1
- package/src/lib/mailboxes/mailbox-api.d.ts +5 -1
- package/src/lib/mailboxes/mailbox-api.js.map +1 -1
- package/src/lib/mailboxes/mailbox-routes.d.ts +5 -2
- package/src/lib/mailboxes/mailbox-routes.js +10 -7
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
- package/src/lib/mailboxes/mailbox-settings.d.ts +2 -2
- package/src/lib/mailboxes/mailbox-settings.js +1 -1
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +1 -1
- package/src/lib/model/enrollment-timeline.js +22 -3
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +19 -1
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +26 -1
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sales-topic.d.ts +27 -0
- package/src/lib/model/sales-topic.js +27 -0
- package/src/lib/model/sales-topic.js.map +1 -0
- package/src/lib/model/sequence-draft.d.ts +1 -1
- package/src/lib/model/sequence-draft.js +2 -2
- package/src/lib/model/sequence-draft.js.map +1 -1
- package/src/lib/model/step-preview.d.ts +2 -0
- package/src/lib/model/step-preview.js +11 -7
- package/src/lib/model/step-preview.js.map +1 -1
- package/src/lib/model/stored-records.js +28 -2
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/plugin.js +1 -1
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/routes/curate-routes.js +7 -7
- package/src/lib/routes/curate-routes.js.map +1 -1
- package/src/lib/routes/enroll-routes.d.ts +6 -0
- package/src/lib/routes/enroll-routes.js +31 -19
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/preview-routes.js +14 -5
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +9 -9
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/routes/step-test-routes.js +3 -3
- package/src/lib/routes/step-test-routes.js.map +1 -1
- package/src/lib/runtime/campaign-credit.js +2 -2
- package/src/lib/runtime/campaign-credit.js.map +1 -1
- package/src/lib/runtime/campaign-detach.js +3 -2
- package/src/lib/runtime/campaign-detach.js.map +1 -1
- package/src/lib/runtime/lead-records.d.ts +6 -1
- package/src/lib/runtime/lead-records.js +25 -6
- package/src/lib/runtime/lead-records.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.js +3 -3
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +20 -4
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +3 -3
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
- package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
- package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
- package/src/lib/transport/gmail-errors.d.ts +27 -0
- package/src/lib/transport/gmail-errors.js +56 -0
- package/src/lib/transport/gmail-errors.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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"}
|
|
@@ -24,7 +24,8 @@ export interface OutreachApi {
|
|
|
24
24
|
ok: true;
|
|
25
25
|
}>;
|
|
26
26
|
previewEnrollment(sequenceId: string, source: OutreachEnrollSource): Promise<OutreachEnrollPreviewResponse>;
|
|
27
|
-
|
|
27
|
+
/** `startStepIndex` (AGL-3228) is the step everyone begins at, zero-based; the first when absent. */
|
|
28
|
+
enroll(sequenceId: string, people: OutreachEnrollPersonRequest[], startStepIndex?: number): Promise<OutreachEnrollResponse>;
|
|
28
29
|
actOnEnrollment(enrollmentId: string, action: OutreachEnrollmentAction, detail?: string): Promise<OutreachEnrollmentActionResponse>;
|
|
29
30
|
/** The mail gateway in front of one enrolled person's domain (AGL-3332). */
|
|
30
31
|
readEnrollmentGateway(enrollmentId: string): Promise<OutreachEnrollmentGatewayResponse>;
|
|
@@ -111,12 +111,14 @@ import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
|
|
|
111
111
|
source
|
|
112
112
|
}
|
|
113
113
|
}),
|
|
114
|
-
enroll: (sequenceId, people)=>call(OUTREACH_API_ROUTES.enroll, {
|
|
114
|
+
enroll: (sequenceId, people, startStepIndex)=>call(OUTREACH_API_ROUTES.enroll, {
|
|
115
115
|
method: 'POST',
|
|
116
|
-
body: {
|
|
116
|
+
body: _extends({
|
|
117
117
|
sequenceId,
|
|
118
118
|
people
|
|
119
|
-
}
|
|
119
|
+
}, startStepIndex ? {
|
|
120
|
+
startStepIndex
|
|
121
|
+
} : {})
|
|
120
122
|
}),
|
|
121
123
|
actOnEnrollment: (enrollmentId, action, detail)=>call(OUTREACH_API_ROUTES.enrollmentsAction, {
|
|
122
124
|
method: 'POST',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { useUser } from '@aglyn/tenant-feature-instance'\nimport { useCallback, useMemo } from 'react'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport type { OutreachComplianceIssue } from '../model/compliance-settings'\nimport type {\n OutreachCurateDraftRequest,\n OutreachCurateDraftResponse,\n OutreachCurateSaveRequest,\n OutreachCurateSaveResponse,\n OutreachDoNotContactDomainRequest,\n OutreachDoNotContactDomainResponse,\n OutreachDoNotContactDomainsResponse,\n OutreachEnrollmentAction,\n OutreachEnrollmentActionResponse,\n OutreachEnrollmentGatewayResponse,\n OutreachEnrollPersonRequest,\n OutreachEnrollPreviewResponse,\n OutreachEnrollResponse,\n OutreachEnrollSource,\n OutreachLinkDomainRequest,\n OutreachLinkDomainResponse,\n OutreachLinkDomainsResponse,\n OutreachPreviewRequest,\n OutreachPreviewResponse,\n OutreachRouteRefusal,\n OutreachRouteRefusalReason,\n OutreachSequenceAction,\n OutreachSequenceSaveResponse,\n OutreachSequenceStatusResponse,\n OutreachSettingsResponse,\n OutreachSettingsSaveRequest,\n OutreachSettingsSaveResponse,\n OutreachStepTestRequest,\n OutreachStepTestResponse,\n} from '../model/outreach-api'\nimport type {\n OutreachSequenceDraft,\n OutreachSequenceIssue,\n} from '../model/sequence-draft'\n\n/**\n * A route's refusal, carrying its sentence, its stable reason and any\n * field-level issues. `refused` is the one a page shows as \"you can't use\n * this here\" rather than as something that went wrong: no permission, no\n * entitlement, or the release gate answering as if the route did not exist.\n */\nexport class OutreachRouteError extends Error {\n readonly reason: OutreachRouteRefusalReason | 'unreachable' | 'not-found'\n readonly status: number\n readonly issues: ReadonlyArray<\n OutreachComplianceIssue | OutreachSequenceIssue\n >\n\n constructor(\n message: string,\n reason: OutreachRouteError['reason'],\n status: number,\n issues: OutreachRouteError['issues'] = [],\n ) {\n super(message)\n this.name = 'OutreachRouteError'\n this.reason = reason\n this.status = status\n this.issues = issues\n }\n\n get refused(): boolean {\n return (\n this.reason === 'permission' ||\n this.reason === 'entitlement' ||\n this.reason === 'not-org-wide' ||\n this.reason === 'not-a-member' ||\n // The dispatcher answers a route whose plugin is not released to this\n // workspace as though it did not exist.\n this.reason === 'not-found'\n )\n }\n}\n\n/** The routes, as the Outreach pages call them. */\nexport interface OutreachApi {\n readSettings(): Promise<OutreachSettingsResponse>\n saveSettings(\n input: Omit<OutreachSettingsSaveRequest, 'orgId'>,\n ): Promise<OutreachSettingsSaveResponse>\n saveSequence(\n sequenceId: string | null,\n sequence: OutreachSequenceDraft,\n ): Promise<OutreachSequenceSaveResponse>\n setSequenceStatus(\n sequenceId: string,\n action: OutreachSequenceAction,\n ): Promise<OutreachSequenceStatusResponse>\n deleteSequence(sequenceId: string): Promise<{ ok: true }>\n previewEnrollment(\n sequenceId: string,\n source: OutreachEnrollSource,\n ): Promise<OutreachEnrollPreviewResponse>\n enroll(\n sequenceId: string,\n people: OutreachEnrollPersonRequest[],\n ): Promise<OutreachEnrollResponse>\n actOnEnrollment(\n enrollmentId: string,\n action: OutreachEnrollmentAction,\n detail?: string,\n ): Promise<OutreachEnrollmentActionResponse>\n /** The mail gateway in front of one enrolled person's domain (AGL-3332). */\n readEnrollmentGateway(enrollmentId: string): Promise<OutreachEnrollmentGatewayResponse>\n previewEmail(\n input: Omit<OutreachPreviewRequest, 'orgId'>,\n ): Promise<OutreachPreviewResponse>\n /**\n * Sends one step of a saved sequence to the member as a test (AGL-3325):\n * the preview's email, through the sequence's mailbox, `[Test]` in front.\n */\n sendStepTest(\n input: Omit<OutreachStepTestRequest, 'orgId'>,\n ): Promise<OutreachStepTestResponse & { unresolvedFields?: string[] }>\n /**\n * One person's own copies of the sequence's email steps, drafted by the\n * workspace's AI (AGL-3324); nothing is stored until the member confirms.\n */\n curateDrafts(\n input: Omit<OutreachCurateDraftRequest, 'orgId'>,\n ): Promise<OutreachCurateDraftResponse>\n /** Stores a confirmed copy of one step on an enrollment, or clears it with `null`. */\n saveCuratedStep(\n enrollmentId: string,\n stepIndex: number,\n override: OutreachCurateSaveRequest['override'],\n ): Promise<OutreachCurateSaveResponse>\n /** The domains on the do-not-contact list (AGL-3244). */\n readDoNotContactDomains(): Promise<OutreachDoNotContactDomainsResponse>\n /** Puts a domain on the list, or takes one off. */\n changeDoNotContactDomain(\n action: OutreachDoNotContactDomainRequest['action'],\n domain: string,\n detail?: string,\n ): Promise<OutreachDoNotContactDomainResponse>\n /** Each mailbox domain's click-tracking host (AGL-3306). */\n readLinkDomains(): Promise<OutreachLinkDomainsResponse>\n /** Sets a link domain up, checks it, or removes it. */\n changeLinkDomain(\n action: OutreachLinkDomainRequest['action'],\n domain: string,\n ): Promise<OutreachLinkDomainResponse>\n}\n\n/**\n * The Outreach pages' one door to the settings, sequence and enrollment\n * routes (AGL-2980), with the member's session attached and the\n * organization named on every call. A refusal is thrown as an\n * {@link OutreachRouteError} carrying the route's own sentence, which is\n * written for the person reading it.\n */\nexport function useOutreachApi(orgId: string | null): OutreachApi {\n const { data: user } = useUser()\n\n const call = useCallback(\n async <T>(\n route: string,\n init: { method: 'GET' | 'POST'; body?: Record<string, unknown> },\n ): Promise<T> => {\n if (!orgId)\n throw new OutreachRouteError(\n 'Open an organization first.',\n 'org-required',\n 400,\n )\n const path =\n init.method === 'GET'\n ? `/api/${route}?orgId=${encodeURIComponent(orgId)}`\n : `/api/${route}`\n let response: Response\n try {\n response = await authorizedFetch(user, path, {\n method: init.method,\n ...(init.method === 'POST'\n ? {\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ orgId, ...init.body }),\n }\n : {}),\n })\n } catch {\n throw new OutreachRouteError(\n 'Sequences could not be reached. Try again.',\n 'unreachable',\n 0,\n )\n }\n const payload = (await response.json().catch(() => null)) as\n (T & { error?: undefined }) | Partial<OutreachRouteRefusal> | null\n if (!response.ok || !payload) {\n const refusal = (payload ?? {}) as Partial<OutreachRouteRefusal>\n throw new OutreachRouteError(\n refusal.error && refusal.reason\n ? refusal.error\n : response.status === 404\n ? \"Sequences isn't available to this workspace yet.\"\n : 'Sequences could not be reached. Try again.',\n refusal.reason ??\n (response.status === 404 ? 'not-found' : 'unreachable'),\n response.status,\n refusal.issues ?? [],\n )\n }\n return payload as T\n },\n [user, orgId],\n )\n\n return useMemo<OutreachApi>(\n () => ({\n readSettings: () => call(OUTREACH_API_ROUTES.settings, { method: 'GET' }),\n saveSettings: (input) =>\n call(OUTREACH_API_ROUTES.settings, {\n method: 'POST',\n body: { ...input },\n }),\n saveSequence: (sequenceId, sequence) =>\n call(OUTREACH_API_ROUTES.sequencesSave, {\n method: 'POST',\n body: { ...(sequenceId ? { sequenceId } : {}), sequence },\n }),\n setSequenceStatus: (sequenceId, action) =>\n call(OUTREACH_API_ROUTES.sequencesStatus, {\n method: 'POST',\n body: { sequenceId, action },\n }),\n deleteSequence: (sequenceId) =>\n call(OUTREACH_API_ROUTES.sequencesDelete, {\n method: 'POST',\n body: { sequenceId },\n }),\n previewEnrollment: (sequenceId, source) =>\n call(OUTREACH_API_ROUTES.enrollPreview, {\n method: 'POST',\n body: { sequenceId, source },\n }),\n enroll: (sequenceId, people) =>\n call(OUTREACH_API_ROUTES.enroll, {\n method: 'POST',\n body: { sequenceId, people },\n }),\n actOnEnrollment: (enrollmentId, action, detail) =>\n call(OUTREACH_API_ROUTES.enrollmentsAction, {\n method: 'POST',\n body: { enrollmentId, action, ...(detail ? { detail } : {}) },\n }),\n readEnrollmentGateway: (enrollmentId) =>\n call(OUTREACH_API_ROUTES.enrollmentsGateway, {\n method: 'POST',\n body: { enrollmentId },\n }),\n previewEmail: (input) =>\n call(OUTREACH_API_ROUTES.preview, {\n method: 'POST',\n body: { ...input },\n }),\n sendStepTest: (input) =>\n call(OUTREACH_API_ROUTES.stepTest, {\n method: 'POST',\n body: { ...input },\n }),\n curateDrafts: (input) =>\n call(OUTREACH_API_ROUTES.curateDraft, {\n method: 'POST',\n body: { ...input },\n }),\n saveCuratedStep: (enrollmentId, stepIndex, override) =>\n call(OUTREACH_API_ROUTES.curateSave, {\n method: 'POST',\n body: { enrollmentId, stepIndex, override },\n }),\n readDoNotContactDomains: () =>\n call(OUTREACH_API_ROUTES.doNotContactDomains, { method: 'GET' }),\n changeDoNotContactDomain: (action, domain, detail) =>\n call(OUTREACH_API_ROUTES.doNotContactDomains, {\n method: 'POST',\n body: { action, domain, ...(detail ? { detail } : {}) },\n }),\n readLinkDomains: () => call(OUTREACH_API_ROUTES.linkDomains, { method: 'GET' }),\n changeLinkDomain: (action, domain) =>\n call(OUTREACH_API_ROUTES.linkDomains, {\n method: 'POST',\n body: { action, domain },\n }),\n }),\n [call],\n )\n}\n"],"names":["authorizedFetch","useUser","useCallback","useMemo","OUTREACH_API_ROUTES","OutreachRouteError","Error","refused","reason","message","status","issues","name","useOutreachApi","orgId","data","user","call","route","init","path","method","encodeURIComponent","response","headers","body","JSON","stringify","payload","json","catch","ok","refusal","error","readSettings","settings","saveSettings","input","saveSequence","sequenceId","sequence","sequencesSave","setSequenceStatus","action","sequencesStatus","deleteSequence","sequencesDelete","previewEnrollment","source","enrollPreview","enroll","people","actOnEnrollment","enrollmentId","detail","enrollmentsAction","readEnrollmentGateway","enrollmentsGateway","previewEmail","preview","sendStepTest","stepTest","curateDrafts","curateDraft","saveCuratedStep","stepIndex","override","curateSave","readDoNotContactDomains","doNotContactDomains","changeDoNotContactDomain","domain","readLinkDomains","linkDomains","changeLinkDomain"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { useUser } from '@aglyn/tenant-feature-instance'\nimport { useCallback, useMemo } from 'react'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport type { OutreachComplianceIssue } from '../model/compliance-settings'\nimport type {\n OutreachCurateDraftRequest,\n OutreachCurateDraftResponse,\n OutreachCurateSaveRequest,\n OutreachCurateSaveResponse,\n OutreachDoNotContactDomainRequest,\n OutreachDoNotContactDomainResponse,\n OutreachDoNotContactDomainsResponse,\n OutreachEnrollmentAction,\n OutreachEnrollmentActionResponse,\n OutreachEnrollmentGatewayResponse,\n OutreachEnrollPersonRequest,\n OutreachEnrollPreviewResponse,\n OutreachEnrollResponse,\n OutreachEnrollSource,\n OutreachLinkDomainRequest,\n OutreachLinkDomainResponse,\n OutreachLinkDomainsResponse,\n OutreachPreviewRequest,\n OutreachPreviewResponse,\n OutreachRouteRefusal,\n OutreachRouteRefusalReason,\n OutreachSequenceAction,\n OutreachSequenceSaveResponse,\n OutreachSequenceStatusResponse,\n OutreachSettingsResponse,\n OutreachSettingsSaveRequest,\n OutreachSettingsSaveResponse,\n OutreachStepTestRequest,\n OutreachStepTestResponse,\n} from '../model/outreach-api'\nimport type {\n OutreachSequenceDraft,\n OutreachSequenceIssue,\n} from '../model/sequence-draft'\n\n/**\n * A route's refusal, carrying its sentence, its stable reason and any\n * field-level issues. `refused` is the one a page shows as \"you can't use\n * this here\" rather than as something that went wrong: no permission, no\n * entitlement, or the release gate answering as if the route did not exist.\n */\nexport class OutreachRouteError extends Error {\n readonly reason: OutreachRouteRefusalReason | 'unreachable' | 'not-found'\n readonly status: number\n readonly issues: ReadonlyArray<\n OutreachComplianceIssue | OutreachSequenceIssue\n >\n\n constructor(\n message: string,\n reason: OutreachRouteError['reason'],\n status: number,\n issues: OutreachRouteError['issues'] = [],\n ) {\n super(message)\n this.name = 'OutreachRouteError'\n this.reason = reason\n this.status = status\n this.issues = issues\n }\n\n get refused(): boolean {\n return (\n this.reason === 'permission' ||\n this.reason === 'entitlement' ||\n this.reason === 'not-org-wide' ||\n this.reason === 'not-a-member' ||\n // The dispatcher answers a route whose plugin is not released to this\n // workspace as though it did not exist.\n this.reason === 'not-found'\n )\n }\n}\n\n/** The routes, as the Outreach pages call them. */\nexport interface OutreachApi {\n readSettings(): Promise<OutreachSettingsResponse>\n saveSettings(\n input: Omit<OutreachSettingsSaveRequest, 'orgId'>,\n ): Promise<OutreachSettingsSaveResponse>\n saveSequence(\n sequenceId: string | null,\n sequence: OutreachSequenceDraft,\n ): Promise<OutreachSequenceSaveResponse>\n setSequenceStatus(\n sequenceId: string,\n action: OutreachSequenceAction,\n ): Promise<OutreachSequenceStatusResponse>\n deleteSequence(sequenceId: string): Promise<{ ok: true }>\n previewEnrollment(\n sequenceId: string,\n source: OutreachEnrollSource,\n ): Promise<OutreachEnrollPreviewResponse>\n /** `startStepIndex` (AGL-3228) is the step everyone begins at, zero-based; the first when absent. */\n enroll(\n sequenceId: string,\n people: OutreachEnrollPersonRequest[],\n startStepIndex?: number,\n ): Promise<OutreachEnrollResponse>\n actOnEnrollment(\n enrollmentId: string,\n action: OutreachEnrollmentAction,\n detail?: string,\n ): Promise<OutreachEnrollmentActionResponse>\n /** The mail gateway in front of one enrolled person's domain (AGL-3332). */\n readEnrollmentGateway(enrollmentId: string): Promise<OutreachEnrollmentGatewayResponse>\n previewEmail(\n input: Omit<OutreachPreviewRequest, 'orgId'>,\n ): Promise<OutreachPreviewResponse>\n /**\n * Sends one step of a saved sequence to the member as a test (AGL-3325):\n * the preview's email, through the sequence's mailbox, `[Test]` in front.\n */\n sendStepTest(\n input: Omit<OutreachStepTestRequest, 'orgId'>,\n ): Promise<OutreachStepTestResponse & { unresolvedFields?: string[] }>\n /**\n * One person's own copies of the sequence's email steps, drafted by the\n * workspace's AI (AGL-3324); nothing is stored until the member confirms.\n */\n curateDrafts(\n input: Omit<OutreachCurateDraftRequest, 'orgId'>,\n ): Promise<OutreachCurateDraftResponse>\n /** Stores a confirmed copy of one step on an enrollment, or clears it with `null`. */\n saveCuratedStep(\n enrollmentId: string,\n stepIndex: number,\n override: OutreachCurateSaveRequest['override'],\n ): Promise<OutreachCurateSaveResponse>\n /** The domains on the do-not-contact list (AGL-3244). */\n readDoNotContactDomains(): Promise<OutreachDoNotContactDomainsResponse>\n /** Puts a domain on the list, or takes one off. */\n changeDoNotContactDomain(\n action: OutreachDoNotContactDomainRequest['action'],\n domain: string,\n detail?: string,\n ): Promise<OutreachDoNotContactDomainResponse>\n /** Each mailbox domain's click-tracking host (AGL-3306). */\n readLinkDomains(): Promise<OutreachLinkDomainsResponse>\n /** Sets a link domain up, checks it, or removes it. */\n changeLinkDomain(\n action: OutreachLinkDomainRequest['action'],\n domain: string,\n ): Promise<OutreachLinkDomainResponse>\n}\n\n/**\n * The Outreach pages' one door to the settings, sequence and enrollment\n * routes (AGL-2980), with the member's session attached and the\n * organization named on every call. A refusal is thrown as an\n * {@link OutreachRouteError} carrying the route's own sentence, which is\n * written for the person reading it.\n */\nexport function useOutreachApi(orgId: string | null): OutreachApi {\n const { data: user } = useUser()\n\n const call = useCallback(\n async <T>(\n route: string,\n init: { method: 'GET' | 'POST'; body?: Record<string, unknown> },\n ): Promise<T> => {\n if (!orgId)\n throw new OutreachRouteError(\n 'Open an organization first.',\n 'org-required',\n 400,\n )\n const path =\n init.method === 'GET'\n ? `/api/${route}?orgId=${encodeURIComponent(orgId)}`\n : `/api/${route}`\n let response: Response\n try {\n response = await authorizedFetch(user, path, {\n method: init.method,\n ...(init.method === 'POST'\n ? {\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ orgId, ...init.body }),\n }\n : {}),\n })\n } catch {\n throw new OutreachRouteError(\n 'Sequences could not be reached. Try again.',\n 'unreachable',\n 0,\n )\n }\n const payload = (await response.json().catch(() => null)) as\n (T & { error?: undefined }) | Partial<OutreachRouteRefusal> | null\n if (!response.ok || !payload) {\n const refusal = (payload ?? {}) as Partial<OutreachRouteRefusal>\n throw new OutreachRouteError(\n refusal.error && refusal.reason\n ? refusal.error\n : response.status === 404\n ? \"Sequences isn't available to this workspace yet.\"\n : 'Sequences could not be reached. Try again.',\n refusal.reason ??\n (response.status === 404 ? 'not-found' : 'unreachable'),\n response.status,\n refusal.issues ?? [],\n )\n }\n return payload as T\n },\n [user, orgId],\n )\n\n return useMemo<OutreachApi>(\n () => ({\n readSettings: () => call(OUTREACH_API_ROUTES.settings, { method: 'GET' }),\n saveSettings: (input) =>\n call(OUTREACH_API_ROUTES.settings, {\n method: 'POST',\n body: { ...input },\n }),\n saveSequence: (sequenceId, sequence) =>\n call(OUTREACH_API_ROUTES.sequencesSave, {\n method: 'POST',\n body: { ...(sequenceId ? { sequenceId } : {}), sequence },\n }),\n setSequenceStatus: (sequenceId, action) =>\n call(OUTREACH_API_ROUTES.sequencesStatus, {\n method: 'POST',\n body: { sequenceId, action },\n }),\n deleteSequence: (sequenceId) =>\n call(OUTREACH_API_ROUTES.sequencesDelete, {\n method: 'POST',\n body: { sequenceId },\n }),\n previewEnrollment: (sequenceId, source) =>\n call(OUTREACH_API_ROUTES.enrollPreview, {\n method: 'POST',\n body: { sequenceId, source },\n }),\n enroll: (sequenceId, people, startStepIndex) =>\n call(OUTREACH_API_ROUTES.enroll, {\n method: 'POST',\n body: { sequenceId, people, ...(startStepIndex ? { startStepIndex } : {}) },\n }),\n actOnEnrollment: (enrollmentId, action, detail) =>\n call(OUTREACH_API_ROUTES.enrollmentsAction, {\n method: 'POST',\n body: { enrollmentId, action, ...(detail ? { detail } : {}) },\n }),\n readEnrollmentGateway: (enrollmentId) =>\n call(OUTREACH_API_ROUTES.enrollmentsGateway, {\n method: 'POST',\n body: { enrollmentId },\n }),\n previewEmail: (input) =>\n call(OUTREACH_API_ROUTES.preview, {\n method: 'POST',\n body: { ...input },\n }),\n sendStepTest: (input) =>\n call(OUTREACH_API_ROUTES.stepTest, {\n method: 'POST',\n body: { ...input },\n }),\n curateDrafts: (input) =>\n call(OUTREACH_API_ROUTES.curateDraft, {\n method: 'POST',\n body: { ...input },\n }),\n saveCuratedStep: (enrollmentId, stepIndex, override) =>\n call(OUTREACH_API_ROUTES.curateSave, {\n method: 'POST',\n body: { enrollmentId, stepIndex, override },\n }),\n readDoNotContactDomains: () =>\n call(OUTREACH_API_ROUTES.doNotContactDomains, { method: 'GET' }),\n changeDoNotContactDomain: (action, domain, detail) =>\n call(OUTREACH_API_ROUTES.doNotContactDomains, {\n method: 'POST',\n body: { action, domain, ...(detail ? { detail } : {}) },\n }),\n readLinkDomains: () => call(OUTREACH_API_ROUTES.linkDomains, { method: 'GET' }),\n changeLinkDomain: (action, domain) =>\n call(OUTREACH_API_ROUTES.linkDomains, {\n method: 'POST',\n body: { action, domain },\n }),\n }),\n [call],\n )\n}\n"],"names":["authorizedFetch","useUser","useCallback","useMemo","OUTREACH_API_ROUTES","OutreachRouteError","Error","refused","reason","message","status","issues","name","useOutreachApi","orgId","data","user","call","route","init","path","method","encodeURIComponent","response","headers","body","JSON","stringify","payload","json","catch","ok","refusal","error","readSettings","settings","saveSettings","input","saveSequence","sequenceId","sequence","sequencesSave","setSequenceStatus","action","sequencesStatus","deleteSequence","sequencesDelete","previewEnrollment","source","enrollPreview","enroll","people","startStepIndex","actOnEnrollment","enrollmentId","detail","enrollmentsAction","readEnrollmentGateway","enrollmentsGateway","previewEmail","preview","sendStepTest","stepTest","curateDrafts","curateDraft","saveCuratedStep","stepIndex","override","curateSave","readDoNotContactDomains","doNotContactDomains","changeDoNotContactDomain","domain","readLinkDomains","linkDomains","changeLinkDomain"],"mappings":"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"}
|
|
@@ -66,7 +66,7 @@ export interface OutreachComposeError {
|
|
|
66
66
|
}
|
|
67
67
|
export interface ComposeOutreachEmailInput {
|
|
68
68
|
sequence: Pick<OutreachSequence, 'steps'>;
|
|
69
|
-
enrollment: Pick<OutreachEnrollment, 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'> & Partial<Pick<OutreachEnrollment, 'stepOverrides'>>;
|
|
69
|
+
enrollment: Pick<OutreachEnrollment, 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'> & Partial<Pick<OutreachEnrollment, 'stepOverrides' | 'startStepIndex'>>;
|
|
70
70
|
orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined;
|
|
71
71
|
/**
|
|
72
72
|
* What the CRM resolver reads: the contact, the sending site's group id,
|
|
@@ -61,11 +61,11 @@ 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 {
|
|
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";
|
|
68
|
-
import { isInThreadEmailStep, OUTREACH_PERSONAL_LINE_FIELD, outreachSubjectHasReplyPrefix } from "./sequence-validation.js";
|
|
68
|
+
import { isInThreadEmailStep, OUTREACH_PERSONAL_LINE_FIELD, outreachSubjectHasReplyPrefix, outreachThreadStartSubject } from "./sequence-validation.js";
|
|
69
69
|
/** The way out every email offers, word for word. */ export const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply "no" and I won\'t email again.';
|
|
70
70
|
/**
|
|
71
71
|
* The sentence that says what the email is. "Sales email", not "business
|
|
@@ -201,9 +201,13 @@ const refuse = (code, message)=>({
|
|
|
201
201
|
text: '',
|
|
202
202
|
trackedLinks: []
|
|
203
203
|
};
|
|
204
|
-
|
|
204
|
+
// An enrollment that began past the first step (AGL-3228) starts its
|
|
205
|
+
// thread with the first email it sends, under the subject the person
|
|
206
|
+
// already had by hand when the step was written as a reply.
|
|
207
|
+
const startStepIndex = Number.isInteger(input.enrollment.startStepIndex) ? Math.max(0, Number(input.enrollment.startStepIndex)) : 0;
|
|
208
|
+
if (isInThreadEmailStep(steps, stepIndex, startStepIndex)) {
|
|
205
209
|
var _input_enrollment_messageIds, _input_enrollment_gmailThreadId;
|
|
206
|
-
const threadSubject =
|
|
210
|
+
const threadSubject = threadSubjectOf(input.enrollment.threadSubject);
|
|
207
211
|
const messageIds = ((_input_enrollment_messageIds = input.enrollment.messageIds) != null ? _input_enrollment_messageIds : []).map(outreachMessageIdHeader).filter((id)=>id !== null);
|
|
208
212
|
const threadId = String((_input_enrollment_gmailThreadId = input.enrollment.gmailThreadId) != null ? _input_enrollment_gmailThreadId : '').trim();
|
|
209
213
|
if (!threadSubject || !messageIds.length || !threadId) {
|
|
@@ -214,8 +218,8 @@ const refuse = (code, message)=>({
|
|
|
214
218
|
email.inReplyTo = messageIds[messageIds.length - 1];
|
|
215
219
|
email.references = messageIds.join(' ');
|
|
216
220
|
} else {
|
|
217
|
-
var _ref2
|
|
218
|
-
const subject = oneLine(render(String((_ref2 =
|
|
221
|
+
var _ref2;
|
|
222
|
+
const subject = oneLine(render(String((_ref2 = override == null ? void 0 : override.subject) != null ? _ref2 : outreachThreadStartSubject(steps, stepIndex))));
|
|
219
223
|
if (!subject) return refuse('missing_subject', 'This email has no subject.');
|
|
220
224
|
if (outreachSubjectHasReplyPrefix(subject)) {
|
|
221
225
|
return refuse('subject_reply_prefix', 'This email starts a thread, so its subject may not begin with "Re:" or "Fwd:".');
|
|
@@ -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} 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'>>\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 if (isInThreadEmailStep(steps, stepIndex)) {\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 ?? step.subject ?? '')))\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","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","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","tracked","rewriteLink","links","openPixelUrl","html","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
|
|
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/
|
|
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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA2B4C,GAE5C,SAASA,cAAc,QAAQ,qCAAoC;AACnE,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"}
|
|
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":"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"}
|
|
@@ -44,15 +44,23 @@ export interface OutreachTimelineEntry {
|
|
|
44
44
|
}
|
|
45
45
|
/** The key the enrollment's own entry is filed under, restated by the backfill. */
|
|
46
46
|
export declare function outreachEnrolledEntryKey(enrollmentId: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* The line that says an enrollment began past the first step (AGL-3228),
|
|
49
|
+
* and which steps it skipped: "Began at step 3 — steps 1–2 skipped, already
|
|
50
|
+
* done by hand". `''` for one that began at step 1.
|
|
51
|
+
*/
|
|
52
|
+
export declare function outreachStartedAtLine(startStepIndex: number | null | undefined): string;
|
|
47
53
|
/**
|
|
48
54
|
* "Enrolled in <sequence>", and the campaigns it carried the person into
|
|
49
55
|
* when the sequence was in any, named. A sequence with no name — never
|
|
50
|
-
* stored, but a document can lose one — reads "a sequence".
|
|
56
|
+
* stored, but a document can lose one — reads "a sequence". An enrollment
|
|
57
|
+
* that began past the first step says so on a line of its own (AGL-3228).
|
|
51
58
|
*/
|
|
52
59
|
export declare function outreachEnrolledEntry(input: {
|
|
53
60
|
enrollmentId: string;
|
|
54
61
|
sequenceName: string;
|
|
55
62
|
campaignNames: readonly string[];
|
|
63
|
+
startStepIndex?: number;
|
|
56
64
|
}): OutreachTimelineEntry;
|
|
57
65
|
/**
|
|
58
66
|
* The key one curated step's entry is filed under (AGL-3324): once per
|