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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/enrollment-detail.js +13 -3
  3. package/src/lib/components/enrollment-detail.js.map +1 -1
  4. package/src/lib/components/sequence-editor.js +23 -1
  5. package/src/lib/components/sequence-editor.js.map +1 -1
  6. package/src/lib/components/sequence-report-card.d.ts +3 -2
  7. package/src/lib/components/sequence-report-card.js +29 -1
  8. package/src/lib/components/sequence-report-card.js.map +1 -1
  9. package/src/lib/engine/click-tracking.d.ts +9 -0
  10. package/src/lib/engine/click-tracking.js +2 -2
  11. package/src/lib/engine/click-tracking.js.map +1 -1
  12. package/src/lib/engine/compose.d.ts +13 -0
  13. package/src/lib/engine/compose.js +4 -0
  14. package/src/lib/engine/compose.js.map +1 -1
  15. package/src/lib/engine/open-tracking.d.ts +61 -0
  16. package/src/lib/engine/open-tracking.js +164 -0
  17. package/src/lib/engine/open-tracking.js.map +1 -0
  18. package/src/lib/engine/sequence-validation.js +3 -0
  19. package/src/lib/engine/sequence-validation.js.map +1 -1
  20. package/src/lib/model/enrollment-engagement.d.ts +6 -0
  21. package/src/lib/model/enrollment-engagement.js +17 -1
  22. package/src/lib/model/enrollment-engagement.js.map +1 -1
  23. package/src/lib/model/enrollment-history.d.ts +8 -0
  24. package/src/lib/model/enrollment-history.js +29 -0
  25. package/src/lib/model/enrollment-history.js.map +1 -1
  26. package/src/lib/model/enrollment-timeline.d.ts +7 -1
  27. package/src/lib/model/enrollment-timeline.js +41 -4
  28. package/src/lib/model/enrollment-timeline.js.map +1 -1
  29. package/src/lib/model/outreach.types.d.ts +71 -4
  30. package/src/lib/model/outreach.types.js +3 -2
  31. package/src/lib/model/outreach.types.js.map +1 -1
  32. package/src/lib/model/sequence-draft.js +3 -0
  33. package/src/lib/model/sequence-draft.js.map +1 -1
  34. package/src/lib/model/sequence-report.d.ts +25 -2
  35. package/src/lib/model/sequence-report.js +36 -10
  36. package/src/lib/model/sequence-report.js.map +1 -1
  37. package/src/lib/model/step-preview.d.ts +6 -0
  38. package/src/lib/model/step-preview.js +3 -2
  39. package/src/lib/model/step-preview.js.map +1 -1
  40. package/src/lib/model/stored-records.js +9 -1
  41. package/src/lib/model/stored-records.js.map +1 -1
  42. package/src/lib/routes/step-test-routes.js +40 -12
  43. package/src/lib/routes/step-test-routes.js.map +1 -1
  44. package/src/lib/runtime/click-events.d.ts +2 -0
  45. package/src/lib/runtime/click-events.js +6 -6
  46. package/src/lib/runtime/click-events.js.map +1 -1
  47. package/src/lib/runtime/click-link.d.ts +25 -0
  48. package/src/lib/runtime/click-link.js +49 -0
  49. package/src/lib/runtime/click-link.js.map +1 -1
  50. package/src/lib/runtime/click-route.d.ts +4 -0
  51. package/src/lib/runtime/click-route.js +46 -3
  52. package/src/lib/runtime/click-route.js.map +1 -1
  53. package/src/lib/runtime/open-events.d.ts +55 -0
  54. package/src/lib/runtime/open-events.js +121 -0
  55. package/src/lib/runtime/open-events.js.map +1 -0
  56. package/src/lib/runtime/send-job.js +44 -8
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/transport/rfc5322.d.ts +14 -3
  59. package/src/lib/transport/rfc5322.js +44 -9
  60. package/src/lib/transport/rfc5322.js.map +1 -1
  61. package/src/lib/transport/send-message.js +2 -1
  62. package/src/lib/transport/send-message.js.map +1 -1
@@ -215,6 +215,8 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
215
215
  if (!data || typeof data !== 'object') return null;
216
216
  const body = data;
217
217
  if (body['v'] !== 1) return null;
218
+ // A tracking image's document names no destination, and is never followed.
219
+ if (body['kind'] === OUTREACH_OPEN_KIND) return null;
218
220
  const orgId = String((_body_orgId = body['orgId']) != null ? _body_orgId : '');
219
221
  const enrollmentId = String((_body_enrollmentId = body['enrollmentId']) != null ? _body_enrollmentId : '');
220
222
  const url = outreachClickTargetUrl(body['url']);
@@ -231,6 +233,53 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
231
233
  test: true
232
234
  } : {});
233
235
  }
