@aglyn/plugins-outreach 1.0.0-beta.156 → 1.0.0-beta.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +13 -12
  2. package/src/lib/components/enrollments-table.d.ts +7 -0
  3. package/src/lib/components/enrollments-table.js +46 -2
  4. package/src/lib/components/enrollments-table.js.map +1 -1
  5. package/src/lib/components/sequence-detail.js +14 -4
  6. package/src/lib/components/sequence-detail.js.map +1 -1
  7. package/src/lib/components/sequence-editor.js +22 -0
  8. package/src/lib/components/sequence-editor.js.map +1 -1
  9. package/src/lib/components/sequence-report-card.d.ts +23 -0
  10. package/src/lib/components/sequence-report-card.js +191 -0
  11. package/src/lib/components/sequence-report-card.js.map +1 -0
  12. package/src/lib/components/use-outreach-data.d.ts +11 -0
  13. package/src/lib/components/use-outreach-data.js +24 -1
  14. package/src/lib/components/use-outreach-data.js.map +1 -1
  15. package/src/lib/constants/api-routes.d.ts +9 -0
  16. package/src/lib/constants/api-routes.js +9 -1
  17. package/src/lib/constants/api-routes.js.map +1 -1
  18. package/src/lib/engine/click-tracking.d.ts +98 -0
  19. package/src/lib/engine/click-tracking.js +252 -0
  20. package/src/lib/engine/click-tracking.js.map +1 -0
  21. package/src/lib/engine/compose.d.ts +24 -0
  22. package/src/lib/engine/compose.js +15 -2
  23. package/src/lib/engine/compose.js.map +1 -1
  24. package/src/lib/engine/sequence-validation.js +4 -1
  25. package/src/lib/engine/sequence-validation.js.map +1 -1
  26. package/src/lib/model/enrollment-engagement.d.ts +31 -0
  27. package/src/lib/model/enrollment-engagement.js +44 -0
  28. package/src/lib/model/enrollment-engagement.js.map +1 -0
  29. package/src/lib/model/outreach.types.d.ts +89 -0
  30. package/src/lib/model/outreach.types.js +25 -0
  31. package/src/lib/model/outreach.types.js.map +1 -1
  32. package/src/lib/model/sequence-draft.js +5 -1
  33. package/src/lib/model/sequence-draft.js.map +1 -1
  34. package/src/lib/model/sequence-report.d.ts +68 -0
  35. package/src/lib/model/sequence-report.js +118 -0
  36. package/src/lib/model/sequence-report.js.map +1 -0
  37. package/src/lib/model/stored-records.d.ts +11 -1
  38. package/src/lib/model/stored-records.js +34 -1
  39. package/src/lib/model/stored-records.js.map +1 -1
  40. package/src/lib/routes/sequence-routes.js +4 -1
  41. package/src/lib/routes/sequence-routes.js.map +1 -1
  42. package/src/lib/runtime/click-events.d.ts +67 -0
  43. package/src/lib/runtime/click-events.js +232 -0
  44. package/src/lib/runtime/click-events.js.map +1 -0
  45. package/src/lib/runtime/click-link.d.ts +94 -0
  46. package/src/lib/runtime/click-link.js +158 -0
  47. package/src/lib/runtime/click-link.js.map +1 -0
  48. package/src/lib/runtime/click-route.d.ts +19 -0
  49. package/src/lib/runtime/click-route.js +108 -0
  50. package/src/lib/runtime/click-route.js.map +1 -0
  51. package/src/lib/runtime/platform-runtime-deps.d.ts +2 -0
  52. package/src/lib/runtime/platform-runtime-deps.js +9 -0
  53. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  54. package/src/lib/runtime/runtime-deps.d.ts +11 -0
  55. package/src/lib/runtime/runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +65 -5
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/server.js +8 -0
  59. package/src/lib/server.js.map +1 -1
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type OutreachClickJudgement } from '../engine/click-tracking';
18
+ import { type OutreachEnrollment } from '../model/outreach.types';
19
+ import type { OutreachClickTarget } from './click-link';
20
+ import type { OutreachRuntimeDeps } from './runtime-deps';
21
+ /**
22
+ * RECORDING A CLICK (AGL-3239).
23
+ *
24
+ * Every write here happens AFTER the recipient has been sent on their way.
25
+ * A click is bookkeeping beside the act, in the same order `timeline.ts`
26
+ * puts an email and its filing: the destination is in the token, so the
27
+ * redirect needs nothing from Firestore to answer it, and a failure here
28
+ * costs a number rather than a visit.
29
+ *
30
+ * Three places are written, because three different questions are asked of
31
+ * them and no one document answers all three:
32
+ *
33
+ * 1. **The enrollment** — "did THIS person act?", which the enrollments
34
+ * table shows per row and a rep reads before calling someone.
35
+ * 2. **The sequence** — "is this sequence working?", which is the report
36
+ * card, and which cannot be derived from the enrollments because the
37
+ * console lists them a page at a time.
38
+ * 3. **The link rollup** — "which of our links did they follow?", one
39
+ * document holding a bounded map, keyed and capped by the campaign
40
+ * rollup's own rules so a link means the same thing in both reports.
41
+ */
42
+ /** What one recorded click turned out to be. */
43
+ export interface OutreachClickOutcome extends OutreachClickJudgement {
44
+ /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */
45
+ first: boolean;
46
+ /** The enrollment it was recorded against, or `null` when there is none. */
47
+ enrollment: OutreachEnrollment | null;
48
+ }
49
+ /**
50
+ * Records one visit to a tracking link.
51
+ *
52
+ * The enrollment is read and written in a transaction, because two links
53
+ * followed at once must not both read a `clicks` of nought and both write a
54
+ * one — and because whether this is the person's FIRST click is decided from
55
+ * the same read that writes it, which is what keeps `uniqueClicks` from
56
+ * counting one person twice.
57
+ *
58
+ * An enrollment that no longer exists belongs to a person or a workspace
59
+ * already erased. Nothing is recorded for them, anywhere — a deleted record
60
+ * must not be reconstructed from a click on an old email — and the caller
61
+ * has already answered the redirect either way.
62
+ */
63
+ export declare function recordOutreachClick(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>, input: {
64
+ target: OutreachClickTarget;
65
+ method: string;
66
+ userAgent: string | null | undefined;
67
+ }): Promise<OutreachClickOutcome>;
@@ -0,0 +1,232 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { CAMPAIGN_LINK_ROLLUP_MAX, campaignLinkKey } from "@aglyn/shared-ui-email-campaigns/model";
18
+ import { FieldValue } from "firebase-admin/firestore";
19
+ import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
20
+ import { judgeOutreachClick } from "../engine/click-tracking.js";
21
+ import { readOutreachEngagement } from "../model/enrollment-engagement.js";
22
+ import { OUTREACH_LINK_ROLLUP_PATH } from "../model/outreach.types.js";
23
+ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
24
+ /** When the step that carried the link was sent, or `null` when it cannot be found. */ function sentAtMs(enrollment, stepIndex) {
25
+ var _enrollment_stepRecords;
26
+ const records = (_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : [];
27
+ for(let index = records.length - 1; index >= 0; index -= 1){
28
+ const record = records[index];
29
+ if ((record == null ? void 0 : record.stepIndex) === stepIndex && record.kind === 'email') {
30
+ return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ /**
36
+ * Records one visit to a tracking link.
37
+ *
38
+ * The enrollment is read and written in a transaction, because two links
39
+ * followed at once must not both read a `clicks` of nought and both write a
40
+ * one — and because whether this is the person's FIRST click is decided from
41
+ * the same read that writes it, which is what keeps `uniqueClicks` from
42
+ * counting one person twice.
43
+ *
44
+ * An enrollment that no longer exists belongs to a person or a workspace
45
+ * already erased. Nothing is recorded for them, anywhere — a deleted record
46
+ * must not be reconstructed from a click on an old email — and the caller
47
+ * has already answered the redirect either way.
48
+ */ export async function recordOutreachClick(deps, input) {
49
+ const firestore = deps.firestore();
50
+ const { orgId, enrollmentId, stepIndex, url } = input.target;
51
+ const nowMs = deps.now();
52
+ const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId);
53
+ const outcome = await firestore.runTransaction(async (transaction)=>{
54
+ var _held_firstClickAtMs, _campaignLinkKey;
55
+ const snapshot = await transaction.get(ref);
56
+ const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
57
+ if (!enrollment) {
58
+ return {
59
+ human: false,
60
+ machineReason: null,
61
+ first: false,
62
+ enrollment: null
63
+ };
64
+ }
65
+ const sent = sentAtMs(enrollment, stepIndex);
66
+ const judgement = judgeOutreachClick({
67
+ method: input.method,
68
+ userAgent: input.userAgent,
69
+ sinceSentMs: sent === null ? null : nowMs - sent
70
+ });
71
+ const held = readOutreachEngagement(enrollment.engagement);
72
+ const first = judgement.human && held.firstClickAtMs === null;
73
+ const engagement = judgement.human ? {
74
+ clicks: held.clicks + 1,
75
+ firstClickAtMs: (_held_firstClickAtMs = held.firstClickAtMs) != null ? _held_firstClickAtMs : nowMs,
76
+ lastClickAtMs: nowMs,
77
+ lastClickUrl: (_campaignLinkKey = campaignLinkKey(url)) != null ? _campaignLinkKey : held.lastClickUrl,
78
+ machineClicks: held.machineClicks
79
+ } : _extends({}, held, {
80
+ machineClicks: held.machineClicks + 1
81
+ });
82
+ /*
83
+ * `updatedAtMs` is deliberately NOT touched. It is what the enrollments
84
+ * table sorts "last activity" by and what a member reads as "something
85
+ * happened to this enrollment", and a scanner's fetch of a link is not
86
+ * that. The engagement carries its own timestamps for what a click is.
87
+ */ transaction.update(ref, {
88
+ engagement
89
+ });
90
+ return _extends({}, judgement, {
91
+ first,
92
+ enrollment: _extends({}, enrollment, {
93
+ engagement
94
+ })
95
+ });
96
+ });
97
+ if (!outcome.enrollment) return outcome;
98
+ await Promise.all([
99
+ bumpSequenceStats(firestore, outcome, {
100
+ orgId,
101
+ sequenceId: outcome.enrollment.sequenceId,
102
+ nowMs
103
+ }),
104
+ outcome.human ? bumpLinkRollup(firestore, {
105
+ orgId,
106
+ sequenceId: outcome.enrollment.sequenceId,
107
+ url
108
+ }) : null,
109
+ outcome.first ? fileFirstClick(deps, {
110
+ orgId,
111
+ enrollment: outcome.enrollment,
112
+ url,
113
+ nowMs
114
+ }) : null
115
+ ]);
116
+ return outcome;
117
+ }
118
+ /**
119
+ * The sequence's counters, incremented outside the enrollment's transaction.
120
+ *
121
+ * One sequence holds every enrollment's clicks, so a transaction over it
122
+ * would serialise every recipient of a sequence against every other.
123
+ * `FieldValue.increment` is a blind write with no read to contend on, which
124
+ * is exactly what a counter wants.
125
+ */ async function bumpSequenceStats(firestore, outcome, input) {
126
+ if (!input.sequenceId) return;
127
+ const patch = outcome.human ? _extends({
128
+ 'stats.clicks': FieldValue.increment(1),
129
+ 'stats.lastClickAtMs': input.nowMs
130
+ }, outcome.first ? {
131
+ 'stats.uniqueClicks': FieldValue.increment(1)
132
+ } : {}) : {
133
+ 'stats.machineClicks': FieldValue.increment(1)
134
+ };
135
+ await outreachOrgCollection(firestore, input.orgId, 'sequences').doc(input.sequenceId).update(patch).catch((error)=>{
136
+ // A sequence deleted while its mail is still out there. The click is
137
+ // already on the enrollment; the aggregate has nowhere to go.
138
+ console.warn('[outreach] a click could not be counted on its sequence', error);
139
+ });
140
+ }
141
+ /**
142
+ * The per-destination rollup for one sequence.
143
+ *
144
+ * Keyed by `campaignLinkKey`, which drops the query string — a step body
145
+ * goes through the merge resolver per recipient, so keying on the full URL
146
+ * would mint one row per PERSON and turn the aggregate into the
147
+ * per-recipient list it exists to summarise. Capped at
148
+ * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it
149
+ * is one document with a size ceiling. Past the cap a click is counted in
150
+ * `overflowClicks` rather than dropped, so the table still reconciles.
151
+ */ async function bumpLinkRollup(firestore, input) {
152
+ if (!input.sequenceId) return;
153
+ const key = campaignLinkKey(input.url);
154
+ const ref = outreachOrgCollection(firestore, input.orgId, 'sequences').doc(input.sequenceId).collection(OUTREACH_LINK_ROLLUP_PATH[0]).doc(OUTREACH_LINK_ROLLUP_PATH[1]);
155
+ if (!key) {
156
+ await ref.set({
157
+ unattributedClicks: FieldValue.increment(1)
158
+ }, {
159
+ merge: true
160
+ }).catch(()=>undefined);
161
+ return;
162
+ }
163
+ try {
164
+ await firestore.runTransaction(async (transaction)=>{
165
+ var _snapshot_data;
166
+ const snapshot = await transaction.get(ref);
167
+ const links = snapshot.exists ? (_snapshot_data = snapshot.data()) == null ? void 0 : _snapshot_data['links'] : null;
168
+ const held = links == null ? void 0 : links[campaignRollupField(key)];
169
+ if (!held && Object.keys(links != null ? links : {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {
170
+ transaction.set(ref, {
171
+ overflowClicks: FieldValue.increment(1)
172
+ }, {
173
+ merge: true
174
+ });
175
+ return;
176
+ }
177
+ transaction.set(ref, {
178
+ links: {
179
+ [campaignRollupField(key)]: {
180
+ url: key,
181
+ clicks: (Number(held == null ? void 0 : held.clicks) || 0) + 1
182
+ }
183
+ }
184
+ }, {
185
+ merge: true
186
+ });
187
+ });
188
+ } catch (error) {
189
+ console.warn('[outreach] a click could not be added to the link rollup', error);
190
+ }
191
+ }
192
+ /**
193
+ * The map key one destination is held under.
194
+ *
195
+ * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains
196
+ * all three, so the URL itself cannot be the key. It is base64url of the
197
+ * key, with the readable form kept in the row's own `url` — which is what
198
+ * the report renders, so nothing downstream ever decodes this.
199
+ */ function campaignRollupField(key) {
200
+ return Buffer.from(key, 'utf8').toString('base64url');
201
+ }
202
+ /**
203
+ * A person's FIRST click on a sequence's link, on their record.
204
+ *
205
+ * Only the first. A note per click would bury the contact's timeline under
206
+ * the same fact repeated, and the one a rep is reading for is that this
207
+ * person went from ignoring the mail to acting on it.
208
+ */ async function fileFirstClick(deps, input) {
209
+ const writer = deps.timeline();
210
+ if (!writer) return;
211
+ try {
212
+ var _campaignLinkKey;
213
+ const written = await writer.logActivity({
214
+ orgId: input.orgId,
215
+ hostId: input.enrollment.hostId,
216
+ link: outreachEnrollmentLink(input.enrollment),
217
+ sourcePluginId: OUTREACH_PLUGIN_ID,
218
+ kind: 'note',
219
+ atMs: input.nowMs,
220
+ body: `Followed a link in a sequence email: ${(_campaignLinkKey = campaignLinkKey(input.url)) != null ? _campaignLinkKey : input.url}`,
221
+ byUid: '',
222
+ byName: null
223
+ });
224
+ if (written.ok === false) {
225
+ console.warn(`[outreach] the record system did not file a click: ${written.error}`);
226
+ }
227
+ } catch (error) {
228
+ console.error('[outreach] filing a click on the record failed', error);
229
+ }
230
+ }
231
+
232
+ //# sourceMappingURL=click-events.js.map
@@ -0,0 +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 CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\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 { readOutreachEngagement } from '../model/enrollment-engagement'\nimport {\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 * Three places are written, because three different questions are asked of\n * them and no one document answers all three:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone.\n * 2. **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 * 3. **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 the link was sent, or `null` when it cannot be found. */\nfunction sentAtMs(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 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 = sentAtMs(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 engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: campaignLinkKey(url) ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n }\n : { ...held, machineClicks: held.machineClicks + 1 }\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 transaction.update(ref, { engagement })\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 `campaignLinkKey`, 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 * `CAMPAIGN_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 = campaignLinkKey(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 >= CAMPAIGN_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: ${campaignLinkKey(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":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","readOutreachEngagement","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","sentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","human","machineReason","first","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","clicks","lastClickAtMs","lastClickUrl","machineClicks","update","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","key","collection","set","unattributedClicks","merge","links","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239).
19
+ *
20
+ * `/api/outreach/click?t=…` on the console, minted exactly as the one-click
21
+ * unsubscribe link is and for the same reasons: `<payload>.<signature>`,
22
+ * where the payload is base64url JSON and the signature is the platform's
23
+ * signed-link HMAC under Outreach's own purpose. No other link verifies as
24
+ * this one and this one verifies as no other.
25
+ *
26
+ * ## The destination is IN the token
27
+ *
28
+ * The alternative — an index into the enrollment's stored step record — mints
29
+ * a shorter link, and it was rejected. A link in an email has to keep working
30
+ * after the enrollment is gone: a person erased from the workspace, a
31
+ * sequence deleted, a workspace closed. With the destination in the token the
32
+ * redirect needs no read at all to answer, and the RECORDING is what becomes
33
+ * best-effort — bookkeeping beside the act, which is the same order
34
+ * `runtime/timeline.ts` puts them in.
35
+ *
36
+ * It costs a long link, which in a cold plain-text email is a real cost and
37
+ * is why a sequence's `trackClicks` setting is off by default.
38
+ *
39
+ * ## It cannot be turned into an open redirect
40
+ *
41
+ * The signature covers the destination, so the only URLs this route will
42
+ * forward to are ones a send of ours signed, and the only URLs a send signs
43
+ * are the ones written in that sequence's own step. Anything else fails the
44
+ * check and is refused rather than followed.
45
+ *
46
+ * ## It names ids, never an address
47
+ *
48
+ * A tracking URL lands in the recipient's history, in their gateway's logs
49
+ * and in ours. It carries the organization, the enrollment, the step and the
50
+ * link's index — the enrollment names the person, and that lookup is ours to
51
+ * make, not a bystander's to read off a URL.
52
+ */
53
+ /** The purpose Outreach's click links are signed under. */
54
+ export declare const OUTREACH_CLICK_PURPOSE = "outreach-click";
55
+ /** The link's path on the console. */
56
+ export declare const OUTREACH_CLICK_PATH: string;
57
+ /**
58
+ * The longest destination a tracking link will carry.
59
+ *
60
+ * A token is a URL inside a URL, and mail clients wrap, truncate and refuse
61
+ * to linkify very long ones. Past this the link is left alone: an unmeasured
62
+ * click beats a link that arrives broken.
63
+ */
64
+ export declare const OUTREACH_CLICK_TARGET_MAX = 512;
65
+ /** What a click token names. */
66
+ export interface OutreachClickTarget {
67
+ orgId: string;
68
+ enrollmentId: string;
69
+ /** The step whose email carried the link. */
70
+ stepIndex: number;
71
+ /** The link's index among the ones that step rewrote. */
72
+ linkIndex: number;
73
+ /** Where the recipient asked to go. */
74
+ url: string;
75
+ }
76
+ /** A destination we will sign, or `null` for one we will not. */
77
+ export declare function outreachClickTargetUrl(value: unknown): string | null;
78
+ /** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */
79
+ export declare function mintOutreachClickToken(target: OutreachClickTarget, secret?: string): string | null;
80
+ /** What a token names, or `null` for one that is malformed or not ours. */
81
+ export declare function readOutreachClickToken(token: unknown, secret?: string): OutreachClickTarget | null;
82
+ /**
83
+ * The tracking URL for one link on the console at `origin`, or `null` when
84
+ * the origin is not HTTPS, the destination is not one we sign, or there is
85
+ * no secret to sign with — no link rather than one that points at nothing.
86
+ *
87
+ * A `null` leaves the recipient with the destination the step actually
88
+ * wrote, which is what `rewriteOutreachBodyLinks` does with it.
89
+ */
90
+ export declare function outreachClickUrl(input: {
91
+ origin: string | null | undefined;
92
+ target: OutreachClickTarget;
93
+ secret?: string;
94
+ }): string | null;
@@ -0,0 +1,158 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { signedLinkSignature, signedLinkSignatureMatches } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
18
+ import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
19
+ /**
20
+ * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239).
21
+ *
22
+ * `/api/outreach/click?t=…` on the console, minted exactly as the one-click
23
+ * unsubscribe link is and for the same reasons: `<payload>.<signature>`,
24
+ * where the payload is base64url JSON and the signature is the platform's
25
+ * signed-link HMAC under Outreach's own purpose. No other link verifies as
26
+ * this one and this one verifies as no other.
27
+ *
28
+ * ## The destination is IN the token
29
+ *
30
+ * The alternative — an index into the enrollment's stored step record — mints
31
+ * a shorter link, and it was rejected. A link in an email has to keep working
32
+ * after the enrollment is gone: a person erased from the workspace, a
33
+ * sequence deleted, a workspace closed. With the destination in the token the
34
+ * redirect needs no read at all to answer, and the RECORDING is what becomes
35
+ * best-effort — bookkeeping beside the act, which is the same order
36
+ * `runtime/timeline.ts` puts them in.
37
+ *
38
+ * It costs a long link, which in a cold plain-text email is a real cost and
39
+ * is why a sequence's `trackClicks` setting is off by default.
40
+ *
41
+ * ## It cannot be turned into an open redirect
42
+ *
43
+ * The signature covers the destination, so the only URLs this route will
44
+ * forward to are ones a send of ours signed, and the only URLs a send signs
45
+ * are the ones written in that sequence's own step. Anything else fails the
46
+ * check and is refused rather than followed.
47
+ *
48
+ * ## It names ids, never an address
49
+ *
50
+ * A tracking URL lands in the recipient's history, in their gateway's logs
51
+ * and in ours. It carries the organization, the enrollment, the step and the
52
+ * link's index — the enrollment names the person, and that lookup is ours to
53
+ * make, not a bystander's to read off a URL.
54
+ */ /** The purpose Outreach's click links are signed under. */ export const OUTREACH_CLICK_PURPOSE = 'outreach-click';
55
+ /** The link's path on the console. */ export const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`;
56
+ /**
57
+ * The longest destination a tracking link will carry.
58
+ *
59
+ * A token is a URL inside a URL, and mail clients wrap, truncate and refuse
60
+ * to linkify very long ones. Past this the link is left alone: an unmeasured
61
+ * click beats a link that arrives broken.
62
+ */ export const OUTREACH_CLICK_TARGET_MAX = 512;
63
+ /** A document id as a token may carry one. */ const DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/;
64
+ const base64url = (text)=>Buffer.from(text, 'utf8').toString('base64url');
65
+ /** A destination we will sign, or `null` for one we will not. */ export function outreachClickTargetUrl(value) {
66
+ const raw = String(value != null ? value : '').trim();
67
+ if (!raw || raw.length > OUTREACH_CLICK_TARGET_MAX) return null;
68
+ let url;
69
+ try {
70
+ url = new URL(raw);
71
+ } catch (unused) {
72
+ return null;
73
+ }
74
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
75
+ return url.href;
76
+ }
77
+ /** Whether an index is one a token may carry. */ const index = (value)=>typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 1000;
78
+ /** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */ export function mintOutreachClickToken(target, secret) {
79
+ const url = outreachClickTargetUrl(target.url);
80
+ if (!url) return null;
81
+ if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null;
82
+ if (!index(target.stepIndex) || !index(target.linkIndex)) return null;
83
+ const payload = base64url(JSON.stringify({
84
+ v: 1,
85
+ o: target.orgId,
86
+ e: target.enrollmentId,
87
+ s: target.stepIndex,
88
+ i: target.linkIndex,
89
+ u: url
90
+ }));
91
+ const signature = secret === undefined ? signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload) : signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload, secret);
92
+ return signature ? `${payload}.${signature}` : null;
93
+ }
94
+ /** What a token names, or `null` for one that is malformed or not ours. */ export function readOutreachClickToken(token, secret) {
95
+ const raw = typeof token === 'string' ? token.trim() : '';
96
+ const dot = raw.indexOf('.');
97
+ // Generous, because the payload carries a destination — but bounded, so an
98
+ // arbitrarily long string never reaches the HMAC.
99
+ if (dot <= 0 || raw.length > 2048) return null;
100
+ const payload = raw.slice(0, dot);
101
+ const signature = raw.slice(dot + 1);
102
+ if (!signedLinkSignatureMatches(_extends({
103
+ purpose: OUTREACH_CLICK_PURPOSE,
104
+ payload,
105
+ signature
106
+ }, secret === undefined ? {} : {
107
+ secret
108
+ }))) {
109
+ return null;
110
+ }
111
+ try {
112
+ var _body_o, _body_e;
113
+ const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
114
+ if (body['v'] !== 1) return null;
115
+ const orgId = String((_body_o = body['o']) != null ? _body_o : '');
116
+ const enrollmentId = String((_body_e = body['e']) != null ? _body_e : '');
117
+ const stepIndex = body['s'];
118
+ const linkIndex = body['i'];
119
+ // Checked again on the way OUT, not only on the way in: the signature
120
+ // says we minted this payload, and these say it is still one we will act
121
+ // on — which is what keeps a destination that was signable under an
122
+ // older rule from being followed under this one.
123
+ const url = outreachClickTargetUrl(body['u']);
124
+ if (!url) return null;
125
+ if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null;
126
+ if (!index(stepIndex) || !index(linkIndex)) return null;
127
+ return {
128
+ orgId,
129
+ enrollmentId,
130
+ stepIndex: stepIndex,
131
+ linkIndex: linkIndex,
132
+ url
133
+ };
134
+ } catch (unused) {
135
+ return null;
136
+ }
137
+ }
138
+ /**
139
+ * The tracking URL for one link on the console at `origin`, or `null` when
140
+ * the origin is not HTTPS, the destination is not one we sign, or there is
141
+ * no secret to sign with — no link rather than one that points at nothing.
142
+ *
143
+ * A `null` leaves the recipient with the destination the step actually
144
+ * wrote, which is what `rewriteOutreachBodyLinks` does with it.
145
+ */ export function outreachClickUrl(input) {
146
+ let origin;
147
+ try {
148
+ var _input_origin;
149
+ origin = new URL(String((_input_origin = input.origin) != null ? _input_origin : ''));
150
+ } catch (unused) {
151
+ return null;
152
+ }
153
+ if (origin.protocol !== 'https:') return null;
154
+ const token = mintOutreachClickToken(input.target, input.secret);
155
+ return token ? `${origin.origin}${OUTREACH_CLICK_PATH}?t=${token}` : null;
156
+ }
157
+
158
+ //# sourceMappingURL=click-link.js.map
@@ -0,0 +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 {\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 *\n * `/api/outreach/click?t=…` on the console, minted exactly as the one-click\n * unsubscribe link is and for the same reasons: `<payload>.<signature>`,\n * where the payload is base64url JSON and the signature is the platform's\n * signed-link HMAC under Outreach's own purpose. No other link verifies as\n * this one and this one verifies as no other.\n *\n * ## The destination is IN the token\n *\n * The alternative — an index into the enrollment's stored step record — mints\n * a shorter link, and it was rejected. A link in an email has to keep working\n * after the enrollment is gone: a person erased from the workspace, a\n * sequence deleted, a workspace closed. With the destination in the token the\n * redirect needs no read at all to answer, and the RECORDING is what becomes\n * best-effort — bookkeeping beside the act, which is the same order\n * `runtime/timeline.ts` puts them in.\n *\n * It costs a long link, which in a cold plain-text email is a real cost and\n * is why a sequence's `trackClicks` setting is off by default.\n *\n * ## It cannot be turned into an open redirect\n *\n * The signature covers the destination, so the only URLs this route will\n * forward to are ones a send of ours signed, and the only URLs a send signs\n * are the ones written in that sequence's own step. Anything else fails the\n * check and is refused rather than followed.\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. It carries the organization, the enrollment, the step and the\n * link's index — the enrollment names the person, and that lookup is ours to\n * make, not a bystander's to read off a URL.\n */\n\n/** The purpose Outreach's click links are signed under. */\nexport const OUTREACH_CLICK_PURPOSE = 'outreach-click'\n\n/** The link's path on the console. */\nexport const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`\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\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"],"names":["signedLinkSignature","signedLinkSignatureMatches","OUTREACH_API_ROUTES","OUTREACH_CLICK_PURPOSE","OUTREACH_CLICK_PATH","click","OUTREACH_CLICK_TARGET_MAX","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"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,mBAAmB,EACnBC,0BAA0B,QACrB,yDAAwD;AAC/D,SAASC,mBAAmB,QAAQ,6BAAyB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GAED,yDAAyD,GACzD,OAAO,MAAMC,yBAAyB,iBAAgB;AAEtD,oCAAoC,GACpC,OAAO,MAAMC,sBAAsB,CAAC,KAAK,EAAEF,oBAAoBG,KAAK,EAAE,CAAA;AAEtE;;;;;;CAMC,GACD,OAAO,MAAMC,4BAA4B,IAAG;AAc5C,4CAA4C,GAC5C,MAAMC,cAAc;AAEpB,MAAMC,YAAY,CAACC,OAAyBC,OAAOC,IAAI,CAACF,MAAM,QAAQG,QAAQ,CAAC;AAE/E,+DAA+D,GAC/D,OAAO,SAASC,uBAAuBC,KAAc;IACnD,MAAMC,MAAMC,OAAOF,gBAAAA,QAAS,IAAIG,IAAI;IACpC,IAAI,CAACF,OAAOA,IAAIG,MAAM,GAAGZ,2BAA2B,OAAO;IAC3D,IAAIa;IACJ,IAAI;QACFA,MAAM,IAAIC,IAAIL;IAChB,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAII,IAAIE,QAAQ,KAAK,WAAWF,IAAIE,QAAQ,KAAK,UAAU,OAAO;IAClE,OAAOF,IAAIG,IAAI;AACjB;AAEA,+CAA+C,GAC/C,MAAMC,QAAQ,CAACT,QACb,OAAOA,UAAU,YAAYU,OAAOC,SAAS,CAACX,UAAUA,SAAS,KAAKA,QAAQ;AAEhF,4FAA4F,GAC5F,OAAO,SAASY,uBACdC,MAA2B,EAC3BC,MAAe;IAEf,MAAMT,MAAMN,uBAAuBc,OAAOR,GAAG;IAC7C,IAAI,CAACA,KAAK,OAAO;IACjB,IAAI,CAACZ,YAAYsB,IAAI,CAACF,OAAOG,KAAK,KAAK,CAACvB,YAAYsB,IAAI,CAACF,OAAOI,YAAY,GAAG,OAAO;IACtF,IAAI,CAACR,MAAMI,OAAOK,SAAS,KAAK,CAACT,MAAMI,OAAOM,SAAS,GAAG,OAAO;IACjE,MAAMC,UAAU1B,UACd2B,KAAKC,SAAS,CAAC;QACbC,GAAG;QACHC,GAAGX,OAAOG,KAAK;QACfS,GAAGZ,OAAOI,YAAY;QACtBS,GAAGb,OAAOK,SAAS;QACnBS,GAAGd,OAAOM,SAAS;QACnBS,GAAGvB;IACL;IAEF,MAAMwB,YACJf,WAAWgB,YACP5C,oBAAoBG,wBAAwB+B,WAC5ClC,oBAAoBG,wBAAwB+B,SAASN;IAC3D,OAAOe,YAAY,GAAGT,QAAQ,CAAC,EAAES,WAAW,GAAG;AACjD;AAEA,yEAAyE,GACzE,OAAO,SAASE,uBACdC,KAAc,EACdlB,MAAe;IAEf,MAAMb,MAAM,OAAO+B,UAAU,WAAWA,MAAM7B,IAAI,KAAK;IACvD,MAAM8B,MAAMhC,IAAIiC,OAAO,CAAC;IACxB,2EAA2E;IAC3E,kDAAkD;IAClD,IAAID,OAAO,KAAKhC,IAAIG,MAAM,GAAG,MAAM,OAAO;IAC1C,MAAMgB,UAAUnB,IAAIkC,KAAK,CAAC,GAAGF;IAC7B,MAAMJ,YAAY5B,IAAIkC,KAAK,CAACF,MAAM;IAClC,IACE,CAAC9C,2BAA2B;QAC1BiD,SAAS/C;QACT+B;QACAS;OACIf,WAAWgB,YAAY,CAAC,IAAI;QAAEhB;IAAO,KAE3C;QACA,OAAO;IACT;IACA,IAAI;YAGmBuB,SACOA;QAH5B,MAAMA,OAAOhB,KAAKiB,KAAK,CAAC1C,OAAOC,IAAI,CAACuB,SAAS,aAAatB,QAAQ,CAAC;QACnE,IAAIuC,IAAI,CAAC,IAAI,KAAK,GAAG,OAAO;QAC5B,MAAMrB,QAAQd,QAAOmC,UAAAA,IAAI,CAAC,IAAI,YAATA,UAAa;QAClC,MAAMpB,eAAef,QAAOmC,UAAAA,IAAI,CAAC,IAAI,YAATA,UAAa;QACzC,MAAMnB,YAAYmB,IAAI,CAAC,IAAI;QAC3B,MAAMlB,YAAYkB,IAAI,CAAC,IAAI;QAC3B,sEAAsE;QACtE,yEAAyE;QACzE,oEAAoE;QACpE,iDAAiD;QACjD,MAAMhC,MAAMN,uBAAuBsC,IAAI,CAAC,IAAI;QAC5C,IAAI,CAAChC,KAAK,OAAO;QACjB,IAAI,CAACZ,YAAYsB,IAAI,CAACC,UAAU,CAACvB,YAAYsB,IAAI,CAACE,eAAe,OAAO;QACxE,IAAI,CAACR,MAAMS,cAAc,CAACT,MAAMU,YAAY,OAAO;QACnD,OAAO;YACLH;YACAC;YACAC,WAAWA;YACXC,WAAWA;YACXd;QACF;IACF,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASkC,iBAAiBC,KAIhC;IACC,IAAIC;IACJ,IAAI;YACsBD;QAAxBC,SAAS,IAAInC,IAAIJ,QAAOsC,gBAAAA,MAAMC,MAAM,YAAZD,gBAAgB;IAC1C,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAIC,OAAOlC,QAAQ,KAAK,UAAU,OAAO;IACzC,MAAMyB,QAAQpB,uBAAuB4B,MAAM3B,MAAM,EAAE2B,MAAM1B,MAAM;IAC/D,OAAOkB,QAAQ,GAAGS,OAAOA,MAAM,GAAGnD,oBAAoB,GAAG,EAAE0C,OAAO,GAAG;AACvE"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
18
+ import type { OutreachRuntimeDeps } from './runtime-deps';
19
+ export declare function createOutreachClickRoute(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>): PluginWebApiHandler;