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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +13 -12
  2. package/src/lib/components/enrollments-table.d.ts +7 -0
  3. package/src/lib/components/enrollments-table.js +46 -2
  4. package/src/lib/components/enrollments-table.js.map +1 -1
  5. package/src/lib/components/sequence-detail.js +14 -4
  6. package/src/lib/components/sequence-detail.js.map +1 -1
  7. package/src/lib/components/sequence-editor.js +22 -0
  8. package/src/lib/components/sequence-editor.js.map +1 -1
  9. package/src/lib/components/sequence-report-card.d.ts +23 -0
  10. package/src/lib/components/sequence-report-card.js +191 -0
  11. package/src/lib/components/sequence-report-card.js.map +1 -0
  12. package/src/lib/components/use-outreach-data.d.ts +11 -0
  13. package/src/lib/components/use-outreach-data.js +24 -1
  14. package/src/lib/components/use-outreach-data.js.map +1 -1
  15. package/src/lib/constants/api-routes.d.ts +9 -0
  16. package/src/lib/constants/api-routes.js +9 -1
  17. package/src/lib/constants/api-routes.js.map +1 -1
  18. package/src/lib/engine/click-tracking.d.ts +98 -0
  19. package/src/lib/engine/click-tracking.js +252 -0
  20. package/src/lib/engine/click-tracking.js.map +1 -0
  21. package/src/lib/engine/compose.d.ts +24 -0
  22. package/src/lib/engine/compose.js +15 -2
  23. package/src/lib/engine/compose.js.map +1 -1
  24. package/src/lib/engine/sequence-validation.js +4 -1
  25. package/src/lib/engine/sequence-validation.js.map +1 -1
  26. package/src/lib/model/enrollment-engagement.d.ts +31 -0
  27. package/src/lib/model/enrollment-engagement.js +44 -0
  28. package/src/lib/model/enrollment-engagement.js.map +1 -0
  29. package/src/lib/model/outreach.types.d.ts +89 -0
  30. package/src/lib/model/outreach.types.js +25 -0
  31. package/src/lib/model/outreach.types.js.map +1 -1
  32. package/src/lib/model/sequence-draft.js +5 -1
  33. package/src/lib/model/sequence-draft.js.map +1 -1
  34. package/src/lib/model/sequence-report.d.ts +68 -0
  35. package/src/lib/model/sequence-report.js +118 -0
  36. package/src/lib/model/sequence-report.js.map +1 -0
  37. package/src/lib/model/stored-records.d.ts +11 -1
  38. package/src/lib/model/stored-records.js +34 -1
  39. package/src/lib/model/stored-records.js.map +1 -1
  40. package/src/lib/routes/sequence-routes.js +4 -1
  41. package/src/lib/routes/sequence-routes.js.map +1 -1
  42. package/src/lib/runtime/click-events.d.ts +67 -0
  43. package/src/lib/runtime/click-events.js +232 -0
  44. package/src/lib/runtime/click-events.js.map +1 -0
  45. package/src/lib/runtime/click-link.d.ts +94 -0
  46. package/src/lib/runtime/click-link.js +158 -0
  47. package/src/lib/runtime/click-link.js.map +1 -0
  48. package/src/lib/runtime/click-route.d.ts +19 -0
  49. package/src/lib/runtime/click-route.js +108 -0
  50. package/src/lib/runtime/click-route.js.map +1 -0
  51. package/src/lib/runtime/platform-runtime-deps.d.ts +2 -0
  52. package/src/lib/runtime/platform-runtime-deps.js +9 -0
  53. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  54. package/src/lib/runtime/runtime-deps.d.ts +11 -0
  55. package/src/lib/runtime/runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +65 -5
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/server.js +8 -0
  59. package/src/lib/server.js.map +1 -1
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { readOutreachClickToken } from "./click-link.js";
18
+ import { recordOutreachClick } from "./click-events.js";
19
+ // The plain page both recipient-facing routes answer with: no theme, no
20
+ // session, the reader's own colors. It lives beside the first route that
21
+ // needed one rather than being copied for the second.
22
+ import { outreachUnsubscribePage } from "./unsubscribe-route.js";
23
+ /**
24
+ * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239): `GET /api/outreach/click?t=…`.
25
+ *
26
+ * ## The redirect happens first, and it happens for everyone
27
+ *
28
+ * The destination is in the token, so answering needs no read: the handler
29
+ * verifies the signature, and the 302 goes out. Recording is awaited after
30
+ * the answer is built and never gates it — a Firestore that is slow or down
31
+ * costs a number, not a visit.
32
+ *
33
+ * A link scanner is redirected exactly as a person is. Refusing one, or
34
+ * answering it differently, is how a security gateway learns to report the
35
+ * link as broken and the message as suspicious; it is counted apart instead
36
+ * (`../engine/click-tracking.ts`).
37
+ *
38
+ * ## No session, and no release gate
39
+ *
40
+ * Registered as a recipient link, like the one-click unsubscribe. The
41
+ * signature in the token is the whole of the authority, and a link that is
42
+ * already in somebody's inbox has to keep working whether or not Outreach is
43
+ * released to that organization today.
44
+ *
45
+ * ## A token that does not verify is not followed
46
+ *
47
+ * There is nowhere to send them: the destination WAS the thing that failed
48
+ * the check. So it answers the same plain page the unsubscribe route
49
+ * answers, rather than guessing — an endpoint that redirects to an unsigned
50
+ * URL is an open redirect, whatever it is called.
51
+ *
52
+ * ## What the recipient's browser is told
53
+ *
54
+ * `no-store`, so a gateway's copy is never served to the person after it;
55
+ * `no-referrer`, so the destination never learns the token; and `noindex`,
56
+ * because a tracking link that reached a crawler should not be in an index.
57
+ */ const REDIRECT_HEADERS = {
58
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
59
+ 'Referrer-Policy': 'no-referrer',
60
+ 'X-Robots-Tag': 'noindex, nofollow'
61
+ };
62
+ export function createOutreachClickRoute(deps) {
63
+ return async (request)=>{
64
+ // `HEAD` is answered because a gateway often sends one before the `GET`
65
+ // a person makes; it is redirected and counted as the machine it is.
66
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
67
+ return new Response('Method not allowed', {
68
+ status: 405,
69
+ headers: {
70
+ Allow: 'GET, HEAD'
71
+ }
72
+ });
73
+ }
74
+ const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'));
75
+ if (!target) {
76
+ return outreachUnsubscribePage({
77
+ title: 'This link doesn’t work',
78
+ body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.'
79
+ }, 400);
80
+ }
81
+ /*
82
+ * Built by hand rather than with `Response.redirect`, which seals its
83
+ * headers: the three above are the point of the answer as much as the
84
+ * `Location` is. `target.url` is `URL.href` of an http(s) URL — the
85
+ * token reader will return nothing else — so it is a header value that
86
+ * cannot carry a newline into the response.
87
+ */ const redirect = new Response(null, {
88
+ status: 302,
89
+ headers: _extends({}, REDIRECT_HEADERS, {
90
+ Location: target.url
91
+ })
92
+ });
93
+ try {
94
+ await recordOutreachClick(deps, {
95
+ target,
96
+ method: request.method,
97
+ userAgent: request.headers.get('user-agent')
98
+ });
99
+ } catch (error) {
100
+ // The visit already has its answer. A click we failed to count is a
101
+ // missing number; a redirect we failed to send is a broken email.
102
+ console.error('[outreach] a click could not be recorded', error);
103
+ }
104
+ return redirect;
105
+ };
106
+ }
107
+
108
+ //# sourceMappingURL=click-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { readOutreachClickToken } from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239): `GET /api/outreach/click?t=…`.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The destination is in the token, so answering needs no read: the handler\n * verifies the signature, and the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\nexport function createOutreachClickRoute(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') {\n return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n }\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) {\n return outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n }\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — the\n * token reader will return nothing else — so it is a header value that\n * cannot carry a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n try {\n await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n }\n}\n"],"names":["readOutreachClickToken","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","createOutreachClickRoute","deps","request","method","Response","status","headers","Allow","target","URL","url","searchParams","get","title","body","redirect","Location","userAgent","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,sBAAsB,QAAQ,kBAAc;AACrD,SAASC,mBAAmB,QAAQ,oBAAgB;AAEpD,wEAAwE;AACxE,yEAAyE;AACzE,sDAAsD;AACtD,SAASC,uBAAuB,QAAQ,yBAAqB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,MAAMC,mBAAmB;IACvB,iBAAiB;IACjB,mBAAmB;IACnB,gBAAgB;AAClB;AAEA,OAAO,SAASC,yBACdC,IAAiE;IAEjE,OAAO,OAAOC;QACZ,wEAAwE;QACxE,qEAAqE;QACrE,IAAIA,QAAQC,MAAM,KAAK,SAASD,QAAQC,MAAM,KAAK,QAAQ;YACzD,OAAO,IAAIC,SAAS,sBAAsB;gBAAEC,QAAQ;gBAAKC,SAAS;oBAAEC,OAAO;gBAAY;YAAE;QAC3F;QACA,MAAMC,SAASZ,uBAAuB,IAAIa,IAAIP,QAAQQ,GAAG,EAAEC,YAAY,CAACC,GAAG,CAAC;QAC5E,IAAI,CAACJ,QAAQ;YACX,OAAOV,wBACL;gBACEe,OAAO;gBACPC,MAAM;YACR,GACA;QAEJ;QACA;;;;;;KAMC,GACD,MAAMC,WAAW,IAAIX,SAAS,MAAM;YAClCC,QAAQ;YACRC,SAAS,aAAKP;gBAAkBiB,UAAUR,OAAOE,GAAG;;QACtD;QACA,IAAI;YACF,MAAMb,oBAAoBI,MAAM;gBAC9BO;gBACAL,QAAQD,QAAQC,MAAM;gBACtBc,WAAWf,QAAQI,OAAO,CAACM,GAAG,CAAC;YACjC;QACF,EAAE,OAAOM,OAAO;YACd,oEAAoE;YACpE,kEAAkE;YAClEC,QAAQD,KAAK,CAAC,4CAA4CA;QAC5D;QACA,OAAOH;IACT;AACF"}
@@ -25,5 +25,7 @@ import type { OutreachRuntimeDeps } from './runtime-deps';
25
25
  export declare function platformOutreachRuntimeDeps(): OutreachRuntimeDeps;
