@aglyn/plugins-outreach 1.0.0-beta.181 → 1.0.0-beta.182

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 (42) hide show
  1. package/README.md +2 -1
  2. package/package.json +14 -13
  3. package/src/lib/components/link-domains-card.d.ts +28 -0
  4. package/src/lib/components/link-domains-card.js +285 -0
  5. package/src/lib/components/link-domains-card.js.map +1 -0
  6. package/src/lib/components/mailboxes-section.js +5 -1
  7. package/src/lib/components/mailboxes-section.js.map +1 -1
  8. package/src/lib/components/use-outreach-api.d.ts +5 -1
  9. package/src/lib/components/use-outreach-api.js +10 -0
  10. package/src/lib/components/use-outreach-api.js.map +1 -1
  11. package/src/lib/constants/api-routes.d.ts +6 -0
  12. package/src/lib/constants/api-routes.js +5 -0
  13. package/src/lib/constants/api-routes.js.map +1 -1
  14. package/src/lib/engine/sequence-validation.js +4 -1
  15. package/src/lib/engine/sequence-validation.js.map +1 -1
  16. package/src/lib/model/outreach-api.d.ts +48 -1
  17. package/src/lib/model/outreach-api.js.map +1 -1
  18. package/src/lib/routes/link-domain-routes.d.ts +65 -0
  19. package/src/lib/routes/link-domain-routes.js +142 -0
  20. package/src/lib/routes/link-domain-routes.js.map +1 -0
  21. package/src/lib/routes/platform-deps.d.ts +2 -1
  22. package/src/lib/routes/platform-deps.js +2 -1
  23. package/src/lib/routes/platform-deps.js.map +1 -1
  24. package/src/lib/routes/register-routes.d.ts +3 -0
  25. package/src/lib/routes/register-routes.js +19 -0
  26. package/src/lib/routes/register-routes.js.map +1 -1
  27. package/src/lib/runtime/click-link.d.ts +10 -3
  28. package/src/lib/runtime/click-link.js +18 -8
  29. package/src/lib/runtime/click-link.js.map +1 -1
  30. package/src/lib/runtime/platform-runtime-deps.js +13 -3
  31. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  32. package/src/lib/runtime/runtime-deps.d.ts +15 -3
  33. package/src/lib/runtime/runtime-deps.js.map +1 -1
  34. package/src/lib/runtime/send-job.js +22 -2
  35. package/src/lib/runtime/send-job.js.map +1 -1
  36. package/src/lib/runtime/unsubscribe-link.js +17 -9
  37. package/src/lib/runtime/unsubscribe-link.js.map +1 -1
  38. package/src/lib/transport/rfc5322.d.ts +6 -2
  39. package/src/lib/transport/rfc5322.js +6 -2
  40. package/src/lib/transport/rfc5322.js.map +1 -1
  41. package/src/lib/transport/send-message.js +11 -8
  42. package/src/lib/transport/send-message.js.map +1 -1
