@aglyn/plugins-outreach 1.0.0-beta.213 → 1.0.0-beta.215

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/enrollment-detail.js +13 -3
  3. package/src/lib/components/enrollment-detail.js.map +1 -1
  4. package/src/lib/components/sequence-editor.js +23 -1
  5. package/src/lib/components/sequence-editor.js.map +1 -1
  6. package/src/lib/components/sequence-report-card.d.ts +3 -2
  7. package/src/lib/components/sequence-report-card.js +29 -1
  8. package/src/lib/components/sequence-report-card.js.map +1 -1
  9. package/src/lib/engine/click-tracking.d.ts +9 -0
  10. package/src/lib/engine/click-tracking.js +2 -2
  11. package/src/lib/engine/click-tracking.js.map +1 -1
  12. package/src/lib/engine/compose.d.ts +13 -0
  13. package/src/lib/engine/compose.js +4 -0
  14. package/src/lib/engine/compose.js.map +1 -1
  15. package/src/lib/engine/open-tracking.d.ts +61 -0
  16. package/src/lib/engine/open-tracking.js +164 -0
  17. package/src/lib/engine/open-tracking.js.map +1 -0
  18. package/src/lib/engine/sequence-validation.js +3 -0
  19. package/src/lib/engine/sequence-validation.js.map +1 -1
  20. package/src/lib/model/enrollment-engagement.d.ts +6 -0
  21. package/src/lib/model/enrollment-engagement.js +17 -1
  22. package/src/lib/model/enrollment-engagement.js.map +1 -1
  23. package/src/lib/model/enrollment-history.d.ts +8 -0
  24. package/src/lib/model/enrollment-history.js +29 -0
  25. package/src/lib/model/enrollment-history.js.map +1 -1
  26. package/src/lib/model/enrollment-timeline.d.ts +7 -1
  27. package/src/lib/model/enrollment-timeline.js +41 -4
  28. package/src/lib/model/enrollment-timeline.js.map +1 -1
  29. package/src/lib/model/outreach.types.d.ts +71 -4
  30. package/src/lib/model/outreach.types.js +3 -2
  31. package/src/lib/model/outreach.types.js.map +1 -1
  32. package/src/lib/model/sequence-draft.js +3 -0
  33. package/src/lib/model/sequence-draft.js.map +1 -1
  34. package/src/lib/model/sequence-report.d.ts +25 -2
  35. package/src/lib/model/sequence-report.js +36 -10
  36. package/src/lib/model/sequence-report.js.map +1 -1
  37. package/src/lib/model/step-preview.d.ts +6 -0
  38. package/src/lib/model/step-preview.js +3 -2
  39. package/src/lib/model/step-preview.js.map +1 -1
  40. package/src/lib/model/stored-records.js +9 -1
  41. package/src/lib/model/stored-records.js.map +1 -1
  42. package/src/lib/routes/step-test-routes.js +40 -12
  43. package/src/lib/routes/step-test-routes.js.map +1 -1
  44. package/src/lib/runtime/click-events.d.ts +2 -0
  45. package/src/lib/runtime/click-events.js +6 -6
  46. package/src/lib/runtime/click-events.js.map +1 -1
  47. package/src/lib/runtime/click-link.d.ts +25 -0
  48. package/src/lib/runtime/click-link.js +49 -0
  49. package/src/lib/runtime/click-link.js.map +1 -1
  50. package/src/lib/runtime/click-route.d.ts +4 -0
  51. package/src/lib/runtime/click-route.js +46 -3
  52. package/src/lib/runtime/click-route.js.map +1 -1
  53. package/src/lib/runtime/open-events.d.ts +55 -0
  54. package/src/lib/runtime/open-events.js +121 -0
  55. package/src/lib/runtime/open-events.js.map +1 -0
  56. package/src/lib/runtime/send-job.js +44 -8
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/transport/rfc5322.d.ts +14 -3
  59. package/src/lib/transport/rfc5322.js +44 -9
  60. package/src/lib/transport/rfc5322.js.map +1 -1
  61. package/src/lib/transport/send-message.js +2 -1
  62. package/src/lib/transport/send-message.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-editor.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp, type ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { mdiPlus } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useOrgCampaigns, useUser } from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Autocomplete,\n Box,\n Button,\n Chip,\n FormControlLabel,\n Grid,\n Menu,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n isInThreadEmailStep,\n OUTREACH_SEQUENCE_NAME_MAX,\n validateOutreachSequence,\n} from '../engine/sequence-validation'\nimport { outreachCountryLabel } from '../model/compliance-settings'\nimport {\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEPS,\n OUTREACH_TASK_KIND_LABELS,\n OUTREACH_TASK_KINDS,\n type OutreachSequence,\n type OutreachSequenceStep,\n type OutreachTaskKind,\n} from '../model/outreach.types'\nimport {\n emptyOutreachSequenceDraft,\n newOutreachEmailStep,\n newOutreachTaskStep,\n outreachSequenceDraftOf,\n type OutreachSequenceDraft,\n type OutreachSequenceIssue,\n} from '../model/sequence-draft'\nimport { OutreachSendWindowFields } from './send-window-fields'\nimport { OutreachSequenceMailboxPicker } from './sequence-mailbox-picker'\nimport { OutreachSequencePreview } from './sequence-preview'\nimport { OutreachStepCard } from './sequence-step-card'\nimport { OutreachStepTestDialog } from './step-test-dialog'\nimport { OutreachRouteError, useOutreachApi } from './use-outreach-api'\nimport { useOutreachEmailTemplates } from './use-outreach-crm'\nimport type { OutreachMailboxesResult } from './use-outreach-mailboxes'\nimport type { OutreachSettingsLoad } from './use-outreach-settings'\n\nexport interface OutreachSequenceEditorProps {\n orgId: string\n orgMount?: ConsolePluginOrgMount\n /** The organization document, for the consent group a test send's person picker searches (AGL-3325). */\n org?: Record<string, unknown> | null\n /** The stored sequence, or `null` for a new one. */\n sequence: OutreachSequence | null\n /** The organization's compliance settings: its countries, and the preview's footer. */\n settings: OutreachSettingsLoad\n /**\n * The organization's mailboxes, from the Mailboxes section's listener:\n * what the picker offers, and who the preview's emails are from.\n */\n mailboxes: OutreachMailboxesResult\n /** The Mailboxes section, where the picker sends a member to connect one. */\n mailboxesPath: string\n onSaved(sequence: OutreachSequence): void\n onCancel?(): void\n}\n\n/** The step a dotted issue path is about, and the field within it. */\nfunction stepIssueKey(path: string): { index: number; field: string } | null {\n const match = /^steps\\.(\\d+)(?:\\.(.+))?$/.exec(path)\n if (!match) return null\n const field = match[2] ?? 'step'\n return { index: Number(match[1]), field: field === 'id' ? 'step' : field }\n}\n\n/**\n * The sequence editor (AGL-2980): the name, the site and mailbox, the steps,\n * who it sends to and when, beside a live preview of each email.\n *\n * Every issue the engine's validator finds is shown beside its field — the\n * same validator the save route refuses with — errors once a save was\n * attempted, warnings as soon as they appear. What only the server can\n * judge (whose mailbox, which countries the organization allows, what may\n * no longer change once people are enrolled) comes back from the save and\n * is shown in the same places.\n */\nexport function OutreachSequenceEditor(props: OutreachSequenceEditorProps) {\n const { orgId, orgMount, sequence, settings } = props\n const api = useOutreachApi(orgId)\n const { enqueueSnackbar } = useSnackbar()\n const { data: user } = useUser()\n const uid = user?.uid ?? null\n const mountedHosts = orgMount?.hosts\n const hosts = useMemo(() => mountedHosts ?? [], [mountedHosts])\n const orgCountries = settings.settings?.allowedCountries ?? ['US']\n\n const [draft, setDraft] = useState<OutreachSequenceDraft>(() =>\n sequence\n ? outreachSequenceDraftOf(sequence)\n : emptyOutreachSequenceDraft({\n hostId: hosts.length === 1 ? hosts[0].id : '',\n allowedCountries: orgCountries,\n }),\n )\n const [attempted, setAttempted] = useState(false)\n const [serverIssues, setServerIssues] = useState<OutreachSequenceIssue[]>([])\n const [saving, setSaving] = useState(false)\n const [taskAnchor, setTaskAnchor] = useState<HTMLElement | null>(null)\n // The step whose test-send dialog is open (AGL-3325), by index.\n const [testing, setTesting] = useState<number | null>(null)\n\n // A stored sequence the listener updated (a save, a status change) is the\n // new starting point.\n useEffect(() => {\n if (sequence) setDraft(outreachSequenceDraftOf(sequence))\n }, [sequence])\n // A new sequence starts on the organization's countries once they are read.\n const settingsCountries = settings.settings?.allowedCountries.join(',')\n useEffect(() => {\n if (!sequence && settingsCountries) {\n setDraft((previous) => ({\n ...previous,\n settings: {\n ...previous.settings,\n allowedCountries: settingsCountries.split(','),\n },\n }))\n }\n }, [sequence, settingsCountries])\n useEffect(() => {\n if (!sequence && !draft.hostId && hosts.length === 1)\n setDraft((previous) => ({ ...previous, hostId: hosts[0].id }))\n }, [sequence, draft.hostId, hosts])\n\n // A new sequence starts on the member's own mailbox when they have exactly\n // one that is sending.\n const ownSending = props.mailboxes.mailboxes.filter(\n (entry) => entry.connectedByUid === uid && entry.status === 'connected',\n )\n const onlyOwnMailbox = ownSending.length === 1 ? ownSending[0].id : ''\n useEffect(() => {\n if (!sequence && !draft.mailboxId && onlyOwnMailbox)\n setDraft((previous) => ({ ...previous, mailboxId: onlyOwnMailbox }))\n }, [sequence, draft.mailboxId, onlyOwnMailbox])\n\n const templates = useOutreachEmailTemplates(orgId, draft.hostId || null, uid)\n const mailbox =\n props.mailboxes.mailboxes.find((entry) => entry.id === draft.mailboxId) ??\n null\n const archived = sequence?.status === 'archived'\n /*\n * The org's campaigns, for the picker (AGL-3254): read while the editor\n * is open. A sequence is the organization's record and so is a campaign,\n * so the choice is every campaign in the org, whichever site the\n * sequence sends as.\n */\n const campaigns = useOrgCampaigns(orgId, { enabled: true })\n\n const issues = useMemo(() => {\n const judged = [\n ...validateOutreachSequence(draft),\n ...serverIssues,\n ] as OutreachSequenceIssue[]\n return judged.filter(\n (issue) =>\n issue.severity === 'warning' ||\n attempted ||\n serverIssues.includes(issue),\n )\n }, [draft, serverIssues, attempted])\n const issueAt = (path: string) =>\n issues.find((issue) => issue.path === path)?.message\n const stepIssues = (index: number) => {\n const found: Record<string, string> = {}\n for (const issue of issues) {\n const key = stepIssueKey(issue.path)\n if (key?.index === index && !found[key.field])\n found[key.field] = issue.message\n }\n return found\n }\n const listIssue = issueAt('steps')\n const countriesIssue = issues.find((issue) =>\n issue.path.startsWith('settings.allowedCountries'),\n )?.message\n\n const update = (next: Partial<OutreachSequenceDraft>) => {\n setServerIssues([])\n setDraft((previous) => ({ ...previous, ...next }))\n }\n const setStep = (index: number, step: OutreachSequenceStep) =>\n update({\n steps: draft.steps.map((entry, position) =>\n position === index ? step : entry,\n ),\n })\n const moveStep = (index: number, direction: -1 | 1) => {\n const steps = [...draft.steps]\n const [moved] = steps.splice(index, 1)\n steps.splice(index + direction, 0, moved)\n update({ steps })\n }\n const emailCount = draft.steps.filter((step) => step.kind === 'email').length\n const full = draft.steps.length >= OUTREACH_MAX_STEPS\n const threadSubject = (index: number) => {\n for (let position = index - 1; position >= 0; position -= 1) {\n const step = draft.steps[position]\n if (step.kind === 'email' && !isInThreadEmailStep(draft.steps, position))\n return step.subject\n }\n return ''\n }\n\n const save = async () => {\n setAttempted(true)\n if (\n validateOutreachSequence(draft).some(\n (issue) => issue.severity === 'error',\n )\n )\n return\n setSaving(true)\n try {\n const answer = await api.saveSequence(sequence?.id ?? null, draft)\n setServerIssues([])\n setAttempted(false)\n enqueueSnackbar(\n answer.created ? 'Sequence created.' : 'Sequence saved.',\n { variant: 'success' },\n )\n props.onSaved(answer.sequence)\n } catch (error) {\n if (error instanceof OutreachRouteError && error.issues.length) {\n setServerIssues(\n error.issues.filter(\n (issue): issue is OutreachSequenceIssue => 'path' in issue,\n ),\n )\n }\n enqueueSnackbar((error as Error).message, {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setSaving(false)\n }\n }\n\n const siteName = hosts.find((host) => host.id === draft.hostId)?.name ?? ''\n const countryOptions = [\n ...new Set([...orgCountries, ...draft.settings.allowedCountries]),\n ]\n /*\n * A test sends the step as STORED (AGL-3325): the route reads the saved\n * sequence, so a new one has nothing to send yet and an edited one sends\n * its last save — said on the button and in the dialog rather than found\n * out in the inbox.\n */\n const unsaved =\n sequence !== null &&\n JSON.stringify(draft) !== JSON.stringify(outreachSequenceDraftOf(sequence))\n const sendTestDisabledReason = !sequence\n ? 'Create the sequence to send a test of it.'\n : !draft.mailboxId\n ? 'Choose a mailbox to send the test from.'\n : null\n\n return (\n <Grid container spacing={2}>\n <Grid size={{ xs: 12, md: 7 }}>\n <Stack spacing={2}>\n {archived ? (\n <Alert severity=\"info\">\n This sequence is archived, so it can’t be edited.\n </Alert>\n ) : null}\n <CardDisplay\n header=\"Sequence\"\n help={pluginDocsHelp('sequences', { anchor: '#build-a-sequence' })}\n // The whole sequence (steps and sending rules included) saves\n // from its first card's header, where the console puts a card's\n // actions (AGL-3333).\n HeaderProps={{\n action: archived ? undefined : (\n <Stack direction=\"row\" spacing={1}>\n {props.onCancel ? (\n <Button\n size=\"small\"\n variant=\"text\"\n onClick={props.onCancel}\n disabled={saving}\n >\n Cancel\n </Button>\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void save()}\n disabled={saving}\n >\n {saving ? 'Saving…' : sequence ? 'Save' : 'Create sequence'}\n </Button>\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <TextField\n label=\"Name\"\n value={draft.name}\n onChange={(event) => update({ name: event.target.value })}\n disabled={archived}\n error={Boolean(issueAt('name'))}\n helperText={issueAt('name')}\n slotProps={{\n htmlInput: { maxLength: OUTREACH_SEQUENCE_NAME_MAX + 20 },\n }}\n fullWidth\n />\n <TextField\n select\n label=\"Site\"\n value={\n hosts.some((host) => host.id === draft.hostId)\n ? draft.hostId\n : ''\n }\n onChange={(event) => update({ hostId: event.target.value })}\n disabled={archived || !hosts.length}\n error={Boolean(issueAt('hostId'))}\n helperText={\n issueAt('hostId') ??\n 'The site whose contacts this sequence emails.'\n }\n fullWidth\n >\n {hosts.map((host) => (\n <MenuItem key={host.id} value={host.id}>\n {host.name}\n </MenuItem>\n ))}\n </TextField>\n <OutreachSequenceMailboxPicker\n orgId={orgId}\n uid={uid}\n mailboxes={props.mailboxes}\n mailboxesPath={props.mailboxesPath}\n value={draft.mailboxId}\n onChange={(mailboxId) => update({ mailboxId })}\n error={issueAt('mailboxId')}\n disabled={archived}\n />\n {/*\n The campaigns this sequence is part of (AGL-3254), picked the\n way a form's page picks them. Everyone enrolled joins them,\n and what the sequence produces is reported under them; it\n does not change who is enrolled or what is sent.\n */}\n <CampaignPicker\n options={campaigns.options}\n value={draft.campaignIds}\n onChange={(campaignIds) => update({ campaignIds })}\n label=\"Campaigns\"\n helperText={\n issueAt('campaignIds') ??\n 'The campaigns this sequence is part of. Everyone enrolled joins them, and its sends, replies, meetings and conversions count on their pages.'\n }\n disabled={archived}\n empty={campaigns.ready && !campaigns.options.length}\n emptyText=\"There are no campaigns yet. Create one from Marketing to file this sequence under it.\"\n />\n </Stack>\n </CardDisplay>\n\n {listIssue ? <Alert severity=\"error\">{listIssue}</Alert> : null}\n {draft.steps.map((step, index) => (\n <OutreachStepCard\n key={step.id || `step-${index}`}\n step={step}\n index={index}\n count={draft.steps.length}\n firstEmail={\n draft.steps.findIndex((entry) => entry.kind === 'email') ===\n index\n }\n inThread={isInThreadEmailStep(draft.steps, index)}\n threadSubject={threadSubject(index)}\n templates={templates.data}\n issues={stepIssues(index)}\n disabled={archived}\n onChange={(next) => setStep(index, next)}\n onMove={(direction) => moveStep(index, direction)}\n onRemove={() =>\n update({\n steps: draft.steps.filter(\n (_entry, position) => position !== index,\n ),\n })\n }\n onSendTest={\n step.kind === 'email' && !archived\n ? () => setTesting(index)\n : undefined\n }\n sendTestDisabledReason={sendTestDisabledReason}\n />\n ))}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPlus.path} fontSize=\"small\" />}\n disabled={\n archived || full || emailCount >= OUTREACH_MAX_EMAIL_STEPS\n }\n onClick={() =>\n update({\n steps: [...draft.steps, newOutreachEmailStep(draft.steps)],\n })\n }\n >\n Add email\n </Button>\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPlus.path} fontSize=\"small\" />}\n disabled={archived || full}\n onClick={(event) => setTaskAnchor(event.currentTarget)}\n aria-haspopup=\"menu\"\n >\n Add task\n </Button>\n <Menu\n anchorEl={taskAnchor}\n open={Boolean(taskAnchor)}\n onClose={() => setTaskAnchor(null)}\n >\n {OUTREACH_TASK_KINDS.map((kind: OutreachTaskKind) => (\n <MenuItem\n key={kind}\n onClick={() => {\n setTaskAnchor(null)\n update({\n steps: [\n ...draft.steps,\n newOutreachTaskStep(draft.steps, kind),\n ],\n })\n }}\n >\n {OUTREACH_TASK_KIND_LABELS[kind]}\n </MenuItem>\n ))}\n </Menu>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Up to ${OUTREACH_MAX_STEPS} steps, ${OUTREACH_MAX_EMAIL_STEPS} of them emails.`}\n </Typography>\n\n <CardDisplay\n header=\"Who it sends to, and when\"\n help={pluginDocsHelp('sequences', {\n anchor: '#build-a-sequence',\n excerpt:\n 'A sequence sends only to the countries both it and your organization allow, and in its mailbox’s sending hours unless it has its own.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Autocomplete<string, true>\n multiple\n options={countryOptions}\n value={draft.settings.allowedCountries}\n getOptionLabel={outreachCountryLabel}\n onChange={(_event, next) =>\n update({\n settings: { ...draft.settings, allowedCountries: next },\n })\n }\n disabled={archived}\n renderValue={(value, getItemProps) =>\n value.map((code, index) => {\n const { key, ...item } = getItemProps({ index })\n return (\n <Chip\n key={key}\n size=\"small\"\n label={outreachCountryLabel(code)}\n {...item}\n />\n )\n })\n }\n renderInput={(params) => (\n <TextField\n {...params}\n label=\"Countries\"\n error={Boolean(countriesIssue)}\n helperText={\n countriesIssue ??\n 'Among the countries your organization allows. Cold contacts are emailed only in the United States.'\n }\n />\n )}\n />\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.allowCustomers}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n allowCustomers: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Include customers\"\n />\n {/*\n * Click tracking (AGL-3239), with its cost stated where it is\n * chosen rather than in a document nobody opens. Off by\n * default: it changes the link a cold recipient sees, and the\n * emails stay plain text either way — there is no open\n * tracking to turn on, because a tracking image needs an HTML\n * email and these are not.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.trackClicks}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n trackClicks: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Count link clicks\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Links in these emails are replaced with links of ours that\n forward to the same page, so clicks can be counted. The\n recipient sees the replacement, not your address. Opens\n aren’t counted either way: these emails are plain text, and\n counting an open needs a tracking image.\n </Typography>\n </Stack>\n {/*\n * The mail-client unsubscribe button (AGL-3296), off by\n * default: the header is what makes Apple Mail, Gmail and\n * Outlook present a one-to-one email as a mailing list. The\n * footer's \"reply 'no'\" line is the way out either way.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.listUnsubscribe}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n listUnsubscribe: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Add a mail-client unsubscribe button\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Adds the List-Unsubscribe header, so the recipient’s mail\n app shows its own Unsubscribe button. It’s the easiest way\n out for them, and fewer people mark you as spam when they\n have it, but mail apps then label the email as coming from\n a mailing list. Off, the footer’s “reply ‘no’” line is the\n way out, and it’s always there.\n </Typography>\n </Stack>\n <OutreachSendWindowFields\n window={draft.settings.window}\n onChange={(window) =>\n update({ settings: { ...draft.settings, window } })\n }\n daysIssue={issueAt('settings.window.days')}\n hoursIssue={issueAt('settings.window')}\n disabled={archived}\n />\n </Stack>\n </CardDisplay>\n\n </Stack>\n </Grid>\n {sequence && testing !== null ? (\n <OutreachStepTestDialog\n open\n onClose={() => setTesting(null)}\n orgId={orgId}\n sequence={sequence}\n stepIndex={testing}\n contactGroupId={\n sequence.hostId\n ? consentGroupForHost(props.org ?? null, sequence.hostId).groupId\n : ''\n }\n selfEmail={user?.email ?? null}\n unsaved={unsaved}\n api={api}\n />\n ) : null}\n <Grid size={{ xs: 12, md: 5 }}>\n <Box sx={{ position: { md: 'sticky' }, top: { md: 16 } }}>\n <OutreachSequencePreview\n steps={draft.steps}\n orgSettings={settings.settings}\n orgSettingsStatus={settings.status}\n templates={templates.data}\n sender={\n mailbox\n ? {\n name: mailbox.displayName,\n email: mailbox.sendAs || mailbox.email,\n }\n : null\n }\n siteName={siteName}\n />\n </Box>\n </Grid>\n </Grid>\n )\n}\nOutreachSequenceEditor.displayName = 'OutreachSequenceEditor'\n\nexport default OutreachSequenceEditor\n"],"names":["pluginDocsHelp","consentGroupForHost","mdiPlus","CardDisplay","MdiIcon","CampaignPicker","useSnackbar","useOrgCampaigns","useUser","Alert","Autocomplete","Box","Button","Chip","FormControlLabel","Grid","Menu","MenuItem","Stack","Switch","TextField","Typography","useEffect","useMemo","useState","isInThreadEmailStep","OUTREACH_SEQUENCE_NAME_MAX","validateOutreachSequence","outreachCountryLabel","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEPS","OUTREACH_TASK_KIND_LABELS","OUTREACH_TASK_KINDS","emptyOutreachSequenceDraft","newOutreachEmailStep","newOutreachTaskStep","outreachSequenceDraftOf","OutreachSendWindowFields","OutreachSequenceMailboxPicker","OutreachSequencePreview","OutreachStepCard","OutreachStepTestDialog","OutreachRouteError","useOutreachApi","useOutreachEmailTemplates","stepIssueKey","path","match","exec","field","index","Number","OutreachSequenceEditor","props","issueAt","settings","issues","hosts","orgId","orgMount","sequence","api","enqueueSnackbar","data","user","uid","mountedHosts","orgCountries","allowedCountries","draft","setDraft","hostId","length","id","attempted","setAttempted","serverIssues","setServerIssues","saving","setSaving","taskAnchor","setTaskAnchor","testing","setTesting","settingsCountries","join","previous","split","ownSending","mailboxes","filter","entry","connectedByUid","status","onlyOwnMailbox","mailboxId","templates","mailbox","find","archived","campaigns","enabled","judged","issue","severity","includes","message","stepIssues","found","key","listIssue","countriesIssue","startsWith","update","next","setStep","step","steps","map","position","moveStep","direction","moved","splice","emailCount","kind","full","threadSubject","subject","save","some","answer","saveSequence","created","variant","onSaved","error","allowDuplicate","siteName","host","name","countryOptions","Set","unsaved","JSON","stringify","sendTestDisabledReason","container","spacing","size","xs","md","header","help","anchor","HeaderProps","action","undefined","onCancel","onClick","disabled","contentGutterX","contentGutterY","label","value","onChange","event","target","Boolean","helperText","slotProps","htmlInput","maxLength","fullWidth","select","mailboxesPath","options","campaignIds","empty","ready","emptyText","count","firstEmail","findIndex","inThread","onMove","onRemove","_entry","onSendTest","sx","flexWrap","rowGap","startIcon","fontSize","currentTarget","aria-haspopup","anchorEl","open","onClose","color","excerpt","multiple","getOptionLabel","_event","renderValue","getItemProps","code","item","renderInput","params","control","checked","allowCustomers","trackClicks","listUnsubscribe","window","daysIssue","hoursIssue","stepIndex","contactGroupId","org","groupId","selfEmail","email","top","orgSettings","orgSettingsStatus","sender","displayName","sendAs"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-editor.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp, type ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { mdiPlus } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useOrgCampaigns, useUser } from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Autocomplete,\n Box,\n Button,\n Chip,\n FormControlLabel,\n Grid,\n Menu,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n isInThreadEmailStep,\n OUTREACH_SEQUENCE_NAME_MAX,\n validateOutreachSequence,\n} from '../engine/sequence-validation'\nimport { outreachCountryLabel } from '../model/compliance-settings'\nimport {\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEPS,\n OUTREACH_TASK_KIND_LABELS,\n OUTREACH_TASK_KINDS,\n type OutreachSequence,\n type OutreachSequenceStep,\n type OutreachTaskKind,\n} from '../model/outreach.types'\nimport {\n emptyOutreachSequenceDraft,\n newOutreachEmailStep,\n newOutreachTaskStep,\n outreachSequenceDraftOf,\n type OutreachSequenceDraft,\n type OutreachSequenceIssue,\n} from '../model/sequence-draft'\nimport { OutreachSendWindowFields } from './send-window-fields'\nimport { OutreachSequenceMailboxPicker } from './sequence-mailbox-picker'\nimport { OutreachSequencePreview } from './sequence-preview'\nimport { OutreachStepCard } from './sequence-step-card'\nimport { OutreachStepTestDialog } from './step-test-dialog'\nimport { OutreachRouteError, useOutreachApi } from './use-outreach-api'\nimport { useOutreachEmailTemplates } from './use-outreach-crm'\nimport type { OutreachMailboxesResult } from './use-outreach-mailboxes'\nimport type { OutreachSettingsLoad } from './use-outreach-settings'\n\nexport interface OutreachSequenceEditorProps {\n orgId: string\n orgMount?: ConsolePluginOrgMount\n /** The organization document, for the consent group a test send's person picker searches (AGL-3325). */\n org?: Record<string, unknown> | null\n /** The stored sequence, or `null` for a new one. */\n sequence: OutreachSequence | null\n /** The organization's compliance settings: its countries, and the preview's footer. */\n settings: OutreachSettingsLoad\n /**\n * The organization's mailboxes, from the Mailboxes section's listener:\n * what the picker offers, and who the preview's emails are from.\n */\n mailboxes: OutreachMailboxesResult\n /** The Mailboxes section, where the picker sends a member to connect one. */\n mailboxesPath: string\n onSaved(sequence: OutreachSequence): void\n onCancel?(): void\n}\n\n/** The step a dotted issue path is about, and the field within it. */\nfunction stepIssueKey(path: string): { index: number; field: string } | null {\n const match = /^steps\\.(\\d+)(?:\\.(.+))?$/.exec(path)\n if (!match) return null\n const field = match[2] ?? 'step'\n return { index: Number(match[1]), field: field === 'id' ? 'step' : field }\n}\n\n/**\n * The sequence editor (AGL-2980): the name, the site and mailbox, the steps,\n * who it sends to and when, beside a live preview of each email.\n *\n * Every issue the engine's validator finds is shown beside its field — the\n * same validator the save route refuses with — errors once a save was\n * attempted, warnings as soon as they appear. What only the server can\n * judge (whose mailbox, which countries the organization allows, what may\n * no longer change once people are enrolled) comes back from the save and\n * is shown in the same places.\n */\nexport function OutreachSequenceEditor(props: OutreachSequenceEditorProps) {\n const { orgId, orgMount, sequence, settings } = props\n const api = useOutreachApi(orgId)\n const { enqueueSnackbar } = useSnackbar()\n const { data: user } = useUser()\n const uid = user?.uid ?? null\n const mountedHosts = orgMount?.hosts\n const hosts = useMemo(() => mountedHosts ?? [], [mountedHosts])\n const orgCountries = settings.settings?.allowedCountries ?? ['US']\n\n const [draft, setDraft] = useState<OutreachSequenceDraft>(() =>\n sequence\n ? outreachSequenceDraftOf(sequence)\n : emptyOutreachSequenceDraft({\n hostId: hosts.length === 1 ? hosts[0].id : '',\n allowedCountries: orgCountries,\n }),\n )\n const [attempted, setAttempted] = useState(false)\n const [serverIssues, setServerIssues] = useState<OutreachSequenceIssue[]>([])\n const [saving, setSaving] = useState(false)\n const [taskAnchor, setTaskAnchor] = useState<HTMLElement | null>(null)\n // The step whose test-send dialog is open (AGL-3325), by index.\n const [testing, setTesting] = useState<number | null>(null)\n\n // A stored sequence the listener updated (a save, a status change) is the\n // new starting point.\n useEffect(() => {\n if (sequence) setDraft(outreachSequenceDraftOf(sequence))\n }, [sequence])\n // A new sequence starts on the organization's countries once they are read.\n const settingsCountries = settings.settings?.allowedCountries.join(',')\n useEffect(() => {\n if (!sequence && settingsCountries) {\n setDraft((previous) => ({\n ...previous,\n settings: {\n ...previous.settings,\n allowedCountries: settingsCountries.split(','),\n },\n }))\n }\n }, [sequence, settingsCountries])\n useEffect(() => {\n if (!sequence && !draft.hostId && hosts.length === 1)\n setDraft((previous) => ({ ...previous, hostId: hosts[0].id }))\n }, [sequence, draft.hostId, hosts])\n\n // A new sequence starts on the member's own mailbox when they have exactly\n // one that is sending.\n const ownSending = props.mailboxes.mailboxes.filter(\n (entry) => entry.connectedByUid === uid && entry.status === 'connected',\n )\n const onlyOwnMailbox = ownSending.length === 1 ? ownSending[0].id : ''\n useEffect(() => {\n if (!sequence && !draft.mailboxId && onlyOwnMailbox)\n setDraft((previous) => ({ ...previous, mailboxId: onlyOwnMailbox }))\n }, [sequence, draft.mailboxId, onlyOwnMailbox])\n\n const templates = useOutreachEmailTemplates(orgId, draft.hostId || null, uid)\n const mailbox =\n props.mailboxes.mailboxes.find((entry) => entry.id === draft.mailboxId) ??\n null\n const archived = sequence?.status === 'archived'\n /*\n * The org's campaigns, for the picker (AGL-3254): read while the editor\n * is open. A sequence is the organization's record and so is a campaign,\n * so the choice is every campaign in the org, whichever site the\n * sequence sends as.\n */\n const campaigns = useOrgCampaigns(orgId, { enabled: true })\n\n const issues = useMemo(() => {\n const judged = [\n ...validateOutreachSequence(draft),\n ...serverIssues,\n ] as OutreachSequenceIssue[]\n return judged.filter(\n (issue) =>\n issue.severity === 'warning' ||\n attempted ||\n serverIssues.includes(issue),\n )\n }, [draft, serverIssues, attempted])\n const issueAt = (path: string) =>\n issues.find((issue) => issue.path === path)?.message\n const stepIssues = (index: number) => {\n const found: Record<string, string> = {}\n for (const issue of issues) {\n const key = stepIssueKey(issue.path)\n if (key?.index === index && !found[key.field])\n found[key.field] = issue.message\n }\n return found\n }\n const listIssue = issueAt('steps')\n const countriesIssue = issues.find((issue) =>\n issue.path.startsWith('settings.allowedCountries'),\n )?.message\n\n const update = (next: Partial<OutreachSequenceDraft>) => {\n setServerIssues([])\n setDraft((previous) => ({ ...previous, ...next }))\n }\n const setStep = (index: number, step: OutreachSequenceStep) =>\n update({\n steps: draft.steps.map((entry, position) =>\n position === index ? step : entry,\n ),\n })\n const moveStep = (index: number, direction: -1 | 1) => {\n const steps = [...draft.steps]\n const [moved] = steps.splice(index, 1)\n steps.splice(index + direction, 0, moved)\n update({ steps })\n }\n const emailCount = draft.steps.filter((step) => step.kind === 'email').length\n const full = draft.steps.length >= OUTREACH_MAX_STEPS\n const threadSubject = (index: number) => {\n for (let position = index - 1; position >= 0; position -= 1) {\n const step = draft.steps[position]\n if (step.kind === 'email' && !isInThreadEmailStep(draft.steps, position))\n return step.subject\n }\n return ''\n }\n\n const save = async () => {\n setAttempted(true)\n if (\n validateOutreachSequence(draft).some(\n (issue) => issue.severity === 'error',\n )\n )\n return\n setSaving(true)\n try {\n const answer = await api.saveSequence(sequence?.id ?? null, draft)\n setServerIssues([])\n setAttempted(false)\n enqueueSnackbar(\n answer.created ? 'Sequence created.' : 'Sequence saved.',\n { variant: 'success' },\n )\n props.onSaved(answer.sequence)\n } catch (error) {\n if (error instanceof OutreachRouteError && error.issues.length) {\n setServerIssues(\n error.issues.filter(\n (issue): issue is OutreachSequenceIssue => 'path' in issue,\n ),\n )\n }\n enqueueSnackbar((error as Error).message, {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setSaving(false)\n }\n }\n\n const siteName = hosts.find((host) => host.id === draft.hostId)?.name ?? ''\n const countryOptions = [\n ...new Set([...orgCountries, ...draft.settings.allowedCountries]),\n ]\n /*\n * A test sends the step as STORED (AGL-3325): the route reads the saved\n * sequence, so a new one has nothing to send yet and an edited one sends\n * its last save — said on the button and in the dialog rather than found\n * out in the inbox.\n */\n const unsaved =\n sequence !== null &&\n JSON.stringify(draft) !== JSON.stringify(outreachSequenceDraftOf(sequence))\n const sendTestDisabledReason = !sequence\n ? 'Create the sequence to send a test of it.'\n : !draft.mailboxId\n ? 'Choose a mailbox to send the test from.'\n : null\n\n return (\n <Grid container spacing={2}>\n <Grid size={{ xs: 12, md: 7 }}>\n <Stack spacing={2}>\n {archived ? (\n <Alert severity=\"info\">\n This sequence is archived, so it can’t be edited.\n </Alert>\n ) : null}\n <CardDisplay\n header=\"Sequence\"\n help={pluginDocsHelp('sequences', { anchor: '#build-a-sequence' })}\n // The whole sequence (steps and sending rules included) saves\n // from its first card's header, where the console puts a card's\n // actions (AGL-3333).\n HeaderProps={{\n action: archived ? undefined : (\n <Stack direction=\"row\" spacing={1}>\n {props.onCancel ? (\n <Button\n size=\"small\"\n variant=\"text\"\n onClick={props.onCancel}\n disabled={saving}\n >\n Cancel\n </Button>\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void save()}\n disabled={saving}\n >\n {saving ? 'Saving…' : sequence ? 'Save' : 'Create sequence'}\n </Button>\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <TextField\n label=\"Name\"\n value={draft.name}\n onChange={(event) => update({ name: event.target.value })}\n disabled={archived}\n error={Boolean(issueAt('name'))}\n helperText={issueAt('name')}\n slotProps={{\n htmlInput: { maxLength: OUTREACH_SEQUENCE_NAME_MAX + 20 },\n }}\n fullWidth\n />\n <TextField\n select\n label=\"Site\"\n value={\n hosts.some((host) => host.id === draft.hostId)\n ? draft.hostId\n : ''\n }\n onChange={(event) => update({ hostId: event.target.value })}\n disabled={archived || !hosts.length}\n error={Boolean(issueAt('hostId'))}\n helperText={\n issueAt('hostId') ??\n 'The site whose contacts this sequence emails.'\n }\n fullWidth\n >\n {hosts.map((host) => (\n <MenuItem key={host.id} value={host.id}>\n {host.name}\n </MenuItem>\n ))}\n </TextField>\n <OutreachSequenceMailboxPicker\n orgId={orgId}\n uid={uid}\n mailboxes={props.mailboxes}\n mailboxesPath={props.mailboxesPath}\n value={draft.mailboxId}\n onChange={(mailboxId) => update({ mailboxId })}\n error={issueAt('mailboxId')}\n disabled={archived}\n />\n {/*\n The campaigns this sequence is part of (AGL-3254), picked the\n way a form's page picks them. Everyone enrolled joins them,\n and what the sequence produces is reported under them; it\n does not change who is enrolled or what is sent.\n */}\n <CampaignPicker\n options={campaigns.options}\n value={draft.campaignIds}\n onChange={(campaignIds) => update({ campaignIds })}\n label=\"Campaigns\"\n helperText={\n issueAt('campaignIds') ??\n 'The campaigns this sequence is part of. Everyone enrolled joins them, and its sends, replies, meetings and conversions count on their pages.'\n }\n disabled={archived}\n empty={campaigns.ready && !campaigns.options.length}\n emptyText=\"There are no campaigns yet. Create one from Marketing to file this sequence under it.\"\n />\n </Stack>\n </CardDisplay>\n\n {listIssue ? <Alert severity=\"error\">{listIssue}</Alert> : null}\n {draft.steps.map((step, index) => (\n <OutreachStepCard\n key={step.id || `step-${index}`}\n step={step}\n index={index}\n count={draft.steps.length}\n firstEmail={\n draft.steps.findIndex((entry) => entry.kind === 'email') ===\n index\n }\n inThread={isInThreadEmailStep(draft.steps, index)}\n threadSubject={threadSubject(index)}\n templates={templates.data}\n issues={stepIssues(index)}\n disabled={archived}\n onChange={(next) => setStep(index, next)}\n onMove={(direction) => moveStep(index, direction)}\n onRemove={() =>\n update({\n steps: draft.steps.filter(\n (_entry, position) => position !== index,\n ),\n })\n }\n onSendTest={\n step.kind === 'email' && !archived\n ? () => setTesting(index)\n : undefined\n }\n sendTestDisabledReason={sendTestDisabledReason}\n />\n ))}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPlus.path} fontSize=\"small\" />}\n disabled={\n archived || full || emailCount >= OUTREACH_MAX_EMAIL_STEPS\n }\n onClick={() =>\n update({\n steps: [...draft.steps, newOutreachEmailStep(draft.steps)],\n })\n }\n >\n Add email\n </Button>\n <Button\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPlus.path} fontSize=\"small\" />}\n disabled={archived || full}\n onClick={(event) => setTaskAnchor(event.currentTarget)}\n aria-haspopup=\"menu\"\n >\n Add task\n </Button>\n <Menu\n anchorEl={taskAnchor}\n open={Boolean(taskAnchor)}\n onClose={() => setTaskAnchor(null)}\n >\n {OUTREACH_TASK_KINDS.map((kind: OutreachTaskKind) => (\n <MenuItem\n key={kind}\n onClick={() => {\n setTaskAnchor(null)\n update({\n steps: [\n ...draft.steps,\n newOutreachTaskStep(draft.steps, kind),\n ],\n })\n }}\n >\n {OUTREACH_TASK_KIND_LABELS[kind]}\n </MenuItem>\n ))}\n </Menu>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Up to ${OUTREACH_MAX_STEPS} steps, ${OUTREACH_MAX_EMAIL_STEPS} of them emails.`}\n </Typography>\n\n <CardDisplay\n header=\"Who it sends to, and when\"\n help={pluginDocsHelp('sequences', {\n anchor: '#build-a-sequence',\n excerpt:\n 'A sequence sends only to the countries both it and your organization allow, and in its mailbox’s sending hours unless it has its own.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Autocomplete<string, true>\n multiple\n options={countryOptions}\n value={draft.settings.allowedCountries}\n getOptionLabel={outreachCountryLabel}\n onChange={(_event, next) =>\n update({\n settings: { ...draft.settings, allowedCountries: next },\n })\n }\n disabled={archived}\n renderValue={(value, getItemProps) =>\n value.map((code, index) => {\n const { key, ...item } = getItemProps({ index })\n return (\n <Chip\n key={key}\n size=\"small\"\n label={outreachCountryLabel(code)}\n {...item}\n />\n )\n })\n }\n renderInput={(params) => (\n <TextField\n {...params}\n label=\"Countries\"\n error={Boolean(countriesIssue)}\n helperText={\n countriesIssue ??\n 'Among the countries your organization allows. Cold contacts are emailed only in the United States.'\n }\n />\n )}\n />\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.allowCustomers}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n allowCustomers: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Include customers\"\n />\n {/*\n * Click tracking (AGL-3239), with its cost stated where it is\n * chosen rather than in a document nobody opens. Off by\n * default: it changes the link a cold recipient sees. The\n * emails stay plain text either way; opens are the separate\n * switch below.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.trackClicks}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n trackClicks: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Count link clicks\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Links in these emails are replaced with links of ours that\n forward to the same page, so clicks can be counted. The\n recipient sees the replacement, not your address. The\n emails stay plain text.\n </Typography>\n </Stack>\n {/*\n * Open counting (AGL-3395), off by default, its cost stated\n * where it is chosen: an HTML copy with a tracking image is the\n * least one-to-one thing a sequence email can carry, so it is\n * for measuring a slice, not for every sequence.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.countOpens}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n countOpens: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Count opens\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Each email also goes out as HTML — the same words — with a\n tiny tracking image, so opens can be counted. The plain-text\n version is unchanged. It makes the email look less like one\n written by hand, and some spam filters score HTML with a\n remote image against you, so turn it on to measure a slice\n of your sends rather than for every sequence. Gmail opens\n count; Apple Mail loads every image on arrival, so its loads\n are counted separately.\n </Typography>\n </Stack>\n {/*\n * The mail-client unsubscribe button (AGL-3296), off by\n * default: the header is what makes Apple Mail, Gmail and\n * Outlook present a one-to-one email as a mailing list. The\n * footer's \"reply 'no'\" line is the way out either way.\n */}\n <Stack spacing={0}>\n <FormControlLabel\n control={\n <Switch\n checked={draft.settings.listUnsubscribe}\n onChange={(event) =>\n update({\n settings: {\n ...draft.settings,\n listUnsubscribe: event.target.checked,\n },\n })\n }\n disabled={archived}\n />\n }\n label=\"Add a mail-client unsubscribe button\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n Adds the List-Unsubscribe header, so the recipient’s mail\n app shows its own Unsubscribe button. It’s the easiest way\n out for them, and fewer people mark you as spam when they\n have it, but mail apps then label the email as coming from\n a mailing list. Off, the footer’s “reply ‘no’” line is the\n way out, and it’s always there.\n </Typography>\n </Stack>\n <OutreachSendWindowFields\n window={draft.settings.window}\n onChange={(window) =>\n update({ settings: { ...draft.settings, window } })\n }\n daysIssue={issueAt('settings.window.days')}\n hoursIssue={issueAt('settings.window')}\n disabled={archived}\n />\n </Stack>\n </CardDisplay>\n\n </Stack>\n </Grid>\n {sequence && testing !== null ? (\n <OutreachStepTestDialog\n open\n onClose={() => setTesting(null)}\n orgId={orgId}\n sequence={sequence}\n stepIndex={testing}\n contactGroupId={\n sequence.hostId\n ? consentGroupForHost(props.org ?? null, sequence.hostId).groupId\n : ''\n }\n selfEmail={user?.email ?? null}\n unsaved={unsaved}\n api={api}\n />\n ) : null}\n <Grid size={{ xs: 12, md: 5 }}>\n <Box sx={{ position: { md: 'sticky' }, top: { md: 16 } }}>\n <OutreachSequencePreview\n steps={draft.steps}\n orgSettings={settings.settings}\n orgSettingsStatus={settings.status}\n templates={templates.data}\n sender={\n mailbox\n ? {\n name: mailbox.displayName,\n email: mailbox.sendAs || mailbox.email,\n }\n : null\n }\n siteName={siteName}\n />\n </Box>\n </Grid>\n </Grid>\n )\n}\nOutreachSequenceEditor.displayName = 'OutreachSequenceEditor'\n\nexport default OutreachSequenceEditor\n"],"names":["pluginDocsHelp","consentGroupForHost","mdiPlus","CardDisplay","MdiIcon","CampaignPicker","useSnackbar","useOrgCampaigns","useUser","Alert","Autocomplete","Box","Button","Chip","FormControlLabel","Grid","Menu","MenuItem","Stack","Switch","TextField","Typography","useEffect","useMemo","useState","isInThreadEmailStep","OUTREACH_SEQUENCE_NAME_MAX","validateOutreachSequence","outreachCountryLabel","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEPS","OUTREACH_TASK_KIND_LABELS","OUTREACH_TASK_KINDS","emptyOutreachSequenceDraft","newOutreachEmailStep","newOutreachTaskStep","outreachSequenceDraftOf","OutreachSendWindowFields","OutreachSequenceMailboxPicker","OutreachSequencePreview","OutreachStepCard","OutreachStepTestDialog","OutreachRouteError","useOutreachApi","useOutreachEmailTemplates","stepIssueKey","path","match","exec","field","index","Number","OutreachSequenceEditor","props","issueAt","settings","issues","hosts","orgId","orgMount","sequence","api","enqueueSnackbar","data","user","uid","mountedHosts","orgCountries","allowedCountries","draft","setDraft","hostId","length","id","attempted","setAttempted","serverIssues","setServerIssues","saving","setSaving","taskAnchor","setTaskAnchor","testing","setTesting","settingsCountries","join","previous","split","ownSending","mailboxes","filter","entry","connectedByUid","status","onlyOwnMailbox","mailboxId","templates","mailbox","find","archived","campaigns","enabled","judged","issue","severity","includes","message","stepIssues","found","key","listIssue","countriesIssue","startsWith","update","next","setStep","step","steps","map","position","moveStep","direction","moved","splice","emailCount","kind","full","threadSubject","subject","save","some","answer","saveSequence","created","variant","onSaved","error","allowDuplicate","siteName","host","name","countryOptions","Set","unsaved","JSON","stringify","sendTestDisabledReason","container","spacing","size","xs","md","header","help","anchor","HeaderProps","action","undefined","onCancel","onClick","disabled","contentGutterX","contentGutterY","label","value","onChange","event","target","Boolean","helperText","slotProps","htmlInput","maxLength","fullWidth","select","mailboxesPath","options","campaignIds","empty","ready","emptyText","count","firstEmail","findIndex","inThread","onMove","onRemove","_entry","onSendTest","sx","flexWrap","rowGap","startIcon","fontSize","currentTarget","aria-haspopup","anchorEl","open","onClose","color","excerpt","multiple","getOptionLabel","_event","renderValue","getItemProps","code","item","renderInput","params","control","checked","allowCustomers","trackClicks","countOpens","listUnsubscribe","window","daysIssue","hoursIssue","stepIndex","contactGroupId","org","groupId","selfEmail","email","top","orgSettings","orgSettingsStatus","sender","displayName","sendAs"],"mappings":"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"}
@@ -3,8 +3,9 @@ import type { OutreachSequenceLinksLoad } from './use-outreach-data';
3
3
  /**
4
4
  * WHAT A SEQUENCE MEASURED (AGL-3239).
5
5
  *
6
- * Four figures and one rate, and the sentences saying what each of them is
7
- * not. The decisions are all in `model/sequence-report.ts`; this renders
6
+ * Four figures and one rate — and, for a sequence that counts opens
7
+ * (AGL-3395), the opens beside them — and the sentences saying what each of
8
+ * them is not. The decisions are all in `model/sequence-report.ts`; this renders
8
9
  * them and adds no arithmetic of its own — in particular it never divides,
9
10
  * so there is no second place a percentage can be computed differently from
10
11
  * the way a campaign's report computes it.
@@ -95,8 +95,9 @@ import { formatOutreachTime, OutreachLoadProblem } from "./outreach-ui.js";
95
95
  }
96
96
  export function OutreachSequenceReportCard(props) {
97
97
  var _props_links_data;
98
- const report = outreachSequenceReport(props.sequence.stats, props.sequence.settings.trackClicks);
98
+ const report = outreachSequenceReport(props.sequence.stats, props.sequence.settings.trackClicks, props.sequence.settings.countOpens);
99
99
  const clickRate = rateText(report.rates.click);
100
+ const openRate = rateText(report.rates.open);
100
101
  const links = outreachSequenceLinkReport(props.links.status === 'ready' ? (_props_links_data = props.links.data) != null ? _props_links_data : undefined : undefined);
101
102
  return /*#__PURE__*/ _jsx(Card, {
102
103
  variant: "outlined",
@@ -127,6 +128,25 @@ export function OutreachSequenceReportCard(props) {
127
128
  }, report.people === null ? {
128
129
  hint: 'Not counted for this sequence'
129
130
  } : {})),
131
+ report.openTracked ? /*#__PURE__*/ _jsxs(_Fragment, {
132
+ children: [
133
+ /*#__PURE__*/ _jsx(OutreachFigure, {
134
+ label: "Opened",
135
+ value: count(report.uniqueOpens),
136
+ hint: report.opens === report.uniqueOpens ? undefined : `${count(report.opens)} opens in all`
137
+ }),
138
+ /*#__PURE__*/ _jsx(OutreachFigure, {
139
+ label: "Open rate",
140
+ value: openRate.value,
141
+ hint: openRate.hint
142
+ }),
143
+ report.machineOpens > 0 ? /*#__PURE__*/ _jsx(OutreachFigure, {
144
+ label: "Machine opens",
145
+ value: count(report.machineOpens),
146
+ hint: report.proxyOpens > 0 ? `Not in the rate · ${count(report.proxyOpens)} by mail proxies` : 'Not in the rate'
147
+ }) : null
148
+ ]
149
+ }) : null,
130
150
  /*#__PURE__*/ _jsx(OutreachFigure, _extends({
131
151
  label: "Clicked",
132
152
  value: count(report.uniqueClicks),
@@ -147,6 +167,14 @@ export function OutreachSequenceReportCard(props) {
147
167
  }) : null
148
168
  ]
149
169
  }),
