@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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.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 {\n SEND_LINK_ROLLUP_MAX,\n sendLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { outreachLoggedEngagementRows, readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried a link or an image was sent, or `null` when it cannot be found. */\nexport function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = outreachStepSentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = sendLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n ...held,\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `sendLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `SEND_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = sendLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= SEND_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${sendLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["SEND_LINK_ROLLUP_MAX","sendLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","outreachLoggedEngagementRows","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","outreachStepSentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","loggedClicks","loggedMachineClicks","update","clicked","set","collection","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.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 {\n SEND_LINK_ROLLUP_MAX,\n sendLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { outreachLoggedEngagementRows, readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n *\n * A link in a member's ANSWER to a person who wrote back (AGL-4022) is the\n * person's click and not the sequence's: it is written to the enrollment and\n * its history — marked as the answer's — and to their record, but never to\n * the sequence's counters or its link rollup, which report only what the\n * sequence's own steps sent. The route still stamps the person's campaign\n * touch: what they book or fill in next came from the sequence's\n * conversation either way.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried a link or an image was sent, or `null` when it cannot be found. */\nexport function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * When the email that carried a link was sent: the member's answer it was in\n * (AGL-4022), else the step's email; `null` when it cannot be found.\n */\nexport function outreachLinkSentAtMs(\n enrollment: OutreachEnrollment,\n target: Pick<OutreachClickTarget, 'stepIndex' | 'answerKey'>,\n): number | null {\n if (!target.answerKey) return outreachStepSentAtMs(enrollment, target.stepIndex)\n const answer = (enrollment.stepRecords ?? []).find(\n (record) => record?.kind === 'manual_reply' && record.replyKey === target.answerKey,\n )\n return typeof answer?.atMs === 'number' && Number.isFinite(answer.atMs) ? answer.atMs : null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = outreachLinkSentAtMs(enrollment, input.target)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = sendLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n ...held,\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n answer: Boolean(input.target.answerKey),\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n // An answer's link is the person's, never the sequence's (AGL-4022).\n const fromAnswer = Boolean(input.target.answerKey)\n await Promise.all([\n fromAnswer\n ? null\n : bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human && !fromAnswer\n ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url })\n : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs, fromAnswer }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `sendLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `SEND_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = sendLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= SEND_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number; fromAnswer?: boolean },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `${input.fromAnswer ? 'Followed a link in an answer to their reply' : 'Followed a link in a sequence email'}: ${sendLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["SEND_LINK_ROLLUP_MAX","sendLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","outreachLoggedEngagementRows","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","outreachStepSentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","outreachLinkSentAtMs","target","answerKey","answer","find","replyKey","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","loggedClicks","loggedMachineClicks","update","clicked","set","collection","Boolean","fromAnswer","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
@@ -88,6 +88,14 @@ export interface OutreachClickTarget {
88
88
  * `machineClicks`. Its `enrollmentId` is {@link OUTREACH_TEST_LINK_ENROLLMENT}.
89
89
  */
90
90
  test?: boolean;
91
+ /**
92
+ * A link in a member's ANSWER to a person who wrote back (AGL-4022): the
93
+ * `replyKey` of the answer's `manual_reply` step record. Its `stepIndex`
94
+ * is then the step the enrollment was on, not the step that sent it, and
95
+ * the click is the person's — on their enrollment — and never the
96
+ * sequence's, whose figures count only what its steps sent.
97
+ */
98
+ answerKey?: string;
91
99
  }
92
100
  /** The enrollment a test link names, since a test enrolls nobody. */
93
101
  export declare const OUTREACH_TEST_LINK_ENROLLMENT = "test";
@@ -71,6 +71,7 @@ import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
71
71
  */ export const OUTREACH_CLICK_TARGET_MAX = 512;
72
72
  /** The enrollment a test link names, since a test enrolls nobody. */ export const OUTREACH_TEST_LINK_ENROLLMENT = 'test';
73
73
  /** A document id as a token may carry one. */ const DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/;
74
+ /** An answer's reply key, as the reply composer mints one (AGL-4020). */ const ANSWER_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
74
75
  const base64url = (text)=>Buffer.from(text, 'utf8').toString('base64url');
75
76
  /** A destination we will sign, or `null` for one we will not. */ export function outreachClickTargetUrl(value) {
76
77
  const raw = String(value != null ? value : '').trim();
@@ -194,6 +195,7 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
194
195
  if (!url) return null;
195
196
  if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null;
196
197
  if (!index(target.stepIndex) || !index(target.linkIndex)) return null;
198
+ if (target.answerKey !== undefined && !ANSWER_KEY.test(target.answerKey)) return null;
197
199
  return _extends({
198
200
  v: 1,
199
201
  orgId: target.orgId,
@@ -204,6 +206,8 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
204
206
  createdAtMs: nowMs
205
207
  }, target.test ? {
206
208
  test: true
209
+ } : {}, target.answerKey ? {
210
+ answerKey: target.answerKey
207
211
  } : {});
208
212
  }
209
213
  /**
@@ -223,6 +227,7 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
223
227
  if (!url) return null;
224
228
  if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null;
225
229
  if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null;
230
+ const answerKey = body['answerKey'];
226
231
  return _extends({
227
232
  orgId,
228
233
  enrollmentId,
@@ -231,6 +236,8 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
231
236
  url
232
237
  }, body['test'] === true ? {
233
238
  test: true
239
+ } : {}, typeof answerKey === 'string' && ANSWER_KEY.test(answerKey) ? {
240
+ answerKey
234
241
  } : {});
235
242
  }
236
243
  /*==========================================
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-link.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 { randomBytes } from 'node:crypto'\nimport {\n signedLinkSignature,\n signedLinkSignatureMatches,\n} from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\n\n/**\n * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239,\n * AGL-3297).\n *\n * ## Short, and stored\n *\n * Every send since AGL-3297 carries `/api/outreach/l/<id>`: a random\n * {@link OUTREACH_LINK_ID_LENGTH}-character id naming an `outreachLinks`\n * document that holds the organization, the enrollment, the step, the link's\n * index and the destination. The send writes those documents before the\n * email leaves, and the route reads one to answer.\n *\n * The first design put all of that in a signed token instead, so the\n * redirect needed no read. It cost a ~350-character link, which in a\n * one-to-one plain-text email reads as bulk marketing — the whole thing a\n * sequence is not. A read per click is the cheaper of the two.\n *\n * The signed links already in inboxes keep working: the `click` route still\n * verifies and follows them, and {@link readOutreachClickToken} stays for it.\n * Nothing mints one any more.\n *\n * ## It cannot be turned into an open redirect\n *\n * The destination comes only from the stored document, which only the\n * sending runtime writes, and only for a URL written in that sequence's own\n * step; clients cannot read or write the collection at all. An id that names\n * no document is refused rather than followed. For the signed links, the\n * signature covers the destination, to the same end.\n *\n * ## It names ids, never an address\n *\n * A tracking URL lands in the recipient's history, in their gateway's logs\n * and in ours. The short link carries nothing but a random id; the stored\n * document names the enrollment, which names the person — a lookup that is\n * ours to make, not a bystander's to read off a URL.\n *\n * ## The id is unguessable\n *\n * Ten characters of base 62 from the platform's CSPRNG — about 59.5 bits. A\n * guess that lands resolves to a destination someone else was sent, and\n * would count a click that is recorded as the one it is (see\n * `click-events.ts`); it grants nothing else.\n */\n\n/** The purpose Outreach's click links are signed under. */\nexport const OUTREACH_CLICK_PURPOSE = 'outreach-click'\n\n/** The signed link's path on the console — answered, no longer minted. */\nexport const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`\n\n/** The short link's path on the console, before the id (AGL-3297). */\nexport const OUTREACH_SHORT_LINK_PATH = `/api/${OUTREACH_API_ROUTES.shortLink.replace(/\\/:linkId$/, '')}`\n\n/**\n * The longest destination a tracking link will carry.\n *\n * A token is a URL inside a URL, and mail clients wrap, truncate and refuse\n * to linkify very long ones. Past this the link is left alone: an unmeasured\n * click beats a link that arrives broken.\n */\nexport const OUTREACH_CLICK_TARGET_MAX = 512\n\n/** What a click token names. */\nexport interface OutreachClickTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the link. */\n stepIndex: number\n /** The link's index among the ones that step rewrote. */\n linkIndex: number\n /** Where the recipient asked to go. */\n url: string\n /**\n * A link in a TEST of a step (AGL-3325), sent to a member and never to a\n * person: it is followed exactly as any other and recorded nowhere, so\n * the member's own click never moves a sequence's `clicks` or\n * `machineClicks`. Its `enrollmentId` is {@link OUTREACH_TEST_LINK_ENROLLMENT}.\n */\n test?: boolean\n}\n\n/** The enrollment a test link names, since a test enrolls nobody. */\nexport const OUTREACH_TEST_LINK_ENROLLMENT = 'test'\n\n/** A document id as a token may carry one. */\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/\n\nconst base64url = (text: string): string => Buffer.from(text, 'utf8').toString('base64url')\n\n/** A destination we will sign, or `null` for one we will not. */\nexport function outreachClickTargetUrl(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n if (!raw || raw.length > OUTREACH_CLICK_TARGET_MAX) return null\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return null\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.href\n}\n\n/** Whether an index is one a token may carry. */\nconst index = (value: unknown): boolean =>\n typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 1000\n\n/** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */\nexport function mintOutreachClickToken(\n target: OutreachClickTarget,\n secret?: string,\n): string | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n const payload = base64url(\n JSON.stringify({\n v: 1,\n o: target.orgId,\n e: target.enrollmentId,\n s: target.stepIndex,\n i: target.linkIndex,\n u: url,\n }),\n )\n const signature =\n secret === undefined\n ? signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload)\n : signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload, secret)\n return signature ? `${payload}.${signature}` : null\n}\n\n/** What a token names, or `null` for one that is malformed or not ours. */\nexport function readOutreachClickToken(\n token: unknown,\n secret?: string,\n): OutreachClickTarget | null {\n const raw = typeof token === 'string' ? token.trim() : ''\n const dot = raw.indexOf('.')\n // Generous, because the payload carries a destination — but bounded, so an\n // arbitrarily long string never reaches the HMAC.\n if (dot <= 0 || raw.length > 2048) return null\n const payload = raw.slice(0, dot)\n const signature = raw.slice(dot + 1)\n if (\n !signedLinkSignatureMatches({\n purpose: OUTREACH_CLICK_PURPOSE,\n payload,\n signature,\n ...(secret === undefined ? {} : { secret }),\n })\n ) {\n return null\n }\n try {\n const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['o'] ?? '')\n const enrollmentId = String(body['e'] ?? '')\n const stepIndex = body['s']\n const linkIndex = body['i']\n // Checked again on the way OUT, not only on the way in: the signature\n // says we minted this payload, and these say it is still one we will act\n // on — which is what keeps a destination that was signable under an\n // older rule from being followed under this one.\n const url = outreachClickTargetUrl(body['u'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(stepIndex) || !index(linkIndex)) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: stepIndex as number,\n linkIndex: linkIndex as number,\n url,\n }\n } catch {\n return null\n }\n}\n\n/**\n * The tracking URL for one link on the console at `origin`, or `null` when\n * the origin is not HTTPS, the destination is not one we sign, or there is\n * no secret to sign with — no link rather than one that points at nothing.\n *\n * A `null` leaves the recipient with the destination the step actually\n * wrote, which is what `rewriteOutreachBodyLinks` does with it.\n */\nexport function outreachClickUrl(input: {\n origin: string | null | undefined\n target: OutreachClickTarget\n secret?: string\n}): string | null {\n let origin: URL\n try {\n origin = new URL(String(input.origin ?? ''))\n } catch {\n return null\n }\n if (origin.protocol !== 'https:') return null\n const token = mintOutreachClickToken(input.target, input.secret)\n return token ? `${origin.origin}${OUTREACH_CLICK_PATH}?t=${token}` : null\n}\n\n/*==========================================\n * THE SHORT LINK (AGL-3297).\n *=========================================*/\n\n/** How many characters a short link's id has. */\nexport const OUTREACH_LINK_ID_LENGTH = 10\n\nconst LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n\n/** A short link's id as the route will read one. */\nconst LINK_ID = new RegExp(`^[A-Za-z0-9]{${OUTREACH_LINK_ID_LENGTH}}$`)\n\n/** Whether a value is shaped like a short link's id. */\nexport function isOutreachLinkId(value: unknown): value is string {\n return typeof value === 'string' && LINK_ID.test(value)\n}\n\n/**\n * A fresh short link id: base 62 from the CSPRNG, by rejection sampling so\n * every character is equally likely (62 does not divide 256).\n */\nexport function newOutreachLinkId(random: (size: number) => Uint8Array = randomBytes): string {\n let id = ''\n while (id.length < OUTREACH_LINK_ID_LENGTH) {\n for (const byte of random(OUTREACH_LINK_ID_LENGTH * 2)) {\n if (byte >= 248) continue\n id += LINK_ID_ALPHABET[byte % 62]\n if (id.length === OUTREACH_LINK_ID_LENGTH) break\n }\n }\n return id\n}\n\n/** What an `outreachLinks/{id}` document holds. */\nexport interface OutreachStoredLink extends OutreachClickTarget {\n v: 1\n createdAtMs: number\n /** Which sequence a TEST link's step belongs to (AGL-3325); a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/**\n * The document for one link, or `null` when the target is not one we would\n * follow — the same checks the signed token applied at minting.\n */\nexport function outreachStoredLink(target: OutreachClickTarget, nowMs: number): OutreachStoredLink | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n return {\n v: 1,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n linkIndex: target.linkIndex,\n url,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n }\n}\n\n/**\n * What a stored link names, or `null` for a document that is missing or not\n * one we will act on. Checked on the way OUT, as the token is: the document\n * was written by us, and these say it is still one we will follow.\n */\nexport function readOutreachStoredLink(data: unknown): OutreachClickTarget | null {\n if (!data || typeof data !== 'object') return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n // A tracking image's document names no destination, and is never followed.\n if (body['kind'] === OUTREACH_OPEN_KIND) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n const url = outreachClickTargetUrl(body['url'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n linkIndex: body['linkIndex'] as number,\n url,\n ...(body['test'] === true ? { test: true } : {}),\n }\n}\n\n/*==========================================\n * THE TRACKING IMAGE (AGL-3395).\n *\n * A sequence that counts opens gives each email one image, and the image is\n * a short link too: an `outreachLinks` document of kind `open`, minted and\n * stored exactly as a link's is, on the same host. The short-link route\n * reads the document and, finding an image's, answers the image rather than\n * a redirect — so the image needs no route, host or rule a link does not\n * already have, and the email names no address a click does not.\n *\n * It names the enrollment and the step, and no destination: there is\n * nothing to follow.\n *=========================================*/\n\n/** The `kind` an image's document carries; a link's carries none. */\nexport const OUTREACH_OPEN_KIND = 'open'\n\n/** What a tracking image names. */\nexport interface OutreachOpenTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the image. */\n stepIndex: number\n /** An image in a TEST of a step (AGL-3325): answered, and recorded nowhere. */\n test?: boolean\n}\n\n/** What an `outreachLinks/{id}` document holds for a tracking image. */\nexport interface OutreachStoredOpen extends OutreachOpenTarget {\n v: 1\n kind: typeof OUTREACH_OPEN_KIND\n createdAtMs: number\n /** Which sequence a TEST image's step belongs to; a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/** The document for one email's image, or `null` when the target is not one we would record. */\nexport function outreachStoredOpen(target: OutreachOpenTarget, nowMs: number): OutreachStoredOpen | null {\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex)) return null\n return {\n v: 1,\n kind: OUTREACH_OPEN_KIND,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n }\n}\n\n/** Whether a stored document is a tracking image's, whatever else it holds. */\nexport function isOutreachStoredOpen(data: unknown): boolean {\n return Boolean(data) && typeof data === 'object' && (data as Record<string, unknown>)['kind'] === OUTREACH_OPEN_KIND\n}\n\n/** What a stored image names, or `null` for a document that is not one we will record. */\nexport function readOutreachStoredOpen(data: unknown): OutreachOpenTarget | null {\n if (!isOutreachStoredOpen(data)) return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex'])) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n ...(body['test'] === true ? { test: true } : {}),\n }\n}\n\n/**\n * The short link for an id, or `null` when there is no HTTPS origin to put it\n * on or the id is not one — no link rather than one that points at nothing. A\n * `null` leaves the destination the step wrote.\n *\n * On the sending domain's own click-tracking host (AGL-3306) when the org has\n * verified one — `https://links.<domain>/<id>`, which that host answers\n * exactly as the console route — and on the console's\n * `/api/outreach/l/<id>` otherwise, which every link keeps answering on.\n */\nexport function outreachShortLinkUrl(input: {\n origin: string | null | undefined\n linkId: string\n /** `https://links.<domain>`, from `resolveTrackingLinkOrigin`, or nothing. */\n trackingOrigin?: string | null\n}): string | null {\n if (!isOutreachLinkId(input.linkId)) return null\n const tracking = httpsOrigin(input.trackingOrigin)\n if (tracking) return `${tracking}/${input.linkId}`\n const origin = httpsOrigin(input.origin)\n return origin ? `${origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}` : null\n}\n\n/** An HTTPS origin, or `null` for anything else. */\nfunction httpsOrigin(value: string | null | undefined): string | null {\n if (!value) return null\n try {\n const url = new URL(String(value))\n return url.protocol === 'https:' ? url.origin : null\n } catch {\n return null\n }\n}\n"],"names":["randomBytes","signedLinkSignature","signedLinkSignatureMatches","OUTREACH_API_ROUTES","OUTREACH_CLICK_PURPOSE","OUTREACH_CLICK_PATH","click","OUTREACH_SHORT_LINK_PATH","shortLink","replace","OUTREACH_CLICK_TARGET_MAX","OUTREACH_TEST_LINK_ENROLLMENT","DOCUMENT_ID","base64url","text","Buffer","from","toString","outreachClickTargetUrl","value","raw","String","trim","length","url","URL","protocol","href","index","Number","isInteger","mintOutreachClickToken","target","secret","test","orgId","enrollmentId","stepIndex","linkIndex","payload","JSON","stringify","v","o","e","s","i","u","signature","undefined","readOutreachClickToken","token","dot","indexOf","slice","purpose","body","parse","outreachClickUrl","input","origin","OUTREACH_LINK_ID_LENGTH","LINK_ID_ALPHABET","LINK_ID","RegExp","isOutreachLinkId","newOutreachLinkId","random","id","byte","outreachStoredLink","nowMs","createdAtMs","readOutreachStoredLink","data","OUTREACH_OPEN_KIND","outreachStoredOpen","kind","isOutreachStoredOpen","Boolean","readOutreachStoredOpen","outreachShortLinkUrl","linkId","tracking","httpsOrigin","trackingOrigin"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-link.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 { randomBytes } from 'node:crypto'\nimport {\n signedLinkSignature,\n signedLinkSignatureMatches,\n} from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\n\n/**\n * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239,\n * AGL-3297).\n *\n * ## Short, and stored\n *\n * Every send since AGL-3297 carries `/api/outreach/l/<id>`: a random\n * {@link OUTREACH_LINK_ID_LENGTH}-character id naming an `outreachLinks`\n * document that holds the organization, the enrollment, the step, the link's\n * index and the destination. The send writes those documents before the\n * email leaves, and the route reads one to answer.\n *\n * The first design put all of that in a signed token instead, so the\n * redirect needed no read. It cost a ~350-character link, which in a\n * one-to-one plain-text email reads as bulk marketing — the whole thing a\n * sequence is not. A read per click is the cheaper of the two.\n *\n * The signed links already in inboxes keep working: the `click` route still\n * verifies and follows them, and {@link readOutreachClickToken} stays for it.\n * Nothing mints one any more.\n *\n * ## It cannot be turned into an open redirect\n *\n * The destination comes only from the stored document, which only the\n * sending runtime writes, and only for a URL written in that sequence's own\n * step; clients cannot read or write the collection at all. An id that names\n * no document is refused rather than followed. For the signed links, the\n * signature covers the destination, to the same end.\n *\n * ## It names ids, never an address\n *\n * A tracking URL lands in the recipient's history, in their gateway's logs\n * and in ours. The short link carries nothing but a random id; the stored\n * document names the enrollment, which names the person — a lookup that is\n * ours to make, not a bystander's to read off a URL.\n *\n * ## The id is unguessable\n *\n * Ten characters of base 62 from the platform's CSPRNG — about 59.5 bits. A\n * guess that lands resolves to a destination someone else was sent, and\n * would count a click that is recorded as the one it is (see\n * `click-events.ts`); it grants nothing else.\n */\n\n/** The purpose Outreach's click links are signed under. */\nexport const OUTREACH_CLICK_PURPOSE = 'outreach-click'\n\n/** The signed link's path on the console — answered, no longer minted. */\nexport const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`\n\n/** The short link's path on the console, before the id (AGL-3297). */\nexport const OUTREACH_SHORT_LINK_PATH = `/api/${OUTREACH_API_ROUTES.shortLink.replace(/\\/:linkId$/, '')}`\n\n/**\n * The longest destination a tracking link will carry.\n *\n * A token is a URL inside a URL, and mail clients wrap, truncate and refuse\n * to linkify very long ones. Past this the link is left alone: an unmeasured\n * click beats a link that arrives broken.\n */\nexport const OUTREACH_CLICK_TARGET_MAX = 512\n\n/** What a click token names. */\nexport interface OutreachClickTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the link. */\n stepIndex: number\n /** The link's index among the ones that step rewrote. */\n linkIndex: number\n /** Where the recipient asked to go. */\n url: string\n /**\n * A link in a TEST of a step (AGL-3325), sent to a member and never to a\n * person: it is followed exactly as any other and recorded nowhere, so\n * the member's own click never moves a sequence's `clicks` or\n * `machineClicks`. Its `enrollmentId` is {@link OUTREACH_TEST_LINK_ENROLLMENT}.\n */\n test?: boolean\n /**\n * A link in a member's ANSWER to a person who wrote back (AGL-4022): the\n * `replyKey` of the answer's `manual_reply` step record. Its `stepIndex`\n * is then the step the enrollment was on, not the step that sent it, and\n * the click is the person's — on their enrollment — and never the\n * sequence's, whose figures count only what its steps sent.\n */\n answerKey?: string\n}\n\n/** The enrollment a test link names, since a test enrolls nobody. */\nexport const OUTREACH_TEST_LINK_ENROLLMENT = 'test'\n\n/** A document id as a token may carry one. */\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/\n\n/** An answer's reply key, as the reply composer mints one (AGL-4020). */\nconst ANSWER_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/\n\nconst base64url = (text: string): string => Buffer.from(text, 'utf8').toString('base64url')\n\n/** A destination we will sign, or `null` for one we will not. */\nexport function outreachClickTargetUrl(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n if (!raw || raw.length > OUTREACH_CLICK_TARGET_MAX) return null\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return null\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.href\n}\n\n/** Whether an index is one a token may carry. */\nconst index = (value: unknown): boolean =>\n typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 1000\n\n/** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */\nexport function mintOutreachClickToken(\n target: OutreachClickTarget,\n secret?: string,\n): string | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n const payload = base64url(\n JSON.stringify({\n v: 1,\n o: target.orgId,\n e: target.enrollmentId,\n s: target.stepIndex,\n i: target.linkIndex,\n u: url,\n }),\n )\n const signature =\n secret === undefined\n ? signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload)\n : signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload, secret)\n return signature ? `${payload}.${signature}` : null\n}\n\n/** What a token names, or `null` for one that is malformed or not ours. */\nexport function readOutreachClickToken(\n token: unknown,\n secret?: string,\n): OutreachClickTarget | null {\n const raw = typeof token === 'string' ? token.trim() : ''\n const dot = raw.indexOf('.')\n // Generous, because the payload carries a destination — but bounded, so an\n // arbitrarily long string never reaches the HMAC.\n if (dot <= 0 || raw.length > 2048) return null\n const payload = raw.slice(0, dot)\n const signature = raw.slice(dot + 1)\n if (\n !signedLinkSignatureMatches({\n purpose: OUTREACH_CLICK_PURPOSE,\n payload,\n signature,\n ...(secret === undefined ? {} : { secret }),\n })\n ) {\n return null\n }\n try {\n const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['o'] ?? '')\n const enrollmentId = String(body['e'] ?? '')\n const stepIndex = body['s']\n const linkIndex = body['i']\n // Checked again on the way OUT, not only on the way in: the signature\n // says we minted this payload, and these say it is still one we will act\n // on — which is what keeps a destination that was signable under an\n // older rule from being followed under this one.\n const url = outreachClickTargetUrl(body['u'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(stepIndex) || !index(linkIndex)) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: stepIndex as number,\n linkIndex: linkIndex as number,\n url,\n }\n } catch {\n return null\n }\n}\n\n/**\n * The tracking URL for one link on the console at `origin`, or `null` when\n * the origin is not HTTPS, the destination is not one we sign, or there is\n * no secret to sign with — no link rather than one that points at nothing.\n *\n * A `null` leaves the recipient with the destination the step actually\n * wrote, which is what `rewriteOutreachBodyLinks` does with it.\n */\nexport function outreachClickUrl(input: {\n origin: string | null | undefined\n target: OutreachClickTarget\n secret?: string\n}): string | null {\n let origin: URL\n try {\n origin = new URL(String(input.origin ?? ''))\n } catch {\n return null\n }\n if (origin.protocol !== 'https:') return null\n const token = mintOutreachClickToken(input.target, input.secret)\n return token ? `${origin.origin}${OUTREACH_CLICK_PATH}?t=${token}` : null\n}\n\n/*==========================================\n * THE SHORT LINK (AGL-3297).\n *=========================================*/\n\n/** How many characters a short link's id has. */\nexport const OUTREACH_LINK_ID_LENGTH = 10\n\nconst LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n\n/** A short link's id as the route will read one. */\nconst LINK_ID = new RegExp(`^[A-Za-z0-9]{${OUTREACH_LINK_ID_LENGTH}}$`)\n\n/** Whether a value is shaped like a short link's id. */\nexport function isOutreachLinkId(value: unknown): value is string {\n return typeof value === 'string' && LINK_ID.test(value)\n}\n\n/**\n * A fresh short link id: base 62 from the CSPRNG, by rejection sampling so\n * every character is equally likely (62 does not divide 256).\n */\nexport function newOutreachLinkId(random: (size: number) => Uint8Array = randomBytes): string {\n let id = ''\n while (id.length < OUTREACH_LINK_ID_LENGTH) {\n for (const byte of random(OUTREACH_LINK_ID_LENGTH * 2)) {\n if (byte >= 248) continue\n id += LINK_ID_ALPHABET[byte % 62]\n if (id.length === OUTREACH_LINK_ID_LENGTH) break\n }\n }\n return id\n}\n\n/** What an `outreachLinks/{id}` document holds. */\nexport interface OutreachStoredLink extends OutreachClickTarget {\n v: 1\n createdAtMs: number\n /** Which sequence a TEST link's step belongs to (AGL-3325); a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/**\n * The document for one link, or `null` when the target is not one we would\n * follow — the same checks the signed token applied at minting.\n */\nexport function outreachStoredLink(target: OutreachClickTarget, nowMs: number): OutreachStoredLink | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n if (target.answerKey !== undefined && !ANSWER_KEY.test(target.answerKey)) return null\n return {\n v: 1,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n linkIndex: target.linkIndex,\n url,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n ...(target.answerKey ? { answerKey: target.answerKey } : {}),\n }\n}\n\n/**\n * What a stored link names, or `null` for a document that is missing or not\n * one we will act on. Checked on the way OUT, as the token is: the document\n * was written by us, and these say it is still one we will follow.\n */\nexport function readOutreachStoredLink(data: unknown): OutreachClickTarget | null {\n if (!data || typeof data !== 'object') return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n // A tracking image's document names no destination, and is never followed.\n if (body['kind'] === OUTREACH_OPEN_KIND) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n const url = outreachClickTargetUrl(body['url'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null\n const answerKey = body['answerKey']\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n linkIndex: body['linkIndex'] as number,\n url,\n ...(body['test'] === true ? { test: true } : {}),\n ...(typeof answerKey === 'string' && ANSWER_KEY.test(answerKey) ? { answerKey } : {}),\n }\n}\n\n/*==========================================\n * THE TRACKING IMAGE (AGL-3395).\n *\n * A sequence that counts opens gives each email one image, and the image is\n * a short link too: an `outreachLinks` document of kind `open`, minted and\n * stored exactly as a link's is, on the same host. The short-link route\n * reads the document and, finding an image's, answers the image rather than\n * a redirect — so the image needs no route, host or rule a link does not\n * already have, and the email names no address a click does not.\n *\n * It names the enrollment and the step, and no destination: there is\n * nothing to follow.\n *=========================================*/\n\n/** The `kind` an image's document carries; a link's carries none. */\nexport const OUTREACH_OPEN_KIND = 'open'\n\n/** What a tracking image names. */\nexport interface OutreachOpenTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the image. */\n stepIndex: number\n /** An image in a TEST of a step (AGL-3325): answered, and recorded nowhere. */\n test?: boolean\n}\n\n/** What an `outreachLinks/{id}` document holds for a tracking image. */\nexport interface OutreachStoredOpen extends OutreachOpenTarget {\n v: 1\n kind: typeof OUTREACH_OPEN_KIND\n createdAtMs: number\n /** Which sequence a TEST image's step belongs to; a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/** The document for one email's image, or `null` when the target is not one we would record. */\nexport function outreachStoredOpen(target: OutreachOpenTarget, nowMs: number): OutreachStoredOpen | null {\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex)) return null\n return {\n v: 1,\n kind: OUTREACH_OPEN_KIND,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n }\n}\n\n/** Whether a stored document is a tracking image's, whatever else it holds. */\nexport function isOutreachStoredOpen(data: unknown): boolean {\n return Boolean(data) && typeof data === 'object' && (data as Record<string, unknown>)['kind'] === OUTREACH_OPEN_KIND\n}\n\n/** What a stored image names, or `null` for a document that is not one we will record. */\nexport function readOutreachStoredOpen(data: unknown): OutreachOpenTarget | null {\n if (!isOutreachStoredOpen(data)) return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex'])) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n ...(body['test'] === true ? { test: true } : {}),\n }\n}\n\n/**\n * The short link for an id, or `null` when there is no HTTPS origin to put it\n * on or the id is not one — no link rather than one that points at nothing. A\n * `null` leaves the destination the step wrote.\n *\n * On the sending domain's own click-tracking host (AGL-3306) when the org has\n * verified one — `https://links.<domain>/<id>`, which that host answers\n * exactly as the console route — and on the console's\n * `/api/outreach/l/<id>` otherwise, which every link keeps answering on.\n */\nexport function outreachShortLinkUrl(input: {\n origin: string | null | undefined\n linkId: string\n /** `https://links.<domain>`, from `resolveTrackingLinkOrigin`, or nothing. */\n trackingOrigin?: string | null\n}): string | null {\n if (!isOutreachLinkId(input.linkId)) return null\n const tracking = httpsOrigin(input.trackingOrigin)\n if (tracking) return `${tracking}/${input.linkId}`\n const origin = httpsOrigin(input.origin)\n return origin ? `${origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}` : null\n}\n\n/** An HTTPS origin, or `null` for anything else. */\nfunction httpsOrigin(value: string | null | undefined): string | null {\n if (!value) return null\n try {\n const url = new URL(String(value))\n return url.protocol === 'https:' ? url.origin : null\n } catch {\n return null\n }\n}\n"],"names":["randomBytes","signedLinkSignature","signedLinkSignatureMatches","OUTREACH_API_ROUTES","OUTREACH_CLICK_PURPOSE","OUTREACH_CLICK_PATH","click","OUTREACH_SHORT_LINK_PATH","shortLink","replace","OUTREACH_CLICK_TARGET_MAX","OUTREACH_TEST_LINK_ENROLLMENT","DOCUMENT_ID","ANSWER_KEY","base64url","text","Buffer","from","toString","outreachClickTargetUrl","value","raw","String","trim","length","url","URL","protocol","href","index","Number","isInteger","mintOutreachClickToken","target","secret","test","orgId","enrollmentId","stepIndex","linkIndex","payload","JSON","stringify","v","o","e","s","i","u","signature","undefined","readOutreachClickToken","token","dot","indexOf","slice","purpose","body","parse","outreachClickUrl","input","origin","OUTREACH_LINK_ID_LENGTH","LINK_ID_ALPHABET","LINK_ID","RegExp","isOutreachLinkId","newOutreachLinkId","random","id","byte","outreachStoredLink","nowMs","answerKey","createdAtMs","readOutreachStoredLink","data","OUTREACH_OPEN_KIND","outreachStoredOpen","kind","isOutreachStoredOpen","Boolean","readOutreachStoredOpen","outreachShortLinkUrl","linkId","tracking","httpsOrigin","trackingOrigin"],"mappings":"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"}