@@ -19,6 +19,7 @@ import { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record
19
19
  import { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown';
20
20
  import { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations';
21
21
  import { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions';
22
+ import { listTrackingHosts, removeTrackingHost, setUpTrackingHost, verifyTrackingHost } from '@aglyn/tenant-data-admin/server/tracking-hosts';
22
23
  /**
23
24
  * The platform's heavier server modules the Outreach routes reach
24
25
  * (AGL-2980): the permission resolver, the lockdown verdict, the activity
@@ -32,7 +33,7 @@ import { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions';
32
33
  * module of this plugin keeps every import of the platform's libraries a
33
34
  * static one.
34
35
  */
35
- export { creditCampaignSequenceOutcome, lockdownRefusal, logOrgActivity, resolveOrgPermissions, stampRecordEmailState, };
36
+ export { creditCampaignSequenceOutcome, listTrackingHosts, lockdownRefusal, logOrgActivity, removeTrackingHost, resolveOrgPermissions, setUpTrackingHost, stampRecordEmailState, verifyTrackingHost, };
36
37
  /**
37
38
  * Whether the member holds a CATALOG permission (`data.manage`) as the
38
39
  * org's roles resolve it — custom role and per-member overrides included.
@@ -19,6 +19,7 @@ import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record
19
19
  import { lockdownRefusal } from "@aglyn/tenant-data-admin/server/lockdown";
20
20
  import { logOrgActivity, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin/server/organizations";
21
21
  import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
22
+ import { listTrackingHosts, removeTrackingHost, setUpTrackingHost, verifyTrackingHost } from "@aglyn/tenant-data-admin/server/tracking-hosts";
22
23
  /**
23
24
  * The platform's heavier server modules the Outreach routes reach
24
25
  * (AGL-2980): the permission resolver, the lockdown verdict, the activity
@@ -31,7 +32,7 @@ import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
31
32
  * alone reaches the whole tenant data barrel. Loading them through one
32
33
  * module of this plugin keeps every import of the platform's libraries a
33
34
  * static one.
34
- */ export { creditCampaignSequenceOutcome, lockdownRefusal, logOrgActivity, resolveOrgPermissions, stampRecordEmailState, };
35
+ */ export { creditCampaignSequenceOutcome, listTrackingHosts, lockdownRefusal, logOrgActivity, removeTrackingHost, resolveOrgPermissions, setUpTrackingHost, stampRecordEmailState, verifyTrackingHost, };
35
36
  /**
36
37
  * Whether the member holds a CATALOG permission (`data.manage`) as the
37
38
  * org's roles resolve it — custom role and per-member overrides included.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/platform-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 { creditCampaignSequenceOutcome } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { collectDynamicListCandidates } from '@aglyn/tenant-data-admin/server/dynamic-list-materialize'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport {\n logOrgActivity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\n\n/**\n * The platform's heavier server modules the Outreach routes reach\n * (AGL-2980): the permission resolver, the lockdown verdict, the activity\n * log and the saved-view sweep.\n *\n * Imported here statically, and this module is loaded by\n * `register-routes.ts` the first time a request needs one of them: the\n * manifest loads Outreach's server bundle into every console API process\n * whether or not an Outreach route is ever called, and `org-permissions`\n * alone reaches the whole tenant data barrel. Loading them through one\n * module of this plugin keeps every import of the platform's libraries a\n * static one.\n */\n\nexport {\n creditCampaignSequenceOutcome,\n lockdownRefusal,\n logOrgActivity,\n resolveOrgPermissions,\n stampRecordEmailState,\n}\n\n/**\n * Whether the member holds a CATALOG permission (`data.manage`) as the\n * org's roles resolve it — custom role and per-member overrides included.\n * A lookup that failed has not shown the member holds it.\n */\nexport async function holdsOrgCatalogPermission(uid: string, orgId: string, key: string): Promise<boolean> {\n try {\n const membership = await resolveOrgMembership(uid, orgId)\n if (!membership?.member) return false\n return (\n (await memberHasOrgPermission(\n orgId,\n membership.member,\n key as Parameters<typeof memberHasOrgPermission>[2],\n )) === true\n )\n } catch {\n return false\n }\n}\n\n/**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read by the dynamic-list sweep's own scan, and whether it reached the\n * whole view.\n */\nexport async function crmViewEmails(input: {\n hostId: string\n viewId: string\n}): Promise<{ emails: string[]; complete: boolean }> {\n const scan = await collectDynamicListCandidates({\n hostId: input.hostId,\n rule: { sources: ['contacts'], viewId: input.viewId },\n })\n return { emails: scan.candidates.map((candidate) => candidate.email), complete: scan.complete }\n}\n"],"names":["creditCampaignSequenceOutcome","collectDynamicListCandidates","stampRecordEmailState","lockdownRefusal","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","resolveOrgPermissions","holdsOrgCatalogPermission","uid","orgId","key","membership","member","crmViewEmails","input","scan","hostId","rule","sources","viewId","emails","candidates","map","candidate","email","complete"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,6BAA6B,QAAQ,kEAAiE;AAC/G,SAASC,4BAA4B,QAAQ,2DAA0D;AACvG,SAASC,qBAAqB,QAAQ,wDAAuD;AAC7F,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SACEC,cAAc,EACdC,sBAAsB,EACtBC,oBAAoB,QACf,gDAA+C;AACtD,SAASC,qBAAqB,QAAQ,wCAAuC;AAE7E;;;;;;;;;;;;CAYC,GAED,SACEP,6BAA6B,EAC7BG,eAAe,EACfC,cAAc,EACdG,qBAAqB,EACrBL,qBAAqB,KACtB;AAED;;;;CAIC,GACD,OAAO,eAAeM,0BAA0BC,GAAW,EAAEC,KAAa,EAAEC,GAAW;IACrF,IAAI;QACF,MAAMC,aAAa,MAAMN,qBAAqBG,KAAKC;QACnD,IAAI,EAACE,8BAAAA,WAAYC,MAAM,GAAE,OAAO;QAChC,OACE,AAAC,MAAMR,uBACLK,OACAE,WAAWC,MAAM,EACjBF,SACK;IAEX,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeG,cAAcC,KAGnC;IACC,MAAMC,OAAO,MAAMf,6BAA6B;QAC9CgB,QAAQF,MAAME,MAAM;QACpBC,MAAM;YAAEC,SAAS;gBAAC;aAAW;YAAEC,QAAQL,MAAMK,MAAM;QAAC;IACtD;IACA,OAAO;QAAEC,QAAQL,KAAKM,UAAU,CAACC,GAAG,CAAC,CAACC,YAAcA,UAAUC,KAAK;QAAGC,UAAUV,KAAKU,QAAQ;IAAC;AAChG"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/platform-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 { creditCampaignSequenceOutcome } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { collectDynamicListCandidates } from '@aglyn/tenant-data-admin/server/dynamic-list-materialize'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport {\n logOrgActivity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n listTrackingHosts,\n removeTrackingHost,\n setUpTrackingHost,\n verifyTrackingHost,\n} from '@aglyn/tenant-data-admin/server/tracking-hosts'\n\n/**\n * The platform's heavier server modules the Outreach routes reach\n * (AGL-2980): the permission resolver, the lockdown verdict, the activity\n * log and the saved-view sweep.\n *\n * Imported here statically, and this module is loaded by\n * `register-routes.ts` the first time a request needs one of them: the\n * manifest loads Outreach's server bundle into every console API process\n * whether or not an Outreach route is ever called, and `org-permissions`\n * alone reaches the whole tenant data barrel. Loading them through one\n * module of this plugin keeps every import of the platform's libraries a\n * static one.\n */\n\nexport {\n creditCampaignSequenceOutcome,\n listTrackingHosts,\n lockdownRefusal,\n logOrgActivity,\n removeTrackingHost,\n resolveOrgPermissions,\n setUpTrackingHost,\n stampRecordEmailState,\n verifyTrackingHost,\n}\n\n/**\n * Whether the member holds a CATALOG permission (`data.manage`) as the\n * org's roles resolve it — custom role and per-member overrides included.\n * A lookup that failed has not shown the member holds it.\n */\nexport async function holdsOrgCatalogPermission(uid: string, orgId: string, key: string): Promise<boolean> {\n try {\n const membership = await resolveOrgMembership(uid, orgId)\n if (!membership?.member) return false\n return (\n (await memberHasOrgPermission(\n orgId,\n membership.member,\n key as Parameters<typeof memberHasOrgPermission>[2],\n )) === true\n )\n } catch {\n return false\n }\n}\n\n/**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read by the dynamic-list sweep's own scan, and whether it reached the\n * whole view.\n */\nexport async function crmViewEmails(input: {\n hostId: string\n viewId: string\n}): Promise<{ emails: string[]; complete: boolean }> {\n const scan = await collectDynamicListCandidates({\n hostId: input.hostId,\n rule: { sources: ['contacts'], viewId: input.viewId },\n })\n return { emails: scan.candidates.map((candidate) => candidate.email), complete: scan.complete }\n}\n"],"names":["creditCampaignSequenceOutcome","collectDynamicListCandidates","stampRecordEmailState","lockdownRefusal","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","resolveOrgPermissions","listTrackingHosts","removeTrackingHost","setUpTrackingHost","verifyTrackingHost","holdsOrgCatalogPermission","uid","orgId","key","membership","member","crmViewEmails","input","scan","hostId","rule","sources","viewId","emails","candidates","map","candidate","email","complete"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,6BAA6B,QAAQ,kEAAiE;AAC/G,SAASC,4BAA4B,QAAQ,2DAA0D;AACvG,SAASC,qBAAqB,QAAQ,wDAAuD;AAC7F,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SACEC,cAAc,EACdC,sBAAsB,EACtBC,oBAAoB,QACf,gDAA+C;AACtD,SAASC,qBAAqB,QAAQ,wCAAuC;AAC7E,SACEC,iBAAiB,EACjBC,kBAAkB,EAClBC,iBAAiB,EACjBC,kBAAkB,QACb,iDAAgD;AAEvD;;;;;;;;;;;;CAYC,GAED,SACEX,6BAA6B,EAC7BQ,iBAAiB,EACjBL,eAAe,EACfC,cAAc,EACdK,kBAAkB,EAClBF,qBAAqB,EACrBG,iBAAiB,EACjBR,qBAAqB,EACrBS,kBAAkB,KACnB;AAED;;;;CAIC,GACD,OAAO,eAAeC,0BAA0BC,GAAW,EAAEC,KAAa,EAAEC,GAAW;IACrF,IAAI;QACF,MAAMC,aAAa,MAAMV,qBAAqBO,KAAKC;QACnD,IAAI,EAACE,8BAAAA,WAAYC,MAAM,GAAE,OAAO;QAChC,OACE,AAAC,MAAMZ,uBACLS,OACAE,WAAWC,MAAM,EACjBF,SACK;IAEX,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeG,cAAcC,KAGnC;IACC,MAAMC,OAAO,MAAMnB,6BAA6B;QAC9CoB,QAAQF,MAAME,MAAM;QACpBC,MAAM;YAAEC,SAAS;gBAAC;aAAW;YAAEC,QAAQL,MAAMK,MAAM;QAAC;IACtD;IACA,OAAO;QAAEC,QAAQL,KAAKM,UAAU,CAACC,GAAG,CAAC,CAACC,YAAcA,UAAUC,KAAK;QAAGC,UAAUV,KAAKU,QAAQ;IAAC;AAChG"}
@@ -15,8 +15,11 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type OutreachEnrollRouteDeps } from './enroll-routes';
18
+ import { type OutreachLinkDomainRouteDeps } from './link-domain-routes';
18
19
  import type { OutreachRouteGateDeps } from './route-gate';
19
20
  /** The gate's reach into the platform. */
20
21
  export declare function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps;
21
22
  export declare function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps;
23
+ /** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */
24
+ export declare function defaultOutreachLinkDomainDeps(): OutreachLinkDomainRouteDeps;
22
25
  export declare function registerOutreachRoutes(deps?: OutreachEnrollRouteDeps): void;
@@ -21,6 +21,8 @@ import { outreachOrgSubject } from "../mailboxes/register-mailbox-routes.js";
21
21
  import { createOutreachDoNotContactDomainsRoute } from "./do-not-contact-routes.js";
22
22
  import { createOutreachEnrollRoutes } from "./enroll-routes.js";
23
23
  import { createOutreachEnrollmentActionRoute } from "./enrollment-routes.js";
24
+ import { canonicalConsoleOrigin } from "../mailboxes/oauth-redirect.js";
25
+ import { createOutreachLinkDomainsRoute } from "./link-domain-routes.js";
24
26
  import { createOutreachPreviewRoute } from "./preview-routes.js";
25
27
  import { createOutreachSequenceRoutes } from "./sequence-routes.js";
26
28
  import { createOutreachSettingsRoute } from "./settings-routes.js";
@@ -79,6 +81,20 @@ export function defaultOutreachRouteDeps() {
79
81
  }
80
82
  };
81
83
  }
