@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
@@ -60,7 +60,7 @@ import { isInThreadEmailStep } from "../engine/sequence-validation.js";
60
60
  }
61
61
  /** The placeholder a preview lends the composer as the thread's last message. */ const PREVIEW_MESSAGE_ID = '<preview@outreach.invalid>';
62
62
  /** The step's email, or why it can't be written — see the module note. */ export function previewOutreachStep(input) {
63
- var _input_templateBody, _input_rewriteLink;
63
+ var _input_templateBody, _input_rewriteLink, _input_openPixelUrl;
64
64
  const steps = [
65
65
  ...input.steps
66
66
  ];
@@ -95,7 +95,8 @@ import { isInThreadEmailStep } from "../engine/sequence-validation.js";
95
95
  orgSettings: input.orgSettings,
96
96
  merge: input.merge,
97
97
  templateBody: (_input_templateBody = input.templateBody) != null ? _input_templateBody : null,
98
- rewriteLink: (_input_rewriteLink = input.rewriteLink) != null ? _input_rewriteLink : null
98
+ rewriteLink: (_input_rewriteLink = input.rewriteLink) != null ? _input_rewriteLink : null,
99
+ openPixelUrl: (_input_openPixelUrl = input.openPixelUrl) != null ? _input_openPixelUrl : null
99
100
  });
100
101
  }