236
+ /*==========================================
237
+ * THE TRACKING IMAGE (AGL-3395).
238
+ *
239
+ * A sequence that counts opens gives each email one image, and the image is
240
+ * a short link too: an `outreachLinks` document of kind `open`, minted and
241
+ * stored exactly as a link's is, on the same host. The short-link route
242
+ * reads the document and, finding an image's, answers the image rather than
243
+ * a redirect — so the image needs no route, host or rule a link does not
244
+ * already have, and the email names no address a click does not.
245
+ *
246
+ * It names the enrollment and the step, and no destination: there is
247
+ * nothing to follow.
248
+ *=========================================*/ /** The `kind` an image's document carries; a link's carries none. */ export const OUTREACH_OPEN_KIND = 'open';
249
+ /** The document for one email's image, or `null` when the target is not one we would record. */ export function outreachStoredOpen(target, nowMs) {
250
+ if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null;
251
+ if (!index(target.stepIndex)) return null;
252
+ return _extends({
253
+ v: 1,
254
+ kind: OUTREACH_OPEN_KIND,
255
+ orgId: target.orgId,
256
+ enrollmentId: target.enrollmentId,
257
+ stepIndex: target.stepIndex,
258
+ createdAtMs: nowMs
259
+ }, target.test ? {
260
+ test: true
261
+ } : {});
262
+ }
263
+ /** Whether a stored document is a tracking image's, whatever else it holds. */ export function isOutreachStoredOpen(data) {
264
+ return Boolean(data) && typeof data === 'object' && data['kind'] === OUTREACH_OPEN_KIND;
265
+ }
266
+ /** What a stored image names, or `null` for a document that is not one we will record. */ export function readOutreachStoredOpen(data) {
267
+ var _body_orgId, _body_enrollmentId;
268
+ if (!isOutreachStoredOpen(data)) return null;
269
+ const body = data;
270
+ if (body['v'] !== 1) return null;
271
+ const orgId = String((_body_orgId = body['orgId']) != null ? _body_orgId : '');
272
+ const enrollmentId = String((_body_enrollmentId = body['enrollmentId']) != null ? _body_enrollmentId : '');
273
+ if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null;
274
+ if (!index(body['stepIndex'])) return null;
275
+ return _extends({
276
+ orgId,
277
+ enrollmentId,
278
+ stepIndex: body['stepIndex']
279
+ }, body['test'] === true ? {
280
+ test: true
281
+ } : {});
282
+ }
234
283
  /**
235
284
  * The short link for an id, or `null` when there is no HTTPS origin to put it
236
285
  * on or the id is not one — no link rather than one that points at nothing. A
@@ -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 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 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","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\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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"}
@@ -22,6 +22,10 @@ export declare function createOutreachClickRoute(deps: ClickRouteDeps): PluginWe
22
22
  /**
23
23
  * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.
24
24
  *
25
+ * The same route answers a tracking image (AGL-3395) — an `outreachLinks`
26
+ * document of kind `open` — with the image instead of a redirect, so the
27
+ * image lives on the same host and path as every link.
28
+ *
25
29
  * One read of `outreachLinks/<id>` stands where the signature check stood,
26
30
  * and everything after it is the signed route's: the same redirect, the same
27
31
  * headers, the same recording, scanners redirected and counted apart.
@@ -16,8 +16,9 @@
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
18
18
  import { recordOutreachSequenceTouch } from "./campaign-credit.js";
19
- import { isOutreachLinkId, readOutreachClickToken, readOutreachStoredLink } from "./click-link.js";
19
+ import { isOutreachLinkId, isOutreachStoredOpen, readOutreachClickToken, readOutreachStoredLink, readOutreachStoredOpen } from "./click-link.js";
20
20
  import { recordOutreachClick } from "./click-events.js";
21
+ import { recordOutreachOpen } from "./open-events.js";
21
22
  // The plain page both recipient-facing routes answer with: no theme, no
22
23
  // session, the reader's own colors. It lives beside the first route that
23
24
  // needed one rather than being copied for the second.
@@ -116,6 +117,41 @@ const brokenLink = ()=>outreachUnsubscribePage({
116
117
  }
117
118
  return redirect;
118
119
  }
120
+ /**
121
+ * A transparent 1×1 GIF: the tracking image (AGL-3395). The smallest image
122
+ * every mail client renders, and the one that draws nothing if it is shown.
123
+ */ const PIXEL_GIF = Uint8Array.from(Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'));
124
+ /**
125
+ * The image, then the recording (AGL-3395).
126
+ *
127
+ * Every fetch gets the same image — a person, a mail privacy proxy and a
128
+ * gateway alike, and an image whose document is unreadable too — because a
129
+ * broken image in a one-to-one email is a visible fault and a missing open
130
+ * is only a number. `no-store` asks every cache not to keep it, so a later
131
+ * open fetches again; a proxy that caches anyway (Gmail's does) is why the
132
+ * first open is the one the rate is taken over.
133
+ */ async function answerOutreachOpen(deps, request, target) {
134
+ const image = new Response(request.method === 'HEAD' ? null : PIXEL_GIF, {
135
+ status: 200,
136
+ headers: {
137
+ 'Content-Type': 'image/gif',
138
+ 'Content-Length': String(PIXEL_GIF.byteLength),
139
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
140
+ 'X-Robots-Tag': 'noindex, nofollow'
141
+ }
142
+ });
143
+ if (!target || target.test) return image;
144
+ try {
145
+ await recordOutreachOpen(deps, {
146
+ target,
147
+ method: request.method,
148
+ userAgent: request.headers.get('user-agent')
149
+ });
150
+ } catch (error) {
151
+ console.error('[outreach] an open could not be recorded', error);
152
+ }
153
+ return image;
154
+ }
119
155
  /** The signed link, `?t=…` — every tracked email sent before AGL-3297. */ export function createOutreachClickRoute(deps) {
120
156
  return async (request)=>{
121
157
  // `HEAD` is answered because a gateway often sends one before the `GET`
@@ -129,6 +165,10 @@ const brokenLink = ()=>outreachUnsubscribePage({
129
165
  /**
130
166
  * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.
131
167
  *
168
+ * The same route answers a tracking image (AGL-3395) — an `outreachLinks`
169
+ * document of kind `open` — with the image instead of a redirect, so the
170
+ * image lives on the same host and path as every link.
171
+ *
132
172
  * One read of `outreachLinks/<id>` stands where the signature check stood,
133
173
  * and everything after it is the signed route's: the same redirect, the same
134
174
  * headers, the same recording, scanners redirected and counted apart.
@@ -143,10 +183,10 @@ const brokenLink = ()=>outreachUnsubscribePage({
143
183
  // The id is the last path segment: `/api/outreach/l/<id>`.
144
184
  const linkId = new URL(request.url).pathname.split('/').pop();
145
185
  if (!isOutreachLinkId(linkId)) return brokenLink();
146
- let target;
186
+ let data;
147
187
  try {
148
188
  const snapshot = await deps.firestore().collection(OUTREACH_COLLECTIONS.links).doc(linkId).get();
149
- target = snapshot.exists ? readOutreachStoredLink(snapshot.data()) : null;
189
+ data = snapshot.exists ? snapshot.data() : undefined;
150
190
  } catch (error) {
151
191
  console.error('[outreach] a short link could not be read', error);
152
192
  return outreachUnsubscribePage({
@@ -154,6 +194,9 @@ const brokenLink = ()=>outreachUnsubscribePage({
154
194
  body: 'Something went wrong on our side. Try the link again in a minute.'
155
195
  }, 503);
156
196
  }
197
+ // The id names a tracking image (AGL-3395): the image, not a redirect.
198
+ if (isOutreachStoredOpen(data)) return answerOutreachOpen(deps, request, readOutreachStoredOpen(data));
199
+ const target = readOutreachStoredLink(data);
157
200
  if (!target) return brokenLink();
158
201
  return followOutreachClick(deps, request, target);
159
202
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport { recordOutreachSequenceTouch } from './campaign-credit'\nimport {\n isOutreachLinkId,\n readOutreachClickToken,\n readOutreachStoredLink,\n type OutreachClickTarget,\n} from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239, AGL-3297): the short\n * `GET /api/outreach/l/<id>` every send carries now, and the signed\n * `GET /api/outreach/click?t=…` the emails sent before it carry.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The signed link carries its destination and needs no read; the short one\n * needs exactly one, of the document that names it. Then the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\ntype ClickRouteDeps = Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>\n\nconst methodNotAllowed = () =>\n new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n\nconst brokenLink = () =>\n outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n\n/**\n * The redirect, then the recording — the half both routes share once they\n * know where the link goes.\n */\nasync function followOutreachClick(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachClickTarget,\n): Promise<Response> {\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — both\n * readers return nothing else — so it is a header value that cannot carry\n * a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n // A link in a test of a step (AGL-3325) is followed and never counted:\n // the member clicking their own test is not a recipient acting.\n if (target.test) return redirect\n try {\n const outcome = await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n /*\n * A person's click — never a scanner's — on a sequence in a campaign\n * is their last campaign touch on the site (AGL-3254), so the booking\n * or the form they go on to make is credited to the sequence's\n * campaign by the door that credits every other one.\n */\n if (outcome.human && outcome.enrollment) {\n await recordOutreachSequenceTouch(deps, { enrollment: outcome.enrollment, atMs: deps.now() })\n }\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n}\n\n/** The signed link, `?t=…` — every tracked email sent before AGL-3297. */\nexport function createOutreachClickRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n\n/**\n * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.\n *\n * One read of `outreachLinks/<id>` stands where the signature check stood,\n * and everything after it is the signed route's: the same redirect, the same\n * headers, the same recording, scanners redirected and counted apart.\n *\n * The destination comes only from that document. An id that is malformed or\n * names nothing is answered with the plain page, never followed; a read that\n * FAILS is answered with a page saying to try again, because there is\n * nowhere honest to send them either.\n */\nexport function createOutreachShortLinkRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n // The id is the last path segment: `/api/outreach/l/<id>`.\n const linkId = new URL(request.url).pathname.split('/').pop()\n if (!isOutreachLinkId(linkId)) return brokenLink()\n let target: OutreachClickTarget | null\n try {\n const snapshot = await deps.firestore().collection(OUTREACH_COLLECTIONS.links).doc(linkId).get()\n target = snapshot.exists ? readOutreachStoredLink(snapshot.data()) : null\n } catch (error) {\n console.error('[outreach] a short link could not be read', error)\n return outreachUnsubscribePage(\n {\n title: 'This link isn’t working right now',\n body: 'Something went wrong on our side. Try the link again in a minute.',\n },\n 503,\n )\n }\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n"],"names":["OUTREACH_COLLECTIONS","recordOutreachSequenceTouch","isOutreachLinkId","readOutreachClickToken","readOutreachStoredLink","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","methodNotAllowed","Response","status","headers","Allow","brokenLink","title","body","followOutreachClick","deps","request","target","redirect","Location","url","test","outcome","method","userAgent","get","human","enrollment","atMs","now","error","console","createOutreachClickRoute","URL","searchParams","createOutreachShortLinkRoute","linkId","pathname","split","pop","snapshot","firestore","collection","links","doc","exists","data"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport { recordOutreachSequenceTouch } from './campaign-credit'\nimport {\n isOutreachLinkId,\n isOutreachStoredOpen,\n readOutreachClickToken,\n readOutreachStoredLink,\n readOutreachStoredOpen,\n type OutreachClickTarget,\n type OutreachOpenTarget,\n} from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport { recordOutreachOpen } from './open-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239, AGL-3297): the short\n * `GET /api/outreach/l/<id>` every send carries now, and the signed\n * `GET /api/outreach/click?t=…` the emails sent before it carry.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The signed link carries its destination and needs no read; the short one\n * needs exactly one, of the document that names it. Then the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\ntype ClickRouteDeps = Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>\n\nconst methodNotAllowed = () =>\n new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n\nconst brokenLink = () =>\n outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n\n/**\n * The redirect, then the recording — the half both routes share once they\n * know where the link goes.\n */\nasync function followOutreachClick(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachClickTarget,\n): Promise<Response> {\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — both\n * readers return nothing else — so it is a header value that cannot carry\n * a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n // A link in a test of a step (AGL-3325) is followed and never counted:\n // the member clicking their own test is not a recipient acting.\n if (target.test) return redirect\n try {\n const outcome = await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n /*\n * A person's click — never a scanner's — on a sequence in a campaign\n * is their last campaign touch on the site (AGL-3254), so the booking\n * or the form they go on to make is credited to the sequence's\n * campaign by the door that credits every other one.\n */\n if (outcome.human && outcome.enrollment) {\n await recordOutreachSequenceTouch(deps, { enrollment: outcome.enrollment, atMs: deps.now() })\n }\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n}\n\n/**\n * A transparent 1×1 GIF: the tracking image (AGL-3395). The smallest image\n * every mail client renders, and the one that draws nothing if it is shown.\n */\nconst PIXEL_GIF = Uint8Array.from(\n Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'),\n)\n\n/**\n * The image, then the recording (AGL-3395).\n *\n * Every fetch gets the same image — a person, a mail privacy proxy and a\n * gateway alike, and an image whose document is unreadable too — because a\n * broken image in a one-to-one email is a visible fault and a missing open\n * is only a number. `no-store` asks every cache not to keep it, so a later\n * open fetches again; a proxy that caches anyway (Gmail's does) is why the\n * first open is the one the rate is taken over.\n */\nasync function answerOutreachOpen(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachOpenTarget | null,\n): Promise<Response> {\n const image = new Response(request.method === 'HEAD' ? null : PIXEL_GIF, {\n status: 200,\n headers: {\n 'Content-Type': 'image/gif',\n 'Content-Length': String(PIXEL_GIF.byteLength),\n 'Cache-Control': 'no-store, no-cache, must-revalidate, private',\n 'X-Robots-Tag': 'noindex, nofollow',\n },\n })\n if (!target || target.test) return image\n try {\n await recordOutreachOpen(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n } catch (error) {\n console.error('[outreach] an open could not be recorded', error)\n }\n return image\n}\n\n/** The signed link, `?t=…` — every tracked email sent before AGL-3297. */\nexport function createOutreachClickRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n\n/**\n * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.\n *\n * The same route answers a tracking image (AGL-3395) — an `outreachLinks`\n * document of kind `open` — with the image instead of a redirect, so the\n * image lives on the same host and path as every link.\n *\n * One read of `outreachLinks/<id>` stands where the signature check stood,\n * and everything after it is the signed route's: the same redirect, the same\n * headers, the same recording, scanners redirected and counted apart.\n *\n * The destination comes only from that document. An id that is malformed or\n * names nothing is answered with the plain page, never followed; a read that\n * FAILS is answered with a page saying to try again, because there is\n * nowhere honest to send them either.\n */\nexport function createOutreachShortLinkRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n // The id is the last path segment: `/api/outreach/l/<id>`.\n const linkId = new URL(request.url).pathname.split('/').pop()\n if (!isOutreachLinkId(linkId)) return brokenLink()\n let data: unknown\n try {\n const snapshot = await deps.firestore().collection(OUTREACH_COLLECTIONS.links).doc(linkId).get()\n data = snapshot.exists ? snapshot.data() : undefined\n } catch (error) {\n console.error('[outreach] a short link could not be read', error)\n return outreachUnsubscribePage(\n {\n title: 'This link isn’t working right now',\n body: 'Something went wrong on our side. Try the link again in a minute.',\n },\n 503,\n )\n }\n // The id names a tracking image (AGL-3395): the image, not a redirect.\n if (isOutreachStoredOpen(data)) return answerOutreachOpen(deps, request, readOutreachStoredOpen(data))\n const target = readOutreachStoredLink(data)\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n"],"names":["OUTREACH_COLLECTIONS","recordOutreachSequenceTouch","isOutreachLinkId","isOutreachStoredOpen","readOutreachClickToken","readOutreachStoredLink","readOutreachStoredOpen","recordOutreachClick","recordOutreachOpen","outreachUnsubscribePage","REDIRECT_HEADERS","methodNotAllowed","Response","status","headers","Allow","brokenLink","title","body","followOutreachClick","deps","request","target","redirect","Location","url","test","outcome","method","userAgent","get","human","enrollment","atMs","now","error","console","PIXEL_GIF","Uint8Array","from","Buffer","answerOutreachOpen","image","String","byteLength","createOutreachClickRoute","URL","searchParams","createOutreachShortLinkRoute","linkId","pathname","split","pop","data","snapshot","firestore","collection","links","doc","exists","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,oBAAoB,QAAQ,6BAAyB;AAC9D,SAASC,2BAA2B,QAAQ,uBAAmB;AAC/D,SACEC,gBAAgB,EAChBC,oBAAoB,EACpBC,sBAAsB,EACtBC,sBAAsB,EACtBC,sBAAsB,QAGjB,kBAAc;AACrB,SAASC,mBAAmB,QAAQ,oBAAgB;AACpD,SAASC,kBAAkB,QAAQ,mBAAe;AAElD,wEAAwE;AACxE,yEAAyE;AACzE,sDAAsD;AACtD,SAASC,uBAAuB,QAAQ,yBAAqB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,MAAMC,mBAAmB;IACvB,iBAAiB;IACjB,mBAAmB;IACnB,gBAAgB;AAClB;AAIA,MAAMC,mBAAmB,IACvB,IAAIC,SAAS,sBAAsB;QAAEC,QAAQ;QAAKC,SAAS;YAAEC,OAAO;QAAY;IAAE;AAEpF,MAAMC,aAAa,IACjBP,wBACE;QACEQ,OAAO;QACPC,MAAM;IACR,GACA;AAGJ;;;CAGC,GACD,eAAeC,oBACbC,IAAoB,EACpBC,OAAgB,EAChBC,MAA2B;IAE3B;;;;;;GAMC,GACD,MAAMC,WAAW,IAAIX,SAAS,MAAM;QAClCC,QAAQ;QACRC,SAAS,aAAKJ;YAAkBc,UAAUF,OAAOG,GAAG;;IACtD;IACA,uEAAuE;IACvE,gEAAgE;IAChE,IAAIH,OAAOI,IAAI,EAAE,OAAOH;IACxB,IAAI;QACF,MAAMI,UAAU,MAAMpB,oBAAoBa,MAAM;YAC9CE;YACAM,QAAQP,QAAQO,MAAM;YACtBC,WAAWR,QAAQP,OAAO,CAACgB,GAAG,CAAC;QACjC;QACA;;;;;KAKC,GACD,IAAIH,QAAQI,KAAK,IAAIJ,QAAQK,UAAU,EAAE;YACvC,MAAM/B,4BAA4BmB,MAAM;gBAAEY,YAAYL,QAAQK,UAAU;gBAAEC,MAAMb,KAAKc,GAAG;YAAG;QAC7F;IACF,EAAE,OAAOC,OAAO;QACd,oEAAoE;QACpE,kEAAkE;QAClEC,QAAQD,KAAK,CAAC,4CAA4CA;IAC5D;IACA,OAAOZ;AACT;AAEA;;;CAGC,GACD,MAAMc,YAAYC,WAAWC,IAAI,CAC/BC,OAAOD,IAAI,CAAC,4DAA4D;AAG1E;;;;;;;;;CASC,GACD,eAAeE,mBACbrB,IAAoB,EACpBC,OAAgB,EAChBC,MAAiC;IAEjC,MAAMoB,QAAQ,IAAI9B,SAASS,QAAQO,MAAM,KAAK,SAAS,OAAOS,WAAW;QACvExB,QAAQ;QACRC,SAAS;YACP,gBAAgB;YAChB,kBAAkB6B,OAAON,UAAUO,UAAU;YAC7C,iBAAiB;YACjB,gBAAgB;QAClB;IACF;IACA,IAAI,CAACtB,UAAUA,OAAOI,IAAI,EAAE,OAAOgB;IACnC,IAAI;QACF,MAAMlC,mBAAmBY,MAAM;YAC7BE;YACAM,QAAQP,QAAQO,MAAM;YACtBC,WAAWR,QAAQP,OAAO,CAACgB,GAAG,CAAC;QACjC;IACF,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,4CAA4CA;IAC5D;IACA,OAAOO;AACT;AAEA,wEAAwE,GACxE,OAAO,SAASG,yBAAyBzB,IAAoB;IAC3D,OAAO,OAAOC;QACZ,wEAAwE;QACxE,qEAAqE;QACrE,IAAIA,QAAQO,MAAM,KAAK,SAASP,QAAQO,MAAM,KAAK,QAAQ,OAAOjB;QAClE,MAAMW,SAASlB,uBAAuB,IAAI0C,IAAIzB,QAAQI,GAAG,EAAEsB,YAAY,CAACjB,GAAG,CAAC;QAC5E,IAAI,CAACR,QAAQ,OAAON;QACpB,OAAOG,oBAAoBC,MAAMC,SAASC;IAC5C;AACF;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAAS0B,6BAA6B5B,IAAoB;IAC/D,OAAO,OAAOC;QACZ,IAAIA,QAAQO,MAAM,KAAK,SAASP,QAAQO,MAAM,KAAK,QAAQ,OAAOjB;QAClE,2DAA2D;QAC3D,MAAMsC,SAAS,IAAIH,IAAIzB,QAAQI,GAAG,EAAEyB,QAAQ,CAACC,KAAK,CAAC,KAAKC,GAAG;QAC3D,IAAI,CAAClD,iBAAiB+C,SAAS,OAAOjC;QACtC,IAAIqC;QACJ,IAAI;YACF,MAAMC,WAAW,MAAMlC,KAAKmC,SAAS,GAAGC,UAAU,CAACxD,qBAAqByD,KAAK,EAAEC,GAAG,CAACT,QAAQnB,GAAG;YAC9FuB,OAAOC,SAASK,MAAM,GAAGL,SAASD,IAAI,KAAKO;QAC7C,EAAE,OAAOzB,OAAO;YACdC,QAAQD,KAAK,CAAC,6CAA6CA;YAC3D,OAAO1B,wBACL;gBACEQ,OAAO;gBACPC,MAAM;YACR,GACA;QAEJ;QACA,uEAAuE;QACvE,IAAIf,qBAAqBkD,OAAO,OAAOZ,mBAAmBrB,MAAMC,SAASf,uBAAuB+C;QAChG,MAAM/B,SAASjB,uBAAuBgD;QACtC,IAAI,CAAC/B,QAAQ,OAAON;QACpB,OAAOG,oBAAoBC,MAAMC,SAASC;IAC5C;AACF"}
@@ -0,0 +1,55 @@
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 OutreachOpenJudgement } from '../engine/open-tracking';
18
+ import { type OutreachEnrollment } from '../model/outreach.types';
19
+ import type { OutreachOpenTarget } from './click-link';
20
+ import type { OutreachRuntimeDeps } from './runtime-deps';
21
+ /**
22
+ * RECORDING AN OPEN (AGL-3395).
23
+ *
24
+ * A fetch of a tracking image, written in the places a click is
25
+ * (`./click-events.ts`) and for the same questions: the enrollment ("did
26
+ * this person open it?"), its history ("when, and was it them?") and the
27
+ * sequence ("what is its open rate?"). There is no per-destination rollup —
28
+ * an image has no destination — and nothing is filed on the person's CRM
29
+ * record: an open is too weak a signal to put in front of a rep as an act,
30
+ * and a proxy's fetch would put one there for nobody.
31
+ *
32
+ * Every write happens after the image has been answered, and a failure costs
33
+ * a number rather than a broken image.
34
+ */
35
+ /** What one recorded open turned out to be. */
36
+ export interface OutreachOpenOutcome extends OutreachOpenJudgement {
37
+ /** Whether it was this person's first human open — the sequence's `uniqueOpens`. */
38
+ first: boolean;
39
+ /** The enrollment it was recorded against, or `null` when there is none. */
40
+ enrollment: OutreachEnrollment | null;
41
+ }
42
+ /**
43
+ * Records one fetch of a tracking image.
44
+ *
45
+ * In a transaction over the enrollment, as a click is: two fetches at once
46
+ * must not both read `opens` of nought, and whether this is the person's
47
+ * FIRST open is decided from the read that writes it. An enrollment that no
48
+ * longer exists belongs to a person or a workspace already erased, and
49
+ * nothing is recorded for them anywhere.
50
+ */
51
+ export declare function recordOutreachOpen(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now'>, input: {
52
+ target: OutreachOpenTarget;
53
+ method: string;
54
+ userAgent: string | null | undefined;
55
+ }): Promise<OutreachOpenOutcome>;
@@ -0,0 +1,121 @@
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 { FieldValue } from "firebase-admin/firestore";
18
+ import { judgeOutreachOpen, OUTREACH_PROXY_OPEN_REASONS } from "../engine/open-tracking.js";
19
+ import { outreachLoggedEngagementRows, readOutreachEngagement } from "../model/enrollment-engagement.js";
20
+ import { outreachHistoryEntryId, outreachOpenHistoryRow } from "../model/enrollment-history.js";
21
+ import { OUTREACH_ENROLLMENT_HISTORY, OUTREACH_ENROLLMENT_HISTORY_MAX } from "../model/outreach.types.js";
22
+ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
23
+ import { outreachStepSentAtMs } from "./click-events.js";
24
+ const nothing = ()=>({
25
+ human: false,
26
+ machineReason: null,
27
+ first: false,
28
+ enrollment: null
29
+ });
30
+ /**
31
+ * Records one fetch of a tracking image.
32
+ *
33
+ * In a transaction over the enrollment, as a click is: two fetches at once
34
+ * must not both read `opens` of nought, and whether this is the person's
35
+ * FIRST open is decided from the read that writes it. An enrollment that no
36
+ * longer exists belongs to a person or a workspace already erased, and
37
+ * nothing is recorded for them anywhere.
38
+ */ export async function recordOutreachOpen(deps, input) {
39
+ var _outcome_enrollment;
40
+ // A test's image (AGL-3325) names no enrollment and counts on nothing.
41
+ if (input.target.test) return nothing();
42
+ const firestore = deps.firestore();
43
+ const { orgId, enrollmentId, stepIndex } = input.target;
44
+ const nowMs = deps.now();
45
+ const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId);
46
+ const outcome = await firestore.runTransaction(async (transaction)=>{
47
+ var _held_firstOpenAtMs;
48
+ const snapshot = await transaction.get(ref);
49
+ const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
50
+ if (!enrollment) return nothing();
51
+ const sent = outreachStepSentAtMs(enrollment, stepIndex);
52
+ const judgement = judgeOutreachOpen({
53
+ method: input.method,
54
+ userAgent: input.userAgent,
55
+ sinceSentMs: sent === null ? null : nowMs - sent
56
+ });
57
+ const held = readOutreachEngagement(enrollment.engagement);
58
+ const first = judgement.human && held.firstOpenAtMs === null;
59
+ const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX;
60
+ const engagement = judgement.human ? _extends({}, held, {
61
+ opens: held.opens + 1,
62
+ firstOpenAtMs: (_held_firstOpenAtMs = held.firstOpenAtMs) != null ? _held_firstOpenAtMs : nowMs,
63
+ lastOpenAtMs: nowMs,
64
+ loggedOpens: held.loggedOpens + (logged ? 1 : 0)
65
+ }) : _extends({}, held, {
66
+ machineOpens: held.machineOpens + 1,
67
+ loggedOpens: held.loggedOpens + (logged ? 1 : 0)
68
+ });
69
+ // `updatedAtMs` is left alone, as a click leaves it: a proxy fetching an
70
+ // image is not something that happened to the enrollment.
71
+ transaction.update(ref, {
72
+ engagement
73
+ });
74
+ if (logged) {
75
+ transaction.set(ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)), outreachOpenHistoryRow({
76
+ atMs: nowMs,
77
+ stepIndex,
78
+ human: judgement.human,
79
+ machineReason: judgement.machineReason
80
+ }));
81
+ }
82
+ return _extends({}, judgement, {
83
+ first,
84
+ enrollment: _extends({}, enrollment, {
85
+ engagement
86
+ })
87
+ });
88
+ });
89
+ if ((_outcome_enrollment = outcome.enrollment) == null ? void 0 : _outcome_enrollment.sequenceId) {
90
+ await bumpSequenceOpenStats(firestore, outcome, {
91
+ orgId,
92
+ sequenceId: outcome.enrollment.sequenceId,
93
+ nowMs
94
+ });
95
+ }
96
+ return outcome;
97
+ }
98
+ /**
99
+ * The sequence's open counters: blind increments outside the enrollment's
100
+ * transaction, for the reason the click counters are — one sequence holds
101
+ * every recipient, and a transaction over it would serialise them all.
102
+ */ async function bumpSequenceOpenStats(firestore, outcome, input) {
103
+ const proxy = outcome.machineReason !== null && OUTREACH_PROXY_OPEN_REASONS.includes(outcome.machineReason);
104
+ const patch = outcome.human ? _extends({
105
+ 'stats.opens': FieldValue.increment(1),
106
+ 'stats.lastOpenAtMs': input.nowMs
107
+ }, outcome.first ? {
108
+ 'stats.uniqueOpens': FieldValue.increment(1)
109
+ } : {}) : _extends({
110
+ 'stats.machineOpens': FieldValue.increment(1)
111
+ }, proxy ? {
112
+ 'stats.proxyOpens': FieldValue.increment(1)
113
+ } : {});
114
+ await outreachOrgCollection(firestore, input.orgId, 'sequences').doc(input.sequenceId).update(patch).catch((error)=>{
115
+ // A sequence deleted while its mail is still out there: the open is
116
+ // on the enrollment, and the aggregate has nowhere to go.
117
+ console.warn('[outreach] an open could not be counted on its sequence', error);
118
+ });
119
+ }
120
+
121
+ //# sourceMappingURL=open-events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/open-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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n judgeOutreachOpen,\n OUTREACH_PROXY_OPEN_REASONS,\n type OutreachOpenJudgement,\n} from '../engine/open-tracking'\nimport { outreachLoggedEngagementRows, readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachHistoryEntryId, outreachOpenHistoryRow } from '../model/enrollment-history'\nimport {\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport { outreachStepSentAtMs } from './click-events'\nimport type { OutreachOpenTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING AN OPEN (AGL-3395).\n *\n * A fetch of a tracking image, written in the places a click is\n * (`./click-events.ts`) and for the same questions: the enrollment (\"did\n * this person open it?\"), its history (\"when, and was it them?\") and the\n * sequence (\"what is its open rate?\"). There is no per-destination rollup —\n * an image has no destination — and nothing is filed on the person's CRM\n * record: an open is too weak a signal to put in front of a rep as an act,\n * and a proxy's fetch would put one there for nobody.\n *\n * Every write happens after the image has been answered, and a failure costs\n * a number rather than a broken image.\n */\n\n/** What one recorded open turned out to be. */\nexport interface OutreachOpenOutcome extends OutreachOpenJudgement {\n /** Whether it was this person's first human open — the sequence's `uniqueOpens`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\nconst nothing = (): OutreachOpenOutcome => ({ human: false, machineReason: null, first: false, enrollment: null })\n\n/**\n * Records one fetch of a tracking image.\n *\n * In a transaction over the enrollment, as a click is: two fetches at once\n * must not both read `opens` of nought, and whether this is the person's\n * FIRST open is decided from the read that writes it. An enrollment that no\n * longer exists belongs to a person or a workspace already erased, and\n * nothing is recorded for them anywhere.\n */\nexport async function recordOutreachOpen(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now'>,\n input: {\n target: OutreachOpenTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachOpenOutcome> {\n // A test's image (AGL-3325) names no enrollment and counts on nothing.\n if (input.target.test) return nothing()\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex } = 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(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return nothing()\n const sent = outreachStepSentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachOpen({\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.firstOpenAtMs === null\n const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n ...held,\n opens: held.opens + 1,\n firstOpenAtMs: held.firstOpenAtMs ?? nowMs,\n lastOpenAtMs: nowMs,\n loggedOpens: held.loggedOpens + (logged ? 1 : 0),\n }\n : {\n ...held,\n machineOpens: held.machineOpens + 1,\n loggedOpens: held.loggedOpens + (logged ? 1 : 0),\n }\n // `updatedAtMs` is left alone, as a click leaves it: a proxy fetching an\n // image is not something that happened to the enrollment.\n transaction.update(ref, { engagement })\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachOpenHistoryRow({\n atMs: nowMs,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachOpenOutcome\n })\n\n if (outcome.enrollment?.sequenceId) {\n await bumpSequenceOpenStats(firestore, outcome, { orgId, sequenceId: outcome.enrollment.sequenceId, nowMs })\n }\n return outcome\n}\n\n/**\n * The sequence's open counters: blind increments outside the enrollment's\n * transaction, for the reason the click counters are — one sequence holds\n * every recipient, and a transaction over it would serialise them all.\n */\nasync function bumpSequenceOpenStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachOpenOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n const proxy = outcome.machineReason !== null && OUTREACH_PROXY_OPEN_REASONS.includes(outcome.machineReason)\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.opens': FieldValue.increment(1),\n 'stats.lastOpenAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueOpens': FieldValue.increment(1) } : {}),\n }\n : {\n 'stats.machineOpens': FieldValue.increment(1),\n ...(proxy ? { 'stats.proxyOpens': FieldValue.increment(1) } : {}),\n }\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 open is\n // on the enrollment, and the aggregate has nowhere to go.\n console.warn('[outreach] an open could not be counted on its sequence', error)\n })\n}\n"],"names":["FieldValue","judgeOutreachOpen","OUTREACH_PROXY_OPEN_REASONS","outreachLoggedEngagementRows","readOutreachEngagement","outreachHistoryEntryId","outreachOpenHistoryRow","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","outreachOrgCollection","readStoredOutreachEnrollment","outreachStepSentAtMs","nothing","human","machineReason","first","enrollment","recordOutreachOpen","deps","input","outcome","target","test","firestore","orgId","enrollmentId","stepIndex","nowMs","now","ref","doc","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstOpenAtMs","logged","opens","lastOpenAtMs","loggedOpens","machineOpens","update","set","collection","atMs","sequenceId","bumpSequenceOpenStats","proxy","includes","patch","increment","catch","error","console","warn"],"mappings":"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"}