84
+ /** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */ export function defaultOutreachLinkDomainDeps() {
85
+ return {
86
+ hosts: async ()=>{
87
+ const loaded = await platform();
88
+ return {
89
+ list: loaded.listTrackingHosts,
90
+ setUp: loaded.setUpTrackingHost,
91
+ verify: loaded.verifyTrackingHost,
92
+ remove: loaded.removeTrackingHost
93
+ };
94
+ },
95
+ consoleOrigin: canonicalConsoleOrigin
96
+ };
97
+ }
82
98
  export function registerOutreachRoutes(deps = defaultOutreachRouteDeps()) {
83
99
  const sequences = createOutreachSequenceRoutes(deps);
84
100
  const enroll = createOutreachEnrollRoutes(deps);
@@ -112,6 +128,9 @@ export function registerOutreachRoutes(deps = defaultOutreachRouteDeps()) {
112
128
  registerPluginApiRoute(OUTREACH_API_ROUTES.doNotContactDomains, {
113
129
  web: createOutreachDoNotContactDomainsRoute(deps)
114
130
  }, orgSubject);
131
+ registerPluginApiRoute(OUTREACH_API_ROUTES.linkDomains, {
132
+ web: createOutreachLinkDomainsRoute(deps, defaultOutreachLinkDomainDeps())
133
+ }, orgSubject);
115
134
  }
116
135
 
117
136
  //# sourceMappingURL=register-routes.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.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 { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { createOutreachEnrollmentActionRoute } from './enrollment-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, orgId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, orgId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","registerPluginApiRoute","firebaseAdmin","OUTREACH_API_ROUTES","outreachOrgSubject","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","registerOutreachRoutes","deps","sequences","enroll","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","remove","enrollPreview","preview","confirm","enrollmentsAction","doNotContactDomains"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.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 { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { createOutreachEnrollmentActionRoute } from './enrollment-routes'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport { createOutreachLinkDomainsRoute, type OutreachLinkDomainRouteDeps } from './link-domain-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, orgId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, orgId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n }\n}\n\n/** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */\nexport function defaultOutreachLinkDomainDeps(): OutreachLinkDomainRouteDeps {\n return {\n hosts: async () => {\n const loaded = await platform()\n return {\n list: loaded.listTrackingHosts,\n setUp: loaded.setUpTrackingHost,\n verify: loaded.verifyTrackingHost,\n remove: loaded.removeTrackingHost,\n }\n },\n consoleOrigin: canonicalConsoleOrigin,\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.linkDomains,\n { web: createOutreachLinkDomainsRoute(deps, defaultOutreachLinkDomainDeps()) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","registerPluginApiRoute","firebaseAdmin","OUTREACH_API_ROUTES","outreachOrgSubject","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","canonicalConsoleOrigin","createOutreachLinkDomainsRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","defaultOutreachLinkDomainDeps","hosts","loaded","list","listTrackingHosts","setUp","setUpTrackingHost","verify","verifyTrackingHost","remove","removeTrackingHost","consoleOrigin","registerOutreachRoutes","deps","sequences","enroll","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","enrollPreview","preview","confirm","enrollmentsAction","doNotContactDomains","linkDomains"],"mappings":"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"}
@@ -127,11 +127,18 @@ export declare function outreachStoredLink(target: OutreachClickTarget, nowMs: n
127
127
  */
128
128
  export declare function readOutreachStoredLink(data: unknown): OutreachClickTarget | null;
129
129
  /**
130
- * The short link for an id on the console at `origin`, or `null` when the
131
- * origin is not HTTPS or the id is not one — no link rather than one that
132
- * points at nothing. A `null` leaves the destination the step wrote.
130
+ * The short link for an id, or `null` when there is no HTTPS origin to put it
131
+ * on or the id is not one — no link rather than one that points at nothing. A
132
+ * `null` leaves the destination the step wrote.
133
+ *
134
+ * On the sending domain's own click-tracking host (AGL-3306) when the org has
135
+ * verified one — `https://links.<domain>/<id>`, which that host answers
136
+ * exactly as the console route — and on the console's
137
+ * `/api/outreach/l/<id>` otherwise, which every link keeps answering on.
133
138
  */
134
139
  export declare function outreachShortLinkUrl(input: {
135
140
  origin: string | null | undefined;
136
141
  linkId: string;
142
+ /** `https://links.<domain>`, from `resolveTrackingLinkOrigin`, or nothing. */
143
+ trackingOrigin?: string | null;
137
144
  }): string | null;
@@ -227,19 +227,29 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
227
227
  };
228
228
  }
