@aglyn/plugins-outreach 1.0.0-beta.178 → 1.0.0-beta.179

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.
@@ -14,45 +14,54 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { signedLinkSignature, signedLinkSignatureMatches } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
17
+ */ import { randomBytes } from "node:crypto";
18
+ import { signedLinkSignature, signedLinkSignatureMatches } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
18
19
  import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
19
20
  /**
20
- * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239).
21
+ * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239,
22
+ * AGL-3297).
21
23
  *
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.
24
+ * ## Short, and stored
27
25
  *
28
- * ## The destination is IN the token
26
+ * Every send since AGL-3297 carries `/api/outreach/l/<id>`: a random
27
+ * {@link OUTREACH_LINK_ID_LENGTH}-character id naming an `outreachLinks`
28
+ * document that holds the organization, the enrollment, the step, the link's
29
+ * index and the destination. The send writes those documents before the
30
+ * email leaves, and the route reads one to answer.
29
31
  *
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.
32
+ * The first design put all of that in a signed token instead, so the
33
+ * redirect needed no read. It cost a ~350-character link, which in a
34
+ * one-to-one plain-text email reads as bulk marketing — the whole thing a
35
+ * sequence is not. A read per click is the cheaper of the two.
37
36
  *
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.
37
+ * The signed links already in inboxes keep working: the `click` route still
38
+ * verifies and follows them, and {@link readOutreachClickToken} stays for it.
39
+ * Nothing mints one any more.
40
40
  *
41
41
  * ## It cannot be turned into an open redirect
42
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.
43
+ * The destination comes only from the stored document, which only the
44
+ * sending runtime writes, and only for a URL written in that sequence's own
45
+ * step; clients cannot read or write the collection at all. An id that names
46
+ * no document is refused rather than followed. For the signed links, the
47
+ * signature covers the destination, to the same end.
47
48
  *
48
49
  * ## It names ids, never an address
49
50
  *
50
51
  * 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.
52
+ * and in ours. The short link carries nothing but a random id; the stored
53
+ * document names the enrollment, which names the person — a lookup that is
54
+ * ours to make, not a bystander's to read off a URL.
55
+ *
56
+ * ## The id is unguessable
57
+ *
58
+ * Ten characters of base 62 from the platform's CSPRNG — about 59.5 bits. A
59
+ * guess that lands resolves to a destination someone else was sent, and
60
+ * would count a click that is recorded as the one it is (see
61
+ * `click-events.ts`); it grants nothing else.
54
62
  */ /** 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}`;
63
+ /** The signed link's path on the console — answered, no longer minted. */ export const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`;
64
+ /** The short link's path on the console, before the id (AGL-3297). */ export const OUTREACH_SHORT_LINK_PATH = `/api/${OUTREACH_API_ROUTES.shortLink.replace(/\/:linkId$/, '')}`;
56
65
  /**
57
66
  * The longest destination a tracking link will carry.
58
67
  *
@@ -154,5 +163,83 @@ const base64url = (text)=>Buffer.from(text, 'utf8').toString('base64url');
154
163
  const token = mintOutreachClickToken(input.target, input.secret);
155
164
  return token ? `${origin.origin}${OUTREACH_CLICK_PATH}?t=${token}` : null;
156
165
  }
166
+ /*==========================================
167
+ * THE SHORT LINK (AGL-3297).
168
+ *=========================================*/ /** How many characters a short link's id has. */ export const OUTREACH_LINK_ID_LENGTH = 10;
169
+ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
170
+ /** A short link's id as the route will read one. */ const LINK_ID = new RegExp(`^[A-Za-z0-9]{${OUTREACH_LINK_ID_LENGTH}}$`);
171
+ /** Whether a value is shaped like a short link's id. */ export function isOutreachLinkId(value) {
172
+ return typeof value === 'string' && LINK_ID.test(value);
173
+ }
174
+ /**
175
+ * A fresh short link id: base 62 from the CSPRNG, by rejection sampling so
176
+ * every character is equally likely (62 does not divide 256).
177
+ */ export function newOutreachLinkId(random = randomBytes) {
178
+ let id = '';
179
+ while(id.length < OUTREACH_LINK_ID_LENGTH){
180
+ for (const byte of random(OUTREACH_LINK_ID_LENGTH * 2)){
181
+ if (byte >= 248) continue;
182
+ id += LINK_ID_ALPHABET[byte % 62];
183
+ if (id.length === OUTREACH_LINK_ID_LENGTH) break;
184
+ }
185
+ }
186
+ return id;
187
+ }
188
+ /**
189
+ * The document for one link, or `null` when the target is not one we would
190
+ * follow — the same checks the signed token applied at minting.
191
+ */ export function outreachStoredLink(target, nowMs) {
192
+ const url = outreachClickTargetUrl(target.url);
193
+ if (!url) return null;
194
+ if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null;
195
+ if (!index(target.stepIndex) || !index(target.linkIndex)) return null;
196
+ return {
197
+ v: 1,
198
+ orgId: target.orgId,
199
+ enrollmentId: target.enrollmentId,
200
+ stepIndex: target.stepIndex,
201
+ linkIndex: target.linkIndex,
202
+ url,
203
+ createdAtMs: nowMs
204
+ };
205
+ }
206
+ /**
207
+ * What a stored link names, or `null` for a document that is missing or not
208
+ * one we will act on. Checked on the way OUT, as the token is: the document
209
+ * was written by us, and these say it is still one we will follow.
210
+ */ export function readOutreachStoredLink(data) {
211
+ var _body_orgId, _body_enrollmentId;
212
+ if (!data || typeof data !== 'object') return null;
213
+ const body = data;
214
+ if (body['v'] !== 1) return null;
215
+ const orgId = String((_body_orgId = body['orgId']) != null ? _body_orgId : '');
216
+ const enrollmentId = String((_body_enrollmentId = body['enrollmentId']) != null ? _body_enrollmentId : '');
217
+ const url = outreachClickTargetUrl(body['url']);
218
+ if (!url) return null;
219
+ if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null;
220
+ if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null;
221
+ return {
222
+ orgId,
223
+ enrollmentId,
224
+ stepIndex: body['stepIndex'],
225
+ linkIndex: body['linkIndex'],
226
+ url
227
+ };
228
+ }
229
+ /**
230
+ * The short link for an id on the console at `origin`, or `null` when the
231
+ * origin is not HTTPS or the id is not one — no link rather than one that
232
+ * points at nothing. A `null` leaves the destination the step wrote.
233
+ */ export function outreachShortLinkUrl(input) {
234
+ let origin;
235
+ try {
236
+ var _input_origin;
237
+ origin = new URL(String((_input_origin = input.origin) != null ? _input_origin : ''));
238
+ } catch (unused) {
239
+ return null;
240
+ }
241
+ if (origin.protocol !== 'https:' || !isOutreachLinkId(input.linkId)) return null;
242
+ return `${origin.origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}`;
243
+ }
157
244
 
158
245
  //# sourceMappingURL=click-link.js.map
@@ -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 {\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":"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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\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}\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 }\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 }\n}\n\n/**\n * The short link for an id on the console at `origin`, or `null` when the\n * origin is not HTTPS or the id is not one — no link rather than one that\n * points at nothing. A `null` leaves the destination the step wrote.\n */\nexport function outreachShortLinkUrl(input: { origin: string | null | undefined; linkId: string }): 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:' || !isOutreachLinkId(input.linkId)) return null\n return `${origin.origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}`\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","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"],"mappings":"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"}
@@ -16,4 +16,20 @@
16
16
  */
17
17
  import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
18
18
  import type { OutreachRuntimeDeps } from './runtime-deps';
19
- export declare function createOutreachClickRoute(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>): PluginWebApiHandler;
19
+ type ClickRouteDeps = Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>;
20
+ /** The signed link, `?t=…` — every tracked email sent before AGL-3297. */
21
+ export declare function createOutreachClickRoute(deps: ClickRouteDeps): PluginWebApiHandler;
22
+ /**
23
+ * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.
24
+ *
25
+ * One read of `outreachLinks/<id>` stands where the signature check stood,
26
+ * and everything after it is the signed route's: the same redirect, the same
27
+ * headers, the same recording, scanners redirected and counted apart.
28
+ *
29
+ * The destination comes only from that document. An id that is malformed or
30
+ * names nothing is answered with the plain page, never followed; a read that
31
+ * FAILS is answered with a page saying to try again, because there is
32
+ * nowhere honest to send them either.
33
+ */
34
+ export declare function createOutreachShortLinkRoute(deps: ClickRouteDeps): PluginWebApiHandler;
35
+ export {};
@@ -14,20 +14,23 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
17
18
  import { recordOutreachSequenceTouch } from "./campaign-credit.js";
18
- import { readOutreachClickToken } from "./click-link.js";
19
+ import { isOutreachLinkId, readOutreachClickToken, readOutreachStoredLink } from "./click-link.js";
19
20
  import { recordOutreachClick } from "./click-events.js";
20
21
  // The plain page both recipient-facing routes answer with: no theme, no
21
22
  // session, the reader's own colors. It lives beside the first route that
22
23
  // needed one rather than being copied for the second.
23
24
  import { outreachUnsubscribePage } from "./unsubscribe-route.js";
24
25
  /**
25
- * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239): `GET /api/outreach/click?t=…`.
26
+ * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239, AGL-3297): the short
27
+ * `GET /api/outreach/l/<id>` every send carries now, and the signed
28
+ * `GET /api/outreach/click?t=…` the emails sent before it carry.
26
29
  *
27
30
  * ## The redirect happens first, and it happens for everyone
28
31
  *
29
- * The destination is in the token, so answering needs no read: the handler
30
- * verifies the signature, and the 302 goes out. Recording is awaited after
32
+ * The signed link carries its destination and needs no read; the short one
33
+ * needs exactly one, of the document that names it. Then the 302 goes out. Recording is awaited after
31
34
  * the answer is built and never gates it — a Firestore that is slow or down
32
35
  * costs a number, not a visit.
33
36
  *
@@ -60,60 +63,96 @@ import { outreachUnsubscribePage } from "./unsubscribe-route.js";
60
63
  'Referrer-Policy': 'no-referrer',
61
64
  'X-Robots-Tag': 'noindex, nofollow'
62
65
  };
63
- export function createOutreachClickRoute(deps) {
66
+ const methodNotAllowed = ()=>new Response('Method not allowed', {
67
+ status: 405,
68
+ headers: {
69
+ Allow: 'GET, HEAD'
70
+ }
71
+ });
72
+ const brokenLink = ()=>outreachUnsubscribePage({
73
+ title: 'This link doesn’t work',
74
+ body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.'
75
+ }, 400);
76
+ /**
77
+ * The redirect, then the recording — the half both routes share once they
78
+ * know where the link goes.
79
+ */ async function followOutreachClick(deps, request, target) {
80
+ /*
81
+ * Built by hand rather than with `Response.redirect`, which seals its
82
+ * headers: the three above are the point of the answer as much as the
83
+ * `Location` is. `target.url` is `URL.href` of an http(s) URL — both
84
+ * readers return nothing else — so it is a header value that cannot carry
85
+ * a newline into the response.
86
+ */ const redirect = new Response(null, {
87
+ status: 302,
88
+ headers: _extends({}, REDIRECT_HEADERS, {
89
+ Location: target.url
90
+ })
91
+ });
92
+ try {
93
+ const outcome = await recordOutreachClick(deps, {
94
+ target,
95
+ method: request.method,
96
+ userAgent: request.headers.get('user-agent')
97
+ });
98
+ /*
99
+ * A person's click — never a scanner's — on a sequence in a campaign
100
+ * is their last campaign touch on the site (AGL-3254), so the booking
101
+ * or the form they go on to make is credited to the sequence's
102
+ * campaign by the door that credits every other one.
103
+ */ if (outcome.human && outcome.enrollment) {
104
+ await recordOutreachSequenceTouch(deps, {
105
+ enrollment: outcome.enrollment,
106
+ atMs: deps.now()
107
+ });
108
+ }
109
+ } catch (error) {
110
+ // The visit already has its answer. A click we failed to count is a
111
+ // missing number; a redirect we failed to send is a broken email.
112
+ console.error('[outreach] a click could not be recorded', error);
113
+ }
114
+ return redirect;
115
+ }
116
+ /** The signed link, `?t=…` — every tracked email sent before AGL-3297. */ export function createOutreachClickRoute(deps) {
64
117
  return async (request)=>{
65
118
  // `HEAD` is answered because a gateway often sends one before the `GET`
66
119
  // a person makes; it is redirected and counted as the machine it is.
67
- if (request.method !== 'GET' && request.method !== 'HEAD') {
68
- return new Response('Method not allowed', {
69
- status: 405,
70
- headers: {
71
- Allow: 'GET, HEAD'
72
- }
73
- });
74
- }
120
+ if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed();
75
121
  const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'));
76
- if (!target) {
77
- return outreachUnsubscribePage({
78
- title: 'This link doesn’t work',
79
- body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.'
80
- }, 400);
81
- }
82
- /*
83
- * Built by hand rather than with `Response.redirect`, which seals its
84
- * headers: the three above are the point of the answer as much as the
85
- * `Location` is. `target.url` is `URL.href` of an http(s) URL — the
86
- * token reader will return nothing else — so it is a header value that
87
- * cannot carry a newline into the response.
88
- */ const redirect = new Response(null, {
89
- status: 302,
90
- headers: _extends({}, REDIRECT_HEADERS, {
91
- Location: target.url
92
- })
93
- });
122
+ if (!target) return brokenLink();
123
+ return followOutreachClick(deps, request, target);
124
+ };
125
+ }
126
+ /**
127
+ * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.
128
+ *
129
+ * One read of `outreachLinks/<id>` stands where the signature check stood,
130
+ * and everything after it is the signed route's: the same redirect, the same
131
+ * headers, the same recording, scanners redirected and counted apart.
132
+ *
133
+ * The destination comes only from that document. An id that is malformed or
134
+ * names nothing is answered with the plain page, never followed; a read that
135
+ * FAILS is answered with a page saying to try again, because there is
136
+ * nowhere honest to send them either.
137
+ */ export function createOutreachShortLinkRoute(deps) {
138
+ return async (request)=>{
139
+ if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed();
140
+ // The id is the last path segment: `/api/outreach/l/<id>`.
141
+ const linkId = new URL(request.url).pathname.split('/').pop();
142
+ if (!isOutreachLinkId(linkId)) return brokenLink();
143
+ let target;
94
144
  try {
95
- const outcome = await recordOutreachClick(deps, {
96
- target,
97
- method: request.method,
98
- userAgent: request.headers.get('user-agent')
99
- });
100
- /*
101
- * A person's click — never a scanner's — on a sequence in a campaign
102
- * is their last campaign touch on the site (AGL-3254), so the booking
103
- * or the form they go on to make is credited to the sequence's
104
- * campaign by the door that credits every other one.
105
- */ if (outcome.human && outcome.enrollment) {
106
- await recordOutreachSequenceTouch(deps, {
107
- enrollment: outcome.enrollment,
108
- atMs: deps.now()
109
- });
110
- }
145
+ const snapshot = await deps.firestore().collection(OUTREACH_COLLECTIONS.links).doc(linkId).get();
146
+ target = snapshot.exists ? readOutreachStoredLink(snapshot.data()) : null;
111
147
  } catch (error) {
112
- // The visit already has its answer. A click we failed to count is a
113
- // missing number; a redirect we failed to send is a broken email.
114
- console.error('[outreach] a click could not be recorded', error);
148
+ console.error('[outreach] a short link could not be read', error);
149
+ return outreachUnsubscribePage({
150
+ title: 'This link isn’t working right now',
151
+ body: 'Something went wrong on our side. Try the link again in a minute.'
152
+ }, 503);
115
153
  }
116
- return redirect;
154
+ if (!target) return brokenLink();
155
+ return followOutreachClick(deps, request, target);
117
156
  };
118
157
  }
119
158
 
@@ -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 { recordOutreachSequenceTouch } from './campaign-credit'\nimport { readOutreachClickToken } 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): `GET /api/outreach/click?t=…`.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The destination is in the token, so answering needs no read: the handler\n * verifies the signature, and 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\nexport function createOutreachClickRoute(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>,\n): 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') {\n return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n }\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) {\n return 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 * 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 — the\n * token reader will return nothing else — so it is a header value that\n * cannot carry a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\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"],"names":["recordOutreachSequenceTouch","readOutreachClickToken","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","createOutreachClickRoute","deps","request","method","Response","status","headers","Allow","target","URL","url","searchParams","get","title","body","redirect","Location","outcome","userAgent","human","enrollment","atMs","now","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,2BAA2B,QAAQ,uBAAmB;AAC/D,SAASC,sBAAsB,QAAQ,kBAAc;AACrD,SAASC,mBAAmB,QAAQ,oBAAgB;AAEpD,wEAAwE;AACxE,yEAAyE;AACzE,sDAAsD;AACtD,SAASC,uBAAuB,QAAQ,yBAAqB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,MAAMC,mBAAmB;IACvB,iBAAiB;IACjB,mBAAmB;IACnB,gBAAgB;AAClB;AAEA,OAAO,SAASC,yBACdC,IAAoF;IAEpF,OAAO,OAAOC;QACZ,wEAAwE;QACxE,qEAAqE;QACrE,IAAIA,QAAQC,MAAM,KAAK,SAASD,QAAQC,MAAM,KAAK,QAAQ;YACzD,OAAO,IAAIC,SAAS,sBAAsB;gBAAEC,QAAQ;gBAAKC,SAAS;oBAAEC,OAAO;gBAAY;YAAE;QAC3F;QACA,MAAMC,SAASZ,uBAAuB,IAAIa,IAAIP,QAAQQ,GAAG,EAAEC,YAAY,CAACC,GAAG,CAAC;QAC5E,IAAI,CAACJ,QAAQ;YACX,OAAOV,wBACL;gBACEe,OAAO;gBACPC,MAAM;YACR,GACA;QAEJ;QACA;;;;;;KAMC,GACD,MAAMC,WAAW,IAAIX,SAAS,MAAM;YAClCC,QAAQ;YACRC,SAAS,aAAKP;gBAAkBiB,UAAUR,OAAOE,GAAG;;QACtD;QACA,IAAI;YACF,MAAMO,UAAU,MAAMpB,oBAAoBI,MAAM;gBAC9CO;gBACAL,QAAQD,QAAQC,MAAM;gBACtBe,WAAWhB,QAAQI,OAAO,CAACM,GAAG,CAAC;YACjC;YACA;;;;;OAKC,GACD,IAAIK,QAAQE,KAAK,IAAIF,QAAQG,UAAU,EAAE;gBACvC,MAAMzB,4BAA4BM,MAAM;oBAAEmB,YAAYH,QAAQG,UAAU;oBAAEC,MAAMpB,KAAKqB,GAAG;gBAAG;YAC7F;QACF,EAAE,OAAOC,OAAO;YACd,oEAAoE;YACpE,kEAAkE;YAClEC,QAAQD,KAAK,CAAC,4CAA4CA;QAC5D;QACA,OAAOR;IACT;AACF"}
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 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","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":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,oBAAoB,QAAQ,6BAAyB;AAC9D,SAASC,2BAA2B,QAAQ,uBAAmB;AAC/D,SACEC,gBAAgB,EAChBC,sBAAsB,EACtBC,sBAAsB,QAEjB,kBAAc;AACrB,SAASC,mBAAmB,QAAQ,oBAAgB;AAEpD,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,IAAI;QACF,MAAMC,UAAU,MAAMlB,oBAAoBY,MAAM;YAC9CE;YACAK,QAAQN,QAAQM,MAAM;YACtBC,WAAWP,QAAQP,OAAO,CAACe,GAAG,CAAC;QACjC;QACA;;;;;KAKC,GACD,IAAIH,QAAQI,KAAK,IAAIJ,QAAQK,UAAU,EAAE;YACvC,MAAM3B,4BAA4BgB,MAAM;gBAAEW,YAAYL,QAAQK,UAAU;gBAAEC,MAAMZ,KAAKa,GAAG;YAAG;QAC7F;IACF,EAAE,OAAOC,OAAO;QACd,oEAAoE;QACpE,kEAAkE;QAClEC,QAAQD,KAAK,CAAC,4CAA4CA;IAC5D;IACA,OAAOX;AACT;AAEA,wEAAwE,GACxE,OAAO,SAASa,yBAAyBhB,IAAoB;IAC3D,OAAO,OAAOC;QACZ,wEAAwE;QACxE,qEAAqE;QACrE,IAAIA,QAAQM,MAAM,KAAK,SAASN,QAAQM,MAAM,KAAK,QAAQ,OAAOhB;QAClE,MAAMW,SAAShB,uBAAuB,IAAI+B,IAAIhB,QAAQI,GAAG,EAAEa,YAAY,CAACT,GAAG,CAAC;QAC5E,IAAI,CAACP,QAAQ,OAAON;QACpB,OAAOG,oBAAoBC,MAAMC,SAASC;IAC5C;AACF;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASiB,6BAA6BnB,IAAoB;IAC/D,OAAO,OAAOC;QACZ,IAAIA,QAAQM,MAAM,KAAK,SAASN,QAAQM,MAAM,KAAK,QAAQ,OAAOhB;QAClE,2DAA2D;QAC3D,MAAM6B,SAAS,IAAIH,IAAIhB,QAAQI,GAAG,EAAEgB,QAAQ,CAACC,KAAK,CAAC,KAAKC,GAAG;QAC3D,IAAI,CAACtC,iBAAiBmC,SAAS,OAAOxB;QACtC,IAAIM;QACJ,IAAI;YACF,MAAMsB,WAAW,MAAMxB,KAAKyB,SAAS,GAAGC,UAAU,CAAC3C,qBAAqB4C,KAAK,EAAEC,GAAG,CAACR,QAAQX,GAAG;YAC9FP,SAASsB,SAASK,MAAM,GAAG1C,uBAAuBqC,SAASM,IAAI,MAAM;QACvE,EAAE,OAAOhB,OAAO;YACdC,QAAQD,KAAK,CAAC,6CAA6CA;YAC3D,OAAOzB,wBACL;gBACEQ,OAAO;gBACPC,MAAM;YACR,GACA;QAEJ;QACA,IAAI,CAACI,QAAQ,OAAON;QACpB,OAAOG,oBAAoBC,MAAMC,SAASC;IAC5C;AACF"}
@@ -34,5 +34,7 @@ export declare function platformOutreachRuntimeDeps(): OutreachRuntimeDeps;
34
34
  export declare function platformOutreachUnsubscribeRoute(): PluginWebApiHandler;
35
35
  /** The click-tracking redirect on the platform's own reach (AGL-3239). */
36
36
  export declare function platformOutreachClickRoute(): PluginWebApiHandler;
37
+ /** The short tracking link's redirect on the platform's own reach (AGL-3297). */
38
+ export declare function platformOutreachShortLinkRoute(): PluginWebApiHandler;
37
39
  /** The person eraser on the platform's own Firestore. */
38
40
  export declare function platformOutreachPersonEraser(): PluginPersonEraser;
@@ -31,8 +31,8 @@ import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
31
31
  import { composeOutreachMailboxNotice } from "../engine/mailbox-notice.js";
32
32
  import { openOutreachMailboxClient } from "../mailboxes/mailbox-transport.js";
33
33
  import { canonicalConsoleOrigin } from "../mailboxes/oauth-redirect.js";
34
- import { outreachClickUrl } from "./click-link.js";
35
- import { createOutreachClickRoute } from "./click-route.js";
34
+ import { outreachShortLinkUrl } from "./click-link.js";
35
+ import { createOutreachClickRoute, createOutreachShortLinkRoute } from "./click-route.js";
36
36
  import { createOutreachPersonEraser } from "./person-erasure.js";
37
37
  import { outreachUnsubscribeUrl } from "./unsubscribe-link.js";
38
38
  import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
@@ -164,9 +164,9 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
164
164
  origin: canonicalConsoleOrigin(),
165
165
  target
166
166
  }),
167
- clickUrl: (target)=>outreachClickUrl({
167
+ clickLinkUrl: (linkId)=>outreachShortLinkUrl({
168
168
  origin: canonicalConsoleOrigin(),
169
- target
169
+ linkId
170
170
  })
171
171
  };
172
172
  }
@@ -176,6 +176,9 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
176
176
  /** The click-tracking redirect on the platform's own reach (AGL-3239). */ export function platformOutreachClickRoute() {
177
177
  return createOutreachClickRoute(platformOutreachRuntimeDeps());
178
178
  }
179
+ /** The short tracking link's redirect on the platform's own reach (AGL-3297). */ export function platformOutreachShortLinkRoute() {
180
+ return createOutreachShortLinkRoute(platformOutreachRuntimeDeps());
181
+ }
179
182
  /** The person eraser on the platform's own Firestore. */ export function platformOutreachPersonEraser() {
180
183
  return createOutreachPersonEraser({
181
184
  firestore: ()=>firebaseAdmin.app().firestore()
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.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 { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, orgId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, orgId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","orgId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","orgRefusal","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.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 { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachShortLinkUrl } from './click-link'\nimport { createOutreachClickRoute, createOutreachShortLinkRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, orgId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, orgId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickLinkUrl: (linkId) => outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The short tracking link's redirect on the platform's own reach (AGL-3297). */\nexport function platformOutreachShortLinkRoute(): PluginWebApiHandler {\n return createOutreachShortLinkRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachShortLinkUrl","createOutreachClickRoute","createOutreachShortLinkRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","orgId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","orgRefusal","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickLinkUrl","linkId","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}