170
+ report.lastOpenAtMs === null ? null : /*#__PURE__*/ _jsxs(Typography, {
171
+ variant: "body2",
172
+ color: "text.secondary",
173
+ children: [
174
+ "Last open ",
175
+ formatOutreachTime(report.lastOpenAtMs, props.timeZone)
176
+ ]
177
+ }),
150
178
  report.lastClickAtMs === null ? null : /*#__PURE__*/ _jsxs(Typography, {
151
179
  variant: "body2",
152
180
  color: "text.secondary",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-report-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 type { CampaignRate } from '@aglyn/shared-ui-email-campaigns/model'\nimport {\n Alert,\n ButtonBase,\n Card,\n CardContent,\n Divider,\n Link,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport type { ReactNode } from 'react'\n// Not a raw MUI `Table`: a destination is a long string, and a raw table is\n// cut off by the card around it with nothing able to scroll to what is past\n// the edge (AGL-3045).\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport {\n outreachSequenceLinkReport,\n outreachSequenceReport,\n} from '../model/sequence-report'\nimport { formatOutreachTime, OutreachLoadProblem } from './outreach-ui'\nimport type { OutreachSequenceLinksLoad } from './use-outreach-data'\n\n/**\n * WHAT A SEQUENCE MEASURED (AGL-3239).\n *\n * Four figures and one rate, and the sentences saying what each of them is\n * not. The decisions are all in `model/sequence-report.ts`; this renders\n * them and adds no arithmetic of its own — in particular it never divides,\n * so there is no second place a percentage can be computed differently from\n * the way a campaign's report computes it.\n *\n * The caveats are rendered as prominently as the numbers rather than as\n * small print. \"Opens aren't measured\" is the thing a reader coming from\n * the campaign report most needs told, and a blank where an open rate would\n * be says the opposite of what is true.\n */\n\nexport interface OutreachSequenceReportCardProps {\n sequence: Pick<OutreachSequence, 'stats' | 'settings'>\n links: OutreachSequenceLinksLoad\n /** The mailbox's zone, which times are read in; `null` for the reader's own. */\n timeZone: string | null\n /** Lists the people who clicked, on the Enrollments tab (AGL-3332). */\n onShowClickers?: () => void\n /** Lists the people who followed one destination, on the Enrollments tab (AGL-3332). */\n onShowLink?: (url: string) => void\n}\n\n/**\n * One figure, with what it counts under it — and where it leads, when it\n * leads somewhere. The sequence's Results and one person's page (AGL-3332)\n * draw their numbers alike.\n */\nexport function OutreachFigure(props: {\n label: string\n value: string\n hint?: string\n /** Opens the list behind the figure; named for a screen reader by `action`. */\n onClick?: () => void\n action?: string\n}) {\n const body: ReactNode = (\n <>\n <Typography variant=\"h5\" component=\"p\" color={props.onClick ? 'primary' : undefined}>\n {props.value}\n </Typography>\n <Typography variant=\"body2\">{props.label}</Typography>\n {props.hint ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.hint}\n </Typography>\n ) : null}\n </>\n )\n if (!props.onClick) {\n return (\n <Stack spacing={0} sx={{ minWidth: 110 }}>\n {body}\n </Stack>\n )\n }\n return (\n <Tooltip title={props.action}>\n <ButtonBase\n onClick={props.onClick}\n aria-label={`${props.value} ${props.label}. ${props.action}`}\n sx={{\n minWidth: 110,\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'flex-start',\n textAlign: 'left',\n borderRadius: 1,\n px: 1,\n mx: -1,\n '&:hover, &.Mui-focusVisible': { bgcolor: 'action.hover' },\n }}\n >\n {body}\n </ButtonBase>\n </Tooltip>\n )\n}\n\n/** A whole number as the card prints one. */\nconst count = (value: number): string => value.toLocaleString()\n\n/**\n * A rate and the denominator it was taken over, or the em dash that stands\n * for a rate the model refused to take. The denominator is printed beside\n * the percentage, never dropped: \"12% of people emailed\" and \"12% of people\n * who opened\" are different claims and a bare \"12%\" is neither.\n */\nfunction rateText(rate: CampaignRate | null): { value: string; hint?: string } {\n if (!rate) return { value: '—' }\n return {\n value: `${(rate.value * 100).toFixed(1)}%`,\n hint: `${count(rate.numerator)} of ${count(rate.denominator)} ${rate.denominatorLabel}`,\n }\n}\n\nexport function OutreachSequenceReportCard(props: OutreachSequenceReportCardProps) {\n const report = outreachSequenceReport(\n props.sequence.stats,\n props.sequence.settings.trackClicks,\n )\n const clickRate = rateText(report.rates.click)\n const links = outreachSequenceLinkReport(\n props.links.status === 'ready' ? (props.links.data ?? undefined) : undefined,\n )\n\n return (\n <Card variant=\"outlined\">\n <CardContent>\n <Stack spacing={2}>\n <Typography variant=\"h6\" component=\"h2\">\n Results\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <OutreachFigure label=\"Emails sent\" value={count(report.sent)} />\n <OutreachFigure\n label=\"People emailed\"\n value={report.people === null ? '—' : count(report.people)}\n {...(report.people === null ? { hint: 'Not counted for this sequence' } : {})}\n />\n <OutreachFigure\n label=\"Clicked\"\n value={count(report.uniqueClicks)}\n hint={\n report.clicks === report.uniqueClicks\n ? undefined\n : `${count(report.clicks)} clicks in all`\n }\n {...(props.onShowClickers && report.uniqueClicks > 0\n ? { onClick: props.onShowClickers, action: 'Show who clicked' }\n : {})}\n />\n <OutreachFigure label=\"Click rate\" value={clickRate.value} hint={clickRate.hint} />\n {report.machineClicks > 0 ? (\n <OutreachFigure\n label=\"Scanner clicks\"\n value={count(report.machineClicks)}\n hint=\"Not in the rate\"\n />\n ) : null}\n </Stack>\n {report.lastClickAtMs === null ? null : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n Last click {formatOutreachTime(report.lastClickAtMs, props.timeZone)}\n </Typography>\n )}\n\n {report.caveats.map((entry) => (\n <Alert key={entry.id} severity=\"info\" variant=\"outlined\">\n {entry.message}\n </Alert>\n ))}\n\n {props.links.status === 'error' || props.links.status === 'refused' ? (\n <OutreachLoadProblem status={props.links.status} what=\"the link report\" />\n ) : links.rows.length ? (\n <Stack spacing={1}>\n <Divider />\n <Typography variant=\"subtitle2\" component=\"h3\">\n Links followed\n </Typography>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>Destination</TableCell>\n <TableCell align=\"right\">Clicks</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {links.rows.map((row) => (\n <TableRow key={row.url} hover={Boolean(props.onShowLink)}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {props.onShowLink ? (\n <Link\n component=\"button\"\n type=\"button\"\n variant=\"body2\"\n underline=\"hover\"\n onClick={() => props.onShowLink?.(row.url)}\n aria-label={`Show who followed ${row.url}`}\n sx={{ textAlign: 'left', wordBreak: 'break-all' }}\n >\n {row.url}\n </Link>\n ) : (\n row.url\n )}\n </TableCell>\n <TableCell align=\"right\">{count(row.clicks)}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.onShowLink ? 'Select a destination to see who followed it. ' : ''}\n Two links to the same page are counted as one destination, whatever\n tracking parameters follow the address.\n {links.truncated\n ? ` ${count(links.overflowClicks)} more clicks went to destinations past the ones listed.`\n : ''}\n </Typography>\n </Stack>\n ) : null}\n </Stack>\n </CardContent>\n </Card>\n )\n}\n"],"names":["Alert","ButtonBase","Card","CardContent","Divider","Link","Stack","TableBody","TableCell","TableHead","TableRow","Tooltip","Typography","ScrollTable","outreachSequenceLinkReport","outreachSequenceReport","formatOutreachTime","OutreachLoadProblem","OutreachFigure","props","body","variant","component","color","onClick","undefined","value","label","hint","spacing","sx","minWidth","title","action","aria-label","display","flexDirection","alignItems","textAlign","borderRadius","px","mx","bgcolor","count","toLocaleString","rateText","rate","toFixed","numerator","denominator","denominatorLabel","OutreachSequenceReportCard","report","sequence","stats","settings","trackClicks","clickRate","rates","click","links","status","data","direction","flexWrap","rowGap","sent","people","uniqueClicks","clicks","onShowClickers","machineClicks","lastClickAtMs","timeZone","caveats","map","entry","severity","message","id","what","rows","length","size","align","row","hover","Boolean","onShowLink","wordBreak","type","underline","url","truncated","overflowClicks"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-report-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 type { CampaignRate } from '@aglyn/shared-ui-email-campaigns/model'\nimport {\n Alert,\n ButtonBase,\n Card,\n CardContent,\n Divider,\n Link,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport type { ReactNode } from 'react'\n// Not a raw MUI `Table`: a destination is a long string, and a raw table is\n// cut off by the card around it with nothing able to scroll to what is past\n// the edge (AGL-3045).\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport {\n outreachSequenceLinkReport,\n outreachSequenceReport,\n} from '../model/sequence-report'\nimport { formatOutreachTime, OutreachLoadProblem } from './outreach-ui'\nimport type { OutreachSequenceLinksLoad } from './use-outreach-data'\n\n/**\n * WHAT A SEQUENCE MEASURED (AGL-3239).\n *\n * Four figures and one rate — and, for a sequence that counts opens\n * (AGL-3395), the opens beside them — and the sentences saying what each of\n * them is not. The decisions are all in `model/sequence-report.ts`; this renders\n * them and adds no arithmetic of its own — in particular it never divides,\n * so there is no second place a percentage can be computed differently from\n * the way a campaign's report computes it.\n *\n * The caveats are rendered as prominently as the numbers rather than as\n * small print. \"Opens aren't measured\" is the thing a reader coming from\n * the campaign report most needs told, and a blank where an open rate would\n * be says the opposite of what is true.\n */\n\nexport interface OutreachSequenceReportCardProps {\n sequence: Pick<OutreachSequence, 'stats' | 'settings'>\n links: OutreachSequenceLinksLoad\n /** The mailbox's zone, which times are read in; `null` for the reader's own. */\n timeZone: string | null\n /** Lists the people who clicked, on the Enrollments tab (AGL-3332). */\n onShowClickers?: () => void\n /** Lists the people who followed one destination, on the Enrollments tab (AGL-3332). */\n onShowLink?: (url: string) => void\n}\n\n/**\n * One figure, with what it counts under it — and where it leads, when it\n * leads somewhere. The sequence's Results and one person's page (AGL-3332)\n * draw their numbers alike.\n */\nexport function OutreachFigure(props: {\n label: string\n value: string\n hint?: string\n /** Opens the list behind the figure; named for a screen reader by `action`. */\n onClick?: () => void\n action?: string\n}) {\n const body: ReactNode = (\n <>\n <Typography variant=\"h5\" component=\"p\" color={props.onClick ? 'primary' : undefined}>\n {props.value}\n </Typography>\n <Typography variant=\"body2\">{props.label}</Typography>\n {props.hint ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.hint}\n </Typography>\n ) : null}\n </>\n )\n if (!props.onClick) {\n return (\n <Stack spacing={0} sx={{ minWidth: 110 }}>\n {body}\n </Stack>\n )\n }\n return (\n <Tooltip title={props.action}>\n <ButtonBase\n onClick={props.onClick}\n aria-label={`${props.value} ${props.label}. ${props.action}`}\n sx={{\n minWidth: 110,\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'flex-start',\n textAlign: 'left',\n borderRadius: 1,\n px: 1,\n mx: -1,\n '&:hover, &.Mui-focusVisible': { bgcolor: 'action.hover' },\n }}\n >\n {body}\n </ButtonBase>\n </Tooltip>\n )\n}\n\n/** A whole number as the card prints one. */\nconst count = (value: number): string => value.toLocaleString()\n\n/**\n * A rate and the denominator it was taken over, or the em dash that stands\n * for a rate the model refused to take. The denominator is printed beside\n * the percentage, never dropped: \"12% of people emailed\" and \"12% of people\n * who opened\" are different claims and a bare \"12%\" is neither.\n */\nfunction rateText(rate: CampaignRate | null): { value: string; hint?: string } {\n if (!rate) return { value: '—' }\n return {\n value: `${(rate.value * 100).toFixed(1)}%`,\n hint: `${count(rate.numerator)} of ${count(rate.denominator)} ${rate.denominatorLabel}`,\n }\n}\n\nexport function OutreachSequenceReportCard(props: OutreachSequenceReportCardProps) {\n const report = outreachSequenceReport(\n props.sequence.stats,\n props.sequence.settings.trackClicks,\n props.sequence.settings.countOpens,\n )\n const clickRate = rateText(report.rates.click)\n const openRate = rateText(report.rates.open)\n const links = outreachSequenceLinkReport(\n props.links.status === 'ready' ? (props.links.data ?? undefined) : undefined,\n )\n\n return (\n <Card variant=\"outlined\">\n <CardContent>\n <Stack spacing={2}>\n <Typography variant=\"h6\" component=\"h2\">\n Results\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <OutreachFigure label=\"Emails sent\" value={count(report.sent)} />\n <OutreachFigure\n label=\"People emailed\"\n value={report.people === null ? '—' : count(report.people)}\n {...(report.people === null ? { hint: 'Not counted for this sequence' } : {})}\n />\n {/*\n * Opens (AGL-3395), only for a sequence whose emails have carried\n * the tracking image: for every other one, the caveat below\n * says opens are not measured, and a figure here would say\n * otherwise.\n */}\n {report.openTracked ? (\n <>\n <OutreachFigure\n label=\"Opened\"\n value={count(report.uniqueOpens)}\n hint={\n report.opens === report.uniqueOpens ? undefined : `${count(report.opens)} opens in all`\n }\n />\n <OutreachFigure label=\"Open rate\" value={openRate.value} hint={openRate.hint} />\n {report.machineOpens > 0 ? (\n <OutreachFigure\n label=\"Machine opens\"\n value={count(report.machineOpens)}\n hint={\n report.proxyOpens > 0\n ? `Not in the rate · ${count(report.proxyOpens)} by mail proxies`\n : 'Not in the rate'\n }\n />\n ) : null}\n </>\n ) : null}\n <OutreachFigure\n label=\"Clicked\"\n value={count(report.uniqueClicks)}\n hint={\n report.clicks === report.uniqueClicks\n ? undefined\n : `${count(report.clicks)} clicks in all`\n }\n {...(props.onShowClickers && report.uniqueClicks > 0\n ? { onClick: props.onShowClickers, action: 'Show who clicked' }\n : {})}\n />\n <OutreachFigure label=\"Click rate\" value={clickRate.value} hint={clickRate.hint} />\n {report.machineClicks > 0 ? (\n <OutreachFigure\n label=\"Scanner clicks\"\n value={count(report.machineClicks)}\n hint=\"Not in the rate\"\n />\n ) : null}\n </Stack>\n {report.lastOpenAtMs === null ? null : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n Last open {formatOutreachTime(report.lastOpenAtMs, props.timeZone)}\n </Typography>\n )}\n {report.lastClickAtMs === null ? null : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n Last click {formatOutreachTime(report.lastClickAtMs, props.timeZone)}\n </Typography>\n )}\n\n {report.caveats.map((entry) => (\n <Alert key={entry.id} severity=\"info\" variant=\"outlined\">\n {entry.message}\n </Alert>\n ))}\n\n {props.links.status === 'error' || props.links.status === 'refused' ? (\n <OutreachLoadProblem status={props.links.status} what=\"the link report\" />\n ) : links.rows.length ? (\n <Stack spacing={1}>\n <Divider />\n <Typography variant=\"subtitle2\" component=\"h3\">\n Links followed\n </Typography>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>Destination</TableCell>\n <TableCell align=\"right\">Clicks</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {links.rows.map((row) => (\n <TableRow key={row.url} hover={Boolean(props.onShowLink)}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {props.onShowLink ? (\n <Link\n component=\"button\"\n type=\"button\"\n variant=\"body2\"\n underline=\"hover\"\n onClick={() => props.onShowLink?.(row.url)}\n aria-label={`Show who followed ${row.url}`}\n sx={{ textAlign: 'left', wordBreak: 'break-all' }}\n >\n {row.url}\n </Link>\n ) : (\n row.url\n )}\n </TableCell>\n <TableCell align=\"right\">{count(row.clicks)}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.onShowLink ? 'Select a destination to see who followed it. ' : ''}\n Two links to the same page are counted as one destination, whatever\n tracking parameters follow the address.\n {links.truncated\n ? ` ${count(links.overflowClicks)} more clicks went to destinations past the ones listed.`\n : ''}\n </Typography>\n </Stack>\n ) : null}\n </Stack>\n </CardContent>\n </Card>\n )\n}\n"],"names":["Alert","ButtonBase","Card","CardContent","Divider","Link","Stack","TableBody","TableCell","TableHead","TableRow","Tooltip","Typography","ScrollTable","outreachSequenceLinkReport","outreachSequenceReport","formatOutreachTime","OutreachLoadProblem","OutreachFigure","props","body","variant","component","color","onClick","undefined","value","label","hint","spacing","sx","minWidth","title","action","aria-label","display","flexDirection","alignItems","textAlign","borderRadius","px","mx","bgcolor","count","toLocaleString","rateText","rate","toFixed","numerator","denominator","denominatorLabel","OutreachSequenceReportCard","report","sequence","stats","settings","trackClicks","countOpens","clickRate","rates","click","openRate","open","links","status","data","direction","flexWrap","rowGap","sent","people","openTracked","uniqueOpens","opens","machineOpens","proxyOpens","uniqueClicks","clicks","onShowClickers","machineClicks","lastOpenAtMs","timeZone","lastClickAtMs","caveats","map","entry","severity","message","id","what","rows","length","size","align","row","hover","Boolean","onShowLink","wordBreak","type","underline","url","truncated","overflowClicks"],"mappings":"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"}
@@ -67,6 +67,15 @@ export declare function rewriteOutreachBodyLinks(text: string, mint: (link: {
67
67
  * that reads triple.
68
68
  */
69
69
  export declare const OUTREACH_CLICK_HUMAN_DELAY_MS = 30000;
70
+ /**
71
+ * Agents that say what they are. Matched case-insensitively as substrings of
72
+ * the user agent, so a version bump does not un-match one.
73
+ *
74
+ * Absence from this list is not evidence of a person — most scanners
75
+ * impersonate a browser, which is what {@link OUTREACH_CLICK_HUMAN_DELAY_MS}
76
+ * is for.
77
+ */
78
+ export declare const OUTREACH_MACHINE_AGENTS: readonly string[];
70
79
  /** Why a click was read as a machine's, or `null` when it was a person's. */
71
80
  export type OutreachClickMachineReason = 'agent' | 'too_soon' | 'method';
72
81
  /** What one request to a tracking link was. */
@@ -179,7 +179,7 @@
179
179
  * Absence from this list is not evidence of a person — most scanners
180
180
  * impersonate a browser, which is what {@link OUTREACH_CLICK_HUMAN_DELAY_MS}
181
181
  * is for.
182
- */ const MACHINE_AGENTS = [
182
+ */ export const OUTREACH_MACHINE_AGENTS = [
183
183
  'bot',
184
184
  'crawler',
185
185
  'spider',
@@ -234,7 +234,7 @@
234
234
  if (String((_input_method = input.method) != null ? _input_method : '').toUpperCase() === 'HEAD') return machine('method');
235
235
  const agent = String((_input_userAgent = input.userAgent) != null ? _input_userAgent : '').trim().toLowerCase();
236
236
  if (!agent) return machine('agent');
237
- if (MACHINE_AGENTS.some((needle)=>agent.includes(needle))) return machine('agent');
237
+ if (OUTREACH_MACHINE_AGENTS.some((needle)=>agent.includes(needle))) return machine('agent');
238
238
  /*
239
239
  * A click that arrives BEFORE the send it belongs to is a clock disagreeing
240
240
  * with itself, not a time traveller, and it is read as a person's: the one
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/click-tracking.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 * CLICKS, IN A PLAIN-TEXT EMAIL (AGL-3239).\n *\n * A marketing campaign gets its opens and clicks from Resend, which rewrites\n * the links and injects a pixel into the HTML part before the message leaves.\n * A sequence has neither half of that: it goes out through the rep's own\n * Gmail, and it goes out as plain text on purpose.\n *\n * ## Why there is no open rate here, and never a zero one\n *\n * An open is measured by a pixel, a pixel is an `<img>`, and an `<img>` needs\n * an HTML part. Giving cold one-to-one email a multipart HTML body is the\n * deliverability posture this plugin was built to avoid, so opens are not\n * measured at all — and the report SAYS SO rather than rendering 0%, exactly\n * as `campaign-report.ts` withholds a rate whose denominator was never\n * recorded. A zero that could only ever have been zero measures our sending\n * code, not the recipient.\n *\n * A click needs no HTML. It needs the destination in the body to be a link of\n * ours that forwards to theirs, which is what this module writes.\n *\n * ## What it will and will not rewrite\n *\n * Only bare `http:`/`https:` URLs, and only in the STEP'S OWN BODY — the\n * rewrite runs on the rendered body before the footer is appended, so the\n * organization's legal name, its postal address and the way out are never\n * touched. `mailto:` is left alone: the opt-out address is the one link in a\n * cold email that must not route through us.\n *==========================================*/\n\n/**\n * A URL as it appears in a plain-text body, with where it was found.\n *\n * `end` is exclusive, so `text.slice(start, end)` is exactly `url`.\n */\nexport interface OutreachBodyLink {\n url: string\n start: number\n end: number\n}\n\n/**\n * Trailing characters a sentence puts after a URL that are not part of it.\n *\n * A plain-text body is prose, so a link is routinely followed by a full stop,\n * a comma or a closing bracket. Taking those into the URL would rewrite a\n * destination nobody wrote and forward the recipient to a 404.\n */\nconst TRAILING_PUNCTUATION = /[.,;:!?'\"]+$/\n\n/**\n * A bare http(s) URL, up to the first whitespace or bracket that never\n * belongs in one.\n *\n * `(` and `)` are NOT excluded, because a real path can contain them — a\n * Wikipedia article is the everyday example — and {@link trimUrlEnd}\n * decides which trailing one is the URL's and which is the sentence's.\n */\nconst URL_PATTERN = /https?:\\/\\/[^\\s<>[\\]{}\"']+/gi\n\n/**\n * Trims what the sentence put after the URL, not what the URL contains.\n *\n * A closing bracket is the URL's when the URL opened one, and the\n * sentence's when it did not — which is the usual case, a link inside a\n * parenthetical. Counted rather than merely looked for, so a link that is\n * both parenthesised AND contains brackets loses exactly the outer one.\n */\nfunction trimUrlEnd(raw: string): string {\n let url = raw.replace(TRAILING_PUNCTUATION, '')\n while (url.endsWith(')')) {\n const opened = url.split('(').length - 1\n const closed = url.split(')').length - 1\n if (closed <= opened) break\n url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, '')\n }\n return url\n}\n\n/**\n * Every http(s) URL in a plain-text body, in the order they appear.\n *\n * Duplicates are returned as the separate occurrences they are: the same\n * destination written twice is two links to rewrite, and each gets its own\n * index so a click says which one was followed.\n */\nexport function outreachBodyLinks(text: string): OutreachBodyLink[] {\n const body = String(text ?? '')\n const links: OutreachBodyLink[] = []\n for (const match of body.matchAll(URL_PATTERN)) {\n const start = match.index ?? 0\n const url = trimUrlEnd(match[0])\n if (!url) continue\n let parsed: URL\n try {\n parsed = new URL(url)\n } catch {\n continue\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue\n links.push({ url, start, end: start + url.length })\n }\n return links\n}\n\n/** What {@link rewriteOutreachBodyLinks} produced. */\nexport interface OutreachRewrittenBody {\n text: string\n /**\n * The destinations that were rewritten, in the order their tracking links\n * were minted — the index a click token names.\n *\n * Empty when nothing was rewritten, which is what a body with no links, a\n * sequence with tracking off, and a minter that could not sign all read as.\n */\n links: string[]\n}\n\n/**\n * Rewrites every link in a plain-text body to a tracking link.\n *\n * @param text The rendered step body, footer NOT yet appended.\n * @param mint Answers the tracking URL for the link at `index`, or `null` to\n * leave that one alone. A minter that cannot sign returns `null` for every\n * link and the body goes out exactly as it was written — an email whose\n * links do not work is far worse than an email we cannot measure.\n */\nexport function rewriteOutreachBodyLinks(\n text: string,\n mint: (link: { url: string; index: number }) => string | null,\n): OutreachRewrittenBody {\n const body = String(text ?? '')\n const found = outreachBodyLinks(body)\n if (!found.length) return { text: body, links: [] }\n const links: string[] = []\n let out = ''\n let cursor = 0\n for (const link of found) {\n // The index a token carries is the index in `links`, which counts only\n // the links that were actually rewritten. A minter that skips one must\n // not shift the numbering of the ones after it.\n const tracked = mint({ url: link.url, index: links.length })\n out += body.slice(cursor, link.start)\n if (tracked) {\n out += tracked\n links.push(link.url)\n } else {\n out += link.url\n }\n cursor = link.end\n }\n return { text: out + body.slice(cursor), links }\n}\n\n/*==========================================\n * WHO CLICKED: A PERSON, OR A MACHINE.\n *\n * Every corporate mailbox now runs the links in an incoming email before the\n * recipient ever sees it — Microsoft Defender's Safe Links, Proofpoint,\n * Mimecast, Barracuda, and Gmail's own scanning. Each of those fetches is a\n * click by any definition a redirect handler can apply, and on cold B2B mail\n * they outnumber the human ones.\n *\n * Counting them is how a sequence reports a 60% click rate on an email\n * nobody read. So they are counted APART: `clicks` and `uniqueClicks` are\n * the human ones and carry the rate, `machineClicks` is kept beside them and\n * shown, because a number withheld silently is the other way to mislead.\n *\n * This is the same judgement `campaign-revenue.ts` records about Apple's Mail\n * Privacy Protection inflating opens — stated on the screen rather than\n * quietly corrected away.\n *==========================================*/\n\n/**\n * How soon after a send a click is a machine's however it identifies itself.\n *\n * A scanner fetches as the message is delivered. A person has to receive the\n * mail, notice it and read it, and thirty seconds is faster than anyone does\n * that on a cold email — while being long enough to stay wrong only in the\n * rarest case, which is a rate that reads a little low rather than a rate\n * that reads triple.\n */\nexport const OUTREACH_CLICK_HUMAN_DELAY_MS = 30_000\n\n/**\n * Agents that say what they are. Matched case-insensitively as substrings of\n * the user agent, so a version bump does not un-match one.\n *\n * Absence from this list is not evidence of a person — most scanners\n * impersonate a browser, which is what {@link OUTREACH_CLICK_HUMAN_DELAY_MS}\n * is for.\n */\nconst MACHINE_AGENTS = [\n 'bot',\n 'crawler',\n 'spider',\n 'slurp',\n 'preview',\n 'scanner',\n 'curl',\n 'wget',\n 'python-requests',\n 'java/',\n 'go-http-client',\n 'okhttp',\n 'headlesschrome',\n 'phantomjs',\n 'proofpoint',\n 'mimecast',\n 'barracuda',\n 'symantec',\n 'forcepoint',\n 'microsoft office',\n 'ms-office',\n 'bingpreview',\n 'skypeuripreview',\n 'google-safety',\n 'googleimageproxy',\n 'apache-httpclient',\n 'axios',\n 'node-fetch',\n 'urlresolver',\n 'safelinks',\n]\n\n/** Why a click was read as a machine's, or `null` when it was a person's. */\nexport type OutreachClickMachineReason = 'agent' | 'too_soon' | 'method'\n\n/** What one request to a tracking link was. */\nexport interface OutreachClickJudgement {\n /** Whether it counts toward the click rate. */\n human: boolean\n /** Why it does not; `null` when it does. */\n machineReason: OutreachClickMachineReason | null\n}\n\n/**\n * Whether one request to a tracking link is a person's.\n *\n * Read before the redirect is *recorded*, never before it is *answered*: a\n * machine is forwarded to the destination exactly as a person is, because a\n * scanner that gets a refusal reports the link as broken and the mail as\n * suspicious.\n *\n * @param input.method The HTTP method. A `HEAD` is a fetch of the headers\n * alone, which no mail client does for a person following a link.\n * @param input.userAgent The request's agent, `''` when it sent none — which\n * is itself a machine, since every browser sends one.\n * @param input.sinceSentMs Milliseconds between the send and this request,\n * or `null` when the send it belongs to could not be found.\n */\nexport function judgeOutreachClick(input: {\n method: string\n userAgent: string | null | undefined\n sinceSentMs: number | null\n}): OutreachClickJudgement {\n const machine = (machineReason: OutreachClickMachineReason): OutreachClickJudgement => ({\n human: false,\n machineReason,\n })\n if (String(input.method ?? '').toUpperCase() === 'HEAD') return machine('method')\n const agent = String(input.userAgent ?? '').trim().toLowerCase()\n if (!agent) return machine('agent')\n if (MACHINE_AGENTS.some((needle) => agent.includes(needle))) return machine('agent')\n /*\n * A click that arrives BEFORE the send it belongs to is a clock disagreeing\n * with itself, not a time traveller, and it is read as a person's: the one\n * thing worse than counting a scanner is refusing to count a real reply to\n * an email because two machines are a second apart.\n */\n if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_CLICK_HUMAN_DELAY_MS) {\n return machine('too_soon')\n }\n return { human: true, machineReason: null }\n}\n"],"names":["TRAILING_PUNCTUATION","URL_PATTERN","trimUrlEnd","raw","url","replace","endsWith","opened","split","length","closed","slice","outreachBodyLinks","text","body","String","links","match","matchAll","start","index","parsed","URL","protocol","push","end","rewriteOutreachBodyLinks","mint","found","out","cursor","link","tracked","OUTREACH_CLICK_HUMAN_DELAY_MS","MACHINE_AGENTS","judgeOutreachClick","input","machine","machineReason","human","method","toUpperCase","agent","userAgent","trim","toLowerCase","some","needle","includes","sinceSentMs"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/click-tracking.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 * CLICKS, IN A PLAIN-TEXT EMAIL (AGL-3239).\n *\n * A marketing campaign gets its opens and clicks from Resend, which rewrites\n * the links and injects a pixel into the HTML part before the message leaves.\n * A sequence has neither half of that: it goes out through the rep's own\n * Gmail, and it goes out as plain text on purpose.\n *\n * ## Why there is no open rate here, and never a zero one\n *\n * An open is measured by a pixel, a pixel is an `<img>`, and an `<img>` needs\n * an HTML part. Giving cold one-to-one email a multipart HTML body is the\n * deliverability posture this plugin was built to avoid, so opens are not\n * measured at all — and the report SAYS SO rather than rendering 0%, exactly\n * as `campaign-report.ts` withholds a rate whose denominator was never\n * recorded. A zero that could only ever have been zero measures our sending\n * code, not the recipient.\n *\n * A click needs no HTML. It needs the destination in the body to be a link of\n * ours that forwards to theirs, which is what this module writes.\n *\n * ## What it will and will not rewrite\n *\n * Only bare `http:`/`https:` URLs, and only in the STEP'S OWN BODY — the\n * rewrite runs on the rendered body before the footer is appended, so the\n * organization's legal name, its postal address and the way out are never\n * touched. `mailto:` is left alone: the opt-out address is the one link in a\n * cold email that must not route through us.\n *==========================================*/\n\n/**\n * A URL as it appears in a plain-text body, with where it was found.\n *\n * `end` is exclusive, so `text.slice(start, end)` is exactly `url`.\n */\nexport interface OutreachBodyLink {\n url: string\n start: number\n end: number\n}\n\n/**\n * Trailing characters a sentence puts after a URL that are not part of it.\n *\n * A plain-text body is prose, so a link is routinely followed by a full stop,\n * a comma or a closing bracket. Taking those into the URL would rewrite a\n * destination nobody wrote and forward the recipient to a 404.\n */\nconst TRAILING_PUNCTUATION = /[.,;:!?'\"]+$/\n\n/**\n * A bare http(s) URL, up to the first whitespace or bracket that never\n * belongs in one.\n *\n * `(` and `)` are NOT excluded, because a real path can contain them — a\n * Wikipedia article is the everyday example — and {@link trimUrlEnd}\n * decides which trailing one is the URL's and which is the sentence's.\n */\nconst URL_PATTERN = /https?:\\/\\/[^\\s<>[\\]{}\"']+/gi\n\n/**\n * Trims what the sentence put after the URL, not what the URL contains.\n *\n * A closing bracket is the URL's when the URL opened one, and the\n * sentence's when it did not — which is the usual case, a link inside a\n * parenthetical. Counted rather than merely looked for, so a link that is\n * both parenthesised AND contains brackets loses exactly the outer one.\n */\nfunction trimUrlEnd(raw: string): string {\n let url = raw.replace(TRAILING_PUNCTUATION, '')\n while (url.endsWith(')')) {\n const opened = url.split('(').length - 1\n const closed = url.split(')').length - 1\n if (closed <= opened) break\n url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, '')\n }\n return url\n}\n\n/**\n * Every http(s) URL in a plain-text body, in the order they appear.\n *\n * Duplicates are returned as the separate occurrences they are: the same\n * destination written twice is two links to rewrite, and each gets its own\n * index so a click says which one was followed.\n */\nexport function outreachBodyLinks(text: string): OutreachBodyLink[] {\n const body = String(text ?? '')\n const links: OutreachBodyLink[] = []\n for (const match of body.matchAll(URL_PATTERN)) {\n const start = match.index ?? 0\n const url = trimUrlEnd(match[0])\n if (!url) continue\n let parsed: URL\n try {\n parsed = new URL(url)\n } catch {\n continue\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue\n links.push({ url, start, end: start + url.length })\n }\n return links\n}\n\n/** What {@link rewriteOutreachBodyLinks} produced. */\nexport interface OutreachRewrittenBody {\n text: string\n /**\n * The destinations that were rewritten, in the order their tracking links\n * were minted — the index a click token names.\n *\n * Empty when nothing was rewritten, which is what a body with no links, a\n * sequence with tracking off, and a minter that could not sign all read as.\n */\n links: string[]\n}\n\n/**\n * Rewrites every link in a plain-text body to a tracking link.\n *\n * @param text The rendered step body, footer NOT yet appended.\n * @param mint Answers the tracking URL for the link at `index`, or `null` to\n * leave that one alone. A minter that cannot sign returns `null` for every\n * link and the body goes out exactly as it was written — an email whose\n * links do not work is far worse than an email we cannot measure.\n */\nexport function rewriteOutreachBodyLinks(\n text: string,\n mint: (link: { url: string; index: number }) => string | null,\n): OutreachRewrittenBody {\n const body = String(text ?? '')\n const found = outreachBodyLinks(body)\n if (!found.length) return { text: body, links: [] }\n const links: string[] = []\n let out = ''\n let cursor = 0\n for (const link of found) {\n // The index a token carries is the index in `links`, which counts only\n // the links that were actually rewritten. A minter that skips one must\n // not shift the numbering of the ones after it.\n const tracked = mint({ url: link.url, index: links.length })\n out += body.slice(cursor, link.start)\n if (tracked) {\n out += tracked\n links.push(link.url)\n } else {\n out += link.url\n }\n cursor = link.end\n }\n return { text: out + body.slice(cursor), links }\n}\n\n/*==========================================\n * WHO CLICKED: A PERSON, OR A MACHINE.\n *\n * Every corporate mailbox now runs the links in an incoming email before the\n * recipient ever sees it — Microsoft Defender's Safe Links, Proofpoint,\n * Mimecast, Barracuda, and Gmail's own scanning. Each of those fetches is a\n * click by any definition a redirect handler can apply, and on cold B2B mail\n * they outnumber the human ones.\n *\n * Counting them is how a sequence reports a 60% click rate on an email\n * nobody read. So they are counted APART: `clicks` and `uniqueClicks` are\n * the human ones and carry the rate, `machineClicks` is kept beside them and\n * shown, because a number withheld silently is the other way to mislead.\n *\n * This is the same judgement `campaign-revenue.ts` records about Apple's Mail\n * Privacy Protection inflating opens — stated on the screen rather than\n * quietly corrected away.\n *==========================================*/\n\n/**\n * How soon after a send a click is a machine's however it identifies itself.\n *\n * A scanner fetches as the message is delivered. A person has to receive the\n * mail, notice it and read it, and thirty seconds is faster than anyone does\n * that on a cold email — while being long enough to stay wrong only in the\n * rarest case, which is a rate that reads a little low rather than a rate\n * that reads triple.\n */\nexport const OUTREACH_CLICK_HUMAN_DELAY_MS = 30_000\n\n/**\n * Agents that say what they are. Matched case-insensitively as substrings of\n * the user agent, so a version bump does not un-match one.\n *\n * Absence from this list is not evidence of a person — most scanners\n * impersonate a browser, which is what {@link OUTREACH_CLICK_HUMAN_DELAY_MS}\n * is for.\n */\nexport const OUTREACH_MACHINE_AGENTS: readonly string[] = [\n 'bot',\n 'crawler',\n 'spider',\n 'slurp',\n 'preview',\n 'scanner',\n 'curl',\n 'wget',\n 'python-requests',\n 'java/',\n 'go-http-client',\n 'okhttp',\n 'headlesschrome',\n 'phantomjs',\n 'proofpoint',\n 'mimecast',\n 'barracuda',\n 'symantec',\n 'forcepoint',\n 'microsoft office',\n 'ms-office',\n 'bingpreview',\n 'skypeuripreview',\n 'google-safety',\n 'googleimageproxy',\n 'apache-httpclient',\n 'axios',\n 'node-fetch',\n 'urlresolver',\n 'safelinks',\n]\n\n/** Why a click was read as a machine's, or `null` when it was a person's. */\nexport type OutreachClickMachineReason = 'agent' | 'too_soon' | 'method'\n\n/** What one request to a tracking link was. */\nexport interface OutreachClickJudgement {\n /** Whether it counts toward the click rate. */\n human: boolean\n /** Why it does not; `null` when it does. */\n machineReason: OutreachClickMachineReason | null\n}\n\n/**\n * Whether one request to a tracking link is a person's.\n *\n * Read before the redirect is *recorded*, never before it is *answered*: a\n * machine is forwarded to the destination exactly as a person is, because a\n * scanner that gets a refusal reports the link as broken and the mail as\n * suspicious.\n *\n * @param input.method The HTTP method. A `HEAD` is a fetch of the headers\n * alone, which no mail client does for a person following a link.\n * @param input.userAgent The request's agent, `''` when it sent none — which\n * is itself a machine, since every browser sends one.\n * @param input.sinceSentMs Milliseconds between the send and this request,\n * or `null` when the send it belongs to could not be found.\n */\nexport function judgeOutreachClick(input: {\n method: string\n userAgent: string | null | undefined\n sinceSentMs: number | null\n}): OutreachClickJudgement {\n const machine = (machineReason: OutreachClickMachineReason): OutreachClickJudgement => ({\n human: false,\n machineReason,\n })\n if (String(input.method ?? '').toUpperCase() === 'HEAD') return machine('method')\n const agent = String(input.userAgent ?? '').trim().toLowerCase()\n if (!agent) return machine('agent')\n if (OUTREACH_MACHINE_AGENTS.some((needle) => agent.includes(needle))) return machine('agent')\n /*\n * A click that arrives BEFORE the send it belongs to is a clock disagreeing\n * with itself, not a time traveller, and it is read as a person's: the one\n * thing worse than counting a scanner is refusing to count a real reply to\n * an email because two machines are a second apart.\n */\n if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_CLICK_HUMAN_DELAY_MS) {\n return machine('too_soon')\n }\n return { human: true, machineReason: null }\n}\n"],"names":["TRAILING_PUNCTUATION","URL_PATTERN","trimUrlEnd","raw","url","replace","endsWith","opened","split","length","closed","slice","outreachBodyLinks","text","body","String","links","match","matchAll","start","index","parsed","URL","protocol","push","end","rewriteOutreachBodyLinks","mint","found","out","cursor","link","tracked","OUTREACH_CLICK_HUMAN_DELAY_MS","OUTREACH_MACHINE_AGENTS","judgeOutreachClick","input","machine","machineReason","human","method","toUpperCase","agent","userAgent","trim","toLowerCase","some","needle","includes","sinceSentMs"],"mappings":"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"}
@@ -35,6 +35,12 @@ export interface ComposedOutreachEmail {
35
35
  subject: string;
36
36
  /** The plain-text body, footer included. */
37
37
  text: string;
38
+ /**
39
+ * The same body as HTML with a tracking image (AGL-3395), sent beside
40
+ * `text` as `multipart/alternative`. Absent on every email of a sequence
41
+ * that does not count opens, which goes out as plain text alone.
42
+ */
43
+ html?: string;
38
44
  /** The provider thread a reply step is sent into. */
39
45
  threadId?: string;
40
46
  /** The `Message-ID` this email answers, angle brackets included. */
@@ -92,6 +98,13 @@ export interface ComposeOutreachEmailInput {
92
98
  url: string;
93
99
  index: number;
94
100
  }) => string | null) | null;
101
+ /**
102
+ * The tracking image's address, minted by the runtime for a sequence that
103
+ * counts opens (AGL-3395). Given, the email gains an HTML part — the same
104
+ * text, footer included, and the image; absent or `null`, it stays plain
105
+ * text, which is what a preview and every other caller pass.
106
+ */
107
+ openPixelUrl?: string | null;
95
108
  }
96
109
  export interface OutreachComposeResult {
97
110
  email: ComposedOutreachEmail | null;
@@ -63,6 +63,7 @@ import { CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
63
63
  import { resolveCrmMergeFields } from "@aglyn/aglyn/app-utils/crm-email-templates";
64
64
  import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
65
65
  import { rewriteOutreachBodyLinks } from "./click-tracking.js";
66
+ import { outreachOpenTrackedHtml } from "./open-tracking.js";
66
67
  import { normalizeOutreachPersonalLine } from "./gates.js";
67
68
  import { isInThreadEmailStep, OUTREACH_PERSONAL_LINE_FIELD, outreachSubjectHasReplyPrefix } from "./sequence-validation.js";
68
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.';
@@ -236,6 +237,9 @@ const refuse = (code, message)=>({
236
237
  };
237
238
  email.trackedLinks = tracked.links;
238
239
  email.text = `${tracked.text}\n\n${footer}`;
240
+ // Built from the finished text, so the HTML part says exactly what the
241
+ // plain-text part says — the footer and any rewritten link included.
242
+ if (input.openPixelUrl) email.html = outreachOpenTrackedHtml(email.text, input.openPixelUrl);
239
243
  const url = unsubscribeUrl(input.listUnsubscribeUrl);
240
244
  if (url === null) {
241
245
  return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.');