229
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.
230
+ * The short link for an id, or `null` when there is no HTTPS origin to put it
231
+ * on or the id is not one — no link rather than one that points at nothing. A
232
+ * `null` leaves the destination the step wrote.
233
+ *
234
+ * On the sending domain's own click-tracking host (AGL-3306) when the org has
235
+ * verified one — `https://links.<domain>/<id>`, which that host answers
236
+ * exactly as the console route — and on the console's
237
+ * `/api/outreach/l/<id>` otherwise, which every link keeps answering on.
233
238
  */ export function outreachShortLinkUrl(input) {
234
- let origin;
239
+ if (!isOutreachLinkId(input.linkId)) return null;
240
+ const tracking = httpsOrigin(input.trackingOrigin);
241
+ if (tracking) return `${tracking}/${input.linkId}`;
242
+ const origin = httpsOrigin(input.origin);
243
+ return origin ? `${origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}` : null;
244
+ }
245
+ /** An HTTPS origin, or `null` for anything else. */ function httpsOrigin(value) {
246
+ if (!value) return null;
235
247
  try {
236
- var _input_origin;
237
- origin = new URL(String((_input_origin = input.origin) != null ? _input_origin : ''));
248
+ const url = new URL(String(value));
249
+ return url.protocol === 'https:' ? url.origin : null;
238
250
  } catch (unused) {
239
251
  return null;
240
252
  }
241
- if (origin.protocol !== 'https:' || !isOutreachLinkId(input.linkId)) return null;
242
- return `${origin.origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}`;
243
253
  }