101
102
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/step-preview.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 * ONE STEP'S EMAIL, AS A PERSON WOULD GET IT (AGL-2980).\n *\n * The editor's live preview and the preview route both show an email\n * before anyone is sent it, and both write it with the engine's own\n * composer — the merge, the footer, the threading — so what the rep reads\n * is what the runtime would send. Client-safe.\n *\n * A step later in the thread is sent as `Re:` the thread's subject, and the\n * composer refuses to write one without a thread to answer. A preview has\n * no thread, so it lends the composer one: the subject the thread would\n * have been started with, rendered for the same person, and a placeholder\n * message id that never leaves the preview.\n *==========================================*/\n\nimport type { CrmMergeContext } from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { resolveCrmMergeFields } from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport {\n composeOutreachEmail,\n type ComposeOutreachEmailInput,\n type OutreachComposeResult,\n} from '../engine/compose'\nimport { isInThreadEmailStep } from '../engine/sequence-validation'\nimport type { OutreachOrgSettings, OutreachSequenceStep, OutreachStepOverrides } from './outreach.types'\n\n/** The holder id the sample person's facet is filed under. */\nconst SAMPLE_GROUP = 'outreach-preview'\n\n/** The person a preview is written to when it names no contact. */\nexport const OUTREACH_SAMPLE_PERSON = {\n name: 'Casey Morgan',\n email: 'casey.morgan@example.com',\n companyName: 'Example Co',\n jobTitle: 'Head of Operations',\n personalLine: 'I saw Example Co just opened its second location.',\n} as const\n\n/** The merge context of the sample person, from the sender and site given. */\nexport function outreachSampleMergeContext(input: {\n sender?: CrmMergeContext['sender']\n siteName?: string | null\n}): CrmMergeContext {\n return {\n contact: {\n name: OUTREACH_SAMPLE_PERSON.name,\n email: OUTREACH_SAMPLE_PERSON.email,\n facets: {\n [SAMPLE_GROUP]: {\n companyName: OUTREACH_SAMPLE_PERSON.companyName,\n jobTitle: OUTREACH_SAMPLE_PERSON.jobTitle,\n },\n },\n },\n contactGroupId: SAMPLE_GROUP,\n sender: input.sender ?? null,\n site: { name: input.siteName ?? '' },\n }\n}\n\n/** The placeholder a preview lends the composer as the thread's last message. */\nconst PREVIEW_MESSAGE_ID = '<preview@outreach.invalid>'\n\nexport interface OutreachStepPreviewInput {\n steps: readonly OutreachSequenceStep[]\n stepIndex: number\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null\n merge: CrmMergeContext\n /** The recipient's address. */\n email: string\n personalLine: string\n /** The CRM template's body, when the step names a template. */\n templateBody?: string | null\n /**\n * The composer's link rewriter (AGL-3325), for a TEST send whose links\n * are minted as test links. A preview passes nothing and shows the links\n * as written.\n */\n rewriteLink?: ComposeOutreachEmailInput['rewriteLink']\n /** The person's own copies of steps (AGL-3324), as the enrollment would carry them. */\n stepOverrides?: OutreachStepOverrides | null\n}\n\n/** The step's email, or why it can't be written — see the module note. */\nexport function previewOutreachStep(input: OutreachStepPreviewInput): OutreachComposeResult {\n const steps = [...input.steps]\n let threadSubject: string | null = null\n if (isInThreadEmailStep(steps, input.stepIndex)) {\n // The thread was started by the latest email before this one that did\n // not itself reply in a thread.\n for (let index = input.stepIndex - 1; index >= 0; index -= 1) {\n const step = steps[index]\n if (step?.kind === 'email' && !isInThreadEmailStep(steps, index)) {\n threadSubject = resolveCrmMergeFields(step.subject, input.merge).text.trim() || null\n break\n }\n }\n }\n return composeOutreachEmail({\n sequence: { steps },\n enrollment: {\n email: input.email,\n stepIndex: input.stepIndex,\n personalLine: input.personalLine,\n threadSubject,\n messageIds: threadSubject ? [PREVIEW_MESSAGE_ID] : [],\n gmailThreadId: threadSubject ? 'preview' : null,\n ...(input.stepOverrides ? { stepOverrides: input.stepOverrides } : {}),\n },\n orgSettings: input.orgSettings,\n merge: input.merge,\n templateBody: input.templateBody ?? null,\n rewriteLink: input.rewriteLink ?? null,\n })\n}\n"],"names":["resolveCrmMergeFields","composeOutreachEmail","isInThreadEmailStep","SAMPLE_GROUP","OUTREACH_SAMPLE_PERSON","name","email","companyName","jobTitle","personalLine","outreachSampleMergeContext","input","contact","facets","contactGroupId","sender","site","siteName","PREVIEW_MESSAGE_ID","previewOutreachStep","steps","threadSubject","stepIndex","index","step","kind","subject","merge","text","trim","sequence","enrollment","messageIds","gmailThreadId","stepOverrides","orgSettings","templateBody","rewriteLink"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;4CAa4C;AAG5C,SAASA,qBAAqB,QAAQ,6CAA4C;AAClF,SACEC,oBAAoB,QAGf,uBAAmB;AAC1B,SAASC,mBAAmB,QAAQ,mCAA+B;AAGnE,4DAA4D,GAC5D,MAAMC,eAAe;AAErB,iEAAiE,GACjE,OAAO,MAAMC,yBAAyB;IACpCC,MAAM;IACNC,OAAO;IACPC,aAAa;IACbC,UAAU;IACVC,cAAc;AAChB,EAAU;AAEV,4EAA4E,GAC5E,OAAO,SAASC,2BAA2BC,KAG1C;QAaWA,eACMA;IAbhB,OAAO;QACLC,SAAS;YACPP,MAAMD,uBAAuBC,IAAI;YACjCC,OAAOF,uBAAuBE,KAAK;YACnCO,QAAQ;gBACN,CAACV,aAAa,EAAE;oBACdI,aAAaH,uBAAuBG,WAAW;oBAC/CC,UAAUJ,uBAAuBI,QAAQ;gBAC3C;YACF;QACF;QACAM,gBAAgBX;QAChBY,MAAM,GAAEJ,gBAAAA,MAAMI,MAAM,YAAZJ,gBAAgB;QACxBK,MAAM;YAAEX,IAAI,GAAEM,kBAAAA,MAAMM,QAAQ,YAAdN,kBAAkB;QAAG;IACrC;AACF;AAEA,+EAA+E,GAC/E,MAAMO,qBAAqB;AAsB3B,wEAAwE,GACxE,OAAO,SAASC,oBAAoBR,KAA+B;QA2BjDA,qBACDA;IA3Bf,MAAMS,QAAQ;WAAIT,MAAMS,KAAK;KAAC;IAC9B,IAAIC,gBAA+B;IACnC,IAAInB,oBAAoBkB,OAAOT,MAAMW,SAAS,GAAG;QAC/C,sEAAsE;QACtE,gCAAgC;QAChC,IAAK,IAAIC,QAAQZ,MAAMW,SAAS,GAAG,GAAGC,SAAS,GAAGA,SAAS,EAAG;YAC5D,MAAMC,OAAOJ,KAAK,CAACG,MAAM;YACzB,IAAIC,CAAAA,wBAAAA,KAAMC,IAAI,MAAK,WAAW,CAACvB,oBAAoBkB,OAAOG,QAAQ;gBAChEF,gBAAgBrB,sBAAsBwB,KAAKE,OAAO,EAAEf,MAAMgB,KAAK,EAAEC,IAAI,CAACC,IAAI,MAAM;gBAChF;YACF;QACF;IACF;IACA,OAAO5B,qBAAqB;QAC1B6B,UAAU;YAAEV;QAAM;QAClBW,YAAY;YACVzB,OAAOK,MAAML,KAAK;YAClBgB,WAAWX,MAAMW,SAAS;YAC1Bb,cAAcE,MAAMF,YAAY;YAChCY;YACAW,YAAYX,gBAAgB;gBAACH;aAAmB,GAAG,EAAE;YACrDe,eAAeZ,gBAAgB,YAAY;WACvCV,MAAMuB,aAAa,GAAG;YAAEA,eAAevB,MAAMuB,aAAa;QAAC,IAAI,CAAC;QAEtEC,aAAaxB,MAAMwB,WAAW;QAC9BR,OAAOhB,MAAMgB,KAAK;QAClBS,YAAY,GAAEzB,sBAAAA,MAAMyB,YAAY,YAAlBzB,sBAAsB;QACpC0B,WAAW,GAAE1B,qBAAAA,MAAM0B,WAAW,YAAjB1B,qBAAqB;IACpC;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/step-preview.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 * ONE STEP'S EMAIL, AS A PERSON WOULD GET IT (AGL-2980).\n *\n * The editor's live preview and the preview route both show an email\n * before anyone is sent it, and both write it with the engine's own\n * composer — the merge, the footer, the threading — so what the rep reads\n * is what the runtime would send. Client-safe.\n *\n * A step later in the thread is sent as `Re:` the thread's subject, and the\n * composer refuses to write one without a thread to answer. A preview has\n * no thread, so it lends the composer one: the subject the thread would\n * have been started with, rendered for the same person, and a placeholder\n * message id that never leaves the preview.\n *==========================================*/\n\nimport type { CrmMergeContext } from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { resolveCrmMergeFields } from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport {\n composeOutreachEmail,\n type ComposeOutreachEmailInput,\n type OutreachComposeResult,\n} from '../engine/compose'\nimport { isInThreadEmailStep } from '../engine/sequence-validation'\nimport type { OutreachOrgSettings, OutreachSequenceStep, OutreachStepOverrides } from './outreach.types'\n\n/** The holder id the sample person's facet is filed under. */\nconst SAMPLE_GROUP = 'outreach-preview'\n\n/** The person a preview is written to when it names no contact. */\nexport const OUTREACH_SAMPLE_PERSON = {\n name: 'Casey Morgan',\n email: 'casey.morgan@example.com',\n companyName: 'Example Co',\n jobTitle: 'Head of Operations',\n personalLine: 'I saw Example Co just opened its second location.',\n} as const\n\n/** The merge context of the sample person, from the sender and site given. */\nexport function outreachSampleMergeContext(input: {\n sender?: CrmMergeContext['sender']\n siteName?: string | null\n}): CrmMergeContext {\n return {\n contact: {\n name: OUTREACH_SAMPLE_PERSON.name,\n email: OUTREACH_SAMPLE_PERSON.email,\n facets: {\n [SAMPLE_GROUP]: {\n companyName: OUTREACH_SAMPLE_PERSON.companyName,\n jobTitle: OUTREACH_SAMPLE_PERSON.jobTitle,\n },\n },\n },\n contactGroupId: SAMPLE_GROUP,\n sender: input.sender ?? null,\n site: { name: input.siteName ?? '' },\n }\n}\n\n/** The placeholder a preview lends the composer as the thread's last message. */\nconst PREVIEW_MESSAGE_ID = '<preview@outreach.invalid>'\n\nexport interface OutreachStepPreviewInput {\n steps: readonly OutreachSequenceStep[]\n stepIndex: number\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null\n merge: CrmMergeContext\n /** The recipient's address. */\n email: string\n personalLine: string\n /** The CRM template's body, when the step names a template. */\n templateBody?: string | null\n /**\n * The composer's link rewriter (AGL-3325), for a TEST send whose links\n * are minted as test links. A preview passes nothing and shows the links\n * as written.\n */\n rewriteLink?: ComposeOutreachEmailInput['rewriteLink']\n /**\n * The tracking image's address for a TEST send of a sequence that counts\n * opens (AGL-3395), minted as a test image. A preview passes nothing and\n * shows the plain text alone.\n */\n openPixelUrl?: string | null\n /** The person's own copies of steps (AGL-3324), as the enrollment would carry them. */\n stepOverrides?: OutreachStepOverrides | null\n}\n\n/** The step's email, or why it can't be written — see the module note. */\nexport function previewOutreachStep(input: OutreachStepPreviewInput): OutreachComposeResult {\n const steps = [...input.steps]\n let threadSubject: string | null = null\n if (isInThreadEmailStep(steps, input.stepIndex)) {\n // The thread was started by the latest email before this one that did\n // not itself reply in a thread.\n for (let index = input.stepIndex - 1; index >= 0; index -= 1) {\n const step = steps[index]\n if (step?.kind === 'email' && !isInThreadEmailStep(steps, index)) {\n threadSubject = resolveCrmMergeFields(step.subject, input.merge).text.trim() || null\n break\n }\n }\n }\n return composeOutreachEmail({\n sequence: { steps },\n enrollment: {\n email: input.email,\n stepIndex: input.stepIndex,\n personalLine: input.personalLine,\n threadSubject,\n messageIds: threadSubject ? [PREVIEW_MESSAGE_ID] : [],\n gmailThreadId: threadSubject ? 'preview' : null,\n ...(input.stepOverrides ? { stepOverrides: input.stepOverrides } : {}),\n },\n orgSettings: input.orgSettings,\n merge: input.merge,\n templateBody: input.templateBody ?? null,\n rewriteLink: input.rewriteLink ?? null,\n openPixelUrl: input.openPixelUrl ?? null,\n })\n}\n"],"names":["resolveCrmMergeFields","composeOutreachEmail","isInThreadEmailStep","SAMPLE_GROUP","OUTREACH_SAMPLE_PERSON","name","email","companyName","jobTitle","personalLine","outreachSampleMergeContext","input","contact","facets","contactGroupId","sender","site","siteName","PREVIEW_MESSAGE_ID","previewOutreachStep","steps","threadSubject","stepIndex","index","step","kind","subject","merge","text","trim","sequence","enrollment","messageIds","gmailThreadId","stepOverrides","orgSettings","templateBody","rewriteLink","openPixelUrl"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;4CAa4C;AAG5C,SAASA,qBAAqB,QAAQ,6CAA4C;AAClF,SACEC,oBAAoB,QAGf,uBAAmB;AAC1B,SAASC,mBAAmB,QAAQ,mCAA+B;AAGnE,4DAA4D,GAC5D,MAAMC,eAAe;AAErB,iEAAiE,GACjE,OAAO,MAAMC,yBAAyB;IACpCC,MAAM;IACNC,OAAO;IACPC,aAAa;IACbC,UAAU;IACVC,cAAc;AAChB,EAAU;AAEV,4EAA4E,GAC5E,OAAO,SAASC,2BAA2BC,KAG1C;QAaWA,eACMA;IAbhB,OAAO;QACLC,SAAS;YACPP,MAAMD,uBAAuBC,IAAI;YACjCC,OAAOF,uBAAuBE,KAAK;YACnCO,QAAQ;gBACN,CAACV,aAAa,EAAE;oBACdI,aAAaH,uBAAuBG,WAAW;oBAC/CC,UAAUJ,uBAAuBI,QAAQ;gBAC3C;YACF;QACF;QACAM,gBAAgBX;QAChBY,MAAM,GAAEJ,gBAAAA,MAAMI,MAAM,YAAZJ,gBAAgB;QACxBK,MAAM;YAAEX,IAAI,GAAEM,kBAAAA,MAAMM,QAAQ,YAAdN,kBAAkB;QAAG;IACrC;AACF;AAEA,+EAA+E,GAC/E,MAAMO,qBAAqB;AA4B3B,wEAAwE,GACxE,OAAO,SAASC,oBAAoBR,KAA+B;QA2BjDA,qBACDA,oBACCA;IA5BhB,MAAMS,QAAQ;WAAIT,MAAMS,KAAK;KAAC;IAC9B,IAAIC,gBAA+B;IACnC,IAAInB,oBAAoBkB,OAAOT,MAAMW,SAAS,GAAG;QAC/C,sEAAsE;QACtE,gCAAgC;QAChC,IAAK,IAAIC,QAAQZ,MAAMW,SAAS,GAAG,GAAGC,SAAS,GAAGA,SAAS,EAAG;YAC5D,MAAMC,OAAOJ,KAAK,CAACG,MAAM;YACzB,IAAIC,CAAAA,wBAAAA,KAAMC,IAAI,MAAK,WAAW,CAACvB,oBAAoBkB,OAAOG,QAAQ;gBAChEF,gBAAgBrB,sBAAsBwB,KAAKE,OAAO,EAAEf,MAAMgB,KAAK,EAAEC,IAAI,CAACC,IAAI,MAAM;gBAChF;YACF;QACF;IACF;IACA,OAAO5B,qBAAqB;QAC1B6B,UAAU;YAAEV;QAAM;QAClBW,YAAY;YACVzB,OAAOK,MAAML,KAAK;YAClBgB,WAAWX,MAAMW,SAAS;YAC1Bb,cAAcE,MAAMF,YAAY;YAChCY;YACAW,YAAYX,gBAAgB;gBAACH;aAAmB,GAAG,EAAE;YACrDe,eAAeZ,gBAAgB,YAAY;WACvCV,MAAMuB,aAAa,GAAG;YAAEA,eAAevB,MAAMuB,aAAa;QAAC,IAAI,CAAC;QAEtEC,aAAaxB,MAAMwB,WAAW;QAC9BR,OAAOhB,MAAMgB,KAAK;QAClBS,YAAY,GAAEzB,sBAAAA,MAAMyB,YAAY,YAAlBzB,sBAAsB;QACpC0B,WAAW,GAAE1B,qBAAAA,MAAM0B,WAAW,YAAjB1B,qBAAqB;QAClC2B,YAAY,GAAE3B,sBAAAA,MAAM2B,YAAY,YAAlB3B,sBAAsB;IACtC;AACF"}
@@ -61,7 +61,12 @@ const texts = (value)=>Array.isArray(value) ? value.filter((entry)=>typeof entry
61
61
  'people',
62
62
  'clicks',
63
63
  'uniqueClicks',
64
- 'machineClicks'
64
+ 'machineClicks',
65
+ 'openPeople',
66
+ 'opens',
67
+ 'uniqueOpens',
68
+ 'machineOpens',
69
+ 'proxyOpens'
65
70
  ]){
66
71
  const value = data[key];
67
72
  if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
@@ -71,6 +76,9 @@ const texts = (value)=>Array.isArray(value) ? value.filter((entry)=>typeof entry
71
76
  if (data['clickTracked'] === true) stats.clickTracked = true;
72
77
  const last = data['lastClickAtMs'];
73
78
  if (typeof last === 'number' && Number.isFinite(last)) stats.lastClickAtMs = last;
79
+ if (data['openTracked'] === true) stats.openTracked = true;
80
+ const lastOpen = data['lastOpenAtMs'];
81
+ if (typeof lastOpen === 'number' && Number.isFinite(lastOpen)) stats.lastOpenAtMs = lastOpen;
74
82
  return Object.keys(stats).length ? stats : null;
75
83
  }
76
84
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/stored-records.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 * A stored Outreach document read back into its model shape (AGL-2980),\n * for the server's routes and the console's listeners alike. Client-safe.\n *\n * Read DEFENSIVELY, a field at a time, because the readers act on what they\n * read: a sequence's steps are what gets sent, and an enrollment's status is\n * what decides whether it is sent at all.\n */\n\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { readOutreachSequenceDraft } from './sequence-draft'\nimport {\n OUTREACH_ENROLLMENT_STATUSES,\n OUTREACH_SEQUENCE_STATUSES,\n OUTREACH_STEP_OVERRIDE_SOURCES,\n OUTREACH_STOP_REASONS,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachSequenceStats,\n type OutreachSequenceStatus,\n type OutreachStepOverride,\n type OutreachStepOverrides,\n type OutreachStopReason,\n} from './outreach.types'\n\nconst text = (value: unknown): string =>\n typeof value === 'string' ? value : ''\nconst ms = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) ? value : null\nconst texts = (value: unknown): string[] =>\n Array.isArray(value)\n ? value.filter((entry): entry is string => typeof entry === 'string')\n : []\n\n/** A stored sequence in its model shape, or `null` for no document. */\nexport function readStoredOutreachSequence(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachSequence | null {\n if (!data) return null\n const draft = readOutreachSequenceDraft(data)\n const status = data['status']\n const stats = readOutreachSequenceStats(data['stats'])\n return {\n id,\n ...draft,\n status: (OUTREACH_SEQUENCE_STATUSES as readonly unknown[]).includes(status)\n ? (status as OutreachSequenceStatus)\n : 'draft',\n // Absent rather than an empty object for a sequence that has none\n // (AGL-3239): the report reads an absent counter as \"not recorded\" and\n // a present one as a number, and `{}` would be neither.\n ...(stats ? { stats } : {}),\n createdAtMs: ms(data['createdAtMs']) ?? 0,\n updatedAtMs: ms(data['updatedAtMs']) ?? 0,\n }\n}\n\n/**\n * A sequence's stored counters (AGL-3239), or `null` when it has none.\n *\n * Every field is carried through only when it is READABLE, so an absence\n * survives the round trip as an absence: the whole of the report's honesty\n * is that a counter nobody wrote is not a zero, and a normalizer that\n * defaulted these to `0` would destroy the distinction here, before any\n * reader could make it.\n */\nexport function readOutreachSequenceStats(\n raw: unknown,\n): OutreachSequenceStats | null {\n if (!raw || typeof raw !== 'object') return null\n const data = raw as Record<string, unknown>\n const stats: OutreachSequenceStats = {}\n for (const key of ['sent', 'people', 'clicks', 'uniqueClicks', 'machineClicks'] as const) {\n const value = data[key]\n if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {\n stats[key] = Math.floor(value)\n }\n }\n if (data['clickTracked'] === true) stats.clickTracked = true\n const last = data['lastClickAtMs']\n if (typeof last === 'number' && Number.isFinite(last)) stats.lastClickAtMs = last\n return Object.keys(stats).length ? stats : null\n}\n\n/**\n * A stored enrollment's curated steps (AGL-3324), or `undefined` when it\n * carries none worth reading. Read a field at a time, because what is read\n * here is what gets SENT in place of the step: an entry with neither a\n * subject nor a body, or one keyed by something that is not a step index,\n * is dropped rather than sent as an empty email.\n */\nexport function readOutreachStepOverrides(raw: unknown): OutreachStepOverrides | undefined {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined\n const overrides: OutreachStepOverrides = {}\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!/^(0|[1-9]\\d*)$/.test(key) || !value || typeof value !== 'object') continue\n const entry = value as Record<string, unknown>\n const subject = typeof entry['subject'] === 'string' ? entry['subject'] : undefined\n const body = typeof entry['body'] === 'string' ? entry['body'] : undefined\n if (subject === undefined && body === undefined) continue\n const source = entry['source']\n const override: OutreachStepOverride = {\n ...(subject !== undefined ? { subject } : {}),\n ...(body !== undefined ? { body } : {}),\n // Words nobody can say the source of are read as the member's: the\n // audit line then claims no AI wrote them.\n source: (OUTREACH_STEP_OVERRIDE_SOURCES as readonly unknown[]).includes(source)\n ? (source as OutreachStepOverride['source'])\n : 'member',\n draftedAtMs: ms(entry['draftedAtMs']) ?? 0,\n ...(typeof entry['draftedByUid'] === 'string' ? { draftedByUid: entry['draftedByUid'] } : {}),\n ...(entry['edited'] === true ? { edited: true } : {}),\n ...(typeof entry['prompt'] === 'string' ? { prompt: entry['prompt'] } : {}),\n ...(typeof entry['model'] === 'string' ? { model: entry['model'] } : {}),\n }\n overrides[key] = override\n }\n return Object.keys(overrides).length ? overrides : undefined\n}\n\n/** A stored enrollment in its model shape, or `null` for no document. */\nexport function readStoredOutreachEnrollment(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollment | null {\n if (!data) return null\n const status = data['status']\n const stopReason = data['stopReason']\n const stepIndex = Number(data['stepIndex'])\n // An enrollment written before leads could be sequenced (AGL-3234) names\n // no target and is a contact's; one that names a lead and no target is a\n // lead's, whatever else it says.\n const leadId = text(data['leadId']) || null\n const target: OutreachEnrollmentTarget =\n data['target'] === 'lead' || (data['target'] === undefined && leadId && !text(data['contactId']))\n ? 'lead'\n : 'contact'\n const enrollment: OutreachEnrollment = {\n ...(data as unknown as OutreachEnrollment),\n id,\n sequenceId: text(data['sequenceId']),\n target,\n contactId: text(data['contactId']),\n leadId,\n contactName: text(data['contactName']),\n email: text(data['email']),\n hostId: text(data['hostId']),\n mailboxId: text(data['mailboxId']),\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n nextDueAtMs: ms(data['nextDueAtMs']),\n // A status the model does not name is read as stopped: nothing sends\n // from a document nobody can say the state of.\n status: (OUTREACH_ENROLLMENT_STATUSES as readonly unknown[]).includes(\n status,\n )\n ? (status as OutreachEnrollmentStatus)\n : 'stopped',\n stopReason: (OUTREACH_STOP_REASONS as readonly unknown[]).includes(\n stopReason,\n )\n ? (stopReason as OutreachStopReason)\n : null,\n stopDetail:\n typeof data['stopDetail'] === 'string' ? data['stopDetail'] : null,\n stoppedAtMs: ms(data['stoppedAtMs']),\n stoppedByUid:\n typeof data['stoppedByUid'] === 'string' ? data['stoppedByUid'] : null,\n personalLine: text(data['personalLine']),\n cold: data['cold'] === true,\n enrolledByUid: text(data['enrolledByUid']),\n gmailThreadId:\n typeof data['gmailThreadId'] === 'string' ? data['gmailThreadId'] : null,\n gmailThreadIds: texts(data['gmailThreadIds']),\n threadSubject:\n typeof data['threadSubject'] === 'string' ? data['threadSubject'] : null,\n messageIds: texts(data['messageIds']),\n lastSentAtMs: ms(data['lastSentAtMs']),\n // Absent on an enrollment made before a sequence could join a campaign\n // (AGL-3254), and read as none: nothing is credited to a campaign the\n // enrollment does not name.\n campaignIds: normalizeCampaignIds(data['campaignIds']),\n createdAtMs: ms(data['createdAtMs']) ?? 0,\n updatedAtMs: ms(data['updatedAtMs']) ?? 0,\n }\n // Absent rather than an empty map for an enrollment nobody curated\n // (AGL-3324): the spread above would otherwise carry whatever was stored.\n const stepOverrides = readOutreachStepOverrides(data['stepOverrides'])\n if (stepOverrides) enrollment.stepOverrides = stepOverrides\n else delete enrollment.stepOverrides\n return enrollment\n}\n\n/** A stored mailbox with its id, or `null` for no document. */\nexport function readStoredOutreachMailbox(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachMailbox | null {\n if (!data) return null\n return { ...(data as unknown as OutreachMailbox), id }\n}\n"],"names":["normalizeCampaignIds","readOutreachSequenceDraft","OUTREACH_ENROLLMENT_STATUSES","OUTREACH_SEQUENCE_STATUSES","OUTREACH_STEP_OVERRIDE_SOURCES","OUTREACH_STOP_REASONS","text","value","ms","Number","isFinite","texts","Array","isArray","filter","entry","readStoredOutreachSequence","id","data","draft","status","stats","readOutreachSequenceStats","includes","createdAtMs","updatedAtMs","raw","key","Math","floor","clickTracked","last","lastClickAtMs","Object","keys","length","readOutreachStepOverrides","undefined","overrides","entries","test","subject","body","source","override","draftedAtMs","draftedByUid","edited","prompt","model","readStoredOutreachEnrollment","stopReason","stepIndex","leadId","target","enrollment","sequenceId","contactId","contactName","email","hostId","mailboxId","isInteger","nextDueAtMs","stopDetail","stoppedAtMs","stoppedByUid","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","stepOverrides","readStoredOutreachMailbox"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/stored-records.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 * A stored Outreach document read back into its model shape (AGL-2980),\n * for the server's routes and the console's listeners alike. Client-safe.\n *\n * Read DEFENSIVELY, a field at a time, because the readers act on what they\n * read: a sequence's steps are what gets sent, and an enrollment's status is\n * what decides whether it is sent at all.\n */\n\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { readOutreachSequenceDraft } from './sequence-draft'\nimport {\n OUTREACH_ENROLLMENT_STATUSES,\n OUTREACH_SEQUENCE_STATUSES,\n OUTREACH_STEP_OVERRIDE_SOURCES,\n OUTREACH_STOP_REASONS,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachSequenceStats,\n type OutreachSequenceStatus,\n type OutreachStepOverride,\n type OutreachStepOverrides,\n type OutreachStopReason,\n} from './outreach.types'\n\nconst text = (value: unknown): string =>\n typeof value === 'string' ? value : ''\nconst ms = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) ? value : null\nconst texts = (value: unknown): string[] =>\n Array.isArray(value)\n ? value.filter((entry): entry is string => typeof entry === 'string')\n : []\n\n/** A stored sequence in its model shape, or `null` for no document. */\nexport function readStoredOutreachSequence(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachSequence | null {\n if (!data) return null\n const draft = readOutreachSequenceDraft(data)\n const status = data['status']\n const stats = readOutreachSequenceStats(data['stats'])\n return {\n id,\n ...draft,\n status: (OUTREACH_SEQUENCE_STATUSES as readonly unknown[]).includes(status)\n ? (status as OutreachSequenceStatus)\n : 'draft',\n // Absent rather than an empty object for a sequence that has none\n // (AGL-3239): the report reads an absent counter as \"not recorded\" and\n // a present one as a number, and `{}` would be neither.\n ...(stats ? { stats } : {}),\n createdAtMs: ms(data['createdAtMs']) ?? 0,\n updatedAtMs: ms(data['updatedAtMs']) ?? 0,\n }\n}\n\n/**\n * A sequence's stored counters (AGL-3239), or `null` when it has none.\n *\n * Every field is carried through only when it is READABLE, so an absence\n * survives the round trip as an absence: the whole of the report's honesty\n * is that a counter nobody wrote is not a zero, and a normalizer that\n * defaulted these to `0` would destroy the distinction here, before any\n * reader could make it.\n */\nexport function readOutreachSequenceStats(\n raw: unknown,\n): OutreachSequenceStats | null {\n if (!raw || typeof raw !== 'object') return null\n const data = raw as Record<string, unknown>\n const stats: OutreachSequenceStats = {}\n for (const key of [\n 'sent',\n 'people',\n 'clicks',\n 'uniqueClicks',\n 'machineClicks',\n 'openPeople',\n 'opens',\n 'uniqueOpens',\n 'machineOpens',\n 'proxyOpens',\n ] as const) {\n const value = data[key]\n if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {\n stats[key] = Math.floor(value)\n }\n }\n if (data['clickTracked'] === true) stats.clickTracked = true\n const last = data['lastClickAtMs']\n if (typeof last === 'number' && Number.isFinite(last)) stats.lastClickAtMs = last\n if (data['openTracked'] === true) stats.openTracked = true\n const lastOpen = data['lastOpenAtMs']\n if (typeof lastOpen === 'number' && Number.isFinite(lastOpen)) stats.lastOpenAtMs = lastOpen\n return Object.keys(stats).length ? stats : null\n}\n\n/**\n * A stored enrollment's curated steps (AGL-3324), or `undefined` when it\n * carries none worth reading. Read a field at a time, because what is read\n * here is what gets SENT in place of the step: an entry with neither a\n * subject nor a body, or one keyed by something that is not a step index,\n * is dropped rather than sent as an empty email.\n */\nexport function readOutreachStepOverrides(raw: unknown): OutreachStepOverrides | undefined {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined\n const overrides: OutreachStepOverrides = {}\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!/^(0|[1-9]\\d*)$/.test(key) || !value || typeof value !== 'object') continue\n const entry = value as Record<string, unknown>\n const subject = typeof entry['subject'] === 'string' ? entry['subject'] : undefined\n const body = typeof entry['body'] === 'string' ? entry['body'] : undefined\n if (subject === undefined && body === undefined) continue\n const source = entry['source']\n const override: OutreachStepOverride = {\n ...(subject !== undefined ? { subject } : {}),\n ...(body !== undefined ? { body } : {}),\n // Words nobody can say the source of are read as the member's: the\n // audit line then claims no AI wrote them.\n source: (OUTREACH_STEP_OVERRIDE_SOURCES as readonly unknown[]).includes(source)\n ? (source as OutreachStepOverride['source'])\n : 'member',\n draftedAtMs: ms(entry['draftedAtMs']) ?? 0,\n ...(typeof entry['draftedByUid'] === 'string' ? { draftedByUid: entry['draftedByUid'] } : {}),\n ...(entry['edited'] === true ? { edited: true } : {}),\n ...(typeof entry['prompt'] === 'string' ? { prompt: entry['prompt'] } : {}),\n ...(typeof entry['model'] === 'string' ? { model: entry['model'] } : {}),\n }\n overrides[key] = override\n }\n return Object.keys(overrides).length ? overrides : undefined\n}\n\n/** A stored enrollment in its model shape, or `null` for no document. */\nexport function readStoredOutreachEnrollment(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollment | null {\n if (!data) return null\n const status = data['status']\n const stopReason = data['stopReason']\n const stepIndex = Number(data['stepIndex'])\n // An enrollment written before leads could be sequenced (AGL-3234) names\n // no target and is a contact's; one that names a lead and no target is a\n // lead's, whatever else it says.\n const leadId = text(data['leadId']) || null\n const target: OutreachEnrollmentTarget =\n data['target'] === 'lead' || (data['target'] === undefined && leadId && !text(data['contactId']))\n ? 'lead'\n : 'contact'\n const enrollment: OutreachEnrollment = {\n ...(data as unknown as OutreachEnrollment),\n id,\n sequenceId: text(data['sequenceId']),\n target,\n contactId: text(data['contactId']),\n leadId,\n contactName: text(data['contactName']),\n email: text(data['email']),\n hostId: text(data['hostId']),\n mailboxId: text(data['mailboxId']),\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n nextDueAtMs: ms(data['nextDueAtMs']),\n // A status the model does not name is read as stopped: nothing sends\n // from a document nobody can say the state of.\n status: (OUTREACH_ENROLLMENT_STATUSES as readonly unknown[]).includes(\n status,\n )\n ? (status as OutreachEnrollmentStatus)\n : 'stopped',\n stopReason: (OUTREACH_STOP_REASONS as readonly unknown[]).includes(\n stopReason,\n )\n ? (stopReason as OutreachStopReason)\n : null,\n stopDetail:\n typeof data['stopDetail'] === 'string' ? data['stopDetail'] : null,\n stoppedAtMs: ms(data['stoppedAtMs']),\n stoppedByUid:\n typeof data['stoppedByUid'] === 'string' ? data['stoppedByUid'] : null,\n personalLine: text(data['personalLine']),\n cold: data['cold'] === true,\n enrolledByUid: text(data['enrolledByUid']),\n gmailThreadId:\n typeof data['gmailThreadId'] === 'string' ? data['gmailThreadId'] : null,\n gmailThreadIds: texts(data['gmailThreadIds']),\n threadSubject:\n typeof data['threadSubject'] === 'string' ? data['threadSubject'] : null,\n messageIds: texts(data['messageIds']),\n lastSentAtMs: ms(data['lastSentAtMs']),\n // Absent on an enrollment made before a sequence could join a campaign\n // (AGL-3254), and read as none: nothing is credited to a campaign the\n // enrollment does not name.\n campaignIds: normalizeCampaignIds(data['campaignIds']),\n createdAtMs: ms(data['createdAtMs']) ?? 0,\n updatedAtMs: ms(data['updatedAtMs']) ?? 0,\n }\n // Absent rather than an empty map for an enrollment nobody curated\n // (AGL-3324): the spread above would otherwise carry whatever was stored.\n const stepOverrides = readOutreachStepOverrides(data['stepOverrides'])\n if (stepOverrides) enrollment.stepOverrides = stepOverrides\n else delete enrollment.stepOverrides\n return enrollment\n}\n\n/** A stored mailbox with its id, or `null` for no document. */\nexport function readStoredOutreachMailbox(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachMailbox | null {\n if (!data) return null\n return { ...(data as unknown as OutreachMailbox), id }\n}\n"],"names":["normalizeCampaignIds","readOutreachSequenceDraft","OUTREACH_ENROLLMENT_STATUSES","OUTREACH_SEQUENCE_STATUSES","OUTREACH_STEP_OVERRIDE_SOURCES","OUTREACH_STOP_REASONS","text","value","ms","Number","isFinite","texts","Array","isArray","filter","entry","readStoredOutreachSequence","id","data","draft","status","stats","readOutreachSequenceStats","includes","createdAtMs","updatedAtMs","raw","key","Math","floor","clickTracked","last","lastClickAtMs","openTracked","lastOpen","lastOpenAtMs","Object","keys","length","readOutreachStepOverrides","undefined","overrides","entries","test","subject","body","source","override","draftedAtMs","draftedByUid","edited","prompt","model","readStoredOutreachEnrollment","stopReason","stepIndex","leadId","target","enrollment","sequenceId","contactId","contactName","email","hostId","mailboxId","isInteger","nextDueAtMs","stopDetail","stoppedAtMs","stoppedByUid","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","stepOverrides","readStoredOutreachMailbox"],"mappings":"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"}
@@ -21,7 +21,7 @@ import { outreachLocalDay } from "../mailboxes/mailbox-settings.js";
21
21
  import { markOutreachMailboxReconnectRequired } from "../mailboxes/mailbox-transport.js";
22
22
  import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
23
23
  import { previewOutreachStep } from "../model/step-preview.js";
24
- import { newOutreachLinkId, OUTREACH_TEST_LINK_ENROLLMENT, outreachStoredLink } from "../runtime/click-link.js";
24
+ import { newOutreachLinkId, OUTREACH_TEST_LINK_ENROLLMENT, outreachStoredLink, outreachStoredOpen } from "../runtime/click-link.js";
25
25
  import { readStoredOutreachMailbox } from "../storage/outreach-records.js";
26
26
  import { GmailTransportError } from "../transport/gmail-errors.js";
27
27
  import { Rfc5322MessageError } from "../transport/rfc5322.js";
@@ -81,6 +81,7 @@ const RECONNECT_SENTENCE = "This sequence's mailbox needs to be connected again
81
81
  }
82
82
  export function createOutreachStepTestRoute(deps, testDeps) {
83
83
  return async (request)=>{
84
+ var _ref;
84
85
  if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
85
86
  const body = await readOutreachJsonBody(request);
86
87
  // The address the member typed, when they typed one — read before any
@@ -126,7 +127,7 @@ export function createOutreachStepTestRoute(deps, testDeps) {
126
127
  * marked as a test's: the member sees what a recipient sees, and their
127
128
  * own click counts for nothing.
128
129
  */ let linkOrigin = null;
129
- if (sequence.settings.trackClicks && testDeps.clickLinkOrigin) {
130
+ if ((sequence.settings.trackClicks || sequence.settings.countOpens) && testDeps.clickLinkOrigin) {
130
131
  linkOrigin = await testDeps.clickLinkOrigin({
131
132
  orgId: caller.orgId,
132
133
  senderAddress: sender.address
@@ -153,32 +154,56 @@ export function createOutreachStepTestRoute(deps, testDeps) {
153
154
  });
154
155
  return url;
155
156
  } : null;
157
+ /*
158
+ * The tracking image (AGL-3395), as a real send would carry it and
159
+ * marked as a test's: the member sees both parts a recipient gets, and
160
+ * their own open is recorded nowhere.
161
+ */ let pixel = null;
162
+ if (sequence.settings.countOpens) {
163
+ const doc = outreachStoredOpen({
164
+ orgId: caller.orgId,
165
+ enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,
166
+ stepIndex,
167
+ test: true
168
+ }, nowMs);
169
+ const id = newOutreachLinkId();
170
+ const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null;
171
+ if (doc && url) pixel = {
172
+ id,
173
+ doc: _extends({}, doc, {
174
+ sequenceId: sequence.id
175
+ }),
176
+ url
177
+ };
178
+ }
156
179
  let composed = previewOutreachStep(_extends({}, loaded.input, {
157
- rewriteLink
180
+ rewriteLink,
181
+ openPixelUrl: (_ref = pixel == null ? void 0 : pixel.url) != null ? _ref : null
158
182
  }));
159
183
  if (composed.error || !composed.email) {
160
- var _ref;
184
+ var _ref1;
161
185
  var _composed_error;
162
- return outreachRefusal(409, 'invalid-sequence', (_ref = (_composed_error = composed.error) == null ? void 0 : _composed_error.message) != null ? _ref : "This email can't be written yet.");
186
+ return outreachRefusal(409, 'invalid-sequence', (_ref1 = (_composed_error = composed.error) == null ? void 0 : _composed_error.message) != null ? _ref1 : "This email can't be written yet.");
163
187
  }
164
- if (shortLinks.length) {
188
+ if (shortLinks.length || pixel) {
165
189
  try {
166
190
  const batch = firestore.batch();
167
191
  for (const link of shortLinks){
168
192
  batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc);
169
193
  }
194
+ if (pixel) batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(pixel.id), pixel.doc);
170
195
  await batch.commit();
171
196
  } catch (error) {
172
197
  // A link that resolves nowhere is worse than one shown as written:
173
- // the test goes out with the step's own links.
198
+ // the test goes out with the step's own links, as plain text.
174
199
  console.error('[outreach] the test links could not be stored; the test carries the links as written', error);
175
200
  composed = previewOutreachStep(_extends({}, loaded.input, {
176
201
  rewriteLink: null
177
202
  }));
178
203
  if (composed.error || !composed.email) {
179
- var _ref1;
204
+ var _ref2;
180
205
  var _composed_error1;
181
- return outreachRefusal(409, 'invalid-sequence', (_ref1 = (_composed_error1 = composed.error) == null ? void 0 : _composed_error1.message) != null ? _ref1 : "This email can't be written yet.");
206
+ return outreachRefusal(409, 'invalid-sequence', (_ref2 = (_composed_error1 = composed.error) == null ? void 0 : _composed_error1.message) != null ? _ref2 : "This email can't be written yet.");
182
207
  }
183
208
  }
184
209
  }
@@ -201,12 +226,15 @@ export function createOutreachStepTestRoute(deps, testDeps) {
201
226
  * `References` — since the thread it answers exists only on screen. The
202
227
  * unsubscribe header is left off: it names an enrollment, and there is
203
228
  * none.
204
- */ const email = {
229
+ */ const email = _extends({
205
230
  to,
206
231
  subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,
207
- text: composed.email.text,
232
+ text: composed.email.text
233
+ }, composed.email.html ? {
234
+ html: composed.email.html
235
+ } : {}, {
208
236
  trackedLinks: composed.email.trackedLinks
209
- };
237
+ });
210
238
  let subject;
211
239
  try {
212
240
  subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/step-test-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { ComposedOutreachEmail } from '../engine/compose'\nimport { hasOutreachValidationErrors, validateOutreachSteps } from '../engine/sequence-validation'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type { OutreachStepTestResponse } from '../model/outreach-api'\nimport { OUTREACH_COLLECTIONS, type OutreachMailbox } from '../model/outreach.types'\nimport { previewOutreachStep } from '../model/step-preview'\nimport {\n newOutreachLinkId,\n OUTREACH_TEST_LINK_ENROLLMENT,\n outreachStoredLink,\n type OutreachStoredLink,\n} from '../runtime/click-link'\nimport { readStoredOutreachMailbox } from '../storage/outreach-records'\nimport { GmailTransportError } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendComposedOutreachEmail } from '../transport/send-message'\nimport { readOutreachStepRender } from './preview-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from './route-http'\n\n/**\n * A TEST OF ONE STEP, IN A REAL INBOX (AGL-3325): `outreach/steps/test`.\n *\n * The Mailboxes page's test proves a mailbox can send; this proves what a\n * step LOOKS like once it has been through Gmail. It is the preview's email\n * — the same reader, the same person or sample person, the same personal\n * line, the same footer — sent through the sequence's mailbox to the member\n * who asked, or to an address they typed, with `[Test] ` in front of the\n * subject so it never reads as real mail.\n *\n * ## Rendered as a send, sent as a test\n *\n * A later step in a thread is written as `Re:` the thread's subject, as the\n * preview writes it, but sent as a new message: there is no thread to\n * answer, and the preview's placeholder message id must not leave the\n * server. When the sequence counts clicks, every link is rewritten to a\n * short link as a real send's would be, so the member sees the link their\n * recipients will see — but each is stored as a TEST link, which the click\n * route follows and never records (`click-link.ts`). A member clicking their\n * own test must not move the sequence's `clicks` or `machineClicks`.\n *\n * Merge fields the person has nothing for are sent empty and named in the\n * answer, where a real send would pause: the point of a test is to see the\n * gap, not to be refused it.\n *\n * ## Counted on nothing but the mailbox's tests\n *\n * A test is a real Gmail send and spends the account's own limits. It is\n * not a person emailed: it moves neither the sequence's `stats` nor the\n * mailbox's `health.daily.<day>.sent`, it reserves nothing of the daily cap,\n * it files nothing on anyone's record, and it enrolls nobody. It is tallied\n * on `health.daily.<day>.tests`, which the Mailboxes card shows as \"Tests\n * today\", and held to {@link OUTREACH_STEP_TESTS_PER_HOUR} a mailbox.\n *\n * ## Who, and from what\n *\n * The mailbox's own member, or an organization owner or admin — the people\n * who may edit and activate the sequence. A paused mailbox, or one waiting\n * to be reconnected, refuses and says which; a member who cannot send is\n * told where to fix it rather than shown a Google error.\n */\n\n/** Tests one mailbox may send in an hour: a few steps for a few people. */\nexport const OUTREACH_STEP_TESTS_PER_HOUR = 20\n\n/** What every test's subject starts with. */\nexport const OUTREACH_TEST_SUBJECT_PREFIX = '[Test] '\n\n/** The route's reach beyond the shared route deps: the mailbox's Gmail, the limit, the links. */\nexport interface OutreachStepTestDeps {\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n /** The short tracking link for a stored link's id, or `null` when none can be made. */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /** The click-tracking origin for mail sent as `senderAddress` (AGL-3306), or `null` for the console's. */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n\n/** What the route answers: the response the console reads, and the fields the test sent empty. */\nexport interface OutreachStepTestAnswer extends OutreachStepTestResponse {\n unresolvedFields: string[]\n}\n\nconst RECONNECT_SENTENCE =\n \"This sequence's mailbox needs to be connected again before it can send. Reconnect it on the Mailboxes page.\"\n\n/** Why the mailbox cannot send a test now, in a sentence, or `null`. */\nfunction mailboxRefusal(mailbox: OutreachMailbox | null): string | null {\n if (!mailbox) return 'This sequence has no mailbox. Choose one and save before sending a test.'\n if (mailbox.status === 'paused') {\n return \"This sequence's mailbox is paused. Resume it on the Mailboxes page before sending a test.\"\n }\n if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') return RECONNECT_SENTENCE\n return null\n}\n\nexport function createOutreachStepTestRoute(\n deps: OutreachRouteDeps,\n testDeps: OutreachStepTestDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n // The address the member typed, when they typed one — read before any\n // record, so a typo is answered before a Gmail call.\n const named = typeof body['to'] === 'string' ? body['to'].trim() : ''\n const typed = named ? normalizeContactEmail(named) : null\n if (named && !typed) {\n return outreachRefusal(400, 'invalid-request', `\"${named}\" isn't a valid email address.`)\n }\n const loaded = await readOutreachStepRender(deps, request, body)\n if (loaded instanceof Response) return loaded\n const { caller, sequence, stepIndex, mailbox } = loaded\n\n const refusedBy = mailboxRefusal(mailbox)\n if (refusedBy || !mailbox) return outreachRefusal(409, 'mailbox-unavailable', refusedBy ?? RECONNECT_SENTENCE)\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return outreachRefusal(\n 403,\n 'permission',\n \"Only the member who connected this sequence's mailbox, or an organization owner or admin, can send a test from it.\",\n )\n }\n const to = typed ?? caller.email\n if (!to) return outreachRefusal(400, 'invalid-request', 'Type an address to send the test to.')\n // The rules every sequence email keeps, asked of the stored steps: a\n // sequence saved under an older rule is refused rather than sent broken.\n const issues = validateOutreachSteps(sequence.steps)\n if (hasOutreachValidationErrors(issues)) {\n return outreachRefusal(400, 'invalid-sequence', \"Fix the sequence's steps before sending a test.\", { issues })\n }\n const limited = await testDeps.consumeRateLimit(`outreach-step-test:${mailbox.id}`, {\n limit: OUTREACH_STEP_TESTS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_STEP_TESTS_PER_HOUR} tests an hour. Try again later.`,\n )\n }\n\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const sender = { address: mailbox.sendAs || mailbox.email, name: mailbox.displayName || null }\n /*\n * The links, as a real send would rewrite them (AGL-3239, AGL-3297) and\n * marked as a test's: the member sees what a recipient sees, and their\n * own click counts for nothing.\n */\n let linkOrigin: string | null = null\n if (sequence.settings.trackClicks && testDeps.clickLinkOrigin) {\n linkOrigin = await testDeps\n .clickLinkOrigin({ orgId: caller.orgId, senderAddress: sender.address })\n .catch(() => null)\n }\n const shortLinks: Array<{ id: string; doc: OutreachStoredLink }> = []\n const rewriteLink = sequence.settings.trackClicks\n ? (link: { url: string; index: number }) => {\n const doc = outreachStoredLink(\n {\n orgId: caller.orgId,\n enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,\n stepIndex,\n linkIndex: link.index,\n url: link.url,\n test: true,\n },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (!doc || !url) return null\n shortLinks.push({ id, doc: { ...doc, sequenceId: sequence.id } })\n return url\n }\n : null\n let composed = previewOutreachStep({ ...loaded.input, rewriteLink })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n if (shortLinks.length) {\n try {\n const batch = firestore.batch()\n for (const link of shortLinks) {\n batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc)\n }\n await batch.commit()\n } catch (error) {\n // A link that resolves nowhere is worse than one shown as written:\n // the test goes out with the step's own links.\n console.error('[outreach] the test links could not be stored; the test carries the links as written', error)\n composed = previewOutreachStep({ ...loaded.input, rewriteLink: null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n }\n }\n\n const opened = await testDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', \"Google mailboxes aren't set up on this deployment.\")\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', RECONNECT_SENTENCE)\n }\n /*\n * The preview's email, addressed to the member: `[Test]` in front, and a\n * `Re:` step sent as a new message — no thread id, no `In-Reply-To`, no\n * `References` — since the thread it answers exists only on screen. The\n * unsubscribe header is left off: it names an enrollment, and there is\n * none.\n */\n const email: ComposedOutreachEmail = {\n to,\n subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,\n text: composed.email.text,\n trackedLinks: composed.email.trackedLinks,\n }\n let subject: string\n try {\n subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject\n } catch (error) {\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', 'Google refused this mailbox’s access. Connect it again.')\n }\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`)\n }\n if (!(error instanceof GmailTransportError)) throw error\n return outreachRefusal(\n 502,\n 'google-unavailable',\n error.retryable ? 'Google did not answer. Try again in a moment.' : `Google refused the request: ${error.message}`,\n )\n }\n\n const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs)\n return outreachOk({\n ok: true,\n stepIndex,\n sentTo: to,\n subject,\n sentAtMs: nowMs,\n testsToday,\n unresolvedFields: composed.unresolvedFields,\n } satisfies OutreachStepTestAnswer)\n }\n}\n\n/**\n * One more test on the mailbox's local day, in `health.daily.<day>.tests`\n * beside the day's sends and never in them. Bookkeeping beside the act: the\n * email has left, so a failure is logged and answered as no count.\n */\nasync function countTest(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<number> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return 0\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const tests = Math.max(0, Number(counts.tests) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, tests }, updatedAtMs: nowMs })\n return tests\n })\n } catch (error) {\n console.warn('[outreach] a test send could not be counted on its mailbox', error)\n return 0\n }\n}\n"],"names":["normalizeContactEmail","hasOutreachValidationErrors","validateOutreachSteps","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","OUTREACH_COLLECTIONS","previewOutreachStep","newOutreachLinkId","OUTREACH_TEST_LINK_ENROLLMENT","outreachStoredLink","readStoredOutreachMailbox","GmailTransportError","Rfc5322MessageError","sendComposedOutreachEmail","readOutreachStepRender","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachJsonBody","OUTREACH_STEP_TESTS_PER_HOUR","OUTREACH_TEST_SUBJECT_PREFIX","RECONNECT_SENTENCE","mailboxRefusal","mailbox","status","createOutreachStepTestRoute","deps","testDeps","request","method","body","named","trim","typed","loaded","Response","caller","sequence","stepIndex","refusedBy","connectedByUid","uid","isOrgAdmin","to","email","issues","steps","limited","consumeRateLimit","id","limit","windowMs","allowed","firestore","nowMs","now","sender","address","sendAs","name","displayName","linkOrigin","settings","trackClicks","clickLinkOrigin","orgId","senderAddress","catch","shortLinks","rewriteLink","link","doc","enrollmentId","linkIndex","index","url","test","clickLinkUrl","push","sequenceId","composed","input","error","message","length","batch","create","collection","links","commit","console","opened","openMailbox","ok","reason","mailboxId","errorCode","subject","text","trackedLinks","client","reconnectRequired","code","retryable","testsToday","countTest","sentTo","sentAtMs","unresolvedFields","ref","runTransaction","transaction","stored","snapshot","get","exists","data","undefined","day","timezone","counts","health","daily","sent","bounces","replies","tests","Math","max","Number","update","updatedAtMs","warn"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,QAAQ,kCAAiC;AAGvE,SAASC,2BAA2B,EAAEC,qBAAqB,QAAQ,mCAA+B;AAClG,SAASC,UAAU,QAAQ,sCAAkC;AAC7D,SAASC,gBAAgB,QAAQ,mCAA+B;AAChE,SACEC,oCAAoC,QAE/B,oCAAgC;AAEvC,SAASC,oBAAoB,QAA8B,6BAAyB;AACpF,SAASC,mBAAmB,QAAQ,2BAAuB;AAC3D,SACEC,iBAAiB,EACjBC,6BAA6B,EAC7BC,kBAAkB,QAEb,2BAAuB;AAC9B,SAASC,yBAAyB,QAAQ,iCAA6B;AACvE,SAASC,mBAAmB,QAAQ,+BAA2B;AAC/D,SAASC,mBAAmB,QAAQ,0BAAsB;AAC1D,SAASC,yBAAyB,QAAQ,+BAA2B;AACrE,SAASC,sBAAsB,QAAQ,sBAAkB;AAEzD,SAASC,wBAAwB,EAAEC,UAAU,EAAEC,eAAe,EAAEC,oBAAoB,QAAQ,kBAAc;AAE1G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCC,GAED,yEAAyE,GACzE,OAAO,MAAMC,+BAA+B,GAAE;AAE9C,2CAA2C,GAC3C,OAAO,MAAMC,+BAA+B,UAAS;AAkBrD,MAAMC,qBACJ;AAEF,sEAAsE,GACtE,SAASC,eAAeC,OAA+B;IACrD,IAAI,CAACA,SAAS,OAAO;IACrB,IAAIA,QAAQC,MAAM,KAAK,UAAU;QAC/B,OAAO;IACT;IACA,IAAID,QAAQC,MAAM,KAAK,wBAAwBD,QAAQC,MAAM,KAAK,gBAAgB,OAAOH;IACzF,OAAO;AACT;AAEA,OAAO,SAASI,4BACdC,IAAuB,EACvBC,QAA8B;IAE9B,OAAO,OAAOC;QACZ,IAAIA,QAAQC,MAAM,KAAK,QAAQ,OAAOd,yBAAyB;QAC/D,MAAMe,OAAO,MAAMZ,qBAAqBU;QACxC,sEAAsE;QACtE,qDAAqD;QACrD,MAAMG,QAAQ,OAAOD,IAAI,CAAC,KAAK,KAAK,WAAWA,IAAI,CAAC,KAAK,CAACE,IAAI,KAAK;QACnE,MAAMC,QAAQF,QAAQhC,sBAAsBgC,SAAS;QACrD,IAAIA,SAAS,CAACE,OAAO;YACnB,OAAOhB,gBAAgB,KAAK,mBAAmB,CAAC,CAAC,EAAEc,MAAM,8BAA8B,CAAC;QAC1F;QACA,MAAMG,SAAS,MAAMpB,uBAAuBY,MAAME,SAASE;QAC3D,IAAII,kBAAkBC,UAAU,OAAOD;QACvC,MAAM,EAAEE,MAAM,EAAEC,QAAQ,EAAEC,SAAS,EAAEf,OAAO,EAAE,GAAGW;QAEjD,MAAMK,YAAYjB,eAAeC;QACjC,IAAIgB,aAAa,CAAChB,SAAS,OAAON,gBAAgB,KAAK,uBAAuBsB,oBAAAA,YAAalB;QAC3F,IAAIE,QAAQiB,cAAc,KAAKJ,OAAOK,GAAG,IAAI,CAACL,OAAOM,UAAU,EAAE;YAC/D,OAAOzB,gBACL,KACA,cACA;QAEJ;QACA,MAAM0B,KAAKV,gBAAAA,QAASG,OAAOQ,KAAK;QAChC,IAAI,CAACD,IAAI,OAAO1B,gBAAgB,KAAK,mBAAmB;QACxD,qEAAqE;QACrE,yEAAyE;QACzE,MAAM4B,SAAS5C,sBAAsBoC,SAASS,KAAK;QACnD,IAAI9C,4BAA4B6C,SAAS;YACvC,OAAO5B,gBAAgB,KAAK,oBAAoB,mDAAmD;gBAAE4B;YAAO;QAC9G;QACA,MAAME,UAAU,MAAMpB,SAASqB,gBAAgB,CAAC,CAAC,mBAAmB,EAAEzB,QAAQ0B,EAAE,EAAE,EAAE;YAClFC,OAAO/B;YACPgC,UAAU,KAAK,KAAK;QACtB;QACA,IAAI,CAACJ,QAAQK,OAAO,EAAE;YACpB,OAAOnC,gBACL,KACA,gBACA,CAAC,mBAAmB,EAAEE,6BAA6B,gCAAgC,CAAC;QAExF;QAEA,MAAMkC,YAAY3B,KAAK2B,SAAS;QAChC,MAAMC,QAAQ5B,KAAK6B,GAAG;QACtB,MAAMC,SAAS;YAAEC,SAASlC,QAAQmC,MAAM,IAAInC,QAAQqB,KAAK;YAAEe,MAAMpC,QAAQqC,WAAW,IAAI;QAAK;QAC7F;;;;KAIC,GACD,IAAIC,aAA4B;QAChC,IAAIxB,SAASyB,QAAQ,CAACC,WAAW,IAAIpC,SAASqC,eAAe,EAAE;YAC7DH,aAAa,MAAMlC,SAChBqC,eAAe,CAAC;gBAAEC,OAAO7B,OAAO6B,KAAK;gBAAEC,eAAeV,OAAOC,OAAO;YAAC,GACrEU,KAAK,CAAC,IAAM;QACjB;QACA,MAAMC,aAA6D,EAAE;QACrE,MAAMC,cAAchC,SAASyB,QAAQ,CAACC,WAAW,GAC7C,CAACO;YACC,MAAMC,MAAM9D,mBACV;gBACEwD,OAAO7B,OAAO6B,KAAK;gBACnBO,cAAchE;gBACd8B;gBACAmC,WAAWH,KAAKI,KAAK;gBACrBC,KAAKL,KAAKK,GAAG;gBACbC,MAAM;YACR,GACAtB;YAEF,MAAML,KAAK1C;YACX,MAAMoE,MAAMJ,MAAM5C,SAASkD,YAAY,CAAC5B,IAAIY,cAAc;YAC1D,IAAI,CAACU,OAAO,CAACI,KAAK,OAAO;YACzBP,WAAWU,IAAI,CAAC;gBAAE7B;gBAAIsB,KAAK,aAAKA;oBAAKQ,YAAY1C,SAASY,EAAE;;YAAG;YAC/D,OAAO0B;QACT,IACA;QACJ,IAAIK,WAAW1E,oBAAoB,aAAK4B,OAAO+C,KAAK;YAAEZ;;QACtD,IAAIW,SAASE,KAAK,IAAI,CAACF,SAASpC,KAAK,EAAE;;gBACWoC;YAAhD,OAAO/D,gBAAgB,KAAK,6BAAoB+D,kBAAAA,SAASE,KAAK,qBAAdF,gBAAgBG,OAAO,mBAAI;QAC7E;QACA,IAAIf,WAAWgB,MAAM,EAAE;YACrB,IAAI;gBACF,MAAMC,QAAQhC,UAAUgC,KAAK;gBAC7B,KAAK,MAAMf,QAAQF,WAAY;oBAC7BiB,MAAMC,MAAM,CAACjC,UAAUkC,UAAU,CAAClF,qBAAqBmF,KAAK,EAAEjB,GAAG,CAACD,KAAKrB,EAAE,GAAGqB,KAAKC,GAAG;gBACtF;gBACA,MAAMc,MAAMI,MAAM;YACpB,EAAE,OAAOP,OAAO;gBACd,mEAAmE;gBACnE,+CAA+C;gBAC/CQ,QAAQR,KAAK,CAAC,wFAAwFA;gBACtGF,WAAW1E,oBAAoB,aAAK4B,OAAO+C,KAAK;oBAAEZ,aAAa;;gBAC/D,IAAIW,SAASE,KAAK,IAAI,CAACF,SAASpC,KAAK,EAAE;;wBACWoC;oBAAhD,OAAO/D,gBAAgB,KAAK,8BAAoB+D,mBAAAA,SAASE,KAAK,qBAAdF,iBAAgBG,OAAO,oBAAI;gBAC7E;YACF;QACF;QAEA,MAAMQ,SAAS,MAAMhE,SAASiE,WAAW,CAACrE,QAAQ0B,EAAE;QACpD,IAAI0C,OAAOE,EAAE,KAAK,OAAO;YACvB,IAAIF,OAAOG,MAAM,KAAK,kBAAkB;gBACtC,OAAO7E,gBAAgB,KAAK,uBAAuB;YACrD;YACA,MAAMb,qCAAqCiD,WAAW;gBACpDY,OAAO7B,OAAO6B,KAAK;gBACnB8B,WAAWxE,QAAQ0B,EAAE;gBACrB+C,WAAWL,OAAOG,MAAM;gBACxBxC;YACF;YACA,OAAOrC,gBAAgB,KAAK,uBAAuBI;QACrD;QACA;;;;;;KAMC,GACD,MAAMuB,QAA+B;YACnCD;YACAsD,SAAS,GAAG7E,+BAA+B4D,SAASpC,KAAK,CAACqD,OAAO,EAAE;YACnEC,MAAMlB,SAASpC,KAAK,CAACsD,IAAI;YACzBC,cAAcnB,SAASpC,KAAK,CAACuD,YAAY;QAC3C;QACA,IAAIF;QACJ,IAAI;YACFA,UAAU,AAAC,CAAA,MAAMpF,0BAA0B8E,OAAOS,MAAM,EAAExD,OAAOY,OAAM,EAAGyC,OAAO;QACnF,EAAE,OAAOf,OAAO;YACd,IAAIA,iBAAiBvE,uBAAuBuE,MAAMmB,iBAAiB,EAAE;gBACnE,MAAMjG,qCAAqCiD,WAAW;oBACpDY,OAAO7B,OAAO6B,KAAK;oBACnB8B,WAAWxE,QAAQ0B,EAAE;oBACrB+C,WAAWd,MAAMoB,IAAI;oBACrBhD;gBACF;gBACA,OAAOrC,gBAAgB,KAAK,uBAAuB;YACrD;YACA,IAAIiE,iBAAiBtE,qBAAqB;gBACxC,OAAOK,gBAAgB,KAAK,mBAAmB,CAAC,+BAA+B,EAAEiE,MAAMC,OAAO,EAAE;YAClG;YACA,IAAI,CAAED,CAAAA,iBAAiBvE,mBAAkB,GAAI,MAAMuE;YACnD,OAAOjE,gBACL,KACA,sBACAiE,MAAMqB,SAAS,GAAG,kDAAkD,CAAC,4BAA4B,EAAErB,MAAMC,OAAO,EAAE;QAEtH;QAEA,MAAMqB,aAAa,MAAMC,UAAUpD,WAAWjB,OAAO6B,KAAK,EAAE1C,SAAS+B;QACrE,OAAOtC,WAAW;YAChB6E,IAAI;YACJvD;YACAoE,QAAQ/D;YACRsD;YACAU,UAAUrD;YACVkD;YACAI,kBAAkB5B,SAAS4B,gBAAgB;QAC7C;IACF;AACF;AAEA;;;;CAIC,GACD,eAAeH,UACbpD,SAAsC,EACtCY,KAAa,EACb1C,OAAwB,EACxB+B,KAAa;IAEb,MAAMuD,MAAM3G,WAAWmD,WAAWY,OAAO1C,QAAQ0B,EAAE;IACnD,IAAI;QACF,OAAO,MAAMI,UAAUyD,cAAc,CAAC,OAAOC;;gBAK5BC,sBAAAA;YAJf,MAAMC,WAAW,MAAMF,YAAYG,GAAG,CAACL;YACvC,MAAMG,SAAStG,0BAA0Ba,QAAQ0B,EAAE,EAAEgE,SAASE,MAAM,GAAGF,SAASG,IAAI,KAAKC;YACzF,IAAI,CAACL,QAAQ,OAAO;YACpB,MAAMM,MAAMnH,iBAAiBmD,OAAO0D,OAAOO,QAAQ;YACnD,MAAMC,kBAASR,iBAAAA,OAAOS,MAAM,sBAAbT,uBAAAA,eAAeU,KAAK,qBAApBV,oBAAsB,CAACM,IAAI,mBAAI;gBAAEK,MAAM;gBAAGC,SAAS;gBAAGC,SAAS;YAAE;YAChF,MAAMC,QAAQC,KAAKC,GAAG,CAAC,GAAGC,OAAOT,OAAOM,KAAK,KAAK,KAAK;YACvDf,YAAYmB,MAAM,CAACrB,KAAK;gBAAE,CAAC,CAAC,aAAa,EAAES,KAAK,CAAC,EAAE,aAAKE;oBAAQM;;gBAASK,aAAa7E;YAAM;YAC5F,OAAOwE;QACT;IACF,EAAE,OAAO5C,OAAO;QACdQ,QAAQ0C,IAAI,CAAC,8DAA8DlD;QAC3E,OAAO;IACT;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/step-test-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { ComposedOutreachEmail } from '../engine/compose'\nimport { hasOutreachValidationErrors, validateOutreachSteps } from '../engine/sequence-validation'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type { OutreachStepTestResponse } from '../model/outreach-api'\nimport { OUTREACH_COLLECTIONS, type OutreachMailbox } from '../model/outreach.types'\nimport { previewOutreachStep } from '../model/step-preview'\nimport {\n newOutreachLinkId,\n OUTREACH_TEST_LINK_ENROLLMENT,\n outreachStoredLink,\n outreachStoredOpen,\n type OutreachStoredLink,\n type OutreachStoredOpen,\n} from '../runtime/click-link'\nimport { readStoredOutreachMailbox } from '../storage/outreach-records'\nimport { GmailTransportError } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendComposedOutreachEmail } from '../transport/send-message'\nimport { readOutreachStepRender } from './preview-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from './route-http'\n\n/**\n * A TEST OF ONE STEP, IN A REAL INBOX (AGL-3325): `outreach/steps/test`.\n *\n * The Mailboxes page's test proves a mailbox can send; this proves what a\n * step LOOKS like once it has been through Gmail. It is the preview's email\n * — the same reader, the same person or sample person, the same personal\n * line, the same footer — sent through the sequence's mailbox to the member\n * who asked, or to an address they typed, with `[Test] ` in front of the\n * subject so it never reads as real mail.\n *\n * ## Rendered as a send, sent as a test\n *\n * A later step in a thread is written as `Re:` the thread's subject, as the\n * preview writes it, but sent as a new message: there is no thread to\n * answer, and the preview's placeholder message id must not leave the\n * server. When the sequence counts clicks, every link is rewritten to a\n * short link as a real send's would be, so the member sees the link their\n * recipients will see — but each is stored as a TEST link, which the click\n * route follows and never records (`click-link.ts`). A member clicking their\n * own test must not move the sequence's `clicks` or `machineClicks`.\n *\n * Merge fields the person has nothing for are sent empty and named in the\n * answer, where a real send would pause: the point of a test is to see the\n * gap, not to be refused it.\n *\n * ## Counted on nothing but the mailbox's tests\n *\n * A test is a real Gmail send and spends the account's own limits. It is\n * not a person emailed: it moves neither the sequence's `stats` nor the\n * mailbox's `health.daily.<day>.sent`, it reserves nothing of the daily cap,\n * it files nothing on anyone's record, and it enrolls nobody. It is tallied\n * on `health.daily.<day>.tests`, which the Mailboxes card shows as \"Tests\n * today\", and held to {@link OUTREACH_STEP_TESTS_PER_HOUR} a mailbox.\n *\n * ## Who, and from what\n *\n * The mailbox's own member, or an organization owner or admin — the people\n * who may edit and activate the sequence. A paused mailbox, or one waiting\n * to be reconnected, refuses and says which; a member who cannot send is\n * told where to fix it rather than shown a Google error.\n */\n\n/** Tests one mailbox may send in an hour: a few steps for a few people. */\nexport const OUTREACH_STEP_TESTS_PER_HOUR = 20\n\n/** What every test's subject starts with. */\nexport const OUTREACH_TEST_SUBJECT_PREFIX = '[Test] '\n\n/** The route's reach beyond the shared route deps: the mailbox's Gmail, the limit, the links. */\nexport interface OutreachStepTestDeps {\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n /** The short tracking link for a stored link's id, or `null` when none can be made. */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /** The click-tracking origin for mail sent as `senderAddress` (AGL-3306), or `null` for the console's. */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n\n/** What the route answers: the response the console reads, and the fields the test sent empty. */\nexport interface OutreachStepTestAnswer extends OutreachStepTestResponse {\n unresolvedFields: string[]\n}\n\nconst RECONNECT_SENTENCE =\n \"This sequence's mailbox needs to be connected again before it can send. Reconnect it on the Mailboxes page.\"\n\n/** Why the mailbox cannot send a test now, in a sentence, or `null`. */\nfunction mailboxRefusal(mailbox: OutreachMailbox | null): string | null {\n if (!mailbox) return 'This sequence has no mailbox. Choose one and save before sending a test.'\n if (mailbox.status === 'paused') {\n return \"This sequence's mailbox is paused. Resume it on the Mailboxes page before sending a test.\"\n }\n if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') return RECONNECT_SENTENCE\n return null\n}\n\nexport function createOutreachStepTestRoute(\n deps: OutreachRouteDeps,\n testDeps: OutreachStepTestDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n // The address the member typed, when they typed one — read before any\n // record, so a typo is answered before a Gmail call.\n const named = typeof body['to'] === 'string' ? body['to'].trim() : ''\n const typed = named ? normalizeContactEmail(named) : null\n if (named && !typed) {\n return outreachRefusal(400, 'invalid-request', `\"${named}\" isn't a valid email address.`)\n }\n const loaded = await readOutreachStepRender(deps, request, body)\n if (loaded instanceof Response) return loaded\n const { caller, sequence, stepIndex, mailbox } = loaded\n\n const refusedBy = mailboxRefusal(mailbox)\n if (refusedBy || !mailbox) return outreachRefusal(409, 'mailbox-unavailable', refusedBy ?? RECONNECT_SENTENCE)\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return outreachRefusal(\n 403,\n 'permission',\n \"Only the member who connected this sequence's mailbox, or an organization owner or admin, can send a test from it.\",\n )\n }\n const to = typed ?? caller.email\n if (!to) return outreachRefusal(400, 'invalid-request', 'Type an address to send the test to.')\n // The rules every sequence email keeps, asked of the stored steps: a\n // sequence saved under an older rule is refused rather than sent broken.\n const issues = validateOutreachSteps(sequence.steps)\n if (hasOutreachValidationErrors(issues)) {\n return outreachRefusal(400, 'invalid-sequence', \"Fix the sequence's steps before sending a test.\", { issues })\n }\n const limited = await testDeps.consumeRateLimit(`outreach-step-test:${mailbox.id}`, {\n limit: OUTREACH_STEP_TESTS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_STEP_TESTS_PER_HOUR} tests an hour. Try again later.`,\n )\n }\n\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const sender = { address: mailbox.sendAs || mailbox.email, name: mailbox.displayName || null }\n /*\n * The links, as a real send would rewrite them (AGL-3239, AGL-3297) and\n * marked as a test's: the member sees what a recipient sees, and their\n * own click counts for nothing.\n */\n let linkOrigin: string | null = null\n if ((sequence.settings.trackClicks || sequence.settings.countOpens) && testDeps.clickLinkOrigin) {\n linkOrigin = await testDeps\n .clickLinkOrigin({ orgId: caller.orgId, senderAddress: sender.address })\n .catch(() => null)\n }\n const shortLinks: Array<{ id: string; doc: OutreachStoredLink }> = []\n const rewriteLink = sequence.settings.trackClicks\n ? (link: { url: string; index: number }) => {\n const doc = outreachStoredLink(\n {\n orgId: caller.orgId,\n enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,\n stepIndex,\n linkIndex: link.index,\n url: link.url,\n test: true,\n },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (!doc || !url) return null\n shortLinks.push({ id, doc: { ...doc, sequenceId: sequence.id } })\n return url\n }\n : null\n /*\n * The tracking image (AGL-3395), as a real send would carry it and\n * marked as a test's: the member sees both parts a recipient gets, and\n * their own open is recorded nowhere.\n */\n let pixel: { id: string; doc: OutreachStoredOpen; url: string } | null = null\n if (sequence.settings.countOpens) {\n const doc = outreachStoredOpen(\n { orgId: caller.orgId, enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT, stepIndex, test: true },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (doc && url) pixel = { id, doc: { ...doc, sequenceId: sequence.id }, url }\n }\n let composed = previewOutreachStep({ ...loaded.input, rewriteLink, openPixelUrl: pixel?.url ?? null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n if (shortLinks.length || pixel) {\n try {\n const batch = firestore.batch()\n for (const link of shortLinks) {\n batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc)\n }\n if (pixel) batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(pixel.id), pixel.doc)\n await batch.commit()\n } catch (error) {\n // A link that resolves nowhere is worse than one shown as written:\n // the test goes out with the step's own links, as plain text.\n console.error('[outreach] the test links could not be stored; the test carries the links as written', error)\n composed = previewOutreachStep({ ...loaded.input, rewriteLink: null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n }\n }\n\n const opened = await testDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', \"Google mailboxes aren't set up on this deployment.\")\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', RECONNECT_SENTENCE)\n }\n /*\n * The preview's email, addressed to the member: `[Test]` in front, and a\n * `Re:` step sent as a new message — no thread id, no `In-Reply-To`, no\n * `References` — since the thread it answers exists only on screen. The\n * unsubscribe header is left off: it names an enrollment, and there is\n * none.\n */\n const email: ComposedOutreachEmail = {\n to,\n subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,\n text: composed.email.text,\n ...(composed.email.html ? { html: composed.email.html } : {}),\n trackedLinks: composed.email.trackedLinks,\n }\n let subject: string\n try {\n subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject\n } catch (error) {\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', 'Google refused this mailbox’s access. Connect it again.')\n }\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`)\n }\n if (!(error instanceof GmailTransportError)) throw error\n return outreachRefusal(\n 502,\n 'google-unavailable',\n error.retryable ? 'Google did not answer. Try again in a moment.' : `Google refused the request: ${error.message}`,\n )\n }\n\n const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs)\n return outreachOk({\n ok: true,\n stepIndex,\n sentTo: to,\n subject,\n sentAtMs: nowMs,\n testsToday,\n unresolvedFields: composed.unresolvedFields,\n } satisfies OutreachStepTestAnswer)\n }\n}\n\n/**\n * One more test on the mailbox's local day, in `health.daily.<day>.tests`\n * beside the day's sends and never in them. Bookkeeping beside the act: the\n * email has left, so a failure is logged and answered as no count.\n */\nasync function countTest(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<number> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return 0\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const tests = Math.max(0, Number(counts.tests) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, tests }, updatedAtMs: nowMs })\n return tests\n })\n } catch (error) {\n console.warn('[outreach] a test send could not be counted on its mailbox', error)\n return 0\n }\n}\n"],"names":["normalizeContactEmail","hasOutreachValidationErrors","validateOutreachSteps","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","OUTREACH_COLLECTIONS","previewOutreachStep","newOutreachLinkId","OUTREACH_TEST_LINK_ENROLLMENT","outreachStoredLink","outreachStoredOpen","readStoredOutreachMailbox","GmailTransportError","Rfc5322MessageError","sendComposedOutreachEmail","readOutreachStepRender","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachJsonBody","OUTREACH_STEP_TESTS_PER_HOUR","OUTREACH_TEST_SUBJECT_PREFIX","RECONNECT_SENTENCE","mailboxRefusal","mailbox","status","createOutreachStepTestRoute","deps","testDeps","request","method","body","named","trim","typed","loaded","Response","caller","sequence","stepIndex","refusedBy","connectedByUid","uid","isOrgAdmin","to","email","issues","steps","limited","consumeRateLimit","id","limit","windowMs","allowed","firestore","nowMs","now","sender","address","sendAs","name","displayName","linkOrigin","settings","trackClicks","countOpens","clickLinkOrigin","orgId","senderAddress","catch","shortLinks","rewriteLink","link","doc","enrollmentId","linkIndex","index","url","test","clickLinkUrl","push","sequenceId","pixel","composed","input","openPixelUrl","error","message","length","batch","create","collection","links","commit","console","opened","openMailbox","ok","reason","mailboxId","errorCode","subject","text","html","trackedLinks","client","reconnectRequired","code","retryable","testsToday","countTest","sentTo","sentAtMs","unresolvedFields","ref","runTransaction","transaction","stored","snapshot","get","exists","data","undefined","day","timezone","counts","health","daily","sent","bounces","replies","tests","Math","max","Number","update","updatedAtMs","warn"],"mappings":"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"}
@@ -51,6 +51,8 @@ export interface OutreachClickOutcome extends OutreachClickJudgement {
51
51
  /** The enrollment it was recorded against, or `null` when there is none. */
52
52
  enrollment: OutreachEnrollment | null;
53
53
  }
54
+ /** When the step that carried a link or an image was sent, or `null` when it cannot be found. */
55
+ export declare function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null;
54
56
  /**
55
57
  * Records one visit to a tracking link.
56
58
  *
@@ -18,11 +18,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { FieldValue } from "firebase-admin/firestore";
19
19
  import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
20
20
  import { judgeOutreachClick } from "../engine/click-tracking.js";
21
- import { readOutreachEngagement } from "../model/enrollment-engagement.js";
21
+ import { outreachLoggedEngagementRows, readOutreachEngagement } from "../model/enrollment-engagement.js";
22
22
  import { outreachClickHistoryRow, outreachHistoryEntryId } from "../model/enrollment-history.js";
23
23
  import { OUTREACH_ENGAGEMENT_LINKS_MAX, OUTREACH_ENROLLMENT_HISTORY, OUTREACH_ENROLLMENT_HISTORY_MAX, OUTREACH_LINK_ROLLUP_PATH } from "../model/outreach.types.js";
24
24
  import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
25
- /** When the step that carried the link was sent, or `null` when it cannot be found. */ function sentAtMs(enrollment, stepIndex) {
25
+ /** When the step that carried a link or an image was sent, or `null` when it cannot be found. */ export function outreachStepSentAtMs(enrollment, stepIndex) {
26
26
  var _enrollment_stepRecords;
27
27
  const records = (_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : [];
28
28
  for(let index = records.length - 1; index >= 0; index -= 1){
@@ -73,7 +73,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
73
73
  enrollment: null
74
74
  };
75
75
  }
76
- const sent = sentAtMs(enrollment, stepIndex);
76
+ const sent = outreachStepSentAtMs(enrollment, stepIndex);
77
77
  const judgement = judgeOutreachClick({
78
78
  method: input.method,
79
79
  userAgent: input.userAgent,
@@ -83,7 +83,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
83
83
  const first = judgement.human && held.firstClickAtMs === null;
84
84
  const key = campaignLinkKey(url);
85
85
  // A row for this visit, while the person's history has room for one.
86
- const logged = held.loggedClicks + held.loggedMachineClicks < OUTREACH_ENROLLMENT_HISTORY_MAX;
86
+ const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX;
87
87
  /*
88
88
  * The destinations known to have been followed. A click from before the
89
89
  * per-click history (AGL-3332) is known only by `lastClickUrl`, which the
@@ -94,7 +94,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
94
94
  ...held.links,
95
95
  held.lastClickUrl
96
96
  ] : held.links;
97
- const engagement = judgement.human ? {
97
+ const engagement = judgement.human ? _extends({}, held, {
98
98
  clicks: held.clicks + 1,
99
99
  firstClickAtMs: (_held_firstClickAtMs = held.firstClickAtMs) != null ? _held_firstClickAtMs : nowMs,
100
100
  lastClickAtMs: nowMs,
@@ -106,7 +106,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
106
106
  ] : known,
107
107
  loggedClicks: held.loggedClicks + (logged ? 1 : 0),
108
108
  loggedMachineClicks: held.loggedMachineClicks
109
- } : _extends({}, held, {
109
+ }) : _extends({}, held, {
110
110
  machineClicks: held.machineClicks + 1,
111
111
  loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0)
112
112
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried the link was sent, or `null` when it cannot be found. */\nfunction sentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = sentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = campaignLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = held.loggedClicks + held.loggedMachineClicks < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","sentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","loggedClicks","loggedMachineClicks","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","update","clicked","set","collection","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { outreachLoggedEngagementRows, readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried a link or an image was sent, or `null` when it cannot be found. */\nexport function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = outreachStepSentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = campaignLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n ...held,\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","outreachLoggedEngagementRows","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","outreachStepSentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","loggedClicks","loggedMachineClicks","update","clicked","set","collection","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
@@ -137,6 +137,31 @@ export declare function outreachStoredLink(target: OutreachClickTarget, nowMs: n
137
137
  * was written by us, and these say it is still one we will follow.
138
138
  */
139
139
  export declare function readOutreachStoredLink(data: unknown): OutreachClickTarget | null;
140
+ /** The `kind` an image's document carries; a link's carries none. */
141
+ export declare const OUTREACH_OPEN_KIND = "open";
142
+ /** What a tracking image names. */
143
+ export interface OutreachOpenTarget {
144
+ orgId: string;
145
+ enrollmentId: string;
146
+ /** The step whose email carried the image. */
147
+ stepIndex: number;
148
+ /** An image in a TEST of a step (AGL-3325): answered, and recorded nowhere. */
149
+ test?: boolean;
150
+ }
151
+ /** What an `outreachLinks/{id}` document holds for a tracking image. */
152
+ export interface OutreachStoredOpen extends OutreachOpenTarget {
153
+ v: 1;
154
+ kind: typeof OUTREACH_OPEN_KIND;
155
+ createdAtMs: number;
156
+ /** Which sequence a TEST image's step belongs to; a real send names its enrollment instead. */
157
+ sequenceId?: string;
158
+ }
159
+ /** The document for one email's image, or `null` when the target is not one we would record. */
160
+ export declare function outreachStoredOpen(target: OutreachOpenTarget, nowMs: number): OutreachStoredOpen | null;
161
+ /** Whether a stored document is a tracking image's, whatever else it holds. */
162
+ export declare function isOutreachStoredOpen(data: unknown): boolean;
163
+ /** What a stored image names, or `null` for a document that is not one we will record. */
164
+ export declare function readOutreachStoredOpen(data: unknown): OutreachOpenTarget | null;
140
165
  /**
141
166
  * The short link for an id, or `null` when there is no HTTPS origin to put it
142
167
  * on or the id is not one — no link rather than one that points at nothing. A