26
26
  /** The unsubscribe route on the platform's own reach. */
27
27
  export declare function platformOutreachUnsubscribeRoute(): PluginWebApiHandler;
28
+ /** The click-tracking redirect on the platform's own reach (AGL-3239). */
29
+ export declare function platformOutreachClickRoute(): PluginWebApiHandler;
28
30
  /** The person eraser on the platform's own Firestore. */
29
31
  export declare function platformOutreachPersonEraser(): PluginPersonEraser;
@@ -26,6 +26,8 @@ import { filterEnabledPluginsByReleaseFlags } from "@aglyn/tenant-data-admin/ser
26
26
  import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
27
27
  import { openOutreachMailboxClient } from "../mailboxes/mailbox-transport.js";
28
28
  import { canonicalConsoleOrigin } from "../mailboxes/oauth-redirect.js";
29
+ import { outreachClickUrl } from "./click-link.js";
30
+ import { createOutreachClickRoute } from "./click-route.js";
29
31
  import { createOutreachPersonEraser } from "./person-erasure.js";
30
32
  import { outreachUnsubscribeUrl } from "./unsubscribe-link.js";
31
33
  import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
@@ -83,12 +85,19 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
83
85
  unsubscribeUrl: (target)=>outreachUnsubscribeUrl({
84
86
  origin: canonicalConsoleOrigin(),
85
87
  target
88
+ }),
89
+ clickUrl: (target)=>outreachClickUrl({
90
+ origin: canonicalConsoleOrigin(),
91
+ target
86
92
  })