244
254
 
245
255
  //# 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 { 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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"}
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, 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","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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"}
@@ -26,6 +26,7 @@ import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
26
26
  import { sendOrgMemberNotice } from "@aglyn/tenant-data-admin/server/org-member-notice";
27
27
  import { logOrgActivity } from "@aglyn/tenant-data-admin/server/organizations";
28
28
  import { filterEnabledPluginsByReleaseFlags } from "@aglyn/tenant-data-admin/server/release-flags";
29
+ import { resolveTrackingLinkOrigin } from "@aglyn/tenant-data-admin/server/tracking-hosts";
29
30
  import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
30
31
  import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
31
32
  import { composeOutreachMailboxNotice } from "../engine/mailbox-notice.js";
@@ -164,10 +165,19 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
164
165
  origin: canonicalConsoleOrigin(),
165
166
  target
166
167
  }),
167
- clickLinkUrl: (linkId)=>outreachShortLinkUrl({
168
+ clickLinkUrl: (linkId, trackingOrigin)=>outreachShortLinkUrl({
168
169
  origin: canonicalConsoleOrigin(),
169
- linkId
170
- })
170
+ linkId,
171
+ trackingOrigin
172
+ }),
173
+ // The sending domain's own `links.` host once the organization has
174
+ // verified it (AGL-3306). A static import like every other
175
+ // `tenant-data-admin` module here: a dynamic one marks the whole library
176
+ // lazy-loaded, and `@nx/enforce-module-boundaries` then refuses every
177
+ // static import of it across the plugin and the console.
178
+ clickLinkOrigin ({ orgId, senderAddress }) {
179
+ return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress);
180
+ }
171
181
  };
