@aglyn/plugins-outreach 1.0.0-beta.275 → 1.0.0-beta.277

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,7 +108,7 @@ console alone — each run opens a rep's sealed grant:
108
108
  | -- | -- |
109
109
  | `runtime/send-job` | `outreach-send`: due enrollments claimed in a transaction that reserves the mailbox's day, gates asked again, the email composed — with the `List-Unsubscribe` header when the sequence turns it on (AGL-3296), and its links' `outreachLinks` documents written first when it tracks clicks (AGL-3297) — and sent, the step recorded and filed on the contact's timeline; task steps file the rep's task |
110
110
  | `runtime/sync-job` | `outreach-sync`: replies, out-of-office answers, opt-outs and bounces read with `format=full` and classified by the engine; what the person wrote kept on the enrollment (AGL-4020); the mailbox's health evidence and its automatic pause |
111
- | `routes/reply-routes`, `components/reply-dialog` | **Reply in thread** (AGL-4020): `outreach/enrollments/thread` reads what the person wrote — from the mailbox, for a reply synced before replies were kept — and `outreach/enrollments/reply` sends a member's answer from the enrollment's mailbox in the same thread, kept as a `manual_reply` step record and on the CRM timeline, counted on `health.daily.<day>.manualReplies` and never against the cap |
111
+ | `routes/reply-routes`, `components/reply-dialog` | **Reply in thread** (AGL-4020): `outreach/enrollments/thread` reads what the person wrote — from the mailbox, for a reply synced before replies were kept — and `outreach/enrollments/reply` sends a member's answer from the enrollment's mailbox in the same thread, kept as a `manual_reply` step record and on the CRM timeline, counted on `health.daily.<day>.manualReplies` and never against the cap; on a sequence that tracks clicks its links go out as short links, each a click of the person's and never the sequence's (AGL-4022) |
112
112
  | `runtime/unsubscribe-link`, `runtime/unsubscribe-route` | the signed one-click link (`GET` and RFC 8058 `POST /api/outreach/unsubscribe`), a recipient link that outlives a paused rollout, and the `+unsubscribe` mailto |
113
113
  | `runtime/click-link`, `runtime/click-route` | click tracking: the short link every send carries (`GET /api/outreach/l/<id>`, resolved through the top-level `outreachLinks` document, AGL-3297) and the signed `GET /api/outreach/click?t=…` link older emails carry; both recipient links, redirect first, then the click counted once per person with scanners kept apart; on the mailbox domain's verified `links.` host as `https://links.<domain>/<id>` (AGL-3306), which the console middleware rewrites to the same route |
114
114
  | `routes/link-domain-routes`, `components/link-domains-card` | Sequences → Mailboxes → **Link domains** (AGL-3306): `outreach/link-domains` lists each mailbox domain's `links.` host and sets one up, checks it or removes it through `@aglyn/tenant-data-admin/server/tracking-hosts` — the domain driver attaches it, an HTTPS probe verifies it; set-up and removal are an owner's or admin's |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-outreach",
3
- "version": "1.0.0-beta.275",
3
+ "version": "1.0.0-beta.277",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,19 +25,19 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.275",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.275",
30
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.275",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.275",
32
- "@aglyn/shared-ui-next": "1.0.0-beta.275",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.275",
34
- "@aglyn/shared-util-email": "1.0.0-beta.275",
35
- "@aglyn/shared-util-http": "1.0.0-beta.275",
36
- "@aglyn/shared-util-timestamp": "1.0.0-beta.275",
37
- "@aglyn/shared-util-tools": "1.0.0-beta.275",
38
- "@aglyn/tenant-data-admin": "1.0.0-beta.275",
39
- "@aglyn/tenant-feature-instance": "1.0.0-beta.275",
40
- "@aglyn/tenant-runtime": "1.0.0-beta.275",
28
+ "@aglyn/aglyn": "1.0.0-beta.277",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.277",
30
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.277",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.277",
32
+ "@aglyn/shared-ui-next": "1.0.0-beta.277",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.277",
34
+ "@aglyn/shared-util-email": "1.0.0-beta.277",
35
+ "@aglyn/shared-util-http": "1.0.0-beta.277",
36
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.277",
37
+ "@aglyn/shared-util-tools": "1.0.0-beta.277",
38
+ "@aglyn/tenant-data-admin": "1.0.0-beta.277",
39
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.277",
40
+ "@aglyn/tenant-runtime": "1.0.0-beta.277",
41
41
  "@swc/helpers": "0.5.23"
42
42
  },
43
43
  "peerDependencies": {
@@ -27,7 +27,8 @@ import type { OutreachApi } from './use-outreach-api';
27
27
  * replies were kept — and says where the answer goes: from the enrollment's
28
28
  * own mailbox, to the person, `Re:` their subject. The member writes plain
29
29
  * text; Send puts it in the same thread in both mailboxes, with their message
30
- * quoted beneath. Whatever still refuses — the do-not-contact list, a paused
30
+ * quoted beneath — and, on a sequence that tracks clicks, the links they
31
+ * wrote as the same short links a step's carry (AGL-4022). Whatever still refuses — the do-not-contact list, a paused
31
32
  * or disconnected mailbox — is said before anything is typed, and Send waits.
32
33
  *
33
34
  * The answer's key is minted once per open: sending again after a lost
@@ -318,7 +318,7 @@ export function OutreachReplyDialog(props) {
318
318
  }),
319
319
  /*#__PURE__*/ _jsx(DialogContentText, {
320
320
  variant: "body2",
321
- children: `Sent as plain text from ${draft.from.address}, in the same thread, with their message quoted beneath. The sequence stays where it is.`
321
+ children: `Sent as plain text from ${draft.from.address}, in the same thread, with their message quoted beneath.${draft.tracksLinks ? ' Links you write go out as tracked short links, as in the sequence’s emails.' : ''} The sequence stays where it is.`
322
322
  })
323
323
  ]