87
93
  };
88
94
  }
89
95
  /** The unsubscribe route on the platform's own reach. */ export function platformOutreachUnsubscribeRoute() {
90
96
  return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps());
91
97
  }
98
+ /** The click-tracking redirect on the platform's own reach (AGL-3239). */ export function platformOutreachClickRoute() {
99
+ return createOutreachClickRoute(platformOutreachRuntimeDeps());
100
+ }
92
101
  /** The person eraser on the platform's own Firestore. */ export function platformOutreachPersonEraser() {
93
102
  return createOutreachPersonEraser({
94
103
  firestore: ()=>firebaseAdmin.app().firestore()
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { 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 { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\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 { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\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 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 await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, firestore: firestore() })\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The 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","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","logOrgActivity","filterEnabledPluginsByReleaseFlags","OUTREACH_PLUGIN_ID","openOutreachMailboxClient","canonicalConsoleOrigin","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","suppressBouncedEmail","reason","context","unsubscribeUrl","origin","platformOutreachUnsubscribeRoute","platformOutreachPersonEraser"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,gBAAgB,QAAQ,2CAA0C;AAC3E,SAASC,eAAe,QAAQ,8CAA6C;AAC7E,SAASC,0BAA0B,QAAQ,qDAAoD;AAC/F,SAASC,aAAa,QAAQ,oDAAmD;AACjF,SAASC,iBAAiB,QAAQ,2DAA0D;AAC5F,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,cAAc,QAAQ,gDAA+C;AAC9E,SAASC,kCAAkC,QAAQ,gDAA+C;AAClG,SAASC,kBAAkB,QAAQ,gCAA4B;AAC/D,SAASC,yBAAyB,QAAQ,oCAAgC;AAC1E,SAASC,sBAAsB,QAAQ,iCAA6B;AAGpE,SAASC,0BAA0B,QAAQ,sBAAkB;AAE7D,SAASC,sBAAsB,QAAQ,wBAAoB;AAC3D,SAASC,8BAA8B,QAAQ,yBAAqB;AAEpE;;;;CAIC,GACD,OAAO,SAASC;IACd,MAAMC,YAAY,IAAMX,cAAcY,GAAG,GAAGD,SAAS;IACrD,OAAO;QACLA;QACAE,KAAKC,KAAKD,GAAG;QACbE,QAAQC,KAAKD,MAAM;QACnBE,aAAa,CAACC,YAAcb,0BAA0BM,aAAa;gBAAEO;YAAU;QAC/E,MAAMC,YAAWC,KAAK,EAAEC,GAAG;YACzB,IAAI,CAACzB,gBAAgByB,KAAsCjB,qBAAqB,OAAO;YACvF,IAAI,CAACT,iBAAiB0B,KAAK,aAAa,OAAO;YAC/C,MAAMC,WAAW,MAAMnB,mCAAmC;gBAACC;aAAmB,EAAE;gBAAEgB;YAAM;YACxF,OAAOE,SAASC,MAAM,GAAG,OAAO;QAClC;QACA,MAAMC,aAAY,EAAEH,GAAG,EAAEI,GAAG,EAAE;YAC5B,MAAMC,SAAS,MAAMzB,mBAAmB;gBAAEoB;gBAAKI;gBAAKE,QAAQ;YAAQ;YACpE,OAAOD,SAAS,CAAC,OAAO,EAAEA,OAAOE,KAAK,EAAE,GAAG;QAC7C;QACAC,UAAU;;gBAAMhC;4BAAAA,8BAAAA,iDAAAA,4BAA8BiC,MAAM,mBAAI;;QACxD5B,gBAAgB,CAACkB,OAAOW,OAAOC,QAAQC,SAAW/B,eAAekB,OAAOW,OAAOC,QAAQC;QACvF,MAAMC,oBAAmB,EAAEC,MAAM,EAAEC,KAAK,EAAE;YACxC,MAAMrC,kBAAkBoC,QAAQC,OAAO1C,mBAAmB;gBAAEiB,WAAWA;YAAY;QACrF;QACA,MAAM0B,sBAAqB,EAAED,KAAK,EAAED,MAAM,EAAE;YAC1C,MAAMrC,cAAc;gBAAEsC;gBAAOE,QAAQ;gBAAUC,SAAS;gBAAYJ;gBAAQxB,WAAWA;YAAY;QACrG;QACA6B,gBAAgB,CAACP,SAAWzB,uBAAuB;gBAAEiC,QAAQnC;gBAA0B2B;YAAO;IAChG;AACF;AAEA,uDAAuD,GACvD,OAAO,SAASS;IACd,OAAOjC,+BAA+BC;AACxC;AAEA,uDAAuD,GACvD,OAAO,SAASiC;IACd,OAAOpC,2BAA2B;QAAEI,WAAW,IAAMX,cAAcY,GAAG,GAAGD,SAAS;IAAG;AACvF"}
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 { 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 { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\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 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 await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, firestore: firestore() })\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","logOrgActivity","filterEnabledPluginsByReleaseFlags","OUTREACH_PLUGIN_ID","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","suppressBouncedEmail","reason","context","unsubscribeUrl","origin","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,gBAAgB,QAAQ,2CAA0C;AAC3E,SAASC,eAAe,QAAQ,8CAA6C;AAC7E,SAASC,0BAA0B,QAAQ,qDAAoD;AAC/F,SAASC,aAAa,QAAQ,oDAAmD;AACjF,SAASC,iBAAiB,QAAQ,2DAA0D;AAC5F,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,cAAc,QAAQ,gDAA+C;AAC9E,SAASC,kCAAkC,QAAQ,gDAA+C;AAClG,SAASC,kBAAkB,QAAQ,gCAA4B;AAC/D,SAASC,yBAAyB,QAAQ,oCAAgC;AAC1E,SAASC,sBAAsB,QAAQ,iCAA6B;AAGpE,SAASC,gBAAgB,QAAQ,kBAAc;AAC/C,SAASC,wBAAwB,QAAQ,mBAAe;AACxD,SAASC,0BAA0B,QAAQ,sBAAkB;AAE7D,SAASC,sBAAsB,QAAQ,wBAAoB;AAC3D,SAASC,8BAA8B,QAAQ,yBAAqB;AAEpE;;;;CAIC,GACD,OAAO,SAASC;IACd,MAAMC,YAAY,IAAMb,cAAcc,GAAG,GAAGD,SAAS;IACrD,OAAO;QACLA;QACAE,KAAKC,KAAKD,GAAG;QACbE,QAAQC,KAAKD,MAAM;QACnBE,aAAa,CAACC,YAAcf,0BAA0BQ,aAAa;gBAAEO;YAAU;QAC/E,MAAMC,YAAWC,KAAK,EAAEC,GAAG;YACzB,IAAI,CAAC3B,gBAAgB2B,KAAsCnB,qBAAqB,OAAO;YACvF,IAAI,CAACT,iBAAiB4B,KAAK,aAAa,OAAO;YAC/C,MAAMC,WAAW,MAAMrB,mCAAmC;gBAACC;aAAmB,EAAE;gBAAEkB;YAAM;YACxF,OAAOE,SAASC,MAAM,GAAG,OAAO;QAClC;QACA,MAAMC,aAAY,EAAEH,GAAG,EAAEI,GAAG,EAAE;YAC5B,MAAMC,SAAS,MAAM3B,mBAAmB;gBAAEsB;gBAAKI;gBAAKE,QAAQ;YAAQ;YACpE,OAAOD,SAAS,CAAC,OAAO,EAAEA,OAAOE,KAAK,EAAE,GAAG;QAC7C;QACAC,UAAU;;gBAAMlC;4BAAAA,8BAAAA,iDAAAA,4BAA8BmC,MAAM,mBAAI;;QACxD9B,gBAAgB,CAACoB,OAAOW,OAAOC,QAAQC,SAAWjC,eAAeoB,OAAOW,OAAOC,QAAQC;QACvF,MAAMC,oBAAmB,EAAEC,MAAM,EAAEC,KAAK,EAAE;YACxC,MAAMvC,kBAAkBsC,QAAQC,OAAO5C,mBAAmB;gBAAEmB,WAAWA;YAAY;QACrF;QACA,MAAM0B,sBAAqB,EAAED,KAAK,EAAED,MAAM,EAAE;YAC1C,MAAMvC,cAAc;gBAAEwC;gBAAOE,QAAQ;gBAAUC,SAAS;gBAAYJ;gBAAQxB,WAAWA;YAAY;QACrG;QACA6B,gBAAgB,CAACP,SAAWzB,uBAAuB;gBAAEiC,QAAQrC;gBAA0B6B;YAAO;QAC9FS,UAAU,CAACT,SAAW5B,iBAAiB;gBAAEoC,QAAQrC;gBAA0B6B;YAAO;IACpF;AACF;AAEA,uDAAuD,GACvD,OAAO,SAASU;IACd,OAAOlC,+BAA+BC;AACxC;AAEA,wEAAwE,GACxE,OAAO,SAASkC;IACd,OAAOtC,yBAAyBI;AAClC;AAEA,uDAAuD,GACvD,OAAO,SAASmC;IACd,OAAOtC,2BAA2B;QAAEI,WAAW,IAAMb,cAAcc,GAAG,GAAGD,SAAS;IAAG;AACvF"}
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
18
18
  import type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport';
19
+ import type { OutreachClickTarget } from './click-link';
19
20
  import type { OutreachUnsubscribeTarget } from './unsubscribe-link';
20
21
  /**
21
22
  * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).
@@ -74,4 +75,14 @@ export interface OutreachRuntimeDeps {
74
75
  }): Promise<void>;
75
76
  /** The signed one-click link for an enrollment, or `null` when none can be minted. */
76
77
  unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null;
78
+ /**
79
+ * The signed tracking link for one link in one email, or `null` when none
80
+ * can be minted (AGL-3239) — no console origin, no signing secret, or a
81
+ * destination we will not sign.
82
+ *
83
+ * A `null` is not an error: the link goes out as the step wrote it, and
84
+ * that click is not counted. An email whose links do not work is worse
85
+ * than an email we cannot measure.
86
+ */
87
+ clickUrl(target: OutreachClickTarget): string | null;
77
88
  }
@@ -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 { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\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\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 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 /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAyBD,WAgCC"}
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 { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachClickTarget } from './click-link'\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\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 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 /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The signed tracking link for one link in one email, or `null` when none\n * can be minted (AGL-3239) — no console origin, no signing secret, or a\n * destination we will not sign.\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 clickUrl(target: OutreachClickTarget): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA0BD,WA0CC"}
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  */ import { consentGroupForHost } from "@aglyn/aglyn/app-utils/consent-groups";
18
18
  import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
19
19
  import { normalizeCrmEmailTemplate } from "@aglyn/aglyn/app-utils/crm-email-templates";
20
+ import { FieldValue } from "firebase-admin/firestore";
20
21
  import { randomUUID } from "node:crypto";
21
22
  import { composeOutreachEmail } from "../engine/compose.js";
22
23
  import { planOutreachStepCompletion } from "../engine/enrollment-state.js";
@@ -401,8 +402,15 @@ async function openClient(deps, firestore, run) {
401
402
  const enrollmentRef = outreachOrgCollection(firestore, run.orgId, 'enrollments').doc(input.enrollment.id);
402
403
  const boxRef = mailboxRef(firestore, run.orgId, run.mailbox.id);
403
404
  const nowMs = deps.now();
404
- return firestore.runTransaction(async (transaction)=>{
405
- var _stored_stepRecords, _plan_patch;
405
+ /*
406
+ * Whether this is the FIRST email this person has had from the sequence
407
+ * (AGL-3239), decided from the same read the step is recorded against and
408
+ * so re-decided on every transaction retry. It is what `stats.people`
409
+ * counts: the denominator of every engagement rate, and the thing
410
+ * `stats.sent` is not, since one person takes several steps.
411
+ */ let firstEmail = false;
412
+ const completed = await firestore.runTransaction(async (transaction)=>{
413
+ var _stored_stepRecords, _stored_stepRecords1, _plan_patch;
406
414
  const [current, box] = await Promise.all([
407
415
  transaction.get(enrollmentRef),
408
416
  input.sent ? transaction.get(boxRef) : Promise.resolve(null)
@@ -413,6 +421,7 @@ async function openClient(deps, firestore, run) {
413
421
  // The step RAN, whatever happened to the enrollment while it did — a
414
422
  // member paused it, a reply stopped it — so it is recorded as run, and a
415
423
  // resume never sends it twice.
424
+ firstEmail = input.sent !== null && !((_stored_stepRecords = stored.stepRecords) != null ? _stored_stepRecords : []).some((record)=>record.kind === 'email');
416
425
  const plan = planOutreachStepCompletion({
417
426
  enrollment: _extends({}, stored, {
418
427
  status: 'active'
@@ -426,7 +435,7 @@ async function openClient(deps, firestore, run) {
426
435
  const patch = _extends({
427
436
  sendClaim: null,
428
437
  stepRecords: [
429
- ...(_stored_stepRecords = stored.stepRecords) != null ? _stored_stepRecords : [],
438
+ ...(_stored_stepRecords1 = stored.stepRecords) != null ? _stored_stepRecords1 : [],
430
439
  input.record
431
440
  ].slice(-20),
432
441
  updatedAtMs: nowMs
@@ -465,6 +474,42 @@ async function openClient(deps, firestore, run) {
465
474
  }
466
475
  return true;
467
476
  });
477
+ if (completed && input.sent) {
478
+ var _ref;
479
+ var _input_record_links;
480
+ await bumpOutreachSequenceStats(firestore, {
481
+ orgId: run.orgId,
482
+ sequenceId: input.sequence.id,
483
+ firstEmail,
484
+ trackedLinks: (_ref = (_input_record_links = input.record.links) == null ? void 0 : _input_record_links.length) != null ? _ref : 0
485
+ });
486
+ }
487
+ return completed;
488
+ }
489
+ /**
490
+ * The sequence's send counters (AGL-3239), written outside the enrollment's
491
+ * transaction.
492
+ *
493
+ * One sequence holds every enrollment, so a transaction over it would
494
+ * serialise every recipient against every other; `FieldValue.increment` is a
495
+ * blind write with nothing to contend on. It is bookkeeping beside the act,
496
+ * so a failure is logged and the send stands — the email has already left.
497
+ *
498
+ * `clickTracked` is stamped by the first send that actually carried a
499
+ * rewritten link, never by the SETTING: a sequence with the setting on whose
500
+ * emails have no links in them measured nothing, and the report must say
501
+ * "not measured" for it rather than publish a 0% click rate.
502
+ */ async function bumpOutreachSequenceStats(firestore, input) {
503
+ if (!input.sequenceId) return;
504
+ await outreachOrgCollection(firestore, input.orgId, 'sequences').doc(input.sequenceId).update(_extends({
505
+ 'stats.sent': FieldValue.increment(1)
506
+ }, input.firstEmail ? {
507
+ 'stats.people': FieldValue.increment(1)
508
+ } : {}, input.trackedLinks > 0 ? {
509
+ 'stats.clickTracked': true
510
+ } : {})).catch((error)=>{
511
+ console.warn('[outreach] a send could not be counted on its sequence', error);
512
+ });
468
513
  }
469
514
  /** One task step: the rep's task on the contact, then the step recorded. */ async function runTaskStep(deps, firestore, run, candidate) {
470
515
  const enrollment = candidate.enrollment;
@@ -591,10 +636,23 @@ async function runEmailStep(deps, firestore, run, input) {
591
636
  }
592
637
  const step = sequence.steps[enrollment.stepIndex];
593
638
  const sender = senderOf(mailbox);
639
+ /*
640
+ * Click tracking is the sequence's own setting (AGL-3239) and is read
641
+ * here, per send, rather than once per run: a member turning it off
642
+ * applies to the next email, not to the next hour. A link the minter
643
+ * cannot sign is left exactly as the step wrote it.
644
+ */ const rewriteLink = sequence.settings.trackClicks ? (link)=>deps.clickUrl({
645
+ orgId: run.orgId,
646
+ enrollmentId: enrollment.id,
647
+ stepIndex: enrollment.stepIndex,
648
+ linkIndex: link.index,
649
+ url: link.url
650
+ }) : null;
594
651
  const composed = composeOutreachEmail({
595
652
  sequence,
596
653
  enrollment,
597
654
  orgSettings: run.settings,
655
+ rewriteLink,
598
656
  merge: {
599
657
  // For a lead, the contact-shaped view of it (`leadAsContact`), so a
600
658
  // step written with `{{contact.*}}` reads the lead; `{{lead.*}}` reads
@@ -700,7 +758,7 @@ async function runEmailStep(deps, firestore, run, input) {
700
758
  console.error(`[outreach] a send from a mailbox failed (${code}); its sends wait for the next run`);
701
759
  return;
702
760
  }
703
- const record = {
761
+ const record = _extends({
704
762
  stepIndex: enrollment.stepIndex,
705
763
  stepId: (_ref = step == null ? void 0 : step.id) != null ? _ref : '',
706
764
  kind: 'email',
@@ -709,7 +767,9 @@ async function runEmailStep(deps, firestore, run, input) {
709
767
  gmailThreadId: sent.threadId,
710
768
  messageId: sent.messageId,
711
769
  subject: sent.subject
712
- };
770
+ }, composed.email.trackedLinks.length ? {
771
+ links: composed.email.trackedLinks
772
+ } : {});
713
773
  await completeStep(deps, firestore, run, {
714
774
  enrollment,
715
775
  sequence,