172
182
  }
173
183
  /** The unsubscribe route on the platform's own reach. */ export function platformOutreachUnsubscribeRoute() {
@@ -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 { 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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"}
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 { resolveTrackingLinkOrigin } from '@aglyn/tenant-data-admin/server/tracking-hosts'\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, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n // The sending domain's own `links.` host once the organization has\n // verified it (AGL-3306). A static import like every other\n // `tenant-data-admin` module here: a dynamic one marks the whole library\n // lazy-loaded, and `@nx/enforce-module-boundaries` then refuses every\n // static import of it across the plugin and the console.\n clickLinkOrigin({ orgId, senderAddress }) {\n return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress)\n },\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","resolveTrackingLinkOrigin","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","trackingOrigin","clickLinkOrigin","senderAddress","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}
@@ -175,8 +175,9 @@ export interface OutreachRuntimeDeps {
175
175
  /** The signed one-click link for an enrollment, or `null` when none can be minted. */
176
176
  unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null;
177
177
  /**
178
- * The short tracking link for a stored link's id (AGL-3239, AGL-3297), or
179
- * `null` when none can be made — no HTTPS console origin. The sending
178
+ * The short tracking link for a stored link's id (AGL-3239, AGL-3297) —
179
+ * on `trackingOrigin` when one is given (AGL-3306) — or `null` when none
180
+ * can be made: no HTTPS origin. The sending
180
181
  * runtime writes the `outreachLinks` document the id names before the
181
182
  * email leaves.
182
183
  *
@@ -184,5 +185,16 @@ export interface OutreachRuntimeDeps {
184
185
  * that click is not counted. An email whose links do not work is worse
185
186
  * than an email we cannot measure.
186
187
  */
187
- clickLinkUrl(linkId: string): string | null;
188
+ clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null;
189
+ /**
190
+ * The click-tracking origin for mail sent as `senderAddress` —
191
+ * `https://links.<its domain>` once the organization has verified that
192
+ * host (AGL-3306) — or `null` for the console's own address. Asked once
193
+ * per mailbox per run; a failure reads as `null`, never as a held send.
194
+ * Absent means no deployment-level hosts at all.
195
+ */
196
+ clickLinkOrigin?(input: {
197
+ orgId: string;
198
+ senderAddress: string;
199
+ }): Promise<string | null>;
188
200
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/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 type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OutreachMailboxNotice } from '../engine/mailbox-notice'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: string\n}\n\n/**\n * The verdict on an address, for the record the person is (AGL-3245): what\n * the runtime wrote on a list, said on the lead and the contact that carry\n * the address. The platform finds the records; the runtime names none.\n */\nexport interface OutreachRecordEmailStamp {\n orgId: string\n email: string\n state: EmailState\n}\n\n/**\n * What a sequence produced, credited to the campaigns it is in (AGL-3254).\n *\n * Three doors into the platform's campaign attribution, all on the\n * platform's own join and none on the marketing plugin, which Outreach may\n * not import. Each is called once per enrollment per outcome, from the\n * state change that happens once; none throws, because the send, the reply\n * and the click have already happened and a lost credit understates a\n * campaign while a thrown one loses a step.\n */\nexport interface OutreachCampaignCredit {\n /**\n * Adds one to an outcome under every campaign named, in the campaign's\n * sequences report. `enrolled`, `sent`, `replied` and `converted` are the\n * runtime's to credit; `meetings` is credited by the booking door from\n * the touch {@link OutreachCampaignCredit.recordTouch} stamps.\n */\n credit(input: {\n hostId: string\n /**\n * The org whose campaigns these are, when the caller holds it — an\n * enrollment is an org record, so its routes do. The platform resolves\n * it from the site otherwise.\n */\n orgId?: string | null\n campaignIds: readonly string[]\n outcome: 'enrolled' | 'sent' | 'replied' | 'converted'\n atMs: number\n }): Promise<void>\n /**\n * Writes the conversion record the person's own page reads as \"where\n * this came from\": the sequence's campaign as the touch, on the lead or\n * the contact the enrollment names. Once per record, and never over a\n * record that already carries a web or email touch — the join's own\n * `create()` decides that.\n */\n attributeRecord(input: {\n hostId: string\n kind: 'lead' | 'contact'\n /** The lead's person key, or the contact's id. */\n refId: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n /**\n * Stamps a click on a sequence email as the person's last campaign touch\n * on the site, so a booking or a form they go on to submit is credited to\n * the sequence's campaign by the door that credits every other one.\n */\n recordTouch(input: {\n hostId: string\n email: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n /** The platform's campaign attribution, for what a sequence produced (AGL-3254). */\n campaignCredit: OutreachCampaignCredit\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /**\n * Stamps the verdict on the lead and the contact that carry the address\n * (AGL-3245), beside every write the runtime makes to a list. Never\n * throws: the list is the control, the stamp is what a person reads.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The short tracking link for a stored link's id (AGL-3239, AGL-3297), or\n * `null` when none can be made — no HTTPS console origin. The sending\n * runtime writes the `outreachLinks` document the id names before the\n * email leaves.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickLinkUrl(linkId: string): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAgHD,WA2DC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/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 type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OutreachMailboxNotice } from '../engine/mailbox-notice'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: string\n}\n\n/**\n * The verdict on an address, for the record the person is (AGL-3245): what\n * the runtime wrote on a list, said on the lead and the contact that carry\n * the address. The platform finds the records; the runtime names none.\n */\nexport interface OutreachRecordEmailStamp {\n orgId: string\n email: string\n state: EmailState\n}\n\n/**\n * What a sequence produced, credited to the campaigns it is in (AGL-3254).\n *\n * Three doors into the platform's campaign attribution, all on the\n * platform's own join and none on the marketing plugin, which Outreach may\n * not import. Each is called once per enrollment per outcome, from the\n * state change that happens once; none throws, because the send, the reply\n * and the click have already happened and a lost credit understates a\n * campaign while a thrown one loses a step.\n */\nexport interface OutreachCampaignCredit {\n /**\n * Adds one to an outcome under every campaign named, in the campaign's\n * sequences report. `enrolled`, `sent`, `replied` and `converted` are the\n * runtime's to credit; `meetings` is credited by the booking door from\n * the touch {@link OutreachCampaignCredit.recordTouch} stamps.\n */\n credit(input: {\n hostId: string\n /**\n * The org whose campaigns these are, when the caller holds it — an\n * enrollment is an org record, so its routes do. The platform resolves\n * it from the site otherwise.\n */\n orgId?: string | null\n campaignIds: readonly string[]\n outcome: 'enrolled' | 'sent' | 'replied' | 'converted'\n atMs: number\n }): Promise<void>\n /**\n * Writes the conversion record the person's own page reads as \"where\n * this came from\": the sequence's campaign as the touch, on the lead or\n * the contact the enrollment names. Once per record, and never over a\n * record that already carries a web or email touch — the join's own\n * `create()` decides that.\n */\n attributeRecord(input: {\n hostId: string\n kind: 'lead' | 'contact'\n /** The lead's person key, or the contact's id. */\n refId: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n /**\n * Stamps a click on a sequence email as the person's last campaign touch\n * on the site, so a booking or a form they go on to submit is credited to\n * the sequence's campaign by the door that credits every other one.\n */\n recordTouch(input: {\n hostId: string\n email: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n /** The platform's campaign attribution, for what a sequence produced (AGL-3254). */\n campaignCredit: OutreachCampaignCredit\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /**\n * Stamps the verdict on the lead and the contact that carry the address\n * (AGL-3245), beside every write the runtime makes to a list. Never\n * throws: the list is the control, the stamp is what a person reads.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The short tracking link for a stored link's id (AGL-3239, AGL-3297) —\n * on `trackingOrigin` when one is given (AGL-3306) — or `null` when none\n * can be made: no HTTPS origin. The sending\n * runtime writes the `outreachLinks` document the id names before the\n * email leaves.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /**\n * The click-tracking origin for mail sent as `senderAddress` —\n * `https://links.<its domain>` once the organization has verified that\n * host (AGL-3306) — or `null` for the console's own address. Asked once\n * per mailbox per run; a failure reads as `null`, never as a held send.\n * Absent means no deployment-level hosts at all.\n */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAgHD,WAoEC"}
@@ -313,6 +313,22 @@ async function runMailbox(deps, firestore, input) {
313
313
  detail: blocks.map((block)=>block.reason).join(' ')
314
314
  };
315
315
  }
316
+ /**
317
+ * The click-tracking origin for mail sent as `senderAddress`, or `null` for
318
+ * the console's own address — which is also what a failed lookup reads as: a
319
+ * link on the app address still counts the click, so no send waits on it.
320
+ */ async function trackingLinkOrigin(deps, orgId, senderAddress) {
321
+ if (!deps.clickLinkOrigin) return null;
322
+ try {
323
+ return await deps.clickLinkOrigin({
324
+ orgId,
325
+ senderAddress
326
+ });
327
+ } catch (error) {
328
+ console.warn('[outreach] the click-tracking host could not be read; links use the app address', error);
329
+ return null;
330
+ }
331
+ }
316
332
  /** The sender line a mailbox's mail goes out with. */ const senderOf = (mailbox)=>({
317
333
  address: mailbox.sendAs || mailbox.email,
318
334
  name: mailbox.displayName || null
@@ -651,8 +667,12 @@ async function runEmailStep(deps, firestore, run, input) {
651
667
  * applies to the next email, not to the next hour. Each link becomes a
652
668
  * short link (AGL-3297) whose document is written below, before the email
653
669
  * leaves; a link that cannot be made is left exactly as the step wrote it.
654
- */ const shortLinks = [];
670
+ */ if (sequence.settings.trackClicks && run.linkOrigin === undefined) {
671
+ run.linkOrigin = await trackingLinkOrigin(deps, run.orgId, sender.address);
672
+ }
673
+ const shortLinks = [];
655
674
  const rewriteLink = sequence.settings.trackClicks ? (link)=>{
675
+ var _run_linkOrigin;
656
676
  const doc = outreachStoredLink({
657
677
  orgId: run.orgId,
658
678
  enrollmentId: enrollment.id,
@@ -661,7 +681,7 @@ async function runEmailStep(deps, firestore, run, input) {
661
681
  url: link.url
662
682
  }, nowMs);
663
683
  const id = newOutreachLinkId();
664
- const url = doc ? deps.clickLinkUrl(id) : null;
684
+ const url = doc ? deps.clickLinkUrl(id, (_run_linkOrigin = run.linkOrigin) != null ? _run_linkOrigin : null) : null;
665
685
  if (!doc || !url) return null;
666
686
  shortLinks.push({
667
687
  id,