324
324
  }) : null,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/reply-dialog.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\n'use client'\n\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Box,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogContentText,\n DialogTitle,\n Stack,\n TextField,\n Typography,\n useMediaQuery,\n useTheme,\n} from '@mui/material'\nimport { useEffect, useMemo, useState } from 'react'\nimport { OUTREACH_MANUAL_REPLY_BODY_MAX } from '../engine/manual-reply'\nimport type { OutreachEnrollmentReplyResponse, OutreachEnrollmentThreadResponse } from '../model/outreach-api'\nimport type { OutreachEnrollment } from '../model/outreach.types'\nimport { formatOutreachTime, OutreachLoading } from './outreach-ui'\nimport type { OutreachApi } from './use-outreach-api'\n\n/**\n * ANSWER A PERSON WHO WROTE BACK (AGL-4020): a replied enrollment's\n * \"Reply in thread\", on the Enrollments tab, the person's page, and the CRM\n * record of the person.\n *\n * The dialog reads the thread the moment it opens — what the person wrote,\n * kept on the enrollment or read from the mailbox when it was synced before\n * replies were kept — and says where the answer goes: from the enrollment's\n * own mailbox, to the person, `Re:` their subject. The member writes plain\n * text; Send puts it in the same thread in both mailboxes, with their message\n * quoted beneath. Whatever still refuses — the do-not-contact list, a paused\n * or disconnected mailbox — is said before anything is typed, and Send waits.\n *\n * The answer's key is minted once per open: sending again after a lost\n * answer from the server is the same answer, which the route recognises\n * rather than sending twice.\n */\n\nexport interface OutreachReplyDialogProps {\n /** The enrollment to answer, or `null` while closed. */\n enrollment: OutreachEnrollment | null\n api: Pick<OutreachApi, 'readEnrollmentThread' | 'replyToEnrollment'>\n /** The mailbox's zone, for the times shown. */\n timeZone?: string | null\n onClose(): void\n /** After the answer went. */\n onSent?(answer: OutreachEnrollmentReplyResponse): void\n}\n\ntype Stage =\n | { kind: 'reading' }\n | { kind: 'failed'; message: string }\n | { kind: 'ready'; thread: OutreachEnrollmentThreadResponse }\n\n/** One message of the conversation as the dialog shows it. */\ninterface ThreadLine {\n key: string\n atMs: number\n who: string\n subject: string\n body: string\n /** Theirs, or ours. */\n inbound: boolean\n}\n\n/**\n * The conversation, oldest first: what the person wrote, every answer a\n * member sent, and the sequence's own emails by their subject.\n */\nexport function outreachReplyThreadLines(\n enrollment: Pick<OutreachEnrollment, 'inboundMessages' | 'stepRecords'>,\n): ThreadLine[] {\n const lines: ThreadLine[] = []\n for (const message of enrollment.inboundMessages ?? []) {\n lines.push({\n key: `in-${message.id}`,\n atMs: message.atMs,\n who: message.fromName ? `${message.fromName} <${message.from}>` : message.from,\n subject: message.subject,\n body: message.body,\n inbound: true,\n })\n }\n ;(enrollment.stepRecords ?? []).forEach((record, index) => {\n if (record?.kind === 'manual_reply') {\n lines.push({\n key: `answer-${index}`,\n atMs: record.atMs,\n who: record.from ? `You, from ${record.from}` : 'You',\n subject: record.subject ?? '',\n body: record.body ?? '',\n inbound: false,\n })\n } else if (record?.kind === 'email') {\n lines.push({\n key: `step-${index}`,\n atMs: record.atMs,\n who: 'Sequence email',\n subject: record.subject ?? '',\n body: '',\n inbound: false,\n })\n }\n })\n return lines.sort((a, b) => a.atMs - b.atMs)\n}\n\n/** A fresh key for one answer. */\nfunction newReplyKey(): string {\n const crypto = globalThis.crypto as Crypto | undefined\n if (crypto?.randomUUID) return crypto.randomUUID()\n return `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`\n}\n\nexport function OutreachReplyDialog(props: OutreachReplyDialogProps) {\n const { enrollment, api, timeZone } = props\n const theme = useTheme()\n const narrow = useMediaQuery(theme.breakpoints.down('sm'))\n const { enqueueSnackbar } = useSnackbar()\n const [stage, setStage] = useState<Stage>({ kind: 'reading' })\n const [text, setText] = useState('')\n const [sending, setSending] = useState(false)\n const [problem, setProblem] = useState<string | null>(null)\n const enrollmentId = enrollment?.id ?? null\n // One key per opening: a resend after a lost answer is the same answer.\n const replyKey = useMemo(() => (enrollmentId ? newReplyKey() : ''), [enrollmentId])\n\n useEffect(() => {\n if (!enrollmentId) return undefined\n let cancelled = false\n setStage({ kind: 'reading' })\n setText('')\n setProblem(null)\n api.readEnrollmentThread(enrollmentId).then(\n (thread) => {\n if (!cancelled) setStage({ kind: 'ready', thread })\n },\n (error: unknown) => {\n if (!cancelled) setStage({ kind: 'failed', message: (error as Error).message })\n },\n )\n return () => {\n cancelled = true\n }\n }, [api, enrollmentId])\n\n const thread = stage.kind === 'ready' ? stage.thread : null\n const draft = thread?.draft ?? null\n const lines = thread ? outreachReplyThreadLines(thread.enrollment) : []\n const name = enrollment?.contactName || enrollment?.email || ''\n const title = name ? `Reply to ${name}` : 'Reply in thread'\n const canSend = Boolean(draft) && !thread?.refusal && Boolean(text.trim()) && !sending\n\n const send = async () => {\n if (!enrollmentId || !draft || !canSend) return\n setSending(true)\n setProblem(null)\n try {\n const answer = await api.replyToEnrollment({ enrollmentId, body: text, answerId: draft.answerId, replyKey })\n enqueueSnackbar(\n answer.alreadySent ? `That answer had already gone to ${answer.sentTo}.` : `Answer sent to ${answer.sentTo}.`,\n { variant: 'success' },\n )\n props.onSent?.(answer)\n props.onClose()\n } catch (error) {\n setProblem((error as Error).message)\n } finally {\n setSending(false)\n }\n }\n\n return (\n <Dialog\n open={Boolean(enrollment)}\n onClose={sending ? undefined : props.onClose}\n fullWidth\n maxWidth=\"md\"\n fullScreen={narrow}\n aria-label={title}\n >\n <DialogTitle>{title}</DialogTitle>\n <DialogContent dividers>\n <Stack spacing={2}>\n {stage.kind === 'reading' ? <OutreachLoading label=\"Reading their reply…\" /> : null}\n {stage.kind === 'failed' ? <Alert severity=\"warning\">{stage.message}</Alert> : null}\n {thread && !draft ? (\n <Alert severity=\"info\">\n Nothing they wrote could be read from the mailbox, so there is nothing to answer here yet. Their\n reply is in the mailbox that sent the sequence.\n </Alert>\n ) : null}\n {draft ? (\n <Box\n component=\"dl\"\n sx={{ m: 0, display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 2, rowGap: 0.5 }}\n >\n <Typography component=\"dt\" variant=\"body2\" color=\"text.secondary\">\n From\n </Typography>\n <Typography component=\"dd\" variant=\"body2\" sx={{ m: 0, wordBreak: 'break-word' }}>\n {draft.from.name ? `${draft.from.name} <${draft.from.address}>` : draft.from.address}\n </Typography>\n <Typography component=\"dt\" variant=\"body2\" color=\"text.secondary\">\n To\n </Typography>\n <Typography component=\"dd\" variant=\"body2\" sx={{ m: 0, wordBreak: 'break-word' }}>\n {draft.to}\n </Typography>\n <Typography component=\"dt\" variant=\"body2\" color=\"text.secondary\">\n Subject\n </Typography>\n <Typography component=\"dd\" variant=\"body2\" sx={{ m: 0, wordBreak: 'break-word' }}>\n {draft.subject}\n </Typography>\n </Box>\n ) : null}\n {lines.length ? (\n <Stack\n spacing={1.5}\n role=\"list\"\n aria-label=\"The conversation\"\n sx={{ maxHeight: 320, overflowY: 'auto', border: 1, borderColor: 'divider', borderRadius: 1, p: 1.5 }}\n >\n {lines.map((line) => (\n <Box\n key={line.key}\n role=\"listitem\"\n data-inbound={line.inbound}\n sx={{ pl: 1.5, borderLeft: 2, borderColor: line.inbound ? 'info.main' : 'divider' }}\n >\n <Stack\n direction={{ xs: 'column', sm: 'row' }}\n spacing={{ xs: 0, sm: 1 }}\n sx={{ justifyContent: 'space-between', alignItems: { sm: 'baseline' } }}\n >\n <Typography variant=\"body2\" sx={{ fontWeight: 500, wordBreak: 'break-word' }}>\n {line.who}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" sx={{ whiteSpace: 'nowrap' }}>\n {formatOutreachTime(line.atMs, timeZone)}\n </Typography>\n </Stack>\n {line.subject ? (\n <Typography variant=\"caption\" color=\"text.secondary\" component=\"div\">\n {`Subject: ${line.subject}`}\n </Typography>\n ) : null}\n {line.body ? (\n <Typography variant=\"body2\" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 0.5 }}>\n {line.body}\n </Typography>\n ) : null}\n </Box>\n ))}\n </Stack>\n ) : null}\n {thread?.refusal ? <Alert severity=\"warning\">{thread.refusal.message}</Alert> : null}\n {draft && !thread?.refusal ? (\n <>\n <TextField\n label=\"Your answer\"\n value={text}\n onChange={(event) => setText(event.target.value)}\n multiline\n minRows={6}\n fullWidth\n autoFocus\n disabled={sending}\n slotProps={{ htmlInput: { maxLength: OUTREACH_MANUAL_REPLY_BODY_MAX } }}\n />\n <DialogContentText variant=\"body2\">\n {`Sent as plain text from ${draft.from.address}, in the same thread, with their message quoted beneath. The sequence stays where it is.`}\n </DialogContentText>\n </>\n ) : null}\n {problem ? <Alert severity=\"error\">{problem}</Alert> : null}\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={props.onClose} disabled={sending}>\n Cancel\n </Button>\n <Button variant=\"contained\" onClick={() => void send()} disabled={!canSend}>\n {sending ? 'Sending…' : 'Send answer'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nOutreachReplyDialog.displayName = 'OutreachReplyDialog'\n\nexport default OutreachReplyDialog\n"],"names":["useSnackbar","Alert","Box","Button","Dialog","DialogActions","DialogContent","DialogContentText","DialogTitle","Stack","TextField","Typography","useMediaQuery","useTheme","useEffect","useMemo","useState","OUTREACH_MANUAL_REPLY_BODY_MAX","formatOutreachTime","OutreachLoading","outreachReplyThreadLines","enrollment","lines","message","inboundMessages","push","key","id","atMs","who","fromName","from","subject","body","inbound","stepRecords","forEach","record","index","kind","sort","a","b","newReplyKey","crypto","globalThis","randomUUID","Date","now","toString","Math","random","slice","OutreachReplyDialog","props","api","timeZone","theme","narrow","breakpoints","down","enqueueSnackbar","stage","setStage","text","setText","sending","setSending","problem","setProblem","enrollmentId","replyKey","undefined","cancelled","readEnrollmentThread","then","thread","error","draft","name","contactName","email","title","canSend","Boolean","refusal","trim","send","answer","replyToEnrollment","answerId","alreadySent","sentTo","variant","onSent","onClose","open","fullWidth","maxWidth","fullScreen","aria-label","dividers","spacing","label","severity","component","sx","m","display","gridTemplateColumns","columnGap","rowGap","color","wordBreak","address","to","length","role","maxHeight","overflowY","border","borderColor","borderRadius","p","map","line","data-inbound","pl","borderLeft","direction","xs","sm","justifyContent","alignItems","fontWeight","whiteSpace","mt","value","onChange","event","target","multiline","minRows","autoFocus","disabled","slotProps","htmlInput","maxLength","onClick","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/reply-dialog.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\n'use client'\n\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Box,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogContentText,\n DialogTitle,\n Stack,\n TextField,\n Typography,\n useMediaQuery,\n useTheme,\n} from '@mui/material'\nimport { useEffect, useMemo, useState } from 'react'\nimport { OUTREACH_MANUAL_REPLY_BODY_MAX } from '../engine/manual-reply'\nimport type { OutreachEnrollmentReplyResponse, OutreachEnrollmentThreadResponse } from '../model/outreach-api'\nimport type { OutreachEnrollment } from '../model/outreach.types'\nimport { formatOutreachTime, OutreachLoading } from './outreach-ui'\nimport type { OutreachApi } from './use-outreach-api'\n\n/**\n * ANSWER A PERSON WHO WROTE BACK (AGL-4020): a replied enrollment's\n * \"Reply in thread\", on the Enrollments tab, the person's page, and the CRM\n * record of the person.\n *\n * The dialog reads the thread the moment it opens — what the person wrote,\n * kept on the enrollment or read from the mailbox when it was synced before\n * replies were kept — and says where the answer goes: from the enrollment's\n * own mailbox, to the person, `Re:` their subject. The member writes plain\n * text; Send puts it in the same thread in both mailboxes, with their message\n * quoted beneath — and, on a sequence that tracks clicks, the links they\n * wrote as the same short links a step's carry (AGL-4022). Whatever still refuses — the do-not-contact list, a paused\n * or disconnected mailbox — is said before anything is typed, and Send waits.\n *\n * The answer's key is minted once per open: sending again after a lost\n * answer from the server is the same answer, which the route recognises\n * rather than sending twice.\n */\n\nexport interface OutreachReplyDialogProps {\n /** The enrollment to answer, or `null` while closed. */\n enrollment: OutreachEnrollment | null\n api: Pick<OutreachApi, 'readEnrollmentThread' | 'replyToEnrollment'>\n /** The mailbox's zone, for the times shown. */\n timeZone?: string | null\n onClose(): void\n /** After the answer went. */\n onSent?(answer: OutreachEnrollmentReplyResponse): void\n}\n\ntype Stage =\n | { kind: 'reading' }\n | { kind: 'failed'; message: string }\n | { kind: 'ready'; thread: OutreachEnrollmentThreadResponse }\n\n/** One message of the conversation as the dialog shows it. */\ninterface ThreadLine {\n key: string\n atMs: number\n who: string\n subject: string\n body: string\n /** Theirs, or ours. */\n inbound: boolean\n}\n\n/**\n * The conversation, oldest first: what the person wrote, every answer a\n * member sent, and the sequence's own emails by their subject.\n */\nexport function outreachReplyThreadLines(\n enrollment: Pick<OutreachEnrollment, 'inboundMessages' | 'stepRecords'>,\n): ThreadLine[] {\n const lines: ThreadLine[] = []\n for (const message of enrollment.inboundMessages ?? []) {\n lines.push({\n key: `in-${message.id}`,\n atMs: message.atMs,\n who: message.fromName ? `${message.fromName} <${message.from}>` : message.from,\n subject: message.subject,\n body: message.body,\n inbound: true,\n })\n }\n ;(enrollment.stepRecords ?? []).forEach((record, index) => {\n if (record?.kind === 'manual_reply') {\n lines.push({\n key: `answer-${index}`,\n atMs: record.atMs,\n who: record.from ? `You, from ${record.from}` : 'You',\n subject: record.subject ?? '',\n body: record.body ?? '',\n inbound: false,\n })\n } else if (record?.kind === 'email') {\n lines.push({\n key: `step-${index}`,\n atMs: record.atMs,\n who: 'Sequence email',\n subject: record.subject ?? '',\n body: '',\n inbound: false,\n })\n }\n })\n return lines.sort((a, b) => a.atMs - b.atMs)\n}\n\n/** A fresh key for one answer. */\nfunction newReplyKey(): string {\n const crypto = globalThis.crypto as Crypto | undefined\n if (crypto?.randomUUID) return crypto.randomUUID()\n return `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`\n}\n\nexport function OutreachReplyDialog(props: OutreachReplyDialogProps) {\n const { enrollment, api, timeZone } = props\n const theme = useTheme()\n const narrow = useMediaQuery(theme.breakpoints.down('sm'))\n const { enqueueSnackbar } = useSnackbar()\n const [stage, setStage] = useState<Stage>({ kind: 'reading' })\n const [text, setText] = useState('')\n const [sending, setSending] = useState(false)\n const [problem, setProblem] = useState<string | null>(null)\n const enrollmentId = enrollment?.id ?? null\n // One key per opening: a resend after a lost answer is the same answer.\n const replyKey = useMemo(() => (enrollmentId ? newReplyKey() : ''), [enrollmentId])\n\n useEffect(() => {\n if (!enrollmentId) return undefined\n let cancelled = false\n setStage({ kind: 'reading' })\n setText('')\n setProblem(null)\n api.readEnrollmentThread(enrollmentId).then(\n (thread) => {\n if (!cancelled) setStage({ kind: 'ready', thread })\n },\n (error: unknown) => {\n if (!cancelled) setStage({ kind: 'failed', message: (error as Error).message })\n },\n )\n return () => {\n cancelled = true\n }\n }, [api, enrollmentId])\n\n const thread = stage.kind === 'ready' ? stage.thread : null\n const draft = thread?.draft ?? null\n const lines = thread ? outreachReplyThreadLines(thread.enrollment) : []\n const name = enrollment?.contactName || enrollment?.email || ''\n const title = name ? `Reply to ${name}` : 'Reply in thread'\n const canSend = Boolean(draft) && !thread?.refusal && Boolean(text.trim()) && !sending\n\n const send = async () => {\n if (!enrollmentId || !draft || !canSend) return\n setSending(true)\n setProblem(null)\n try {\n const answer = await api.replyToEnrollment({ enrollmentId, body: text, answerId: draft.answerId, replyKey })\n enqueueSnackbar(\n answer.alreadySent ? `That answer had already gone to ${answer.sentTo}.` : `Answer sent to ${answer.sentTo}.`,\n { variant: 'success' },\n )\n props.onSent?.(answer)\n props.onClose()\n } catch (error) {\n setProblem((error as Error).message)\n } finally {\n setSending(false)\n }\n }\n\n return (\n <Dialog\n open={Boolean(enrollment)}\n onClose={sending ? undefined : props.onClose}\n fullWidth\n maxWidth=\"md\"\n fullScreen={narrow}\n aria-label={title}\n >\n <DialogTitle>{title}</DialogTitle>\n <DialogContent dividers>\n <Stack spacing={2}>\n {stage.kind === 'reading' ? <OutreachLoading label=\"Reading their reply…\" /> : null}\n {stage.kind === 'failed' ? <Alert severity=\"warning\">{stage.message}</Alert> : null}\n {thread && !draft ? (\n <Alert severity=\"info\">\n Nothing they wrote could be read from the mailbox, so there is nothing to answer here yet. Their\n reply is in the mailbox that sent the sequence.\n </Alert>\n ) : null}\n {draft ? (\n <Box\n component=\"dl\"\n sx={{ m: 0, display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 2, rowGap: 0.5 }}\n >\n <Typography component=\"dt\" variant=\"body2\" color=\"text.secondary\">\n From\n </Typography>\n <Typography component=\"dd\" variant=\"body2\" sx={{ m: 0, wordBreak: 'break-word' }}>\n {draft.from.name ? `${draft.from.name} <${draft.from.address}>` : draft.from.address}\n </Typography>\n <Typography component=\"dt\" variant=\"body2\" color=\"text.secondary\">\n To\n </Typography>\n <Typography component=\"dd\" variant=\"body2\" sx={{ m: 0, wordBreak: 'break-word' }}>\n {draft.to}\n </Typography>\n <Typography component=\"dt\" variant=\"body2\" color=\"text.secondary\">\n Subject\n </Typography>\n <Typography component=\"dd\" variant=\"body2\" sx={{ m: 0, wordBreak: 'break-word' }}>\n {draft.subject}\n </Typography>\n </Box>\n ) : null}\n {lines.length ? (\n <Stack\n spacing={1.5}\n role=\"list\"\n aria-label=\"The conversation\"\n sx={{ maxHeight: 320, overflowY: 'auto', border: 1, borderColor: 'divider', borderRadius: 1, p: 1.5 }}\n >\n {lines.map((line) => (\n <Box\n key={line.key}\n role=\"listitem\"\n data-inbound={line.inbound}\n sx={{ pl: 1.5, borderLeft: 2, borderColor: line.inbound ? 'info.main' : 'divider' }}\n >\n <Stack\n direction={{ xs: 'column', sm: 'row' }}\n spacing={{ xs: 0, sm: 1 }}\n sx={{ justifyContent: 'space-between', alignItems: { sm: 'baseline' } }}\n >\n <Typography variant=\"body2\" sx={{ fontWeight: 500, wordBreak: 'break-word' }}>\n {line.who}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" sx={{ whiteSpace: 'nowrap' }}>\n {formatOutreachTime(line.atMs, timeZone)}\n </Typography>\n </Stack>\n {line.subject ? (\n <Typography variant=\"caption\" color=\"text.secondary\" component=\"div\">\n {`Subject: ${line.subject}`}\n </Typography>\n ) : null}\n {line.body ? (\n <Typography variant=\"body2\" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 0.5 }}>\n {line.body}\n </Typography>\n ) : null}\n </Box>\n ))}\n </Stack>\n ) : null}\n {thread?.refusal ? <Alert severity=\"warning\">{thread.refusal.message}</Alert> : null}\n {draft && !thread?.refusal ? (\n <>\n <TextField\n label=\"Your answer\"\n value={text}\n onChange={(event) => setText(event.target.value)}\n multiline\n minRows={6}\n fullWidth\n autoFocus\n disabled={sending}\n slotProps={{ htmlInput: { maxLength: OUTREACH_MANUAL_REPLY_BODY_MAX } }}\n />\n <DialogContentText variant=\"body2\">\n {`Sent as plain text from ${draft.from.address}, in the same thread, with their message quoted beneath.${\n draft.tracksLinks ? ' Links you write go out as tracked short links, as in the sequence’s emails.' : ''\n } The sequence stays where it is.`}\n </DialogContentText>\n </>\n ) : null}\n {problem ? <Alert severity=\"error\">{problem}</Alert> : null}\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={props.onClose} disabled={sending}>\n Cancel\n </Button>\n <Button variant=\"contained\" onClick={() => void send()} disabled={!canSend}>\n {sending ? 'Sending…' : 'Send answer'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nOutreachReplyDialog.displayName = 'OutreachReplyDialog'\n\nexport default OutreachReplyDialog\n"],"names":["useSnackbar","Alert","Box","Button","Dialog","DialogActions","DialogContent","DialogContentText","DialogTitle","Stack","TextField","Typography","useMediaQuery","useTheme","useEffect","useMemo","useState","OUTREACH_MANUAL_REPLY_BODY_MAX","formatOutreachTime","OutreachLoading","outreachReplyThreadLines","enrollment","lines","message","inboundMessages","push","key","id","atMs","who","fromName","from","subject","body","inbound","stepRecords","forEach","record","index","kind","sort","a","b","newReplyKey","crypto","globalThis","randomUUID","Date","now","toString","Math","random","slice","OutreachReplyDialog","props","api","timeZone","theme","narrow","breakpoints","down","enqueueSnackbar","stage","setStage","text","setText","sending","setSending","problem","setProblem","enrollmentId","replyKey","undefined","cancelled","readEnrollmentThread","then","thread","error","draft","name","contactName","email","title","canSend","Boolean","refusal","trim","send","answer","replyToEnrollment","answerId","alreadySent","sentTo","variant","onSent","onClose","open","fullWidth","maxWidth","fullScreen","aria-label","dividers","spacing","label","severity","component","sx","m","display","gridTemplateColumns","columnGap","rowGap","color","wordBreak","address","to","length","role","maxHeight","overflowY","border","borderColor","borderRadius","p","map","line","data-inbound","pl","borderLeft","direction","xs","sm","justifyContent","alignItems","fontWeight","whiteSpace","mt","value","onChange","event","target","multiline","minRows","autoFocus","disabled","slotProps","htmlInput","maxLength","tracksLinks","onClick","displayName"],"mappings":"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"}
@@ -61,8 +61,13 @@ export interface OutreachManualReply {
61
61
  subject: string;
62
62
  /** The whole text sent: the member's words, then the quote. */
63
63
  text: string;
64
- /** The member's words alone, bounded, as the records keep them. */
64
+ /** The member's words alone, as sent — tracked links in place — bounded, as the records keep them. */
65
65
  body: string;
66
+ /**
67
+ * The destinations the minter rewrote, in the order a short link's
68
+ * `linkIndex` names them (AGL-4022). Empty when nothing was rewritten.
69
+ */
70
+ trackedLinks: string[];
66
71
  /** The provider thread it is filed into; `null` lets the provider thread it by its headers. */
67
72
  threadId: string | null;
68
73
  /** The answered message's `Message-ID`; `null` when it carried none. */
@@ -84,6 +89,15 @@ export declare function composeOutreachManualReply(input: {
84
89
  body: unknown;
85
90
  /** The mailbox's zone, for the quote's time. */
86
91
  timeZone: string | null | undefined;
92
+ /**
93
+ * Mints the short link for the link at `index` in the member's words, or
94
+ * `null` to leave it as written (AGL-4022). Absent — a sequence that does
95
+ * not track clicks — sends every link as written.
96
+ */
97
+ rewriteLink?: ((link: {
98
+ url: string;
99
+ index: number;
100
+ }) => string | null) | null;
87
101
  }): OutreachManualReplyCompose;
88
102
  export type OutreachManualReplyRefusalCode = 'opted-out' | 'bounced' | 'do-not-contact' | 'unchecked' | 'mailbox-unavailable' | 'permission';
89
103
  export interface OutreachManualReplyRefusal {
@@ -42,6 +42,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
42
42
  * beneath. No footer and no `List-Unsubscribe`: the person wrote first, and
43
43
  * this is the answer a person would type in their own mail client.
44
44
  *
45
+ * On a sequence that tracks link clicks, the links in the member's words go
46
+ * out as the same short links a step's do (AGL-4022), through the caller's
47
+ * minter — never the links in the quoted message, which are theirs.
48
+ *
45
49
  * ## What still refuses
46
50
  *
47
51
  * {@link outreachManualReplyRefusal}: an address or a domain on the
@@ -54,6 +58,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
54
58
  import { CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
55
59
  import { emailAddressOf, emailExcerpt, threadSubject } from "@aglyn/aglyn/app-utils/email-text";
56
60
  import { OUTREACH_INBOUND_KINDS, OUTREACH_INBOUND_MESSAGES_MAX } from "../model/outreach.types.js";
61
+ import { rewriteOutreachBodyLinks } from "./click-tracking.js";
57
62
  import { outreachMessageIdHeader } from "./compose.js";
58
63
  import { outreachEmailDomain } from "./do-not-contact-domain.js";
59
64
  import { classifyOutreachMessage } from "./thread-classification.js";
@@ -218,12 +223,17 @@ import { outreachHeader } from "./thread-message.js";
218
223
  }
219
224
  };
220
225
  }
226
+ const rewritten = input.rewriteLink ? rewriteOutreachBodyLinks(words, input.rewriteLink) : {
227
+ text: words,
228
+ links: []
229
+ };
221
230
  return {
222
231
  reply: {
223
232
  to,
224
233
  subject: outreachReplySubject(input.answering, input.enrollment.threadSubject),
225
- text: `${words}\n\n${outreachReplyQuote(input.answering, input.timeZone)}`,
226
- body: words.slice(0, OUTREACH_MANUAL_REPLY_STORED_MAX),
234
+ text: `${rewritten.text}\n\n${outreachReplyQuote(input.answering, input.timeZone)}`,
235
+ body: rewritten.text.slice(0, OUTREACH_MANUAL_REPLY_STORED_MAX),
236
+ trackedLinks: rewritten.links,
227
237
  threadId: input.answering.threadId,
228
238
  inReplyTo: input.answering.messageId,
229
239
  references: outreachReplyReferences((_input_enrollment_messageIds = input.enrollment.messageIds) != null ? _input_enrollment_messageIds : [], input.answering)
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/manual-reply.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 * ANSWERING A PERSON WHO WROTE BACK (AGL-4020).\n *\n * A person who answers a sequence email is answered from the same mailbox,\n * in the same thread, by a member: the reply is a one-to-one email, never a\n * step of the sequence. This module is the engine's half of it — what the\n * person wrote, as the enrollment keeps it, and the answer, as the\n * transport sends it — and decides nothing about storage or delivery.\n *\n * ## What the person wrote\n *\n * {@link outreachInboundMessagesOf} keeps the messages of a thread the\n * classifier reads as theirs: a reply, an automatic answer, an opt-out. Each\n * carries its sender, its time, its `Message-ID` and `References` (what an\n * answer threads by), and its text with the quoted history trimmed, the way\n * the record's timeline keeps it (`emailExcerpt`).\n *\n * ## The answer\n *\n * {@link composeOutreachManualReply} writes it as every mail client writes a\n * reply: `Re:` the subject of the message it answers, `In-Reply-To` that\n * message's `Message-ID`, and `References` the whole chain — the sequence's\n * own sends, what the person's message already named, and the message\n * itself — so it lands in the same thread in the person's mailbox and in\n * ours. Plain text, the member's words first and the message answered quoted\n * beneath. No footer and no `List-Unsubscribe`: the person wrote first, and\n * this is the answer a person would type in their own mail client.\n *\n * ## What still refuses\n *\n * {@link outreachManualReplyRefusal}: an address or a domain on the\n * do-not-contact list, a person who opted out or whose address bounced, and\n * a mailbox that is paused, waiting to be reconnected, or gone. The cold\n * rules — the allowed countries, the company-domain rule, the personal line,\n * the daily cap — do not apply to someone who wrote first, and are not\n * asked.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { emailAddressOf, emailExcerpt, threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport {\n OUTREACH_INBOUND_KINDS,\n OUTREACH_INBOUND_MESSAGES_MAX,\n type OutreachEnrollment,\n type OutreachInboundKind,\n type OutreachInboundMessage,\n type OutreachMailbox,\n} from '../model/outreach.types'\nimport { outreachMessageIdHeader } from './compose'\nimport { outreachEmailDomain } from './do-not-contact-domain'\nimport { classifyOutreachMessage, type OutreachMessageContext } from './thread-classification'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\n/** The most a member may write in one answer. */\nexport const OUTREACH_MANUAL_REPLY_BODY_MAX = 10_000\n\n/** The most of an answer the enrollment and the record keep: the timeline's excerpt length. */\nexport const OUTREACH_MANUAL_REPLY_STORED_MAX = 4_000\n\n/** The most `Message-ID`s one answer's `References` names: the first, and the latest. */\nconst REFERENCES_MAX = 20\n\n/** The display name in a `From` value — `\"Ada Lovelace\" <ada@example.com>` answers `Ada Lovelace` — or `''`. */\nexport function outreachDisplayNameOf(value: unknown): string {\n const raw = String(value ?? '').trim()\n const angled = raw.lastIndexOf('<')\n if (angled <= 0) return ''\n return raw\n .slice(0, angled)\n .trim()\n .replace(/^\"(.*)\"$/, '$1')\n .replace(/\\\\(.)/g, '$1')\n .trim()\n}\n\n/** Every `Message-ID` a `References` or `In-Reply-To` value names, in header form, in order, once each. */\nexport function outreachMessageIdList(value: unknown): string[] {\n const found: string[] = []\n for (const match of String(value ?? '').matchAll(/<[^<>\\s]+>/g)) {\n const id = outreachMessageIdHeader(match[0])\n if (id && !found.includes(id)) found.push(id)\n }\n return found\n}\n\n/** A message's text as the record keeps it: the text part, else the HTML's words, else the preview. */\nfunction bodyOf(message: OutreachThreadMessage): string {\n return emailExcerpt(message.textBody ?? '', message.htmlBody ?? '') || emailExcerpt(message.snippet ?? '')\n}\n\n/**\n * The person's own messages among a thread's — see the module note — oldest\n * first. A message with no sender address is left out: it could be neither\n * shown as theirs nor answered.\n */\nexport function outreachInboundMessagesOf(\n messages: readonly OutreachThreadMessage[],\n context: OutreachMessageContext,\n): OutreachInboundMessage[] {\n const kept: OutreachInboundMessage[] = []\n for (const message of messages) {\n if (!message?.id) continue\n const classification = classifyOutreachMessage(message, context)\n if (!(OUTREACH_INBOUND_KINDS as readonly string[]).includes(classification.kind)) continue\n const from = emailAddressOf(message.from)\n if (!from) continue\n const replyTo = emailAddressOf(outreachHeader(message, 'Reply-To'))\n kept.push({\n id: message.id,\n threadId: message.threadId ?? null,\n messageId: outreachMessageIdHeader(outreachHeader(message, 'Message-ID')),\n references: outreachMessageIdList(outreachHeader(message, 'References')),\n from,\n fromName: outreachDisplayNameOf(message.from),\n replyTo: replyTo && replyTo !== from ? replyTo : null,\n subject: String(message.subject ?? '').replace(/\\s+/g, ' ').trim(),\n atMs: classification.atMs,\n kind: classification.kind as OutreachInboundKind,\n body: bodyOf(message),\n })\n }\n return kept.sort((a, b) => a.atMs - b.atMs)\n}\n\n/**\n * What an enrollment keeps once more of the person's messages are read: one\n * entry per message — a re-read replaces the earlier copy — oldest first,\n * the last {@link OUTREACH_INBOUND_MESSAGES_MAX}.\n */\nexport function mergeOutreachInboundMessages(\n stored: readonly OutreachInboundMessage[] | null | undefined,\n found: readonly OutreachInboundMessage[],\n): OutreachInboundMessage[] {\n const byId = new Map<string, OutreachInboundMessage>()\n for (const message of [...(stored ?? []), ...found]) byId.set(message.id, message)\n return [...byId.values()].sort((a, b) => a.atMs - b.atMs).slice(-OUTREACH_INBOUND_MESSAGES_MAX)\n}\n\n/**\n * The message an answer replies to: the one named, when the enrollment\n * kept it, else the newest the person wrote. `null` when they wrote nothing\n * the enrollment kept.\n */\nexport function outreachMessageToAnswer(\n inbound: readonly OutreachInboundMessage[] | null | undefined,\n id?: string | null,\n): OutreachInboundMessage | null {\n const messages = [...(inbound ?? [])].sort((a, b) => a.atMs - b.atMs)\n if (id) return messages.find((message) => message.id === id) ?? null\n return messages.at(-1) ?? null\n}\n\n/** Whether a member may answer this enrollment at all: the person wrote something it kept, or a reply stopped it. */\nexport function outreachEnrollmentHasInbound(\n enrollment: Pick<OutreachEnrollment, 'inboundMessages' | 'stopReason' | 'status'>,\n): boolean {\n return Boolean(enrollment.inboundMessages?.length) || enrollment.stopReason === 'reply' || enrollment.status === 'replied'\n}\n\n/**\n * The answer's `References` — see the module note: the sequence's own sends,\n * then what the answered message named, then the message itself, each once.\n * A long chain keeps its first id and its latest, as RFC 5322 §3.6.4 allows.\n */\nexport function outreachReplyReferences(\n sentMessageIds: readonly string[],\n answering: Pick<OutreachInboundMessage, 'references' | 'messageId'>,\n): string[] {\n const chain: string[] = []\n for (const id of [...sentMessageIds, ...answering.references, answering.messageId]) {\n const header = outreachMessageIdHeader(id)\n if (header && !chain.includes(header)) chain.push(header)\n }\n return chain.length > REFERENCES_MAX ? [chain[0], ...chain.slice(-(REFERENCES_MAX - 1))] : chain\n}\n\n/** `Re:` the subject of the message answered, else of the thread the sequence started. */\nexport function outreachReplySubject(\n answering: Pick<OutreachInboundMessage, 'subject'>,\n enrollmentThreadSubject: string | null | undefined,\n): string {\n const base = threadSubject(answering.subject) || threadSubject(enrollmentThreadSubject)\n return (base ? `Re: ${base}` : 'Re:').slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\n/** \"Tue, Oct 21, 2026, 8:02 AM\", in the mailbox's zone. */\nfunction quotedTime(atMs: number, timeZone: string | null | undefined): string {\n const options: Intl.DateTimeFormatOptions = {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n }\n // Plain spaces: newer ICU puts a narrow no-break space before \"AM\".\n const format = (zone: string) =>\n new Intl.DateTimeFormat('en-US', { ...options, timeZone: zone }).format(atMs).replace(/[\\u00a0\\u202f]/g, ' ')\n try {\n return format(timeZone || 'UTC')\n } catch {\n return format('UTC')\n }\n}\n\n/** The answered message beneath the answer, quoted the way mail clients quote: `On …, … wrote:` and `> ` lines. */\nexport function outreachReplyQuote(\n answering: Pick<OutreachInboundMessage, 'atMs' | 'from' | 'fromName' | 'body'>,\n timeZone: string | null | undefined,\n): string {\n const who = answering.fromName ? `${answering.fromName} <${answering.from}>` : answering.from\n const lines = answering.body.split('\\n').map((line) => (line ? `> ${line}` : '>'))\n return [`On ${quotedTime(answering.atMs, timeZone)}, ${who} wrote:`, ...lines].join('\\n')\n}\n\n/** An answer, written: what the transport sends and the records keep. */\nexport interface OutreachManualReply {\n to: string\n subject: string\n /** The whole text sent: the member's words, then the quote. */\n text: string\n /** The member's words alone, bounded, as the records keep them. */\n body: string\n /** The provider thread it is filed into; `null` lets the provider thread it by its headers. */\n threadId: string | null\n /** The answered message's `Message-ID`; `null` when it carried none. */\n inReplyTo: string | null\n references: string[]\n}\n\nexport type OutreachManualReplyErrorCode = 'empty' | 'too_long' | 'invalid_recipient'\n\nexport interface OutreachManualReplyCompose {\n reply: OutreachManualReply | null\n error: { code: OutreachManualReplyErrorCode; message: string } | null\n}\n\n/** Writes the answer — see the module note. */\nexport function composeOutreachManualReply(input: {\n enrollment: Pick<OutreachEnrollment, 'messageIds' | 'threadSubject'>\n answering: OutreachInboundMessage\n body: unknown\n /** The mailbox's zone, for the quote's time. */\n timeZone: string | null | undefined\n}): OutreachManualReplyCompose {\n const words = String(input.body ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[ \\t]+$/gm, '')\n .replace(/^\\n+|\\n+$/g, '')\n if (!words.trim()) return { reply: null, error: { code: 'empty', message: 'Write your answer before sending it.' } }\n if (words.length > OUTREACH_MANUAL_REPLY_BODY_MAX) {\n return {\n reply: null,\n error: {\n code: 'too_long',\n message: `An answer can be at most ${OUTREACH_MANUAL_REPLY_BODY_MAX.toLocaleString('en-US')} characters.`,\n },\n }\n }\n const to = normalizeContactEmail(input.answering.replyTo ?? input.answering.from)\n if (!to) {\n return { reply: null, error: { code: 'invalid_recipient', message: 'Their message names no address to answer.' } }\n }\n return {\n reply: {\n to,\n subject: outreachReplySubject(input.answering, input.enrollment.threadSubject),\n text: `${words}\\n\\n${outreachReplyQuote(input.answering, input.timeZone)}`,\n body: words.slice(0, OUTREACH_MANUAL_REPLY_STORED_MAX),\n threadId: input.answering.threadId,\n inReplyTo: input.answering.messageId,\n references: outreachReplyReferences(input.enrollment.messageIds ?? [], input.answering),\n },\n error: null,\n }\n}\n\nexport type OutreachManualReplyRefusalCode =\n | 'opted-out'\n | 'bounced'\n | 'do-not-contact'\n | 'unchecked'\n | 'mailbox-unavailable'\n | 'permission'\n\nexport interface OutreachManualReplyRefusal {\n code: OutreachManualReplyRefusalCode\n message: string\n}\n\n/** The address a mailbox sends as. */\nexport const outreachMailboxAddress = (mailbox: Pick<OutreachMailbox, 'sendAs' | 'email'>): string =>\n mailbox.sendAs || mailbox.email\n\n/**\n * Why an answer cannot go now, in the member's words, or `null` — see the\n * module note. The person's own state first, then the lists, then the\n * mailbox, then who may send from it.\n */\nexport function outreachManualReplyRefusal(input: {\n enrollment: Pick<OutreachEnrollment, 'status' | 'email'>\n /** Every address the answer would reach, each looked up. */\n doNotContact: boolean | null\n doNotContactDomain: boolean | null\n mailbox: Pick<OutreachMailbox, 'status' | 'sendAs' | 'email'> | null\n /** The member is the mailbox's own, or an organization owner or admin. */\n mayUseMailbox: boolean\n}): OutreachManualReplyRefusal | null {\n const { enrollment, mailbox } = input\n if (enrollment.status === 'opted_out') {\n return { code: 'opted-out', message: 'They asked not to be emailed, so no answer can be sent to them.' }\n }\n if (enrollment.status === 'bounced') {\n return { code: 'bounced', message: 'Their address bounced, so an answer would not reach them.' }\n }\n if (input.doNotContact === true) {\n return {\n code: 'do-not-contact',\n message: `${enrollment.email} is on your do-not-contact list. Take it off the list on the Compliance page to answer them.`,\n }\n }\n if (input.doNotContactDomain === true) {\n const domain = outreachEmailDomain(enrollment.email) ?? 'Their domain'\n return {\n code: 'do-not-contact',\n message: `${domain} is on your do-not-contact list. Take it off the list on the Compliance page to answer them.`,\n }\n }\n if (input.doNotContact === null || input.doNotContactDomain === null) {\n return { code: 'unchecked', message: 'The do-not-contact list could not be checked. Try again in a moment.' }\n }\n if (!mailbox) {\n return {\n code: 'mailbox-unavailable',\n message: 'The mailbox that wrote to them is no longer connected, so the answer cannot go from it.',\n }\n }\n const address = outreachMailboxAddress(mailbox)\n if (mailbox.status === 'paused') {\n return {\n code: 'mailbox-unavailable',\n message: `${address} is paused. Resume it on the Mailboxes page to answer from it.`,\n }\n }\n if (mailbox.status !== 'connected') {\n return {\n code: 'mailbox-unavailable',\n message: `${address} needs to be connected again. Reconnect it on the Mailboxes page to answer from it.`,\n }\n }\n if (!input.mayUseMailbox) {\n return {\n code: 'permission',\n message: `Only the member who connected ${address}, or an organization owner or admin, can answer from it.`,\n }\n }\n return null\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","emailAddressOf","emailExcerpt","threadSubject","OUTREACH_INBOUND_KINDS","OUTREACH_INBOUND_MESSAGES_MAX","outreachMessageIdHeader","outreachEmailDomain","classifyOutreachMessage","outreachHeader","OUTREACH_MANUAL_REPLY_BODY_MAX","OUTREACH_MANUAL_REPLY_STORED_MAX","REFERENCES_MAX","outreachDisplayNameOf","value","raw","String","trim","angled","lastIndexOf","slice","replace","outreachMessageIdList","found","match","matchAll","id","includes","push","bodyOf","message","textBody","htmlBody","snippet","outreachInboundMessagesOf","messages","context","kept","classification","kind","from","replyTo","threadId","messageId","references","fromName","subject","atMs","body","sort","a","b","mergeOutreachInboundMessages","stored","byId","Map","set","values","outreachMessageToAnswer","inbound","find","at","outreachEnrollmentHasInbound","enrollment","Boolean","inboundMessages","length","stopReason","status","outreachReplyReferences","sentMessageIds","answering","chain","header","outreachReplySubject","enrollmentThreadSubject","base","quotedTime","timeZone","options","weekday","month","day","year","hour","minute","format","zone","Intl","DateTimeFormat","outreachReplyQuote","who","lines","split","map","line","join","composeOutreachManualReply","input","words","reply","error","code","toLocaleString","to","text","inReplyTo","messageIds","outreachMailboxAddress","mailbox","sendAs","email","outreachManualReplyRefusal","doNotContact","doNotContactDomain","domain","address","mayUseMailbox"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/manual-reply.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 * ANSWERING A PERSON WHO WROTE BACK (AGL-4020).\n *\n * A person who answers a sequence email is answered from the same mailbox,\n * in the same thread, by a member: the reply is a one-to-one email, never a\n * step of the sequence. This module is the engine's half of it — what the\n * person wrote, as the enrollment keeps it, and the answer, as the\n * transport sends it — and decides nothing about storage or delivery.\n *\n * ## What the person wrote\n *\n * {@link outreachInboundMessagesOf} keeps the messages of a thread the\n * classifier reads as theirs: a reply, an automatic answer, an opt-out. Each\n * carries its sender, its time, its `Message-ID` and `References` (what an\n * answer threads by), and its text with the quoted history trimmed, the way\n * the record's timeline keeps it (`emailExcerpt`).\n *\n * ## The answer\n *\n * {@link composeOutreachManualReply} writes it as every mail client writes a\n * reply: `Re:` the subject of the message it answers, `In-Reply-To` that\n * message's `Message-ID`, and `References` the whole chain — the sequence's\n * own sends, what the person's message already named, and the message\n * itself — so it lands in the same thread in the person's mailbox and in\n * ours. Plain text, the member's words first and the message answered quoted\n * beneath. No footer and no `List-Unsubscribe`: the person wrote first, and\n * this is the answer a person would type in their own mail client.\n *\n * On a sequence that tracks link clicks, the links in the member's words go\n * out as the same short links a step's do (AGL-4022), through the caller's\n * minter — never the links in the quoted message, which are theirs.\n *\n * ## What still refuses\n *\n * {@link outreachManualReplyRefusal}: an address or a domain on the\n * do-not-contact list, a person who opted out or whose address bounced, and\n * a mailbox that is paused, waiting to be reconnected, or gone. The cold\n * rules — the allowed countries, the company-domain rule, the personal line,\n * the daily cap — do not apply to someone who wrote first, and are not\n * asked.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { emailAddressOf, emailExcerpt, threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport {\n OUTREACH_INBOUND_KINDS,\n OUTREACH_INBOUND_MESSAGES_MAX,\n type OutreachEnrollment,\n type OutreachInboundKind,\n type OutreachInboundMessage,\n type OutreachMailbox,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { outreachMessageIdHeader } from './compose'\nimport { outreachEmailDomain } from './do-not-contact-domain'\nimport { classifyOutreachMessage, type OutreachMessageContext } from './thread-classification'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\n/** The most a member may write in one answer. */\nexport const OUTREACH_MANUAL_REPLY_BODY_MAX = 10_000\n\n/** The most of an answer the enrollment and the record keep: the timeline's excerpt length. */\nexport const OUTREACH_MANUAL_REPLY_STORED_MAX = 4_000\n\n/** The most `Message-ID`s one answer's `References` names: the first, and the latest. */\nconst REFERENCES_MAX = 20\n\n/** The display name in a `From` value — `\"Ada Lovelace\" <ada@example.com>` answers `Ada Lovelace` — or `''`. */\nexport function outreachDisplayNameOf(value: unknown): string {\n const raw = String(value ?? '').trim()\n const angled = raw.lastIndexOf('<')\n if (angled <= 0) return ''\n return raw\n .slice(0, angled)\n .trim()\n .replace(/^\"(.*)\"$/, '$1')\n .replace(/\\\\(.)/g, '$1')\n .trim()\n}\n\n/** Every `Message-ID` a `References` or `In-Reply-To` value names, in header form, in order, once each. */\nexport function outreachMessageIdList(value: unknown): string[] {\n const found: string[] = []\n for (const match of String(value ?? '').matchAll(/<[^<>\\s]+>/g)) {\n const id = outreachMessageIdHeader(match[0])\n if (id && !found.includes(id)) found.push(id)\n }\n return found\n}\n\n/** A message's text as the record keeps it: the text part, else the HTML's words, else the preview. */\nfunction bodyOf(message: OutreachThreadMessage): string {\n return emailExcerpt(message.textBody ?? '', message.htmlBody ?? '') || emailExcerpt(message.snippet ?? '')\n}\n\n/**\n * The person's own messages among a thread's — see the module note — oldest\n * first. A message with no sender address is left out: it could be neither\n * shown as theirs nor answered.\n */\nexport function outreachInboundMessagesOf(\n messages: readonly OutreachThreadMessage[],\n context: OutreachMessageContext,\n): OutreachInboundMessage[] {\n const kept: OutreachInboundMessage[] = []\n for (const message of messages) {\n if (!message?.id) continue\n const classification = classifyOutreachMessage(message, context)\n if (!(OUTREACH_INBOUND_KINDS as readonly string[]).includes(classification.kind)) continue\n const from = emailAddressOf(message.from)\n if (!from) continue\n const replyTo = emailAddressOf(outreachHeader(message, 'Reply-To'))\n kept.push({\n id: message.id,\n threadId: message.threadId ?? null,\n messageId: outreachMessageIdHeader(outreachHeader(message, 'Message-ID')),\n references: outreachMessageIdList(outreachHeader(message, 'References')),\n from,\n fromName: outreachDisplayNameOf(message.from),\n replyTo: replyTo && replyTo !== from ? replyTo : null,\n subject: String(message.subject ?? '').replace(/\\s+/g, ' ').trim(),\n atMs: classification.atMs,\n kind: classification.kind as OutreachInboundKind,\n body: bodyOf(message),\n })\n }\n return kept.sort((a, b) => a.atMs - b.atMs)\n}\n\n/**\n * What an enrollment keeps once more of the person's messages are read: one\n * entry per message — a re-read replaces the earlier copy — oldest first,\n * the last {@link OUTREACH_INBOUND_MESSAGES_MAX}.\n */\nexport function mergeOutreachInboundMessages(\n stored: readonly OutreachInboundMessage[] | null | undefined,\n found: readonly OutreachInboundMessage[],\n): OutreachInboundMessage[] {\n const byId = new Map<string, OutreachInboundMessage>()\n for (const message of [...(stored ?? []), ...found]) byId.set(message.id, message)\n return [...byId.values()].sort((a, b) => a.atMs - b.atMs).slice(-OUTREACH_INBOUND_MESSAGES_MAX)\n}\n\n/**\n * The message an answer replies to: the one named, when the enrollment\n * kept it, else the newest the person wrote. `null` when they wrote nothing\n * the enrollment kept.\n */\nexport function outreachMessageToAnswer(\n inbound: readonly OutreachInboundMessage[] | null | undefined,\n id?: string | null,\n): OutreachInboundMessage | null {\n const messages = [...(inbound ?? [])].sort((a, b) => a.atMs - b.atMs)\n if (id) return messages.find((message) => message.id === id) ?? null\n return messages.at(-1) ?? null\n}\n\n/** Whether a member may answer this enrollment at all: the person wrote something it kept, or a reply stopped it. */\nexport function outreachEnrollmentHasInbound(\n enrollment: Pick<OutreachEnrollment, 'inboundMessages' | 'stopReason' | 'status'>,\n): boolean {\n return Boolean(enrollment.inboundMessages?.length) || enrollment.stopReason === 'reply' || enrollment.status === 'replied'\n}\n\n/**\n * The answer's `References` — see the module note: the sequence's own sends,\n * then what the answered message named, then the message itself, each once.\n * A long chain keeps its first id and its latest, as RFC 5322 §3.6.4 allows.\n */\nexport function outreachReplyReferences(\n sentMessageIds: readonly string[],\n answering: Pick<OutreachInboundMessage, 'references' | 'messageId'>,\n): string[] {\n const chain: string[] = []\n for (const id of [...sentMessageIds, ...answering.references, answering.messageId]) {\n const header = outreachMessageIdHeader(id)\n if (header && !chain.includes(header)) chain.push(header)\n }\n return chain.length > REFERENCES_MAX ? [chain[0], ...chain.slice(-(REFERENCES_MAX - 1))] : chain\n}\n\n/** `Re:` the subject of the message answered, else of the thread the sequence started. */\nexport function outreachReplySubject(\n answering: Pick<OutreachInboundMessage, 'subject'>,\n enrollmentThreadSubject: string | null | undefined,\n): string {\n const base = threadSubject(answering.subject) || threadSubject(enrollmentThreadSubject)\n return (base ? `Re: ${base}` : 'Re:').slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\n/** \"Tue, Oct 21, 2026, 8:02 AM\", in the mailbox's zone. */\nfunction quotedTime(atMs: number, timeZone: string | null | undefined): string {\n const options: Intl.DateTimeFormatOptions = {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n }\n // Plain spaces: newer ICU puts a narrow no-break space before \"AM\".\n const format = (zone: string) =>\n new Intl.DateTimeFormat('en-US', { ...options, timeZone: zone }).format(atMs).replace(/[\\u00a0\\u202f]/g, ' ')\n try {\n return format(timeZone || 'UTC')\n } catch {\n return format('UTC')\n }\n}\n\n/** The answered message beneath the answer, quoted the way mail clients quote: `On …, … wrote:` and `> ` lines. */\nexport function outreachReplyQuote(\n answering: Pick<OutreachInboundMessage, 'atMs' | 'from' | 'fromName' | 'body'>,\n timeZone: string | null | undefined,\n): string {\n const who = answering.fromName ? `${answering.fromName} <${answering.from}>` : answering.from\n const lines = answering.body.split('\\n').map((line) => (line ? `> ${line}` : '>'))\n return [`On ${quotedTime(answering.atMs, timeZone)}, ${who} wrote:`, ...lines].join('\\n')\n}\n\n/** An answer, written: what the transport sends and the records keep. */\nexport interface OutreachManualReply {\n to: string\n subject: string\n /** The whole text sent: the member's words, then the quote. */\n text: string\n /** The member's words alone, as sent — tracked links in place — bounded, as the records keep them. */\n body: string\n /**\n * The destinations the minter rewrote, in the order a short link's\n * `linkIndex` names them (AGL-4022). Empty when nothing was rewritten.\n */\n trackedLinks: string[]\n /** The provider thread it is filed into; `null` lets the provider thread it by its headers. */\n threadId: string | null\n /** The answered message's `Message-ID`; `null` when it carried none. */\n inReplyTo: string | null\n references: string[]\n}\n\nexport type OutreachManualReplyErrorCode = 'empty' | 'too_long' | 'invalid_recipient'\n\nexport interface OutreachManualReplyCompose {\n reply: OutreachManualReply | null\n error: { code: OutreachManualReplyErrorCode; message: string } | null\n}\n\n/** Writes the answer — see the module note. */\nexport function composeOutreachManualReply(input: {\n enrollment: Pick<OutreachEnrollment, 'messageIds' | 'threadSubject'>\n answering: OutreachInboundMessage\n body: unknown\n /** The mailbox's zone, for the quote's time. */\n timeZone: string | null | undefined\n /**\n * Mints the short link for the link at `index` in the member's words, or\n * `null` to leave it as written (AGL-4022). Absent — a sequence that does\n * not track clicks — sends every link as written.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n}): OutreachManualReplyCompose {\n const words = String(input.body ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[ \\t]+$/gm, '')\n .replace(/^\\n+|\\n+$/g, '')\n if (!words.trim()) return { reply: null, error: { code: 'empty', message: 'Write your answer before sending it.' } }\n if (words.length > OUTREACH_MANUAL_REPLY_BODY_MAX) {\n return {\n reply: null,\n error: {\n code: 'too_long',\n message: `An answer can be at most ${OUTREACH_MANUAL_REPLY_BODY_MAX.toLocaleString('en-US')} characters.`,\n },\n }\n }\n const to = normalizeContactEmail(input.answering.replyTo ?? input.answering.from)\n if (!to) {\n return { reply: null, error: { code: 'invalid_recipient', message: 'Their message names no address to answer.' } }\n }\n const rewritten = input.rewriteLink ? rewriteOutreachBodyLinks(words, input.rewriteLink) : { text: words, links: [] }\n return {\n reply: {\n to,\n subject: outreachReplySubject(input.answering, input.enrollment.threadSubject),\n text: `${rewritten.text}\\n\\n${outreachReplyQuote(input.answering, input.timeZone)}`,\n body: rewritten.text.slice(0, OUTREACH_MANUAL_REPLY_STORED_MAX),\n trackedLinks: rewritten.links,\n threadId: input.answering.threadId,\n inReplyTo: input.answering.messageId,\n references: outreachReplyReferences(input.enrollment.messageIds ?? [], input.answering),\n },\n error: null,\n }\n}\n\nexport type OutreachManualReplyRefusalCode =\n | 'opted-out'\n | 'bounced'\n | 'do-not-contact'\n | 'unchecked'\n | 'mailbox-unavailable'\n | 'permission'\n\nexport interface OutreachManualReplyRefusal {\n code: OutreachManualReplyRefusalCode\n message: string\n}\n\n/** The address a mailbox sends as. */\nexport const outreachMailboxAddress = (mailbox: Pick<OutreachMailbox, 'sendAs' | 'email'>): string =>\n mailbox.sendAs || mailbox.email\n\n/**\n * Why an answer cannot go now, in the member's words, or `null` — see the\n * module note. The person's own state first, then the lists, then the\n * mailbox, then who may send from it.\n */\nexport function outreachManualReplyRefusal(input: {\n enrollment: Pick<OutreachEnrollment, 'status' | 'email'>\n /** Every address the answer would reach, each looked up. */\n doNotContact: boolean | null\n doNotContactDomain: boolean | null\n mailbox: Pick<OutreachMailbox, 'status' | 'sendAs' | 'email'> | null\n /** The member is the mailbox's own, or an organization owner or admin. */\n mayUseMailbox: boolean\n}): OutreachManualReplyRefusal | null {\n const { enrollment, mailbox } = input\n if (enrollment.status === 'opted_out') {\n return { code: 'opted-out', message: 'They asked not to be emailed, so no answer can be sent to them.' }\n }\n if (enrollment.status === 'bounced') {\n return { code: 'bounced', message: 'Their address bounced, so an answer would not reach them.' }\n }\n if (input.doNotContact === true) {\n return {\n code: 'do-not-contact',\n message: `${enrollment.email} is on your do-not-contact list. Take it off the list on the Compliance page to answer them.`,\n }\n }\n if (input.doNotContactDomain === true) {\n const domain = outreachEmailDomain(enrollment.email) ?? 'Their domain'\n return {\n code: 'do-not-contact',\n message: `${domain} is on your do-not-contact list. Take it off the list on the Compliance page to answer them.`,\n }\n }\n if (input.doNotContact === null || input.doNotContactDomain === null) {\n return { code: 'unchecked', message: 'The do-not-contact list could not be checked. Try again in a moment.' }\n }\n if (!mailbox) {\n return {\n code: 'mailbox-unavailable',\n message: 'The mailbox that wrote to them is no longer connected, so the answer cannot go from it.',\n }\n }\n const address = outreachMailboxAddress(mailbox)\n if (mailbox.status === 'paused') {\n return {\n code: 'mailbox-unavailable',\n message: `${address} is paused. Resume it on the Mailboxes page to answer from it.`,\n }\n }\n if (mailbox.status !== 'connected') {\n return {\n code: 'mailbox-unavailable',\n message: `${address} needs to be connected again. Reconnect it on the Mailboxes page to answer from it.`,\n }\n }\n if (!input.mayUseMailbox) {\n return {\n code: 'permission',\n message: `Only the member who connected ${address}, or an organization owner or admin, can answer from it.`,\n }\n }\n return null\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","emailAddressOf","emailExcerpt","threadSubject","OUTREACH_INBOUND_KINDS","OUTREACH_INBOUND_MESSAGES_MAX","rewriteOutreachBodyLinks","outreachMessageIdHeader","outreachEmailDomain","classifyOutreachMessage","outreachHeader","OUTREACH_MANUAL_REPLY_BODY_MAX","OUTREACH_MANUAL_REPLY_STORED_MAX","REFERENCES_MAX","outreachDisplayNameOf","value","raw","String","trim","angled","lastIndexOf","slice","replace","outreachMessageIdList","found","match","matchAll","id","includes","push","bodyOf","message","textBody","htmlBody","snippet","outreachInboundMessagesOf","messages","context","kept","classification","kind","from","replyTo","threadId","messageId","references","fromName","subject","atMs","body","sort","a","b","mergeOutreachInboundMessages","stored","byId","Map","set","values","outreachMessageToAnswer","inbound","find","at","outreachEnrollmentHasInbound","enrollment","Boolean","inboundMessages","length","stopReason","status","outreachReplyReferences","sentMessageIds","answering","chain","header","outreachReplySubject","enrollmentThreadSubject","base","quotedTime","timeZone","options","weekday","month","day","year","hour","minute","format","zone","Intl","DateTimeFormat","outreachReplyQuote","who","lines","split","map","line","join","composeOutreachManualReply","input","words","reply","error","code","toLocaleString","to","rewritten","rewriteLink","text","links","trackedLinks","inReplyTo","messageIds","outreachMailboxAddress","mailbox","sendAs","email","outreachManualReplyRefusal","doNotContact","doNotContactDomain","domain","address","mayUseMailbox"],"mappings":"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"}
@@ -25,6 +25,8 @@ export declare function outreachClickHistoryRow(input: {
25
25
  stepIndex: number;
26
26
  human: boolean;
27
27
  machineReason: OutreachClickMachineReason | null;
28
+ /** A link in a member's answer in the thread (AGL-4022), not a step's. */
29
+ answer?: boolean;
28
30
  }): Record<string, unknown>;
29
31
  /**
30
32
  * An open row as the short-link route writes it for a tracking image
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { OUTREACH_OPEN_SOURCES } from "../engine/open-source.js";
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { OUTREACH_OPEN_SOURCES } from "../engine/open-source.js";
17
18
  /**
18
19
  * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.
19
20
  *
@@ -42,14 +43,16 @@ const ACTIONS = [
42
43
  'do_not_contact'
43
44
  ];
44
45
  /** A click row as the click route writes it. */ export function outreachClickHistoryRow(input) {
45
- return {
46
+ return _extends({
46
47
  kind: 'click',
47
48
  atMs: input.atMs,
48
49
  url: input.url,
49
50
  stepIndex: input.stepIndex,
50
51
  human: input.human,
51
52
  machineReason: input.human ? null : input.machineReason
52
- };
53
+ }, input.answer ? {
54
+ answer: true
55
+ } : {});
53
56
  }
54
57
  /**
55
58
  * An open row as the short-link route writes it for a tracking image
@@ -92,7 +95,7 @@ const ACTIONS = [
92
95
  const stepIndex = Number(data['stepIndex']);
93
96
  const human = data['human'] === true;
94
97
  const reason = data['machineReason'];
95
- return {
98
+ return _extends({
96
99
  id,
97
100
  kind: 'click',
98
101
  atMs,
@@ -100,7 +103,9 @@ const ACTIONS = [
100
103
  stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,
101
104
  human,
102
105
  machineReason: human ? null : MACHINE_REASONS.includes(reason) ? reason : null
103
- };
106
+ }, data['answer'] === true ? {
107
+ answer: true
108
+ } : {});
104
109
  }
105
110
  if (data['kind'] === 'open') {
106
111
  const stepIndex = Number(data['stepIndex']);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-history.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\nimport type { OutreachClickMachineReason } from '../engine/click-tracking'\nimport { OUTREACH_OPEN_SOURCES, type OutreachOpenSource } from '../engine/open-source'\nimport type { OutreachOpenMachineReason } from '../engine/open-tracking'\nimport type {\n OutreachEnrollmentHistoryEntry,\n OutreachHistoryAction,\n} from './outreach.types'\n\n/**\n * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.\n *\n * Client-safe, beside the other readers, because the click route and the\n * member-action route write these rows on the server and the detail view\n * reads them in the browser, and both must agree on the shape. Read a field\n * at a time: a row that names no time, or no kind this model knows, is\n * dropped rather than shown as something that happened.\n */\n\nconst MACHINE_REASONS: readonly OutreachClickMachineReason[] = ['agent', 'too_soon', 'method']\nconst OPEN_MACHINE_REASONS: readonly OutreachOpenMachineReason[] = [\n 'agent',\n 'too_soon',\n 'method',\n 'image_proxy',\n 'privacy_proxy',\n 'scanner',\n]\nconst ACTIONS: readonly OutreachHistoryAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\n/** A click row as the click route writes it. */\nexport function outreachClickHistoryRow(input: {\n atMs: number\n url: string | null\n stepIndex: number\n human: boolean\n machineReason: OutreachClickMachineReason | null\n}): Record<string, unknown> {\n return {\n kind: 'click',\n atMs: input.atMs,\n url: input.url,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n }\n}\n\n/**\n * An open row as the short-link route writes it for a tracking image\n * (AGL-3395), with the evidence it was judged on (AGL-3488): the agent the\n * fetch sent, and the network it came from — never its address.\n */\nexport function outreachOpenHistoryRow(input: {\n atMs: number\n stepIndex: number\n human: boolean\n machineReason: OutreachOpenMachineReason | null\n userAgent?: string | null\n source?: OutreachOpenSource | null\n}): Record<string, unknown> {\n return {\n kind: 'open',\n atMs: input.atMs,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n userAgent: input.userAgent ?? null,\n source: input.source ?? null,\n }\n}\n\n/** A member's act as the action route writes it. */\nexport function outreachActionHistoryRow(input: {\n atMs: number\n action: OutreachHistoryAction\n byUid: string\n detail: string | null\n}): Record<string, unknown> {\n const detail = typeof input.detail === 'string' ? input.detail.replace(/\\s+/g, ' ').trim().slice(0, 500) : ''\n return {\n kind: 'action',\n atMs: input.atMs,\n action: input.action,\n byUid: input.byUid,\n detail: detail || null,\n }\n}\n\n/**\n * A row id that sorts by time and cannot collide: two clicks in the same\n * millisecond — a scanner fetching every link at once — are two rows.\n */\nexport function outreachHistoryEntryId(atMs: number, random: () => number = Math.random): string {\n const suffix = Math.floor(random() * 36 ** 6)\n .toString(36)\n .padStart(6, '0')\n return `${String(Math.max(0, Math.floor(atMs))).padStart(15, '0')}-${suffix}`\n}\n\n/** A stored history row in its model shape, or `null` for one nothing can be said about. */\nexport function readOutreachHistoryEntry(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollmentHistoryEntry | null {\n if (!data) return null\n const atMs = data['atMs']\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n if (data['kind'] === 'click') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'click',\n atMs,\n url: typeof data['url'] === 'string' && data['url'] ? data['url'] : null,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachClickMachineReason)\n : null,\n }\n }\n if (data['kind'] === 'open') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'open',\n atMs,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (OPEN_MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachOpenMachineReason)\n : null,\n // Absent on every row from before AGL-3488, which recorded neither.\n userAgent: typeof data['userAgent'] === 'string' && data['userAgent'] ? data['userAgent'] : null,\n source: (OUTREACH_OPEN_SOURCES as readonly unknown[]).includes(data['source'])\n ? (data['source'] as OutreachOpenSource)\n : null,\n }\n }\n if (data['kind'] === 'action') {\n const action = data['action']\n if (!(ACTIONS as readonly unknown[]).includes(action)) return null\n return {\n id,\n kind: 'action',\n atMs,\n action: action as OutreachHistoryAction,\n byUid: typeof data['byUid'] === 'string' ? data['byUid'] : '',\n detail: typeof data['detail'] === 'string' && data['detail'] ? data['detail'] : null,\n }\n }\n return null\n}\n"],"names":["OUTREACH_OPEN_SOURCES","MACHINE_REASONS","OPEN_MACHINE_REASONS","ACTIONS","outreachClickHistoryRow","input","kind","atMs","url","stepIndex","human","machineReason","outreachOpenHistoryRow","userAgent","source","outreachActionHistoryRow","detail","replace","trim","slice","action","byUid","outreachHistoryEntryId","random","Math","suffix","floor","toString","padStart","String","max","readOutreachHistoryEntry","id","data","Number","isFinite","reason","isInteger","includes"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-history.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\nimport type { OutreachClickMachineReason } from '../engine/click-tracking'\nimport { OUTREACH_OPEN_SOURCES, type OutreachOpenSource } from '../engine/open-source'\nimport type { OutreachOpenMachineReason } from '../engine/open-tracking'\nimport type {\n OutreachEnrollmentHistoryEntry,\n OutreachHistoryAction,\n} from './outreach.types'\n\n/**\n * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.\n *\n * Client-safe, beside the other readers, because the click route and the\n * member-action route write these rows on the server and the detail view\n * reads them in the browser, and both must agree on the shape. Read a field\n * at a time: a row that names no time, or no kind this model knows, is\n * dropped rather than shown as something that happened.\n */\n\nconst MACHINE_REASONS: readonly OutreachClickMachineReason[] = ['agent', 'too_soon', 'method']\nconst OPEN_MACHINE_REASONS: readonly OutreachOpenMachineReason[] = [\n 'agent',\n 'too_soon',\n 'method',\n 'image_proxy',\n 'privacy_proxy',\n 'scanner',\n]\nconst ACTIONS: readonly OutreachHistoryAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\n/** A click row as the click route writes it. */\nexport function outreachClickHistoryRow(input: {\n atMs: number\n url: string | null\n stepIndex: number\n human: boolean\n machineReason: OutreachClickMachineReason | null\n /** A link in a member's answer in the thread (AGL-4022), not a step's. */\n answer?: boolean\n}): Record<string, unknown> {\n return {\n kind: 'click',\n atMs: input.atMs,\n url: input.url,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n ...(input.answer ? { answer: true } : {}),\n }\n}\n\n/**\n * An open row as the short-link route writes it for a tracking image\n * (AGL-3395), with the evidence it was judged on (AGL-3488): the agent the\n * fetch sent, and the network it came from — never its address.\n */\nexport function outreachOpenHistoryRow(input: {\n atMs: number\n stepIndex: number\n human: boolean\n machineReason: OutreachOpenMachineReason | null\n userAgent?: string | null\n source?: OutreachOpenSource | null\n}): Record<string, unknown> {\n return {\n kind: 'open',\n atMs: input.atMs,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n userAgent: input.userAgent ?? null,\n source: input.source ?? null,\n }\n}\n\n/** A member's act as the action route writes it. */\nexport function outreachActionHistoryRow(input: {\n atMs: number\n action: OutreachHistoryAction\n byUid: string\n detail: string | null\n}): Record<string, unknown> {\n const detail = typeof input.detail === 'string' ? input.detail.replace(/\\s+/g, ' ').trim().slice(0, 500) : ''\n return {\n kind: 'action',\n atMs: input.atMs,\n action: input.action,\n byUid: input.byUid,\n detail: detail || null,\n }\n}\n\n/**\n * A row id that sorts by time and cannot collide: two clicks in the same\n * millisecond — a scanner fetching every link at once — are two rows.\n */\nexport function outreachHistoryEntryId(atMs: number, random: () => number = Math.random): string {\n const suffix = Math.floor(random() * 36 ** 6)\n .toString(36)\n .padStart(6, '0')\n return `${String(Math.max(0, Math.floor(atMs))).padStart(15, '0')}-${suffix}`\n}\n\n/** A stored history row in its model shape, or `null` for one nothing can be said about. */\nexport function readOutreachHistoryEntry(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollmentHistoryEntry | null {\n if (!data) return null\n const atMs = data['atMs']\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n if (data['kind'] === 'click') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'click',\n atMs,\n url: typeof data['url'] === 'string' && data['url'] ? data['url'] : null,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachClickMachineReason)\n : null,\n ...(data['answer'] === true ? { answer: true as const } : {}),\n }\n }\n if (data['kind'] === 'open') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'open',\n atMs,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (OPEN_MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachOpenMachineReason)\n : null,\n // Absent on every row from before AGL-3488, which recorded neither.\n userAgent: typeof data['userAgent'] === 'string' && data['userAgent'] ? data['userAgent'] : null,\n source: (OUTREACH_OPEN_SOURCES as readonly unknown[]).includes(data['source'])\n ? (data['source'] as OutreachOpenSource)\n : null,\n }\n }\n if (data['kind'] === 'action') {\n const action = data['action']\n if (!(ACTIONS as readonly unknown[]).includes(action)) return null\n return {\n id,\n kind: 'action',\n atMs,\n action: action as OutreachHistoryAction,\n byUid: typeof data['byUid'] === 'string' ? data['byUid'] : '',\n detail: typeof data['detail'] === 'string' && data['detail'] ? data['detail'] : null,\n }\n }\n return null\n}\n"],"names":["OUTREACH_OPEN_SOURCES","MACHINE_REASONS","OPEN_MACHINE_REASONS","ACTIONS","outreachClickHistoryRow","input","kind","atMs","url","stepIndex","human","machineReason","answer","outreachOpenHistoryRow","userAgent","source","outreachActionHistoryRow","detail","replace","trim","slice","action","byUid","outreachHistoryEntryId","random","Math","suffix","floor","toString","padStart","String","max","readOutreachHistoryEntry","id","data","Number","isFinite","reason","isInteger","includes"],"mappings":"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"}
@@ -40,7 +40,8 @@
40
40
  * opt-out the enrollment kept, with their words. The one that stopped the
41
41
  * sequence carries the stop, in place of a second entry saying it.
42
42
  * - **Each answer a member sent** in the thread (AGL-4020), with their
43
- * words and who sent it — never counted as one of the sequence's sends.
43
+ * words and who sent it — never counted as one of the sequence's sends —
44
+ * and a click on one of its links says it came from the answer (AGL-4022).
44
45
  *
45
46
  * Client-safe and free of React: the view renders these entries, and the
46
47
  * spec reads them.
@@ -218,6 +219,7 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
218
219
  // A member's answer in the thread (AGL-4020): their words, never a step.
219
220
  if (record.kind === 'manual_reply') {
220
221
  var _record_gmailThreadId1;
222
+ var _record_links1;
221
223
  entries.push({
222
224
  key: `answered-${index}`,
223
225
  kind: 'answered',
@@ -227,7 +229,8 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
227
229
  facts: [
228
230
  byWhom(record.byUid, memberName),
229
231
  record.from ? `From ${record.from}` : null,
230
- record.subject ? `Subject: ${record.subject}` : null
232
+ record.subject ? `Subject: ${record.subject}` : null,
233
+ ((_record_links1 = record.links) == null ? void 0 : _record_links1.length) ? plural(record.links.length, 'tracked link', 'tracked links') : null
231
234
  ].filter((fact)=>Boolean(fact)),
232
235
  gmailThreadId: (_record_gmailThreadId1 = record.gmailThreadId) != null ? _record_gmailThreadId1 : null,
233
236
  tone: 'default'
@@ -271,6 +274,8 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
271
274
  });
272
275
  });
273
276
  // ── Clicks ──────────────────────────────────────────────────────────────
277
+ // Where the link was: a step's email, or a member's answer (AGL-4022).
278
+ const clickSource = (row)=>row.answer ? 'From an answer in the thread' : `From ${outreachStepName(steps, row.stepIndex)}`;
274
279
  for (const row of history){
275
280
  if (row.kind !== 'click') continue;
276
281
  entries.push(row.human ? {
@@ -280,7 +285,7 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
280
285
  title: 'Clicked a link',
281
286
  url: row.url,
282
287
  facts: [
283
- `From ${outreachStepName(steps, row.stepIndex)}`
288
+ clickSource(row)
284
289
  ],
285
290
  tone: 'success'
286
291
  } : {
@@ -290,7 +295,7 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
290
295
  title: 'A scanner followed a link — not counted',
291
296
  url: row.url,
292
297
  facts: [
293
- `From ${outreachStepName(steps, row.stepIndex)}`,
298
+ clickSource(row),
294
299
  row.machineReason ? `Read as a scanner: ${OUTREACH_MACHINE_REASON_LABELS[row.machineReason]}` : null
295
300
  ].filter((fact)=>Boolean(fact)),
296
301
  tone: 'default'