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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/enrollment-detail.js +13 -3
  3. package/src/lib/components/enrollment-detail.js.map +1 -1
  4. package/src/lib/components/sequence-editor.js +23 -1
  5. package/src/lib/components/sequence-editor.js.map +1 -1
  6. package/src/lib/components/sequence-report-card.d.ts +3 -2
  7. package/src/lib/components/sequence-report-card.js +29 -1
  8. package/src/lib/components/sequence-report-card.js.map +1 -1
  9. package/src/lib/engine/click-tracking.d.ts +9 -0
  10. package/src/lib/engine/click-tracking.js +2 -2
  11. package/src/lib/engine/click-tracking.js.map +1 -1
  12. package/src/lib/engine/compose.d.ts +13 -0
  13. package/src/lib/engine/compose.js +4 -0
  14. package/src/lib/engine/compose.js.map +1 -1
  15. package/src/lib/engine/open-tracking.d.ts +61 -0
  16. package/src/lib/engine/open-tracking.js +164 -0
  17. package/src/lib/engine/open-tracking.js.map +1 -0
  18. package/src/lib/engine/sequence-validation.js +3 -0
  19. package/src/lib/engine/sequence-validation.js.map +1 -1
  20. package/src/lib/model/enrollment-engagement.d.ts +6 -0
  21. package/src/lib/model/enrollment-engagement.js +17 -1
  22. package/src/lib/model/enrollment-engagement.js.map +1 -1
  23. package/src/lib/model/enrollment-history.d.ts +8 -0
  24. package/src/lib/model/enrollment-history.js +29 -0
  25. package/src/lib/model/enrollment-history.js.map +1 -1
  26. package/src/lib/model/enrollment-timeline.d.ts +7 -1
  27. package/src/lib/model/enrollment-timeline.js +41 -4
  28. package/src/lib/model/enrollment-timeline.js.map +1 -1
  29. package/src/lib/model/outreach.types.d.ts +71 -4
  30. package/src/lib/model/outreach.types.js +3 -2
  31. package/src/lib/model/outreach.types.js.map +1 -1
  32. package/src/lib/model/sequence-draft.js +3 -0
  33. package/src/lib/model/sequence-draft.js.map +1 -1
  34. package/src/lib/model/sequence-report.d.ts +25 -2
  35. package/src/lib/model/sequence-report.js +36 -10
  36. package/src/lib/model/sequence-report.js.map +1 -1
  37. package/src/lib/model/step-preview.d.ts +6 -0
  38. package/src/lib/model/step-preview.js +3 -2
  39. package/src/lib/model/step-preview.js.map +1 -1
  40. package/src/lib/model/stored-records.js +9 -1
  41. package/src/lib/model/stored-records.js.map +1 -1
  42. package/src/lib/routes/step-test-routes.js +40 -12
  43. package/src/lib/routes/step-test-routes.js.map +1 -1
  44. package/src/lib/runtime/click-events.d.ts +2 -0
  45. package/src/lib/runtime/click-events.js +6 -6
  46. package/src/lib/runtime/click-events.js.map +1 -1
  47. package/src/lib/runtime/click-link.d.ts +25 -0
  48. package/src/lib/runtime/click-link.js +49 -0
  49. package/src/lib/runtime/click-link.js.map +1 -1
  50. package/src/lib/runtime/click-route.d.ts +4 -0
  51. package/src/lib/runtime/click-route.js +46 -3
  52. package/src/lib/runtime/click-route.js.map +1 -1
  53. package/src/lib/runtime/open-events.d.ts +55 -0
  54. package/src/lib/runtime/open-events.js +121 -0
  55. package/src/lib/runtime/open-events.js.map +1 -0
  56. package/src/lib/runtime/send-job.js +44 -8
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/transport/rfc5322.d.ts +14 -3
  59. package/src/lib/transport/rfc5322.js +44 -9
  60. package/src/lib/transport/rfc5322.js.map +1 -1
  61. package/src/lib/transport/send-message.js +2 -1
  62. package/src/lib/transport/send-message.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-engagement.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 { OUTREACH_ENGAGEMENT_LINKS_MAX, type OutreachEnrollmentEngagement } from './outreach.types'\n\n/**\n * WHAT ONE PERSON DID WITH THE LINKS THEY WERE SENT (AGL-3239), read back\n * from the stored map.\n *\n * Client-safe, and here rather than beside the writer for that reason: the\n * enrollments table renders this in the browser and the click route writes\n * it on the server, and both must agree on what an absent field means.\n *\n * Read defensively, a field at a time. `readOutreachEngagement(undefined)`\n * is what an enrollment that has never been clicked reads as — which is\n * every enrollment of every sequence that does not track clicks, so it is\n * the common case rather than the edge one.\n */\nexport function readOutreachEngagement(raw: unknown): Required<OutreachEnrollmentEngagement> {\n const data = (raw ?? {}) as Record<string, unknown>\n const count = (value: unknown): number => {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0\n }\n const ms = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) ? value : null\n const clicks = count(data['clicks'])\n const machineClicks = count(data['machineClicks'])\n const links = Array.isArray(data['links'])\n ? [...new Set(data['links'].filter((entry): entry is string => typeof entry === 'string' && entry !== ''))]\n : []\n return {\n clicks,\n firstClickAtMs: ms(data['firstClickAtMs']),\n lastClickAtMs: ms(data['lastClickAtMs']),\n lastClickUrl: typeof data['lastClickUrl'] === 'string' ? data['lastClickUrl'] : null,\n machineClicks,\n links: links.slice(0, OUTREACH_ENGAGEMENT_LINKS_MAX),\n // Never more than the totals they are part of: a history row is written\n // in the same transaction as the count it itemizes.\n loggedClicks: Math.min(count(data['loggedClicks']), clicks),\n loggedMachineClicks: Math.min(count(data['loggedMachineClicks']), machineClicks),\n }\n}\n\n/**\n * What the enrollments table and the detail view say about one person's\n * clicks (AGL-3332), from the engagement alone.\n *\n * `linkCount` is the number of distinct destinations they followed when that\n * number is KNOWN, and `null` when it is not: a click from before the\n * per-click history kept only the last destination, so a person with two\n * such clicks followed one link or two, and neither is claimed.\n * `linksCapped` says the list stopped at {@link OUTREACH_ENGAGEMENT_LINKS_MAX}.\n */\nexport interface OutreachClickSummary {\n clicks: number\n machineClicks: number\n /** Every destination known to have been followed: the history's, and the last one. */\n followed: string[]\n linkCount: number | null\n linksCapped: boolean\n /** Human clicks counted as totals only, with no row of their own. */\n unloggedClicks: number\n unloggedMachineClicks: number\n}\n\nexport function outreachClickSummary(raw: unknown): OutreachClickSummary {\n const engagement = readOutreachEngagement(raw)\n const followed = [...engagement.links]\n if (engagement.lastClickUrl && !followed.includes(engagement.lastClickUrl)) followed.push(engagement.lastClickUrl)\n const unloggedClicks = engagement.clicks - engagement.loggedClicks\n const linksCapped = engagement.links.length >= OUTREACH_ENGAGEMENT_LINKS_MAX\n // Exact when every human click has a row, or when there was only one.\n const exact = unloggedClicks === 0 || (engagement.clicks === 1 && engagement.lastClickUrl !== null)\n return {\n clicks: engagement.clicks,\n machineClicks: engagement.machineClicks,\n followed,\n linkCount: engagement.clicks === 0 ? 0 : exact ? followed.length : null,\n linksCapped,\n unloggedClicks,\n unloggedMachineClicks: engagement.machineClicks - engagement.loggedMachineClicks,\n }\n}\n\n/** The Clicks cell: \"2 · 1 link\", or the count alone when the links are not known. */\nexport function outreachClickCountLabel(summary: OutreachClickSummary): string {\n if (!summary.clicks) return '—'\n if (summary.linkCount === null) return String(summary.clicks)\n const links = `${summary.linkCount}${summary.linksCapped ? '+' : ''}`\n return `${summary.clicks} · ${links} ${summary.linkCount === 1 && !summary.linksCapped ? 'link' : 'links'}`\n}\n"],"names":["OUTREACH_ENGAGEMENT_LINKS_MAX","readOutreachEngagement","raw","data","count","value","parsed","Number","isFinite","Math","floor","ms","clicks","machineClicks","links","Array","isArray","Set","filter","entry","firstClickAtMs","lastClickAtMs","lastClickUrl","slice","loggedClicks","min","loggedMachineClicks","outreachClickSummary","engagement","followed","includes","push","unloggedClicks","linksCapped","length","exact","linkCount","unloggedMachineClicks","outreachClickCountLabel","summary","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,6BAA6B,QAA2C,sBAAkB;AAEnG;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,uBAAuBC,GAAY;IACjD,MAAMC,OAAQD,cAAAA,MAAO,CAAC;IACtB,MAAME,QAAQ,CAACC;QACb,MAAMC,SAASC,OAAOF;QACtB,OAAOE,OAAOC,QAAQ,CAACF,WAAWA,SAAS,IAAIG,KAAKC,KAAK,CAACJ,UAAU;IACtE;IACA,MAAMK,KAAK,CAACN,QACV,OAAOA,UAAU,YAAYE,OAAOC,QAAQ,CAACH,SAASA,QAAQ;IAChE,MAAMO,SAASR,MAAMD,IAAI,CAAC,SAAS;IACnC,MAAMU,gBAAgBT,MAAMD,IAAI,CAAC,gBAAgB;IACjD,MAAMW,QAAQC,MAAMC,OAAO,CAACb,IAAI,CAAC,QAAQ,IACrC;WAAI,IAAIc,IAAId,IAAI,CAAC,QAAQ,CAACe,MAAM,CAAC,CAACC,QAA2B,OAAOA,UAAU,YAAYA,UAAU;KAAK,GACzG,EAAE;IACN,OAAO;QACLP;QACAQ,gBAAgBT,GAAGR,IAAI,CAAC,iBAAiB;QACzCkB,eAAeV,GAAGR,IAAI,CAAC,gBAAgB;QACvCmB,cAAc,OAAOnB,IAAI,CAAC,eAAe,KAAK,WAAWA,IAAI,CAAC,eAAe,GAAG;QAChFU;QACAC,OAAOA,MAAMS,KAAK,CAAC,GAAGvB;QACtB,wEAAwE;QACxE,oDAAoD;QACpDwB,cAAcf,KAAKgB,GAAG,CAACrB,MAAMD,IAAI,CAAC,eAAe,GAAGS;QACpDc,qBAAqBjB,KAAKgB,GAAG,CAACrB,MAAMD,IAAI,CAAC,sBAAsB,GAAGU;IACpE;AACF;AAwBA,OAAO,SAASc,qBAAqBzB,GAAY;IAC/C,MAAM0B,aAAa3B,uBAAuBC;IAC1C,MAAM2B,WAAW;WAAID,WAAWd,KAAK;KAAC;IACtC,IAAIc,WAAWN,YAAY,IAAI,CAACO,SAASC,QAAQ,CAACF,WAAWN,YAAY,GAAGO,SAASE,IAAI,CAACH,WAAWN,YAAY;IACjH,MAAMU,iBAAiBJ,WAAWhB,MAAM,GAAGgB,WAAWJ,YAAY;IAClE,MAAMS,cAAcL,WAAWd,KAAK,CAACoB,MAAM,IAAIlC;IAC/C,sEAAsE;IACtE,MAAMmC,QAAQH,mBAAmB,KAAMJ,WAAWhB,MAAM,KAAK,KAAKgB,WAAWN,YAAY,KAAK;IAC9F,OAAO;QACLV,QAAQgB,WAAWhB,MAAM;QACzBC,eAAee,WAAWf,aAAa;QACvCgB;QACAO,WAAWR,WAAWhB,MAAM,KAAK,IAAI,IAAIuB,QAAQN,SAASK,MAAM,GAAG;QACnED;QACAD;QACAK,uBAAuBT,WAAWf,aAAa,GAAGe,WAAWF,mBAAmB;IAClF;AACF;AAEA,oFAAoF,GACpF,OAAO,SAASY,wBAAwBC,OAA6B;IACnE,IAAI,CAACA,QAAQ3B,MAAM,EAAE,OAAO;IAC5B,IAAI2B,QAAQH,SAAS,KAAK,MAAM,OAAOI,OAAOD,QAAQ3B,MAAM;IAC5D,MAAME,QAAQ,GAAGyB,QAAQH,SAAS,GAAGG,QAAQN,WAAW,GAAG,MAAM,IAAI;IACrE,OAAO,GAAGM,QAAQ3B,MAAM,CAAC,GAAG,EAAEE,MAAM,CAAC,EAAEyB,QAAQH,SAAS,KAAK,KAAK,CAACG,QAAQN,WAAW,GAAG,SAAS,SAAS;AAC7G"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-engagement.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 { OUTREACH_ENGAGEMENT_LINKS_MAX, type OutreachEnrollmentEngagement } from './outreach.types'\n\n/**\n * WHAT ONE PERSON DID WITH THE LINKS THEY WERE SENT (AGL-3239), read back\n * from the stored map.\n *\n * Client-safe, and here rather than beside the writer for that reason: the\n * enrollments table renders this in the browser and the click route writes\n * it on the server, and both must agree on what an absent field means.\n *\n * Read defensively, a field at a time. `readOutreachEngagement(undefined)`\n * is what an enrollment that has never been clicked reads as — which is\n * every enrollment of every sequence that does not track clicks, so it is\n * the common case rather than the edge one.\n */\nexport function readOutreachEngagement(raw: unknown): Required<OutreachEnrollmentEngagement> {\n const data = (raw ?? {}) as Record<string, unknown>\n const count = (value: unknown): number => {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0\n }\n const ms = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) ? value : null\n const clicks = count(data['clicks'])\n const machineClicks = count(data['machineClicks'])\n const opens = count(data['opens'])\n const machineOpens = count(data['machineOpens'])\n const links = Array.isArray(data['links'])\n ? [...new Set(data['links'].filter((entry): entry is string => typeof entry === 'string' && entry !== ''))]\n : []\n return {\n clicks,\n firstClickAtMs: ms(data['firstClickAtMs']),\n lastClickAtMs: ms(data['lastClickAtMs']),\n lastClickUrl: typeof data['lastClickUrl'] === 'string' ? data['lastClickUrl'] : null,\n machineClicks,\n links: links.slice(0, OUTREACH_ENGAGEMENT_LINKS_MAX),\n // Never more than the totals they are part of: a history row is written\n // in the same transaction as the count it itemizes.\n loggedClicks: Math.min(count(data['loggedClicks']), clicks),\n loggedMachineClicks: Math.min(count(data['loggedMachineClicks']), machineClicks),\n // Opens (AGL-3395): absent on every enrollment of a sequence that does\n // not count them, and read as none.\n opens,\n firstOpenAtMs: ms(data['firstOpenAtMs']),\n lastOpenAtMs: ms(data['lastOpenAtMs']),\n machineOpens,\n loggedOpens: Math.min(count(data['loggedOpens']), opens + machineOpens),\n }\n}\n\n/**\n * How many rows one enrollment's history holds for its clicks and opens:\n * what the writers compare with `OUTREACH_ENROLLMENT_HISTORY_MAX` before\n * adding another.\n */\nexport function outreachLoggedEngagementRows(engagement: Required<OutreachEnrollmentEngagement>): number {\n return engagement.loggedClicks + engagement.loggedMachineClicks + engagement.loggedOpens\n}\n\n/**\n * What the enrollments table and the detail view say about one person's\n * clicks (AGL-3332), from the engagement alone.\n *\n * `linkCount` is the number of distinct destinations they followed when that\n * number is KNOWN, and `null` when it is not: a click from before the\n * per-click history kept only the last destination, so a person with two\n * such clicks followed one link or two, and neither is claimed.\n * `linksCapped` says the list stopped at {@link OUTREACH_ENGAGEMENT_LINKS_MAX}.\n */\nexport interface OutreachClickSummary {\n clicks: number\n machineClicks: number\n /** Every destination known to have been followed: the history's, and the last one. */\n followed: string[]\n linkCount: number | null\n linksCapped: boolean\n /** Human clicks counted as totals only, with no row of their own. */\n unloggedClicks: number\n unloggedMachineClicks: number\n}\n\nexport function outreachClickSummary(raw: unknown): OutreachClickSummary {\n const engagement = readOutreachEngagement(raw)\n const followed = [...engagement.links]\n if (engagement.lastClickUrl && !followed.includes(engagement.lastClickUrl)) followed.push(engagement.lastClickUrl)\n const unloggedClicks = engagement.clicks - engagement.loggedClicks\n const linksCapped = engagement.links.length >= OUTREACH_ENGAGEMENT_LINKS_MAX\n // Exact when every human click has a row, or when there was only one.\n const exact = unloggedClicks === 0 || (engagement.clicks === 1 && engagement.lastClickUrl !== null)\n return {\n clicks: engagement.clicks,\n machineClicks: engagement.machineClicks,\n followed,\n linkCount: engagement.clicks === 0 ? 0 : exact ? followed.length : null,\n linksCapped,\n unloggedClicks,\n unloggedMachineClicks: engagement.machineClicks - engagement.loggedMachineClicks,\n }\n}\n\n/** The Clicks cell: \"2 · 1 link\", or the count alone when the links are not known. */\nexport function outreachClickCountLabel(summary: OutreachClickSummary): string {\n if (!summary.clicks) return '—'\n if (summary.linkCount === null) return String(summary.clicks)\n const links = `${summary.linkCount}${summary.linksCapped ? '+' : ''}`\n return `${summary.clicks} · ${links} ${summary.linkCount === 1 && !summary.linksCapped ? 'link' : 'links'}`\n}\n"],"names":["OUTREACH_ENGAGEMENT_LINKS_MAX","readOutreachEngagement","raw","data","count","value","parsed","Number","isFinite","Math","floor","ms","clicks","machineClicks","opens","machineOpens","links","Array","isArray","Set","filter","entry","firstClickAtMs","lastClickAtMs","lastClickUrl","slice","loggedClicks","min","loggedMachineClicks","firstOpenAtMs","lastOpenAtMs","loggedOpens","outreachLoggedEngagementRows","engagement","outreachClickSummary","followed","includes","push","unloggedClicks","linksCapped","length","exact","linkCount","unloggedMachineClicks","outreachClickCountLabel","summary","String"],"mappings":"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"}
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { OutreachClickMachineReason } from '../engine/click-tracking';
18
+ import type { OutreachOpenMachineReason } from '../engine/open-tracking';
18
19
  import type { OutreachEnrollmentHistoryEntry, OutreachHistoryAction } from './outreach.types';
19
20
  /** A click row as the click route writes it. */
20
21
  export declare function outreachClickHistoryRow(input: {
@@ -24,6 +25,13 @@ export declare function outreachClickHistoryRow(input: {
24
25
  human: boolean;
25
26
  machineReason: OutreachClickMachineReason | null;
26
27
  }): Record<string, unknown>;
28
+ /** An open row as the short-link route writes it for a tracking image (AGL-3395). */
29
+ export declare function outreachOpenHistoryRow(input: {
30
+ atMs: number;
31
+ stepIndex: number;
32
+ human: boolean;
33
+ machineReason: OutreachOpenMachineReason | null;
34
+ }): Record<string, unknown>;
27
35
  /** A member's act as the action route writes it. */
28
36
  export declare function outreachActionHistoryRow(input: {
29
37
  atMs: number;
@@ -26,6 +26,13 @@
26
26
  'too_soon',
27
27
  'method'
28
28
  ];
29
+ const OPEN_MACHINE_REASONS = [
30
+ 'agent',
31
+ 'too_soon',
32
+ 'method',
33
+ 'image_proxy',
34
+ 'privacy_proxy'
35
+ ];
29
36
  const ACTIONS = [
30
37
  'pause',
31
38
  'resume',
@@ -42,6 +49,15 @@ const ACTIONS = [
42
49
  machineReason: input.human ? null : input.machineReason
43
50
  };
44
51
  }
52
+ /** An open row as the short-link route writes it for a tracking image (AGL-3395). */ export function outreachOpenHistoryRow(input) {
53
+ return {
54
+ kind: 'open',
55
+ atMs: input.atMs,
56
+ stepIndex: input.stepIndex,
57
+ human: input.human,
58
+ machineReason: input.human ? null : input.machineReason
59
+ };
60
+ }
45
61
  /** A member's act as the action route writes it. */ export function outreachActionHistoryRow(input) {
46
62
  const detail = typeof input.detail === 'string' ? input.detail.replace(/\s+/g, ' ').trim().slice(0, 500) : '';
47
63
  return {
@@ -77,6 +93,19 @@ const ACTIONS = [
77
93
  machineReason: human ? null : MACHINE_REASONS.includes(reason) ? reason : null
78
94
  };
79
95
  }
96
+ if (data['kind'] === 'open') {
97
+ const stepIndex = Number(data['stepIndex']);
98
+ const human = data['human'] === true;
99
+ const reason = data['machineReason'];
100
+ return {
101
+ id,
102
+ kind: 'open',
103
+ atMs,
104
+ stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,
105
+ human,
106
+ machineReason: human ? null : OPEN_MACHINE_REASONS.includes(reason) ? reason : null
107
+ };
108
+ }
80
109
  if (data['kind'] === 'action') {
81
110
  const action = data['action'];
82
111
  if (!ACTIONS.includes(action)) return null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-history.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 { OutreachClickMachineReason } from '../engine/click-tracking'\nimport type {\n OutreachEnrollmentHistoryEntry,\n OutreachHistoryAction,\n} from './outreach.types'\n\n/**\n * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.\n *\n * Client-safe, beside the other readers, because the click route and the\n * member-action route write these rows on the server and the detail view\n * reads them in the browser, and both must agree on the shape. Read a field\n * at a time: a row that names no time, or no kind this model knows, is\n * dropped rather than shown as something that happened.\n */\n\nconst MACHINE_REASONS: readonly OutreachClickMachineReason[] = ['agent', 'too_soon', 'method']\nconst ACTIONS: readonly OutreachHistoryAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\n/** A click row as the click route writes it. */\nexport function outreachClickHistoryRow(input: {\n atMs: number\n url: string | null\n stepIndex: number\n human: boolean\n machineReason: OutreachClickMachineReason | null\n}): Record<string, unknown> {\n return {\n kind: 'click',\n atMs: input.atMs,\n url: input.url,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n }\n}\n\n/** A member's act as the action route writes it. */\nexport function outreachActionHistoryRow(input: {\n atMs: number\n action: OutreachHistoryAction\n byUid: string\n detail: string | null\n}): Record<string, unknown> {\n const detail = typeof input.detail === 'string' ? input.detail.replace(/\\s+/g, ' ').trim().slice(0, 500) : ''\n return {\n kind: 'action',\n atMs: input.atMs,\n action: input.action,\n byUid: input.byUid,\n detail: detail || null,\n }\n}\n\n/**\n * A row id that sorts by time and cannot collide: two clicks in the same\n * millisecond — a scanner fetching every link at once — are two rows.\n */\nexport function outreachHistoryEntryId(atMs: number, random: () => number = Math.random): string {\n const suffix = Math.floor(random() * 36 ** 6)\n .toString(36)\n .padStart(6, '0')\n return `${String(Math.max(0, Math.floor(atMs))).padStart(15, '0')}-${suffix}`\n}\n\n/** A stored history row in its model shape, or `null` for one nothing can be said about. */\nexport function readOutreachHistoryEntry(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollmentHistoryEntry | null {\n if (!data) return null\n const atMs = data['atMs']\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n if (data['kind'] === 'click') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'click',\n atMs,\n url: typeof data['url'] === 'string' && data['url'] ? data['url'] : null,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachClickMachineReason)\n : null,\n }\n }\n if (data['kind'] === 'action') {\n const action = data['action']\n if (!(ACTIONS as readonly unknown[]).includes(action)) return null\n return {\n id,\n kind: 'action',\n atMs,\n action: action as OutreachHistoryAction,\n byUid: typeof data['byUid'] === 'string' ? data['byUid'] : '',\n detail: typeof data['detail'] === 'string' && data['detail'] ? data['detail'] : null,\n }\n }\n return null\n}\n"],"names":["MACHINE_REASONS","ACTIONS","outreachClickHistoryRow","input","kind","atMs","url","stepIndex","human","machineReason","outreachActionHistoryRow","detail","replace","trim","slice","action","byUid","outreachHistoryEntryId","random","Math","suffix","floor","toString","padStart","String","max","readOutreachHistoryEntry","id","data","Number","isFinite","reason","isInteger","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAQD;;;;;;;;CAQC,GAED,MAAMA,kBAAyD;IAAC;IAAS;IAAY;CAAS;AAC9F,MAAMC,UAA4C;IAAC;IAAS;IAAU;IAAQ;CAAiB;AAE/F,8CAA8C,GAC9C,OAAO,SAASC,wBAAwBC,KAMvC;IACC,OAAO;QACLC,MAAM;QACNC,MAAMF,MAAME,IAAI;QAChBC,KAAKH,MAAMG,GAAG;QACdC,WAAWJ,MAAMI,SAAS;QAC1BC,OAAOL,MAAMK,KAAK;QAClBC,eAAeN,MAAMK,KAAK,GAAG,OAAOL,MAAMM,aAAa;IACzD;AACF;AAEA,kDAAkD,GAClD,OAAO,SAASC,yBAAyBP,KAKxC;IACC,MAAMQ,SAAS,OAAOR,MAAMQ,MAAM,KAAK,WAAWR,MAAMQ,MAAM,CAACC,OAAO,CAAC,QAAQ,KAAKC,IAAI,GAAGC,KAAK,CAAC,GAAG,OAAO;IAC3G,OAAO;QACLV,MAAM;QACNC,MAAMF,MAAME,IAAI;QAChBU,QAAQZ,MAAMY,MAAM;QACpBC,OAAOb,MAAMa,KAAK;QAClBL,QAAQA,UAAU;IACpB;AACF;AAEA;;;CAGC,GACD,OAAO,SAASM,uBAAuBZ,IAAY,EAAEa,SAAuBC,KAAKD,MAAM;IACrF,MAAME,SAASD,KAAKE,KAAK,CAACH,WAAW,MAAM,GACxCI,QAAQ,CAAC,IACTC,QAAQ,CAAC,GAAG;IACf,OAAO,GAAGC,OAAOL,KAAKM,GAAG,CAAC,GAAGN,KAAKE,KAAK,CAAChB,QAAQkB,QAAQ,CAAC,IAAI,KAAK,CAAC,EAAEH,QAAQ;AAC/E;AAEA,0FAA0F,GAC1F,OAAO,SAASM,yBACdC,EAAU,EACVC,IAAyC;IAEzC,IAAI,CAACA,MAAM,OAAO;IAClB,MAAMvB,OAAOuB,IAAI,CAAC,OAAO;IACzB,IAAI,OAAOvB,SAAS,YAAY,CAACwB,OAAOC,QAAQ,CAACzB,OAAO,OAAO;IAC/D,IAAIuB,IAAI,CAAC,OAAO,KAAK,SAAS;QAC5B,MAAMrB,YAAYsB,OAAOD,IAAI,CAAC,YAAY;QAC1C,MAAMpB,QAAQoB,IAAI,CAAC,QAAQ,KAAK;QAChC,MAAMG,SAASH,IAAI,CAAC,gBAAgB;QACpC,OAAO;YACLD;YACAvB,MAAM;YACNC;YACAC,KAAK,OAAOsB,IAAI,CAAC,MAAM,KAAK,YAAYA,IAAI,CAAC,MAAM,GAAGA,IAAI,CAAC,MAAM,GAAG;YACpErB,WAAWsB,OAAOG,SAAS,CAACzB,cAAcA,aAAa,IAAIA,YAAY;YACvEC;YACAC,eAAeD,QACX,OACA,AAACR,gBAAuCiC,QAAQ,CAACF,UAC9CA,SACD;QACR;IACF;IACA,IAAIH,IAAI,CAAC,OAAO,KAAK,UAAU;QAC7B,MAAMb,SAASa,IAAI,CAAC,SAAS;QAC7B,IAAI,CAAC,AAAC3B,QAA+BgC,QAAQ,CAAClB,SAAS,OAAO;QAC9D,OAAO;YACLY;YACAvB,MAAM;YACNC;YACAU,QAAQA;YACRC,OAAO,OAAOY,IAAI,CAAC,QAAQ,KAAK,WAAWA,IAAI,CAAC,QAAQ,GAAG;YAC3DjB,QAAQ,OAAOiB,IAAI,CAAC,SAAS,KAAK,YAAYA,IAAI,CAAC,SAAS,GAAGA,IAAI,CAAC,SAAS,GAAG;QAClF;IACF;IACA,OAAO;AACT"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-history.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 { OutreachClickMachineReason } from '../engine/click-tracking'\nimport type { OutreachOpenMachineReason } from '../engine/open-tracking'\nimport type {\n OutreachEnrollmentHistoryEntry,\n OutreachHistoryAction,\n} from './outreach.types'\n\n/**\n * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.\n *\n * Client-safe, beside the other readers, because the click route and the\n * member-action route write these rows on the server and the detail view\n * reads them in the browser, and both must agree on the shape. Read a field\n * at a time: a row that names no time, or no kind this model knows, is\n * dropped rather than shown as something that happened.\n */\n\nconst MACHINE_REASONS: readonly OutreachClickMachineReason[] = ['agent', 'too_soon', 'method']\nconst OPEN_MACHINE_REASONS: readonly OutreachOpenMachineReason[] = [\n 'agent',\n 'too_soon',\n 'method',\n 'image_proxy',\n 'privacy_proxy',\n]\nconst ACTIONS: readonly OutreachHistoryAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\n/** A click row as the click route writes it. */\nexport function outreachClickHistoryRow(input: {\n atMs: number\n url: string | null\n stepIndex: number\n human: boolean\n machineReason: OutreachClickMachineReason | null\n}): Record<string, unknown> {\n return {\n kind: 'click',\n atMs: input.atMs,\n url: input.url,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n }\n}\n\n/** An open row as the short-link route writes it for a tracking image (AGL-3395). */\nexport function outreachOpenHistoryRow(input: {\n atMs: number\n stepIndex: number\n human: boolean\n machineReason: OutreachOpenMachineReason | null\n}): Record<string, unknown> {\n return {\n kind: 'open',\n atMs: input.atMs,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n }\n}\n\n/** A member's act as the action route writes it. */\nexport function outreachActionHistoryRow(input: {\n atMs: number\n action: OutreachHistoryAction\n byUid: string\n detail: string | null\n}): Record<string, unknown> {\n const detail = typeof input.detail === 'string' ? input.detail.replace(/\\s+/g, ' ').trim().slice(0, 500) : ''\n return {\n kind: 'action',\n atMs: input.atMs,\n action: input.action,\n byUid: input.byUid,\n detail: detail || null,\n }\n}\n\n/**\n * A row id that sorts by time and cannot collide: two clicks in the same\n * millisecond — a scanner fetching every link at once — are two rows.\n */\nexport function outreachHistoryEntryId(atMs: number, random: () => number = Math.random): string {\n const suffix = Math.floor(random() * 36 ** 6)\n .toString(36)\n .padStart(6, '0')\n return `${String(Math.max(0, Math.floor(atMs))).padStart(15, '0')}-${suffix}`\n}\n\n/** A stored history row in its model shape, or `null` for one nothing can be said about. */\nexport function readOutreachHistoryEntry(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollmentHistoryEntry | null {\n if (!data) return null\n const atMs = data['atMs']\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n if (data['kind'] === 'click') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'click',\n atMs,\n url: typeof data['url'] === 'string' && data['url'] ? data['url'] : null,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachClickMachineReason)\n : null,\n }\n }\n if (data['kind'] === 'open') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'open',\n atMs,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (OPEN_MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachOpenMachineReason)\n : null,\n }\n }\n if (data['kind'] === 'action') {\n const action = data['action']\n if (!(ACTIONS as readonly unknown[]).includes(action)) return null\n return {\n id,\n kind: 'action',\n atMs,\n action: action as OutreachHistoryAction,\n byUid: typeof data['byUid'] === 'string' ? data['byUid'] : '',\n detail: typeof data['detail'] === 'string' && data['detail'] ? data['detail'] : null,\n }\n }\n return null\n}\n"],"names":["MACHINE_REASONS","OPEN_MACHINE_REASONS","ACTIONS","outreachClickHistoryRow","input","kind","atMs","url","stepIndex","human","machineReason","outreachOpenHistoryRow","outreachActionHistoryRow","detail","replace","trim","slice","action","byUid","outreachHistoryEntryId","random","Math","suffix","floor","toString","padStart","String","max","readOutreachHistoryEntry","id","data","Number","isFinite","reason","isInteger","includes"],"mappings":"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"}
@@ -15,10 +15,11 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { OutreachClickMachineReason } from '../engine/click-tracking';
18
+ import type { OutreachOpenMachineReason } from '../engine/open-tracking';
18
19
  import { type OutreachMailGateway } from '../engine/mail-gateway';
19
20
  import { type OutreachEnrollment, type OutreachEnrollmentHistoryEntry, type OutreachSequenceStep } from './outreach.types';
20
21
  /** What kind of thing an entry is, which the view draws an icon for. */
21
- export type OutreachTimelineKind = 'enrolled' | 'curated' | 'sent' | 'task' | 'click' | 'scanner' | 'earlier-clicks' | 'paused' | 'resumed' | 'stopped' | 'do-not-contact' | 'replied' | 'bounced' | 'opted-out' | 'held' | 'released' | 'failed' | 'finished';
22
+ export type OutreachTimelineKind = 'enrolled' | 'curated' | 'sent' | 'task' | 'click' | 'scanner' | 'earlier-clicks' | 'open' | 'machine-open' | 'paused' | 'resumed' | 'stopped' | 'do-not-contact' | 'replied' | 'bounced' | 'opted-out' | 'held' | 'released' | 'failed' | 'finished';
22
23
  /** How loudly the view draws an entry. */
23
24
  export type OutreachTimelineTone = 'default' | 'success' | 'info' | 'warning' | 'error';
24
25
  /** One line of the timeline. */
@@ -57,6 +58,8 @@ export interface OutreachTimelineInput {
57
58
  export declare function outreachStepName(steps: readonly OutreachSequenceStep[], stepIndex: number): string;
58
59
  /** Why a click was read as a scanner's, as the timeline says it. */
59
60
  export declare const OUTREACH_MACHINE_REASON_LABELS: Record<OutreachClickMachineReason, string>;
61
+ /** Why a fetch of the tracking image was not counted as an open (AGL-3395). */
62
+ export declare const OUTREACH_OPEN_MACHINE_REASON_LABELS: Record<OutreachOpenMachineReason, string>;
60
63
  /**
61
64
  * The timeline, newest first — see the module note.
62
65
  */
@@ -71,6 +74,9 @@ export interface OutreachEnrollmentFigures {
71
74
  atLeast: boolean;
72
75
  };
73
76
  scannerClicks: number;
77
+ /** Opens judged theirs, and the image fetches that were a machine's (AGL-3395). */
78
+ opens: number;
79
+ machineOpens: number;
74
80
  replies: number;
75
81
  /** Whether anything has happened to them beyond being enrolled. */
76
82
  anything: boolean;
@@ -39,7 +39,7 @@
39
39
  * spec reads them.
40
40
  *=========================================*/ import { _ as _extends } from "@swc/helpers/_/_extends";
41
41
  import { OUTREACH_MAIL_GATEWAY_LABELS } from "../engine/mail-gateway.js";
42
- import { outreachClickSummary } from "./enrollment-engagement.js";
42
+ import { outreachClickSummary, outreachLoggedEngagementRows, readOutreachEngagement } from "./enrollment-engagement.js";
43
43
  import { OUTREACH_ENROLLMENT_HISTORY_MAX, OUTREACH_TASK_KIND_LABELS } from "./outreach.types.js";
44
44
  /** "Email 2 · step 3" for an email step, "Step 2 · Call" for a task, "Step 4" for one the sequence no longer has. */ export function outreachStepName(steps, stepIndex) {
45
45
  const step = steps[stepIndex];
@@ -55,6 +55,13 @@ import { OUTREACH_ENROLLMENT_HISTORY_MAX, OUTREACH_TASK_KIND_LABELS } from "./ou
55
55
  too_soon: 'it came within 30 seconds of the email arriving, faster than anyone reads',
56
56
  method: 'it checked the link without opening the page'
57
57
  };
58
+ /** Why a fetch of the tracking image was not counted as an open (AGL-3395). */ export const OUTREACH_OPEN_MACHINE_REASON_LABELS = {
59
+ agent: 'the request named itself as a scanner or a preview',
60
+ too_soon: 'it came within 30 seconds of the email arriving, faster than anyone reads',
61
+ method: 'it checked the image without loading it',
62
+ image_proxy: 'Yahoo’s image proxy fetched it, which it does whether or not the person reads it',
63
+ privacy_proxy: 'Apple Mail’s privacy protection fetched it as the email arrived'
64
+ };
58
65
  const ACTION_TITLES = {
59
66
  pause: 'Paused',
60
67
  resume: 'Resumed',
@@ -82,6 +89,8 @@ const ACTION_KINDS = {
82
89
  click: 4,
83
90
  scanner: 4,
84
91
  'earlier-clicks': 4,
92
+ open: 4,
93
+ 'machine-open': 4,
85
94
  task: 5,
86
95
  sent: 5,
87
96
  curated: 6,
@@ -166,7 +175,8 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
166
175
  detail: record.subject ? `Subject: ${record.subject}` : null,
167
176
  facts: [
168
177
  record.curated === 'ai' ? 'Curated copy — AI draft' : record.curated === 'member' ? 'Curated copy — written by a member' : null,
169
- ((_record_links = record.links) == null ? void 0 : _record_links.length) ? plural(record.links.length, 'tracked link', 'tracked links') : null
178
+ ((_record_links = record.links) == null ? void 0 : _record_links.length) ? plural(record.links.length, 'tracked link', 'tracked links') : null,
179
+ record.openTracked ? 'Tracking image' : null
170
180
  ].filter((fact)=>Boolean(fact)),
171
181
  gmailThreadId: startsThread ? thread : null,
172
182
  tone: 'default'
@@ -198,6 +208,30 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
198
208
  tone: 'default'
199
209
  });
200
210
  }
211
+ // ── Opens (AGL-3395) ────────────────────────────────────────────────────
212
+ for (const row of history){
213
+ if (row.kind !== 'open') continue;
214
+ entries.push(row.human ? {
215
+ key: `history-${row.id}`,
216
+ kind: 'open',
217
+ atMs: row.atMs,
218
+ title: 'Opened an email',
219
+ facts: [
220
+ `${outreachStepName(steps, row.stepIndex)}`
221
+ ],
222
+ tone: 'info'
223
+ } : {
224
+ key: `history-${row.id}`,
225
+ kind: 'machine-open',
226
+ atMs: row.atMs,
227
+ title: 'The tracking image was fetched by a machine — not counted',
228
+ facts: [
229
+ outreachStepName(steps, row.stepIndex),
230
+ row.machineReason ? `Read as a machine: ${OUTREACH_OPEN_MACHINE_REASON_LABELS[row.machineReason]}` : null
231
+ ].filter((fact)=>Boolean(fact)),
232
+ tone: 'default'
233
+ });
234
+ }
201
235
  /*
202
236
  * Clicks with no row of their own: counted before the per-click history
203
237
  * began, or past its limit. ONE entry, placed at the first of them when
@@ -208,7 +242,7 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
208
242
  var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
209
243
  var _emailRecords_;
210
244
  const everyHumanUnlogged = clicks.unloggedClicks === clicks.clicks;
211
- const loggedRows = clicks.clicks - clicks.unloggedClicks + (clicks.machineClicks - clicks.unloggedMachineClicks);
245
+ const loggedRows = outreachLoggedEngagementRows(readOutreachEngagement(enrollment.engagement));
212
246
  const facts = [];
213
247
  const first = (_ref = engagement == null ? void 0 : engagement.firstClickAtMs) != null ? _ref : null;
214
248
  const last = (_ref1 = engagement == null ? void 0 : engagement.lastClickAtMs) != null ? _ref1 : null;
@@ -409,6 +443,7 @@ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
409
443
  export function outreachEnrollmentFigures(enrollment) {
410
444
  var _enrollment_stepRecords;
411
445
  const clicks = outreachClickSummary(enrollment.engagement);
446
+ const engagement = readOutreachEngagement(enrollment.engagement);
412
447
  const emailsSent = emailRecords(enrollment).length;
413
448
  const replies = enrollment.status === 'replied' || enrollment.stopReason === 'reply' || enrollment.stopReason === 'opt_out_reply' ? 1 : 0;
414
449
  return {
@@ -422,8 +457,10 @@ export function outreachEnrollmentFigures(enrollment) {
422
457
  atLeast: clicks.linksCapped
423
458
  },
424
459
  scannerClicks: clicks.machineClicks,
460
+ opens: engagement.opens,
461
+ machineOpens: engagement.machineOpens,
425
462
  replies,
426
- anything: emailsSent > 0 || ((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).length > 0 || clicks.clicks > 0 || clicks.machineClicks > 0 || enrollment.stoppedAtMs !== null
463
+ anything: emailsSent > 0 || ((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).length > 0 || clicks.clicks > 0 || clicks.machineClicks > 0 || engagement.opens > 0 || engagement.machineOpens > 0 || enrollment.stoppedAtMs !== null
427
464
  };
428
465
  }
429
466
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-timeline.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\n/*==========================================\n * EVERYTHING THAT HAPPENED TO ONE PERSON IN ONE SEQUENCE (AGL-3332).\n *\n * The enrollment's detail view is one list, newest first, and this builds\n * it from what is stored — the enrollment itself, and its history rows —\n * without inventing anything that is not:\n *\n * - **Each send** is a step record: the step, the time, the subject as\n * sent, and whether it went out as the person's curated copy.\n * - **Each click** is a history row: the destination, the step whose email\n * carried it, and a person's or a scanner's. Clicks from before the\n * history began exist only as totals, and are ONE row saying so — never\n * a row per click, which would be a fabrication with a timestamp on it.\n * - **Each member's act** — pause, resume, stop, do-not-contact — is a\n * history row with who and why. An enrollment holds only its current\n * stop, so the row is the only record of a pause that was resumed.\n * - **The current stop** — a reply, a bounce, an opt-out, a hold — comes\n * from the enrollment's own fields, with its full reason, unless a\n * history row already says it.\n * - **The gateway hold** (AGL-3326), and its release.\n * - **The enrollment**, with who enrolled them, and each curated step.\n *\n * Client-safe and free of React: the view renders these entries, and the\n * spec reads them.\n *=========================================*/\n\nimport type { OutreachClickMachineReason } from '../engine/click-tracking'\nimport { OUTREACH_MAIL_GATEWAY_LABELS, type OutreachMailGateway } from '../engine/mail-gateway'\nimport { outreachClickSummary } from './enrollment-engagement'\nimport {\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_TASK_KIND_LABELS,\n type OutreachEnrollment,\n type OutreachEnrollmentHistoryEntry,\n type OutreachHistoryAction,\n type OutreachSequenceStep,\n type OutreachStepRecord,\n} from './outreach.types'\n\n/** What kind of thing an entry is, which the view draws an icon for. */\nexport type OutreachTimelineKind =\n | 'enrolled'\n | 'curated'\n | 'sent'\n | 'task'\n | 'click'\n | 'scanner'\n | 'earlier-clicks'\n | 'paused'\n | 'resumed'\n | 'stopped'\n | 'do-not-contact'\n | 'replied'\n | 'bounced'\n | 'opted-out'\n | 'held'\n | 'released'\n | 'failed'\n | 'finished'\n\n/** How loudly the view draws an entry. */\nexport type OutreachTimelineTone = 'default' | 'success' | 'info' | 'warning' | 'error'\n\n/** One line of the timeline. */\nexport interface OutreachTimelineEntry {\n /** Stable across renders, for React and for a spec. */\n key: string\n kind: OutreachTimelineKind\n atMs: number\n /** What happened, in a few words. */\n title: string\n /** The one fact that matters most, shown in full: a subject, a reason, a diagnostic. */\n detail?: string | null\n /** The rest, each short: the step, who, a scanner's reason. */\n facts: string[]\n /** A destination the person followed, shown whole and linkable. */\n url?: string | null\n /** What `url` is, when it is not simply \"the link they followed\". */\n urlLabel?: string\n /** The Gmail thread this entry opened, for an \"Open in Gmail\" link. */\n gmailThreadId?: string | null\n tone: OutreachTimelineTone\n}\n\nexport interface OutreachTimelineInput {\n enrollment: OutreachEnrollment\n steps: readonly OutreachSequenceStep[]\n /** The history rows, newest or oldest first; `null` while unread or unreadable. */\n history: readonly OutreachEnrollmentHistoryEntry[] | null\n /** A member's name for a stored uid; the uid itself when the roster does not know it. */\n memberName: (uid: string) => string\n /** A moment as the reader reads it — in the mailbox's zone. */\n formatTime: (ms: number) => string\n /** The mail gateway in front of the person's domain, when it is known. */\n gateway?: OutreachMailGateway | null\n}\n\n/** \"Email 2 · step 3\" for an email step, \"Step 2 · Call\" for a task, \"Step 4\" for one the sequence no longer has. */\nexport function outreachStepName(steps: readonly OutreachSequenceStep[], stepIndex: number): string {\n const step = steps[stepIndex]\n if (step?.kind === 'email') {\n const position = steps.slice(0, stepIndex + 1).filter((entry) => entry.kind === 'email').length\n return `Email ${position} · step ${stepIndex + 1}`\n }\n if (step?.kind === 'task') return `Step ${stepIndex + 1} · ${OUTREACH_TASK_KIND_LABELS[step.taskKind]}`\n return `Step ${stepIndex + 1}`\n}\n\n/** Why a click was read as a scanner's, as the timeline says it. */\nexport const OUTREACH_MACHINE_REASON_LABELS: Record<OutreachClickMachineReason, string> = {\n agent: 'the request named itself as a scanner or a link preview',\n too_soon: 'it came within 30 seconds of the email arriving, faster than anyone reads',\n method: 'it checked the link without opening the page',\n}\n\nconst ACTION_TITLES: Record<OutreachHistoryAction, string> = {\n pause: 'Paused',\n resume: 'Resumed',\n stop: 'Stopped by a member',\n do_not_contact: 'Marked do-not-contact',\n}\nconst ACTION_KINDS: Record<OutreachHistoryAction, OutreachTimelineKind> = {\n pause: 'paused',\n resume: 'resumed',\n stop: 'stopped',\n do_not_contact: 'do-not-contact',\n}\n\n/** Which of two entries at the same moment reads first: the outcome above what led to it. */\nconst TIE_ORDER: Record<OutreachTimelineKind, number> = {\n finished: 0,\n replied: 1,\n bounced: 1,\n 'opted-out': 1,\n failed: 1,\n stopped: 2,\n 'do-not-contact': 2,\n paused: 2,\n held: 2,\n released: 3,\n resumed: 3,\n click: 4,\n scanner: 4,\n 'earlier-clicks': 4,\n task: 5,\n sent: 5,\n curated: 6,\n enrolled: 7,\n}\n\nconst plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`\nconst byWhom = (uid: string | null | undefined, memberName: (uid: string) => string) =>\n uid ? `By ${memberName(uid)}` : null\n\n/** The step records that are emails, in the order they went. */\nfunction emailRecords(enrollment: OutreachEnrollment): OutreachStepRecord[] {\n return (enrollment.stepRecords ?? []).filter((record) => record?.kind === 'email')\n}\n\n/**\n * The timeline, newest first — see the module note.\n */\nexport function outreachEnrollmentTimeline(input: OutreachTimelineInput): OutreachTimelineEntry[] {\n const { enrollment, steps, memberName } = input\n const entries: OutreachTimelineEntry[] = []\n const history = input.history ?? []\n const gatewayName = input.gateway ? OUTREACH_MAIL_GATEWAY_LABELS[input.gateway] : null\n\n // ── The enrollment ──────────────────────────────────────────────────────\n if (enrollment.createdAtMs) {\n entries.push({\n key: 'enrolled',\n kind: 'enrolled',\n atMs: enrollment.createdAtMs,\n title: 'Enrolled',\n facts: [\n byWhom(enrollment.enrolledByUid, memberName),\n enrollment.target === 'lead' ? 'As a lead' : 'As a contact',\n enrollment.cold ? 'Cold — confirmed by the member who enrolled them' : null,\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n })\n }\n\n // ── Curated copies (AGL-3324) ───────────────────────────────────────────\n for (const [key, override] of Object.entries(enrollment.stepOverrides ?? {})) {\n if (!override.draftedAtMs) continue\n entries.push({\n key: `curated-${key}`,\n kind: 'curated',\n atMs: override.draftedAtMs,\n title: `Curated ${outreachStepName(steps, Number(key))}`,\n facts: [\n override.source === 'ai' ? (override.edited ? 'AI draft, edited' : 'AI draft') : 'Written by a member',\n byWhom(override.draftedByUid, memberName),\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n })\n }\n\n // ── Sends and tasks ─────────────────────────────────────────────────────\n let previousThread: string | null = null\n ;(enrollment.stepRecords ?? []).forEach((record, index) => {\n if (!record || typeof record.atMs !== 'number') return\n if (record.kind === 'task') {\n const step = steps.find((entry) => entry.id === record.stepId) ?? steps[record.stepIndex]\n entries.push({\n key: `task-${index}`,\n kind: 'task',\n atMs: record.atMs,\n title:\n step?.kind === 'task'\n ? `${OUTREACH_TASK_KIND_LABELS[step.taskKind]} task filed`\n : 'Task filed',\n detail: step?.kind === 'task' ? step.title : null,\n facts: [\n outreachStepName(steps, record.stepIndex),\n record.taskId === null ? 'The CRM could not file it' : null,\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n })\n return\n }\n const thread = record.gmailThreadId ?? null\n // The link to Gmail sits on the email that STARTED a thread, once.\n const startsThread = Boolean(thread) && thread !== previousThread\n previousThread = thread ?? previousThread\n entries.push({\n key: `sent-${index}`,\n kind: 'sent',\n atMs: record.atMs,\n title: `${outreachStepName(steps, record.stepIndex)} sent`,\n detail: record.subject ? `Subject: ${record.subject}` : null,\n facts: [\n record.curated === 'ai'\n ? 'Curated copy — AI draft'\n : record.curated === 'member'\n ? 'Curated copy — written by a member'\n : null,\n record.links?.length ? plural(record.links.length, 'tracked link', 'tracked links') : null,\n ].filter((fact): fact is string => Boolean(fact)),\n gmailThreadId: startsThread ? thread : null,\n tone: 'default',\n })\n })\n\n // ── Clicks ──────────────────────────────────────────────────────────────\n for (const row of history) {\n if (row.kind !== 'click') continue\n entries.push(\n row.human\n ? {\n key: `history-${row.id}`,\n kind: 'click',\n atMs: row.atMs,\n title: 'Clicked a link',\n url: row.url,\n facts: [`From ${outreachStepName(steps, row.stepIndex)}`],\n tone: 'success',\n }\n : {\n key: `history-${row.id}`,\n kind: 'scanner',\n atMs: row.atMs,\n title: 'A scanner followed a link — not counted',\n url: row.url,\n facts: [\n `From ${outreachStepName(steps, row.stepIndex)}`,\n row.machineReason ? `Read as a scanner: ${OUTREACH_MACHINE_REASON_LABELS[row.machineReason]}` : null,\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n },\n )\n }\n\n /*\n * Clicks with no row of their own: counted before the per-click history\n * began, or past its limit. ONE entry, placed at the first of them when\n * that is known, saying what the totals hold and nothing more.\n */\n const clicks = outreachClickSummary(enrollment.engagement)\n const engagement = enrollment.engagement\n if (clicks.unloggedClicks > 0 || clicks.unloggedMachineClicks > 0) {\n const everyHumanUnlogged = clicks.unloggedClicks === clicks.clicks\n const loggedRows = clicks.clicks - clicks.unloggedClicks + (clicks.machineClicks - clicks.unloggedMachineClicks)\n const facts: string[] = []\n const first = engagement?.firstClickAtMs ?? null\n const last = engagement?.lastClickAtMs ?? null\n if (clicks.unloggedClicks > 0 && first !== null && everyHumanUnlogged && last !== null) {\n facts.push(\n clicks.unloggedClicks === 1\n ? `At ${input.formatTime(first)}`\n : first === last\n ? `First and last at ${input.formatTime(first)}`\n : `First at ${input.formatTime(first)}, last at ${input.formatTime(last)}`,\n )\n } else if (clicks.unloggedClicks > 0 && first !== null) {\n // Later clicks have rows of their own, so only the first is one of these.\n facts.push(`First at ${input.formatTime(first)}`)\n }\n if (clicks.unloggedMachineClicks > 0) {\n facts.push(plural(clicks.unloggedMachineClicks, 'scanner click, not counted', 'scanner clicks, not counted'))\n }\n entries.push({\n key: 'earlier-clicks',\n kind: 'earlier-clicks',\n atMs:\n (clicks.unloggedClicks > 0 ? engagement?.firstClickAtMs : null) ??\n engagement?.lastClickAtMs ??\n emailRecords(enrollment)[0]?.atMs ??\n enrollment.createdAtMs,\n title:\n clicks.unloggedClicks > 0\n ? `${plural(clicks.unloggedClicks, 'earlier click', 'earlier clicks')}, kept as a total`\n : `${plural(clicks.unloggedMachineClicks, 'earlier scanner click', 'earlier scanner clicks')}, kept as a total`,\n detail:\n loggedRows >= OUTREACH_ENROLLMENT_HISTORY_MAX\n ? `Each click is listed on its own up to ${OUTREACH_ENROLLMENT_HISTORY_MAX}; these came before per-click history began, or after this person reached that limit.`\n : 'These came before each click was recorded on its own, so only their count and times were kept.',\n // The last destination is theirs only when every counted click was.\n url: clicks.unloggedClicks > 0 && everyHumanUnlogged ? (engagement?.lastClickUrl ?? null) : null,\n urlLabel: clicks.unloggedClicks === 1 ? 'Link' : 'Last link',\n facts,\n tone: clicks.unloggedClicks > 0 ? 'success' : 'default',\n })\n }\n\n // ── Members' acts ───────────────────────────────────────────────────────\n const actionTimes = new Set<number>()\n for (const row of history) {\n if (row.kind !== 'action') continue\n actionTimes.add(row.atMs)\n const released =\n row.action === 'resume' && enrollment.gatewayHold?.releasedAtMs === row.atMs\n if (released) continue\n entries.push({\n key: `history-${row.id}`,\n kind: ACTION_KINDS[row.action],\n atMs: row.atMs,\n title: ACTION_TITLES[row.action],\n detail: row.detail,\n facts: [byWhom(row.byUid, memberName)].filter((fact): fact is string => Boolean(fact)),\n tone: row.action === 'resume' ? 'default' : row.action === 'pause' ? 'warning' : 'default',\n })\n }\n\n // ── The gateway hold (AGL-3326) ─────────────────────────────────────────\n const hold = enrollment.gatewayHold\n const holdGateway = hold ? OUTREACH_MAIL_GATEWAY_LABELS[hold.gateway] : null\n if (hold?.heldAtMs) {\n entries.push({\n key: 'held',\n kind: 'held',\n atMs: hold.heldAtMs,\n title: `Held — ${holdGateway} refused this sender`,\n detail:\n enrollment.stopReason === 'gateway_blocked_here' && enrollment.stoppedAtMs === hold.heldAtMs\n ? enrollment.stopDetail\n : null,\n facts: ['Nothing was sent to them while it was held'],\n tone: 'warning',\n })\n }\n if (hold?.releasedAtMs) {\n entries.push({\n key: 'released',\n kind: 'released',\n atMs: hold.releasedAtMs,\n title: hold.heldAtMs\n ? `Resumed past the ${holdGateway} hold — sent anyway`\n : `Enrolled past the ${holdGateway} warning`,\n facts: [byWhom(hold.releasedByUid, memberName)].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n })\n }\n\n // ── The current stop, from the enrollment ───────────────────────────────\n const stoppedAtMs = enrollment.stoppedAtMs\n const alreadySaid =\n stoppedAtMs !== null &&\n (actionTimes.has(stoppedAtMs) ||\n (enrollment.stopReason === 'gateway_blocked_here' && hold?.heldAtMs === stoppedAtMs))\n if (stoppedAtMs && !alreadySaid && enrollment.status !== 'active' && enrollment.status !== 'finished') {\n const stop = currentStop(enrollment, memberName, gatewayName)\n if (stop) entries.push({ key: 'stop', atMs: stoppedAtMs, ...stop })\n }\n\n // ── Finished ────────────────────────────────────────────────────────────\n if (enrollment.status === 'finished') {\n const last = (enrollment.stepRecords ?? []).at(-1)\n entries.push({\n key: 'finished',\n kind: 'finished',\n atMs: last?.atMs ?? enrollment.updatedAtMs,\n title: 'Finished — every step ran',\n facts: [],\n tone: 'success',\n })\n }\n\n return entries.sort((a, b) => b.atMs - a.atMs || TIE_ORDER[a.kind] - TIE_ORDER[b.kind])\n}\n\n/** The entry for the stop the enrollment is in now. */\nfunction currentStop(\n enrollment: OutreachEnrollment,\n memberName: (uid: string) => string,\n gatewayName: string | null,\n): Omit<OutreachTimelineEntry, 'key' | 'atMs'> | null {\n const detail = enrollment.stopDetail\n const by = byWhom(enrollment.stoppedByUid, memberName)\n const facts = (...values: Array<string | null>) => values.filter((fact): fact is string => Boolean(fact))\n switch (enrollment.stopReason) {\n case 'reply':\n return {\n kind: 'replied',\n title: 'Replied',\n detail,\n facts: ['The sequence stopped for them'],\n gmailThreadId: enrollment.gmailThreadId ?? enrollment.gmailThreadIds.at(-1) ?? null,\n tone: 'info',\n }\n case 'hard_bounce':\n return {\n kind: 'bounced',\n title: 'Bounced',\n detail,\n facts: facts(gatewayName ? `Their mail gateway: ${gatewayName}` : null, 'The address is on the do-not-contact list'),\n tone: 'error',\n }\n case 'opt_out_reply':\n return { kind: 'opted-out', title: 'Asked not to be emailed, in a reply', detail, facts: [], tone: 'warning' }\n case 'unsubscribe':\n return { kind: 'opted-out', title: 'Unsubscribed', detail, facts: [], tone: 'warning' }\n case 'do_not_contact':\n return { kind: 'do-not-contact', title: 'Put on the do-not-contact list', detail, facts: facts(by), tone: 'warning' }\n case 'manual':\n return enrollment.status === 'paused'\n ? { kind: 'paused', title: 'Paused', detail, facts: facts(by), tone: 'warning' }\n : { kind: 'stopped', title: 'Stopped by a member', detail, facts: facts(by), tone: 'default' }\n case 'gate':\n return { kind: 'stopped', title: 'Stopped — no longer eligible', detail, facts: [], tone: 'default' }\n case 'sequence_archived':\n return { kind: 'stopped', title: 'Stopped — the sequence was archived', detail, facts: [], tone: 'default' }\n case 'gateway_blocked_here':\n return { kind: 'held', title: 'Held — their mail gateway refused this sender', detail, facts: [], tone: 'warning' }\n case 'send_failed':\n return { kind: 'failed', title: 'An email couldn’t be sent', detail, facts: [], tone: 'error' }\n default:\n return null\n }\n}\n\n/** The five figures above the timeline. */\nexport interface OutreachEnrollmentFigures {\n emailsSent: number\n clicks: number\n /** Distinct destinations followed; `atLeast` when only a floor is known. */\n linksFollowed: { count: number; atLeast: boolean }\n scannerClicks: number\n replies: number\n /** Whether anything has happened to them beyond being enrolled. */\n anything: boolean\n}\n\nexport function outreachEnrollmentFigures(enrollment: OutreachEnrollment): OutreachEnrollmentFigures {\n const clicks = outreachClickSummary(enrollment.engagement)\n const emailsSent = emailRecords(enrollment).length\n const replies =\n enrollment.status === 'replied' || enrollment.stopReason === 'reply' || enrollment.stopReason === 'opt_out_reply'\n ? 1\n : 0\n return {\n emailsSent,\n clicks: clicks.clicks,\n linksFollowed:\n clicks.linkCount === null\n ? { count: clicks.followed.length, atLeast: true }\n : { count: clicks.linkCount, atLeast: clicks.linksCapped },\n scannerClicks: clicks.machineClicks,\n replies,\n anything:\n emailsSent > 0 ||\n (enrollment.stepRecords ?? []).length > 0 ||\n clicks.clicks > 0 ||\n clicks.machineClicks > 0 ||\n enrollment.stoppedAtMs !== null,\n }\n}\n"],"names":["OUTREACH_MAIL_GATEWAY_LABELS","outreachClickSummary","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_TASK_KIND_LABELS","outreachStepName","steps","stepIndex","step","kind","position","slice","filter","entry","length","taskKind","OUTREACH_MACHINE_REASON_LABELS","agent","too_soon","method","ACTION_TITLES","pause","resume","stop","do_not_contact","ACTION_KINDS","TIE_ORDER","finished","replied","bounced","failed","stopped","paused","held","released","resumed","click","scanner","task","sent","curated","enrolled","plural","count","one","many","byWhom","uid","memberName","emailRecords","enrollment","stepRecords","record","outreachEnrollmentTimeline","input","entries","history","gatewayName","gateway","createdAtMs","push","key","atMs","title","facts","enrolledByUid","target","cold","fact","Boolean","tone","override","Object","stepOverrides","draftedAtMs","Number","source","edited","draftedByUid","previousThread","forEach","index","find","id","stepId","detail","taskId","thread","gmailThreadId","startsThread","subject","links","row","human","url","machineReason","clicks","engagement","unloggedClicks","unloggedMachineClicks","everyHumanUnlogged","loggedRows","machineClicks","first","firstClickAtMs","last","lastClickAtMs","formatTime","lastClickUrl","urlLabel","actionTimes","Set","add","action","gatewayHold","releasedAtMs","byUid","hold","holdGateway","heldAtMs","stopReason","stoppedAtMs","stopDetail","releasedByUid","alreadySaid","has","status","currentStop","at","updatedAtMs","sort","a","b","by","stoppedByUid","values","gmailThreadIds","outreachEnrollmentFigures","emailsSent","replies","linksFollowed","linkCount","followed","atLeast","linksCapped","scannerClicks","anything"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-timeline.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\n/*==========================================\n * EVERYTHING THAT HAPPENED TO ONE PERSON IN ONE SEQUENCE (AGL-3332).\n *\n * The enrollment's detail view is one list, newest first, and this builds\n * it from what is stored — the enrollment itself, and its history rows —\n * without inventing anything that is not:\n *\n * - **Each send** is a step record: the step, the time, the subject as\n * sent, and whether it went out as the person's curated copy.\n * - **Each click** is a history row: the destination, the step whose email\n * carried it, and a person's or a scanner's. Clicks from before the\n * history began exist only as totals, and are ONE row saying so — never\n * a row per click, which would be a fabrication with a timestamp on it.\n * - **Each member's act** — pause, resume, stop, do-not-contact — is a\n * history row with who and why. An enrollment holds only its current\n * stop, so the row is the only record of a pause that was resumed.\n * - **The current stop** — a reply, a bounce, an opt-out, a hold — comes\n * from the enrollment's own fields, with its full reason, unless a\n * history row already says it.\n * - **The gateway hold** (AGL-3326), and its release.\n * - **The enrollment**, with who enrolled them, and each curated step.\n *\n * Client-safe and free of React: the view renders these entries, and the\n * spec reads them.\n *=========================================*/\n\nimport type { OutreachClickMachineReason } from '../engine/click-tracking'\nimport type { OutreachOpenMachineReason } from '../engine/open-tracking'\nimport { OUTREACH_MAIL_GATEWAY_LABELS, type OutreachMailGateway } from '../engine/mail-gateway'\nimport {\n outreachClickSummary,\n outreachLoggedEngagementRows,\n readOutreachEngagement,\n} from './enrollment-engagement'\nimport {\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_TASK_KIND_LABELS,\n type OutreachEnrollment,\n type OutreachEnrollmentHistoryEntry,\n type OutreachHistoryAction,\n type OutreachSequenceStep,\n type OutreachStepRecord,\n} from './outreach.types'\n\n/** What kind of thing an entry is, which the view draws an icon for. */\nexport type OutreachTimelineKind =\n | 'enrolled'\n | 'curated'\n | 'sent'\n | 'task'\n | 'click'\n | 'scanner'\n | 'earlier-clicks'\n | 'open'\n | 'machine-open'\n | 'paused'\n | 'resumed'\n | 'stopped'\n | 'do-not-contact'\n | 'replied'\n | 'bounced'\n | 'opted-out'\n | 'held'\n | 'released'\n | 'failed'\n | 'finished'\n\n/** How loudly the view draws an entry. */\nexport type OutreachTimelineTone = 'default' | 'success' | 'info' | 'warning' | 'error'\n\n/** One line of the timeline. */\nexport interface OutreachTimelineEntry {\n /** Stable across renders, for React and for a spec. */\n key: string\n kind: OutreachTimelineKind\n atMs: number\n /** What happened, in a few words. */\n title: string\n /** The one fact that matters most, shown in full: a subject, a reason, a diagnostic. */\n detail?: string | null\n /** The rest, each short: the step, who, a scanner's reason. */\n facts: string[]\n /** A destination the person followed, shown whole and linkable. */\n url?: string | null\n /** What `url` is, when it is not simply \"the link they followed\". */\n urlLabel?: string\n /** The Gmail thread this entry opened, for an \"Open in Gmail\" link. */\n gmailThreadId?: string | null\n tone: OutreachTimelineTone\n}\n\nexport interface OutreachTimelineInput {\n enrollment: OutreachEnrollment\n steps: readonly OutreachSequenceStep[]\n /** The history rows, newest or oldest first; `null` while unread or unreadable. */\n history: readonly OutreachEnrollmentHistoryEntry[] | null\n /** A member's name for a stored uid; the uid itself when the roster does not know it. */\n memberName: (uid: string) => string\n /** A moment as the reader reads it — in the mailbox's zone. */\n formatTime: (ms: number) => string\n /** The mail gateway in front of the person's domain, when it is known. */\n gateway?: OutreachMailGateway | null\n}\n\n/** \"Email 2 · step 3\" for an email step, \"Step 2 · Call\" for a task, \"Step 4\" for one the sequence no longer has. */\nexport function outreachStepName(steps: readonly OutreachSequenceStep[], stepIndex: number): string {\n const step = steps[stepIndex]\n if (step?.kind === 'email') {\n const position = steps.slice(0, stepIndex + 1).filter((entry) => entry.kind === 'email').length\n return `Email ${position} · step ${stepIndex + 1}`\n }\n if (step?.kind === 'task') return `Step ${stepIndex + 1} · ${OUTREACH_TASK_KIND_LABELS[step.taskKind]}`\n return `Step ${stepIndex + 1}`\n}\n\n/** Why a click was read as a scanner's, as the timeline says it. */\nexport const OUTREACH_MACHINE_REASON_LABELS: Record<OutreachClickMachineReason, string> = {\n agent: 'the request named itself as a scanner or a link preview',\n too_soon: 'it came within 30 seconds of the email arriving, faster than anyone reads',\n method: 'it checked the link without opening the page',\n}\n\n/** Why a fetch of the tracking image was not counted as an open (AGL-3395). */\nexport const OUTREACH_OPEN_MACHINE_REASON_LABELS: Record<OutreachOpenMachineReason, string> = {\n agent: 'the request named itself as a scanner or a preview',\n too_soon: 'it came within 30 seconds of the email arriving, faster than anyone reads',\n method: 'it checked the image without loading it',\n image_proxy: 'Yahoo’s image proxy fetched it, which it does whether or not the person reads it',\n privacy_proxy: 'Apple Mail’s privacy protection fetched it as the email arrived',\n}\n\nconst ACTION_TITLES: Record<OutreachHistoryAction, string> = {\n pause: 'Paused',\n resume: 'Resumed',\n stop: 'Stopped by a member',\n do_not_contact: 'Marked do-not-contact',\n}\nconst ACTION_KINDS: Record<OutreachHistoryAction, OutreachTimelineKind> = {\n pause: 'paused',\n resume: 'resumed',\n stop: 'stopped',\n do_not_contact: 'do-not-contact',\n}\n\n/** Which of two entries at the same moment reads first: the outcome above what led to it. */\nconst TIE_ORDER: Record<OutreachTimelineKind, number> = {\n finished: 0,\n replied: 1,\n bounced: 1,\n 'opted-out': 1,\n failed: 1,\n stopped: 2,\n 'do-not-contact': 2,\n paused: 2,\n held: 2,\n released: 3,\n resumed: 3,\n click: 4,\n scanner: 4,\n 'earlier-clicks': 4,\n open: 4,\n 'machine-open': 4,\n task: 5,\n sent: 5,\n curated: 6,\n enrolled: 7,\n}\n\nconst plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`\nconst byWhom = (uid: string | null | undefined, memberName: (uid: string) => string) =>\n uid ? `By ${memberName(uid)}` : null\n\n/** The step records that are emails, in the order they went. */\nfunction emailRecords(enrollment: OutreachEnrollment): OutreachStepRecord[] {\n return (enrollment.stepRecords ?? []).filter((record) => record?.kind === 'email')\n}\n\n/**\n * The timeline, newest first — see the module note.\n */\nexport function outreachEnrollmentTimeline(input: OutreachTimelineInput): OutreachTimelineEntry[] {\n const { enrollment, steps, memberName } = input\n const entries: OutreachTimelineEntry[] = []\n const history = input.history ?? []\n const gatewayName = input.gateway ? OUTREACH_MAIL_GATEWAY_LABELS[input.gateway] : null\n\n // ── The enrollment ──────────────────────────────────────────────────────\n if (enrollment.createdAtMs) {\n entries.push({\n key: 'enrolled',\n kind: 'enrolled',\n atMs: enrollment.createdAtMs,\n title: 'Enrolled',\n facts: [\n byWhom(enrollment.enrolledByUid, memberName),\n enrollment.target === 'lead' ? 'As a lead' : 'As a contact',\n enrollment.cold ? 'Cold — confirmed by the member who enrolled them' : null,\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n })\n }\n\n // ── Curated copies (AGL-3324) ───────────────────────────────────────────\n for (const [key, override] of Object.entries(enrollment.stepOverrides ?? {})) {\n if (!override.draftedAtMs) continue\n entries.push({\n key: `curated-${key}`,\n kind: 'curated',\n atMs: override.draftedAtMs,\n title: `Curated ${outreachStepName(steps, Number(key))}`,\n facts: [\n override.source === 'ai' ? (override.edited ? 'AI draft, edited' : 'AI draft') : 'Written by a member',\n byWhom(override.draftedByUid, memberName),\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n })\n }\n\n // ── Sends and tasks ─────────────────────────────────────────────────────\n let previousThread: string | null = null\n ;(enrollment.stepRecords ?? []).forEach((record, index) => {\n if (!record || typeof record.atMs !== 'number') return\n if (record.kind === 'task') {\n const step = steps.find((entry) => entry.id === record.stepId) ?? steps[record.stepIndex]\n entries.push({\n key: `task-${index}`,\n kind: 'task',\n atMs: record.atMs,\n title:\n step?.kind === 'task'\n ? `${OUTREACH_TASK_KIND_LABELS[step.taskKind]} task filed`\n : 'Task filed',\n detail: step?.kind === 'task' ? step.title : null,\n facts: [\n outreachStepName(steps, record.stepIndex),\n record.taskId === null ? 'The CRM could not file it' : null,\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n })\n return\n }\n const thread = record.gmailThreadId ?? null\n // The link to Gmail sits on the email that STARTED a thread, once.\n const startsThread = Boolean(thread) && thread !== previousThread\n previousThread = thread ?? previousThread\n entries.push({\n key: `sent-${index}`,\n kind: 'sent',\n atMs: record.atMs,\n title: `${outreachStepName(steps, record.stepIndex)} sent`,\n detail: record.subject ? `Subject: ${record.subject}` : null,\n facts: [\n record.curated === 'ai'\n ? 'Curated copy — AI draft'\n : record.curated === 'member'\n ? 'Curated copy — written by a member'\n : null,\n record.links?.length ? plural(record.links.length, 'tracked link', 'tracked links') : null,\n record.openTracked ? 'Tracking image' : null,\n ].filter((fact): fact is string => Boolean(fact)),\n gmailThreadId: startsThread ? thread : null,\n tone: 'default',\n })\n })\n\n // ── Clicks ──────────────────────────────────────────────────────────────\n for (const row of history) {\n if (row.kind !== 'click') continue\n entries.push(\n row.human\n ? {\n key: `history-${row.id}`,\n kind: 'click',\n atMs: row.atMs,\n title: 'Clicked a link',\n url: row.url,\n facts: [`From ${outreachStepName(steps, row.stepIndex)}`],\n tone: 'success',\n }\n : {\n key: `history-${row.id}`,\n kind: 'scanner',\n atMs: row.atMs,\n title: 'A scanner followed a link — not counted',\n url: row.url,\n facts: [\n `From ${outreachStepName(steps, row.stepIndex)}`,\n row.machineReason ? `Read as a scanner: ${OUTREACH_MACHINE_REASON_LABELS[row.machineReason]}` : null,\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n },\n )\n }\n\n // ── Opens (AGL-3395) ────────────────────────────────────────────────────\n for (const row of history) {\n if (row.kind !== 'open') continue\n entries.push(\n row.human\n ? {\n key: `history-${row.id}`,\n kind: 'open',\n atMs: row.atMs,\n title: 'Opened an email',\n facts: [`${outreachStepName(steps, row.stepIndex)}`],\n tone: 'info',\n }\n : {\n key: `history-${row.id}`,\n kind: 'machine-open',\n atMs: row.atMs,\n title: 'The tracking image was fetched by a machine — not counted',\n facts: [\n outreachStepName(steps, row.stepIndex),\n row.machineReason ? `Read as a machine: ${OUTREACH_OPEN_MACHINE_REASON_LABELS[row.machineReason]}` : null,\n ].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n },\n )\n }\n\n /*\n * Clicks with no row of their own: counted before the per-click history\n * began, or past its limit. ONE entry, placed at the first of them when\n * that is known, saying what the totals hold and nothing more.\n */\n const clicks = outreachClickSummary(enrollment.engagement)\n const engagement = enrollment.engagement\n if (clicks.unloggedClicks > 0 || clicks.unloggedMachineClicks > 0) {\n const everyHumanUnlogged = clicks.unloggedClicks === clicks.clicks\n const loggedRows = outreachLoggedEngagementRows(readOutreachEngagement(enrollment.engagement))\n const facts: string[] = []\n const first = engagement?.firstClickAtMs ?? null\n const last = engagement?.lastClickAtMs ?? null\n if (clicks.unloggedClicks > 0 && first !== null && everyHumanUnlogged && last !== null) {\n facts.push(\n clicks.unloggedClicks === 1\n ? `At ${input.formatTime(first)}`\n : first === last\n ? `First and last at ${input.formatTime(first)}`\n : `First at ${input.formatTime(first)}, last at ${input.formatTime(last)}`,\n )\n } else if (clicks.unloggedClicks > 0 && first !== null) {\n // Later clicks have rows of their own, so only the first is one of these.\n facts.push(`First at ${input.formatTime(first)}`)\n }\n if (clicks.unloggedMachineClicks > 0) {\n facts.push(plural(clicks.unloggedMachineClicks, 'scanner click, not counted', 'scanner clicks, not counted'))\n }\n entries.push({\n key: 'earlier-clicks',\n kind: 'earlier-clicks',\n atMs:\n (clicks.unloggedClicks > 0 ? engagement?.firstClickAtMs : null) ??\n engagement?.lastClickAtMs ??\n emailRecords(enrollment)[0]?.atMs ??\n enrollment.createdAtMs,\n title:\n clicks.unloggedClicks > 0\n ? `${plural(clicks.unloggedClicks, 'earlier click', 'earlier clicks')}, kept as a total`\n : `${plural(clicks.unloggedMachineClicks, 'earlier scanner click', 'earlier scanner clicks')}, kept as a total`,\n detail:\n loggedRows >= OUTREACH_ENROLLMENT_HISTORY_MAX\n ? `Each click is listed on its own up to ${OUTREACH_ENROLLMENT_HISTORY_MAX}; these came before per-click history began, or after this person reached that limit.`\n : 'These came before each click was recorded on its own, so only their count and times were kept.',\n // The last destination is theirs only when every counted click was.\n url: clicks.unloggedClicks > 0 && everyHumanUnlogged ? (engagement?.lastClickUrl ?? null) : null,\n urlLabel: clicks.unloggedClicks === 1 ? 'Link' : 'Last link',\n facts,\n tone: clicks.unloggedClicks > 0 ? 'success' : 'default',\n })\n }\n\n // ── Members' acts ───────────────────────────────────────────────────────\n const actionTimes = new Set<number>()\n for (const row of history) {\n if (row.kind !== 'action') continue\n actionTimes.add(row.atMs)\n const released =\n row.action === 'resume' && enrollment.gatewayHold?.releasedAtMs === row.atMs\n if (released) continue\n entries.push({\n key: `history-${row.id}`,\n kind: ACTION_KINDS[row.action],\n atMs: row.atMs,\n title: ACTION_TITLES[row.action],\n detail: row.detail,\n facts: [byWhom(row.byUid, memberName)].filter((fact): fact is string => Boolean(fact)),\n tone: row.action === 'resume' ? 'default' : row.action === 'pause' ? 'warning' : 'default',\n })\n }\n\n // ── The gateway hold (AGL-3326) ─────────────────────────────────────────\n const hold = enrollment.gatewayHold\n const holdGateway = hold ? OUTREACH_MAIL_GATEWAY_LABELS[hold.gateway] : null\n if (hold?.heldAtMs) {\n entries.push({\n key: 'held',\n kind: 'held',\n atMs: hold.heldAtMs,\n title: `Held — ${holdGateway} refused this sender`,\n detail:\n enrollment.stopReason === 'gateway_blocked_here' && enrollment.stoppedAtMs === hold.heldAtMs\n ? enrollment.stopDetail\n : null,\n facts: ['Nothing was sent to them while it was held'],\n tone: 'warning',\n })\n }\n if (hold?.releasedAtMs) {\n entries.push({\n key: 'released',\n kind: 'released',\n atMs: hold.releasedAtMs,\n title: hold.heldAtMs\n ? `Resumed past the ${holdGateway} hold — sent anyway`\n : `Enrolled past the ${holdGateway} warning`,\n facts: [byWhom(hold.releasedByUid, memberName)].filter((fact): fact is string => Boolean(fact)),\n tone: 'default',\n })\n }\n\n // ── The current stop, from the enrollment ───────────────────────────────\n const stoppedAtMs = enrollment.stoppedAtMs\n const alreadySaid =\n stoppedAtMs !== null &&\n (actionTimes.has(stoppedAtMs) ||\n (enrollment.stopReason === 'gateway_blocked_here' && hold?.heldAtMs === stoppedAtMs))\n if (stoppedAtMs && !alreadySaid && enrollment.status !== 'active' && enrollment.status !== 'finished') {\n const stop = currentStop(enrollment, memberName, gatewayName)\n if (stop) entries.push({ key: 'stop', atMs: stoppedAtMs, ...stop })\n }\n\n // ── Finished ────────────────────────────────────────────────────────────\n if (enrollment.status === 'finished') {\n const last = (enrollment.stepRecords ?? []).at(-1)\n entries.push({\n key: 'finished',\n kind: 'finished',\n atMs: last?.atMs ?? enrollment.updatedAtMs,\n title: 'Finished — every step ran',\n facts: [],\n tone: 'success',\n })\n }\n\n return entries.sort((a, b) => b.atMs - a.atMs || TIE_ORDER[a.kind] - TIE_ORDER[b.kind])\n}\n\n/** The entry for the stop the enrollment is in now. */\nfunction currentStop(\n enrollment: OutreachEnrollment,\n memberName: (uid: string) => string,\n gatewayName: string | null,\n): Omit<OutreachTimelineEntry, 'key' | 'atMs'> | null {\n const detail = enrollment.stopDetail\n const by = byWhom(enrollment.stoppedByUid, memberName)\n const facts = (...values: Array<string | null>) => values.filter((fact): fact is string => Boolean(fact))\n switch (enrollment.stopReason) {\n case 'reply':\n return {\n kind: 'replied',\n title: 'Replied',\n detail,\n facts: ['The sequence stopped for them'],\n gmailThreadId: enrollment.gmailThreadId ?? enrollment.gmailThreadIds.at(-1) ?? null,\n tone: 'info',\n }\n case 'hard_bounce':\n return {\n kind: 'bounced',\n title: 'Bounced',\n detail,\n facts: facts(gatewayName ? `Their mail gateway: ${gatewayName}` : null, 'The address is on the do-not-contact list'),\n tone: 'error',\n }\n case 'opt_out_reply':\n return { kind: 'opted-out', title: 'Asked not to be emailed, in a reply', detail, facts: [], tone: 'warning' }\n case 'unsubscribe':\n return { kind: 'opted-out', title: 'Unsubscribed', detail, facts: [], tone: 'warning' }\n case 'do_not_contact':\n return { kind: 'do-not-contact', title: 'Put on the do-not-contact list', detail, facts: facts(by), tone: 'warning' }\n case 'manual':\n return enrollment.status === 'paused'\n ? { kind: 'paused', title: 'Paused', detail, facts: facts(by), tone: 'warning' }\n : { kind: 'stopped', title: 'Stopped by a member', detail, facts: facts(by), tone: 'default' }\n case 'gate':\n return { kind: 'stopped', title: 'Stopped — no longer eligible', detail, facts: [], tone: 'default' }\n case 'sequence_archived':\n return { kind: 'stopped', title: 'Stopped — the sequence was archived', detail, facts: [], tone: 'default' }\n case 'gateway_blocked_here':\n return { kind: 'held', title: 'Held — their mail gateway refused this sender', detail, facts: [], tone: 'warning' }\n case 'send_failed':\n return { kind: 'failed', title: 'An email couldn’t be sent', detail, facts: [], tone: 'error' }\n default:\n return null\n }\n}\n\n/** The five figures above the timeline. */\nexport interface OutreachEnrollmentFigures {\n emailsSent: number\n clicks: number\n /** Distinct destinations followed; `atLeast` when only a floor is known. */\n linksFollowed: { count: number; atLeast: boolean }\n scannerClicks: number\n /** Opens judged theirs, and the image fetches that were a machine's (AGL-3395). */\n opens: number\n machineOpens: number\n replies: number\n /** Whether anything has happened to them beyond being enrolled. */\n anything: boolean\n}\n\nexport function outreachEnrollmentFigures(enrollment: OutreachEnrollment): OutreachEnrollmentFigures {\n const clicks = outreachClickSummary(enrollment.engagement)\n const engagement = readOutreachEngagement(enrollment.engagement)\n const emailsSent = emailRecords(enrollment).length\n const replies =\n enrollment.status === 'replied' || enrollment.stopReason === 'reply' || enrollment.stopReason === 'opt_out_reply'\n ? 1\n : 0\n return {\n emailsSent,\n clicks: clicks.clicks,\n linksFollowed:\n clicks.linkCount === null\n ? { count: clicks.followed.length, atLeast: true }\n : { count: clicks.linkCount, atLeast: clicks.linksCapped },\n scannerClicks: clicks.machineClicks,\n opens: engagement.opens,\n machineOpens: engagement.machineOpens,\n replies,\n anything:\n emailsSent > 0 ||\n (enrollment.stepRecords ?? []).length > 0 ||\n clicks.clicks > 0 ||\n clicks.machineClicks > 0 ||\n engagement.opens > 0 ||\n engagement.machineOpens > 0 ||\n enrollment.stoppedAtMs !== null,\n }\n}\n"],"names":["OUTREACH_MAIL_GATEWAY_LABELS","outreachClickSummary","outreachLoggedEngagementRows","readOutreachEngagement","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_TASK_KIND_LABELS","outreachStepName","steps","stepIndex","step","kind","position","slice","filter","entry","length","taskKind","OUTREACH_MACHINE_REASON_LABELS","agent","too_soon","method","OUTREACH_OPEN_MACHINE_REASON_LABELS","image_proxy","privacy_proxy","ACTION_TITLES","pause","resume","stop","do_not_contact","ACTION_KINDS","TIE_ORDER","finished","replied","bounced","failed","stopped","paused","held","released","resumed","click","scanner","open","task","sent","curated","enrolled","plural","count","one","many","byWhom","uid","memberName","emailRecords","enrollment","stepRecords","record","outreachEnrollmentTimeline","input","entries","history","gatewayName","gateway","createdAtMs","push","key","atMs","title","facts","enrolledByUid","target","cold","fact","Boolean","tone","override","Object","stepOverrides","draftedAtMs","Number","source","edited","draftedByUid","previousThread","forEach","index","find","id","stepId","detail","taskId","thread","gmailThreadId","startsThread","subject","links","openTracked","row","human","url","machineReason","clicks","engagement","unloggedClicks","unloggedMachineClicks","everyHumanUnlogged","loggedRows","first","firstClickAtMs","last","lastClickAtMs","formatTime","lastClickUrl","urlLabel","actionTimes","Set","add","action","gatewayHold","releasedAtMs","byUid","hold","holdGateway","heldAtMs","stopReason","stoppedAtMs","stopDetail","releasedByUid","alreadySaid","has","status","currentStop","at","updatedAtMs","sort","a","b","by","stoppedByUid","values","gmailThreadIds","outreachEnrollmentFigures","emailsSent","replies","linksFollowed","linkCount","followed","atLeast","linksCapped","scannerClicks","machineClicks","opens","machineOpens","anything"],"mappings":"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"}
@@ -35,6 +35,7 @@
35
35
  * `nextDueAtMs` has to be compared in.
36
36
  */
37
37
  import type { OutreachClickMachineReason } from '../engine/click-tracking';
38
+ import type { OutreachOpenMachineReason } from '../engine/open-tracking';
38
39
  import type { OutreachGatewayDayCounts, OutreachMailGateway } from '../engine/mail-gateway';
39
40
  /**
40
41
  * Where Outreach keeps its records.
@@ -394,11 +395,23 @@ export interface OutreachSequenceSettings {
394
395
  *
395
396
  * It buys the one engagement number a plain-text sequence can honestly
396
397
  * report. It costs a visible link: the destination in the body becomes a
397
- * signed link on the console that forwards to it. There is no equivalent
398
- * setting for opens, because a pixel needs an HTML part — see
399
- * `../engine/click-tracking.ts`.
398
+ * short link of ours that forwards to it. Opens are the separate
399
+ * {@link countOpens}, because a pixel needs an HTML part.
400
400
  */
401
401
  trackClicks: boolean;
402
+ /**
403
+ * Whether this sequence's emails carry an HTML part with a tracking image,
404
+ * so opens are counted (AGL-3395). Default `false`, and `false` on every
405
+ * sequence written before the setting existed.
406
+ *
407
+ * On, each email goes out as `multipart/alternative`: the plain-text part
408
+ * exactly as it would have been, and beside it the same words as HTML with
409
+ * a 1×1 image on the short-link host. It costs the plain-text one-to-one
410
+ * look, and some gateways score an HTML part with a remote image — see
411
+ * `../engine/open-tracking.ts`. Off, nothing about the email changes and
412
+ * the report says opens are not measured.
413
+ */
414
+ countOpens: boolean;
402
415
  /**
403
416
  * Whether this sequence's emails carry `List-Unsubscribe` and
404
417
  * `List-Unsubscribe-Post` (RFC 8058) (AGL-3296). Default `false`, and
@@ -455,6 +468,33 @@ export interface OutreachSequenceStats {
455
468
  machineClicks?: number;
456
469
  /** When a person last followed a link. */
457
470
  lastClickAtMs?: number | null;
471
+ /**
472
+ * At least one email of this sequence went out with a tracking image
473
+ * (AGL-3395). Absent is "never recorded", and the report says opens are
474
+ * not measured rather than showing 0%.
475
+ */
476
+ openTracked?: boolean;
477
+ /**
478
+ * Distinct enrollments that have had at least one email carrying the
479
+ * image — the open rate's denominator. Not `people`: a sequence that turned
480
+ * the setting on halfway through emailed some people no image at all, and
481
+ * they could never have been counted as opening.
482
+ */
483
+ openPeople?: number;
484
+ /** Opens judged a person's. One reader opening twice counts two. */
485
+ opens?: number;
486
+ /** Enrollments whose FIRST human open was seen: the open rate's numerator. */
487
+ uniqueOpens?: number;
488
+ /**
489
+ * Fetches of the image a machine made — a mail privacy proxy prefetching
490
+ * it, a gateway scanning the message — counted apart and never in the
491
+ * rate (`../engine/open-tracking.ts`).
492
+ */
493
+ machineOpens?: number;
494
+ /** Of {@link machineOpens}, the ones a mail provider's image proxy made. */
495
+ proxyOpens?: number;
496
+ /** When a person last opened one of this sequence's emails. */
497
+ lastOpenAtMs?: number | null;
458
498
  }
459
499
  /** What one enrollment did with the links it was sent (AGL-3239). */
460
500
  export interface OutreachEnrollmentEngagement {
@@ -482,6 +522,15 @@ export interface OutreachEnrollmentEngagement {
482
522
  */
483
523
  loggedClicks?: number;
484
524
  loggedMachineClicks?: number;
525
+ /** Opens judged this person's, machines excluded (AGL-3395). */
526
+ opens?: number;
527
+ /** The first, which is what makes them one of the sequence's `uniqueOpens`. */
528
+ firstOpenAtMs?: number | null;
529
+ lastOpenAtMs?: number | null;
530
+ /** Fetches of this person's tracking image that were a machine's. */
531
+ machineOpens?: number;
532
+ /** How many of `opens` and `machineOpens` have a row in the history. */
533
+ loggedOpens?: number;
485
534
  }
486
535
  /**
487
536
  * The most distinct destinations {@link OutreachEnrollmentEngagement.links}
@@ -525,7 +574,19 @@ export interface OutreachActionHistoryEntry {
525
574
  /** The reason the member typed, if any. */
526
575
  detail: string | null;
527
576
  }
528
- export type OutreachEnrollmentHistoryEntry = OutreachClickHistoryEntry | OutreachActionHistoryEntry;
577
+ /** One fetch of the tracking image in this person's email (AGL-3395). */
578
+ export interface OutreachOpenHistoryEntry {
579
+ id: string;
580
+ kind: 'open';
581
+ atMs: number;
582
+ /** The step whose email carried the image. */
583
+ stepIndex: number;
584
+ /** Whether it counts: a person's open, not a proxy's or a scanner's. */
585
+ human: boolean;
586
+ /** Why it was read as a machine's; `null` for a person's. */
587
+ machineReason: OutreachOpenMachineReason | null;
588
+ }
589
+ export type OutreachEnrollmentHistoryEntry = OutreachClickHistoryEntry | OutreachOpenHistoryEntry | OutreachActionHistoryEntry;
529
590
  /** An ordered set of steps sent from one mailbox (`orgs/{orgId}/outreachSequences/{id}`). */
530
591
  export interface OutreachSequence extends OutreachTimestamps {
531
592
  id: string;
@@ -821,6 +882,12 @@ export interface OutreachStepRecord {
821
882
  * read afterwards for what it actually offered them.
822
883
  */
823
884
  links?: string[];
885
+ /**
886
+ * The email carried a tracking image (AGL-3395). Absent on every email
887
+ * sent without one — which is every email of a sequence that does not
888
+ * count opens.
889
+ */
890
+ openTracked?: boolean;
824
891
  /**
825
892
  * The email went out as this person's curated copy (AGL-3324), and who
826
893
  * wrote it. Absent on a step sent as the sequence wrote it.
@@ -180,8 +180,9 @@ export const OUTREACH_TASK_KIND_LABELS = {
180
180
  *
181
181
  * `orgs/{orgId}/outreachEnrollments/{id}/history/{entryId}`: a row for each
182
182
  * visit to a tracking link in this person's emails — which link, from which
183
- * step, when, and whether a person or a scanner made it — and for each
184
- * pause, resume, stop and do-not-contact a member applied. The enrollment
183
+ * step, when, and whether a person or a scanner made it — for each fetch
184
+ * of the tracking image in a sequence that counts opens (AGL-3395), and for
185
+ * each pause, resume, stop and do-not-contact a member applied. The enrollment
185
186
  * keeps totals; this keeps the events, and is read only when somebody opens
186
187
  * the person, so the table's listener never carries it.
187
188
  *