@aglyn/plugins-outreach 1.0.0-beta.196 → 1.0.0-beta.198

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 (105) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/compliance-section.d.ts +3 -1
  3. package/src/lib/components/compliance-section.js +110 -45
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.d.ts +7 -1
  6. package/src/lib/components/enroll-dialog.js +106 -3
  7. package/src/lib/components/enroll-dialog.js.map +1 -1
  8. package/src/lib/components/mailbox-card.js +11 -2
  9. package/src/lib/components/mailbox-card.js.map +1 -1
  10. package/src/lib/components/outreach-ui.js +1 -0
  11. package/src/lib/components/outreach-ui.js.map +1 -1
  12. package/src/lib/components/sequence-detail.js +1 -0
  13. package/src/lib/components/sequence-detail.js.map +1 -1
  14. package/src/lib/components/sequence-editor.d.ts +2 -0
  15. package/src/lib/components/sequence-editor.js +51 -26
  16. package/src/lib/components/sequence-editor.js.map +1 -1
  17. package/src/lib/components/sequence-step-card.d.ts +7 -0
  18. package/src/lib/components/sequence-step-card.js +37 -14
  19. package/src/lib/components/sequence-step-card.js.map +1 -1
  20. package/src/lib/components/sequences-section.js +12 -3
  21. package/src/lib/components/sequences-section.js.map +1 -1
  22. package/src/lib/components/step-test-dialog.d.ts +31 -0
  23. package/src/lib/components/step-test-dialog.js +247 -0
  24. package/src/lib/components/step-test-dialog.js.map +1 -0
  25. package/src/lib/components/use-outreach-api.d.ts +8 -1
  26. package/src/lib/components/use-outreach-api.js +4 -0
  27. package/src/lib/components/use-outreach-api.js.map +1 -1
  28. package/src/lib/components/use-outreach-data.d.ts +6 -1
  29. package/src/lib/components/use-outreach-data.js +6 -3
  30. package/src/lib/components/use-outreach-data.js.map +1 -1
  31. package/src/lib/constants/api-routes.d.ts +7 -0
  32. package/src/lib/constants/api-routes.js +6 -0
  33. package/src/lib/constants/api-routes.js.map +1 -1
  34. package/src/lib/engine/delivery-status.d.ts +8 -0
  35. package/src/lib/engine/delivery-status.js +10 -5
  36. package/src/lib/engine/delivery-status.js.map +1 -1
  37. package/src/lib/engine/enrollment-state.d.ts +2 -0
  38. package/src/lib/engine/enrollment-state.js +2 -2
  39. package/src/lib/engine/enrollment-state.js.map +1 -1
  40. package/src/lib/engine/gates.d.ts +28 -1
  41. package/src/lib/engine/gates.js +28 -1
  42. package/src/lib/engine/gates.js.map +1 -1
  43. package/src/lib/engine/mail-gateway.d.ts +97 -0
  44. package/src/lib/engine/mail-gateway.js +226 -0
  45. package/src/lib/engine/mail-gateway.js.map +1 -0
  46. package/src/lib/engine/mailbox-health.d.ts +3 -3
  47. package/src/lib/engine/mailbox-health.js +17 -11
  48. package/src/lib/engine/mailbox-health.js.map +1 -1
  49. package/src/lib/engine/thread-classification.d.ts +2 -0
  50. package/src/lib/engine/thread-classification.js +2 -1
  51. package/src/lib/engine/thread-classification.js.map +1 -1
  52. package/src/lib/enrollment/enroll-people.d.ts +6 -1
  53. package/src/lib/enrollment/enroll-people.js +18 -4
  54. package/src/lib/enrollment/enroll-people.js.map +1 -1
  55. package/src/lib/enrollment/gate-lookups.d.ts +7 -0
  56. package/src/lib/enrollment/gate-lookups.js +11 -2
  57. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  58. package/src/lib/model/outreach-api.d.ts +35 -1
  59. package/src/lib/model/outreach-api.js.map +1 -1
  60. package/src/lib/model/outreach.types.d.ts +85 -3
  61. package/src/lib/model/outreach.types.js +21 -2
  62. package/src/lib/model/outreach.types.js.map +1 -1
  63. package/src/lib/model/step-preview.d.ts +7 -1
  64. package/src/lib/model/step-preview.js +3 -2
  65. package/src/lib/model/step-preview.js.map +1 -1
  66. package/src/lib/routes/enroll-routes.js +23 -4
  67. package/src/lib/routes/enroll-routes.js.map +1 -1
  68. package/src/lib/routes/enrollment-routes.js +15 -1
  69. package/src/lib/routes/enrollment-routes.js.map +1 -1
  70. package/src/lib/routes/platform-deps.d.ts +2 -2
  71. package/src/lib/routes/platform-deps.js +2 -2
  72. package/src/lib/routes/platform-deps.js.map +1 -1
  73. package/src/lib/routes/preview-routes.d.ts +24 -0
  74. package/src/lib/routes/preview-routes.js +99 -95
  75. package/src/lib/routes/preview-routes.js.map +1 -1
  76. package/src/lib/routes/register-routes.d.ts +7 -0
  77. package/src/lib/routes/register-routes.js +27 -0
  78. package/src/lib/routes/register-routes.js.map +1 -1
  79. package/src/lib/routes/route-deps.d.ts +6 -0
  80. package/src/lib/routes/route-deps.js.map +1 -1
  81. package/src/lib/routes/step-test-routes.d.ts +88 -0
  82. package/src/lib/routes/step-test-routes.js +275 -0
  83. package/src/lib/routes/step-test-routes.js.map +1 -0
  84. package/src/lib/runtime/click-events.js +10 -0
  85. package/src/lib/runtime/click-events.js.map +1 -1
  86. package/src/lib/runtime/click-link.d.ts +11 -0
  87. package/src/lib/runtime/click-link.js +9 -4
  88. package/src/lib/runtime/click-link.js.map +1 -1
  89. package/src/lib/runtime/click-route.js +3 -0
  90. package/src/lib/runtime/click-route.js.map +1 -1
  91. package/src/lib/runtime/gateway-ledger.d.ts +46 -0
  92. package/src/lib/runtime/gateway-ledger.js +113 -0
  93. package/src/lib/runtime/gateway-ledger.js.map +1 -0
  94. package/src/lib/runtime/platform-runtime-deps.js +3 -0
  95. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  96. package/src/lib/runtime/runtime-deps.d.ts +6 -0
  97. package/src/lib/runtime/runtime-deps.js.map +1 -1
  98. package/src/lib/runtime/send-job.js +48 -0
  99. package/src/lib/runtime/send-job.js.map +1 -1
  100. package/src/lib/runtime/sync-job.d.ts +2 -0
  101. package/src/lib/runtime/sync-job.js +40 -3
  102. package/src/lib/runtime/sync-job.js.map +1 -1
  103. package/src/lib/storage/domain-intel-store.d.ts +107 -0
  104. package/src/lib/storage/domain-intel-store.js +320 -0
  105. package/src/lib/storage/domain-intel-store.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport { recordOutreachSequenceTouch } from './campaign-credit'\nimport {\n isOutreachLinkId,\n readOutreachClickToken,\n readOutreachStoredLink,\n type OutreachClickTarget,\n} from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239, AGL-3297): the short\n * `GET /api/outreach/l/<id>` every send carries now, and the signed\n * `GET /api/outreach/click?t=…` the emails sent before it carry.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The signed link carries its destination and needs no read; the short one\n * needs exactly one, of the document that names it. Then the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\ntype ClickRouteDeps = Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>\n\nconst methodNotAllowed = () =>\n new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n\nconst brokenLink = () =>\n outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n\n/**\n * The redirect, then the recording — the half both routes share once they\n * know where the link goes.\n */\nasync function followOutreachClick(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachClickTarget,\n): Promise<Response> {\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — both\n * readers return nothing else — so it is a header value that cannot carry\n * a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n try {\n const outcome = await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n /*\n * A person's click — never a scanner's — on a sequence in a campaign\n * is their last campaign touch on the site (AGL-3254), so the booking\n * or the form they go on to make is credited to the sequence's\n * campaign by the door that credits every other one.\n */\n if (outcome.human && outcome.enrollment) {\n await recordOutreachSequenceTouch(deps, { enrollment: outcome.enrollment, atMs: deps.now() })\n }\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n}\n\n/** The signed link, `?t=…` — every tracked email sent before AGL-3297. */\nexport function createOutreachClickRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n\n/**\n * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.\n *\n * One read of `outreachLinks/<id>` stands where the signature check stood,\n * and everything after it is the signed route's: the same redirect, the same\n * headers, the same recording, scanners redirected and counted apart.\n *\n * The destination comes only from that document. An id that is malformed or\n * names nothing is answered with the plain page, never followed; a read that\n * FAILS is answered with a page saying to try again, because there is\n * nowhere honest to send them either.\n */\nexport function createOutreachShortLinkRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n // The id is the last path segment: `/api/outreach/l/<id>`.\n const linkId = new URL(request.url).pathname.split('/').pop()\n if (!isOutreachLinkId(linkId)) return brokenLink()\n let target: OutreachClickTarget | null\n try {\n const snapshot = await deps.firestore().collection(OUTREACH_COLLECTIONS.links).doc(linkId).get()\n target = snapshot.exists ? readOutreachStoredLink(snapshot.data()) : null\n } catch (error) {\n console.error('[outreach] a short link could not be read', error)\n return outreachUnsubscribePage(\n {\n title: 'This link isn’t working right now',\n body: 'Something went wrong on our side. Try the link again in a minute.',\n },\n 503,\n )\n }\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n"],"names":["OUTREACH_COLLECTIONS","recordOutreachSequenceTouch","isOutreachLinkId","readOutreachClickToken","readOutreachStoredLink","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","methodNotAllowed","Response","status","headers","Allow","brokenLink","title","body","followOutreachClick","deps","request","target","redirect","Location","url","outcome","method","userAgent","get","human","enrollment","atMs","now","error","console","createOutreachClickRoute","URL","searchParams","createOutreachShortLinkRoute","linkId","pathname","split","pop","snapshot","firestore","collection","links","doc","exists","data"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,oBAAoB,QAAQ,6BAAyB;AAC9D,SAASC,2BAA2B,QAAQ,uBAAmB;AAC/D,SACEC,gBAAgB,EAChBC,sBAAsB,EACtBC,sBAAsB,QAEjB,kBAAc;AACrB,SAASC,mBAAmB,QAAQ,oBAAgB;AAEpD,wEAAwE;AACxE,yEAAyE;AACzE,sDAAsD;AACtD,SAASC,uBAAuB,QAAQ,yBAAqB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,MAAMC,mBAAmB;IACvB,iBAAiB;IACjB,mBAAmB;IACnB,gBAAgB;AAClB;AAIA,MAAMC,mBAAmB,IACvB,IAAIC,SAAS,sBAAsB;QAAEC,QAAQ;QAAKC,SAAS;YAAEC,OAAO;QAAY;IAAE;AAEpF,MAAMC,aAAa,IACjBP,wBACE;QACEQ,OAAO;QACPC,MAAM;IACR,GACA;AAGJ;;;CAGC,GACD,eAAeC,oBACbC,IAAoB,EACpBC,OAAgB,EAChBC,MAA2B;IAE3B;;;;;;GAMC,GACD,MAAMC,WAAW,IAAIX,SAAS,MAAM;QAClCC,QAAQ;QACRC,SAAS,aAAKJ;YAAkBc,UAAUF,OAAOG,GAAG;;IACtD;IACA,IAAI;QACF,MAAMC,UAAU,MAAMlB,oBAAoBY,MAAM;YAC9CE;YACAK,QAAQN,QAAQM,MAAM;YACtBC,WAAWP,QAAQP,OAAO,CAACe,GAAG,CAAC;QACjC;QACA;;;;;KAKC,GACD,IAAIH,QAAQI,KAAK,IAAIJ,QAAQK,UAAU,EAAE;YACvC,MAAM3B,4BAA4BgB,MAAM;gBAAEW,YAAYL,QAAQK,UAAU;gBAAEC,MAAMZ,KAAKa,GAAG;YAAG;QAC7F;IACF,EAAE,OAAOC,OAAO;QACd,oEAAoE;QACpE,kEAAkE;QAClEC,QAAQD,KAAK,CAAC,4CAA4CA;IAC5D;IACA,OAAOX;AACT;AAEA,wEAAwE,GACxE,OAAO,SAASa,yBAAyBhB,IAAoB;IAC3D,OAAO,OAAOC;QACZ,wEAAwE;QACxE,qEAAqE;QACrE,IAAIA,QAAQM,MAAM,KAAK,SAASN,QAAQM,MAAM,KAAK,QAAQ,OAAOhB;QAClE,MAAMW,SAAShB,uBAAuB,IAAI+B,IAAIhB,QAAQI,GAAG,EAAEa,YAAY,CAACT,GAAG,CAAC;QAC5E,IAAI,CAACP,QAAQ,OAAON;QACpB,OAAOG,oBAAoBC,MAAMC,SAASC;IAC5C;AACF;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASiB,6BAA6BnB,IAAoB;IAC/D,OAAO,OAAOC;QACZ,IAAIA,QAAQM,MAAM,KAAK,SAASN,QAAQM,MAAM,KAAK,QAAQ,OAAOhB;QAClE,2DAA2D;QAC3D,MAAM6B,SAAS,IAAIH,IAAIhB,QAAQI,GAAG,EAAEgB,QAAQ,CAACC,KAAK,CAAC,KAAKC,GAAG;QAC3D,IAAI,CAACtC,iBAAiBmC,SAAS,OAAOxB;QACtC,IAAIM;QACJ,IAAI;YACF,MAAMsB,WAAW,MAAMxB,KAAKyB,SAAS,GAAGC,UAAU,CAAC3C,qBAAqB4C,KAAK,EAAEC,GAAG,CAACR,QAAQX,GAAG;YAC9FP,SAASsB,SAASK,MAAM,GAAG1C,uBAAuBqC,SAASM,IAAI,MAAM;QACvE,EAAE,OAAOhB,OAAO;YACdC,QAAQD,KAAK,CAAC,6CAA6CA;YAC3D,OAAOzB,wBACL;gBACEQ,OAAO;gBACPC,MAAM;YACR,GACA;QAEJ;QACA,IAAI,CAACI,QAAQ,OAAON;QACpB,OAAOG,oBAAoBC,MAAMC,SAASC;IAC5C;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport { recordOutreachSequenceTouch } from './campaign-credit'\nimport {\n isOutreachLinkId,\n readOutreachClickToken,\n readOutreachStoredLink,\n type OutreachClickTarget,\n} from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239, AGL-3297): the short\n * `GET /api/outreach/l/<id>` every send carries now, and the signed\n * `GET /api/outreach/click?t=…` the emails sent before it carry.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The signed link carries its destination and needs no read; the short one\n * needs exactly one, of the document that names it. Then the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\ntype ClickRouteDeps = Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>\n\nconst methodNotAllowed = () =>\n new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n\nconst brokenLink = () =>\n outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n\n/**\n * The redirect, then the recording — the half both routes share once they\n * know where the link goes.\n */\nasync function followOutreachClick(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachClickTarget,\n): Promise<Response> {\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — both\n * readers return nothing else — so it is a header value that cannot carry\n * a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n // A link in a test of a step (AGL-3325) is followed and never counted:\n // the member clicking their own test is not a recipient acting.\n if (target.test) return redirect\n try {\n const outcome = await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n /*\n * A person's click — never a scanner's — on a sequence in a campaign\n * is their last campaign touch on the site (AGL-3254), so the booking\n * or the form they go on to make is credited to the sequence's\n * campaign by the door that credits every other one.\n */\n if (outcome.human && outcome.enrollment) {\n await recordOutreachSequenceTouch(deps, { enrollment: outcome.enrollment, atMs: deps.now() })\n }\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n}\n\n/** The signed link, `?t=…` — every tracked email sent before AGL-3297. */\nexport function createOutreachClickRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n\n/**\n * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.\n *\n * One read of `outreachLinks/<id>` stands where the signature check stood,\n * and everything after it is the signed route's: the same redirect, the same\n * headers, the same recording, scanners redirected and counted apart.\n *\n * The destination comes only from that document. An id that is malformed or\n * names nothing is answered with the plain page, never followed; a read that\n * FAILS is answered with a page saying to try again, because there is\n * nowhere honest to send them either.\n */\nexport function createOutreachShortLinkRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n // The id is the last path segment: `/api/outreach/l/<id>`.\n const linkId = new URL(request.url).pathname.split('/').pop()\n if (!isOutreachLinkId(linkId)) return brokenLink()\n let target: OutreachClickTarget | null\n try {\n const snapshot = await deps.firestore().collection(OUTREACH_COLLECTIONS.links).doc(linkId).get()\n target = snapshot.exists ? readOutreachStoredLink(snapshot.data()) : null\n } catch (error) {\n console.error('[outreach] a short link could not be read', error)\n return outreachUnsubscribePage(\n {\n title: 'This link isn’t working right now',\n body: 'Something went wrong on our side. Try the link again in a minute.',\n },\n 503,\n )\n }\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n"],"names":["OUTREACH_COLLECTIONS","recordOutreachSequenceTouch","isOutreachLinkId","readOutreachClickToken","readOutreachStoredLink","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","methodNotAllowed","Response","status","headers","Allow","brokenLink","title","body","followOutreachClick","deps","request","target","redirect","Location","url","test","outcome","method","userAgent","get","human","enrollment","atMs","now","error","console","createOutreachClickRoute","URL","searchParams","createOutreachShortLinkRoute","linkId","pathname","split","pop","snapshot","firestore","collection","links","doc","exists","data"],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { OutreachEnrollment } from '../model/outreach.types';
18
+ import { type OutreachResolveMx } from '../storage/domain-intel-store';
19
+ type Firestore = FirebaseFirestore.Firestore;
20
+ /** A gateway block, counted against the gateway the bounce names — see the module note. */
21
+ export declare function recordOutreachGatewayBlock(firestore: Firestore, input: {
22
+ orgId: string;
23
+ email: string;
24
+ remoteMta: string | null;
25
+ resolveMx: OutreachResolveMx;
26
+ nowMs: number;
27
+ }): Promise<void>;
28
+ /**
29
+ * The email steps of an enrollment that count as delivered now: those a
30
+ * day old, less the last one sent when the enrollment bounced — a bounce
31
+ * answers the last send — and less the ones already credited.
32
+ */
33
+ export declare function outreachDeliveredStepCount(enrollment: Pick<OutreachEnrollment, 'status' | 'stepRecords'>, nowMs: number): number;
34
+ /**
35
+ * Credits every watched enrollment's day-old, unbounced email steps to the
36
+ * ledger as delivered — see the module note — and marks each enrollment
37
+ * with how many it has credited, so the next run credits only what is new.
38
+ * Answers how many deliveries it counted.
39
+ */
40
+ export declare function creditOutreachGatewayDeliveries(firestore: Firestore, input: {
41
+ orgId: string;
42
+ enrollments: readonly OutreachEnrollment[];
43
+ resolveMx: OutreachResolveMx;
44
+ nowMs: number;
45
+ }): Promise<number>;
46
+ export {};
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * WHAT THE RUNTIME TEACHES THE GATEWAY LEDGER (AGL-3326).
18
+ *
19
+ * The ledger learns from outcomes, and the runtime is where outcomes
20
+ * happen: the send job counts a SEND the moment an email leaves, the sync
21
+ * counts a BLOCK when a hard bounce reads as the gateway refusing the
22
+ * sender, and the sync counts a DELIVERY for every email step that is a
23
+ * day old with no bounce against it — the one verdict a small sender never
24
+ * hears in words, so it is inferred from silence, and only after
25
+ * {@link OUTREACH_GATEWAY_DELIVERED_AFTER_MS} of it.
26
+ *
27
+ * Which gateway a block is filed under is read from the bounce itself
28
+ * where it can be: a DSN's `Remote-MTA` names the server that refused,
29
+ * and `*.ess.barracudanetworks.com` is Barracuda whatever the MX says
30
+ * today. A bounce that names no known gateway is filed under the domain's
31
+ * own intel, looked up if it never was.
32
+ */ import { outreachEmailDomain } from "../engine/do-not-contact-domain.js";
33
+ import { OUTREACH_GATEWAY_DELIVERED_AFTER_MS, outreachMailGatewayOfHost } from "../engine/mail-gateway.js";
34
+ import { readOutreachDomainIntel, recordOutreachGatewayOutcome } from "../storage/domain-intel-store.js";
35
+ import { outreachOrgCollection } from "../storage/outreach-records.js";
36
+ /** A gateway block, counted against the gateway the bounce names — see the module note. */ export async function recordOutreachGatewayBlock(firestore, input) {
37
+ const domain = outreachEmailDomain(input.email);
38
+ if (!domain) return;
39
+ let gateway = outreachMailGatewayOfHost(input.remoteMta);
40
+ if (!gateway) {
41
+ var _ref;
42
+ var _intel_get;
43
+ const intel = await readOutreachDomainIntel(firestore, input.orgId, [
44
+ input.email
45
+ ], {
46
+ resolveMx: input.resolveMx,
47
+ nowMs: input.nowMs
48
+ });
49
+ gateway = (_ref = (_intel_get = intel.get(input.email)) == null ? void 0 : _intel_get.gateway) != null ? _ref : null;
50
+ }
51
+ if (!gateway || gateway === 'none') return;
52
+ await recordOutreachGatewayOutcome(firestore, input.orgId, {
53
+ domain,
54
+ gateway,
55
+ outcome: 'blocked',
56
+ atMs: input.nowMs
57
+ });
58
+ }
59
+ /**
60
+ * The email steps of an enrollment that count as delivered now: those a
61
+ * day old, less the last one sent when the enrollment bounced — a bounce
62
+ * answers the last send — and less the ones already credited.
63
+ */ export function outreachDeliveredStepCount(enrollment, nowMs) {
64
+ var _enrollment_stepRecords;
65
+ const emails = ((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).filter((record)=>(record == null ? void 0 : record.kind) === 'email');
66
+ const bounced = enrollment.status === 'bounced' ? emails.slice(0, -1) : emails;
67
+ return bounced.filter((record)=>Number(record.atMs) <= nowMs - OUTREACH_GATEWAY_DELIVERED_AFTER_MS).length;
68
+ }
69
+ /**
70
+ * Credits every watched enrollment's day-old, unbounced email steps to the
71
+ * ledger as delivered — see the module note — and marks each enrollment
72
+ * with how many it has credited, so the next run credits only what is new.
73
+ * Answers how many deliveries it counted.
74
+ */ export async function creditOutreachGatewayDeliveries(firestore, input) {
75
+ const due = input.enrollments.map((enrollment)=>({
76
+ enrollment,
77
+ delivered: outreachDeliveredStepCount(enrollment, input.nowMs)
78
+ })).filter(({ enrollment, delivered })=>delivered > Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0));
79
+ if (!due.length) return 0;
80
+ const intel = await readOutreachDomainIntel(firestore, input.orgId, due.map(({ enrollment })=>enrollment.email), {
81
+ resolveMx: input.resolveMx,
82
+ nowMs: input.nowMs
83
+ });
84
+ const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments');
85
+ let counted = 0;
86
+ for (const { enrollment, delivered } of due){
87
+ var _ref;
88
+ var _intel_get;
89
+ const domain = outreachEmailDomain(enrollment.email);
90
+ const gateway = (_ref = (_intel_get = intel.get(enrollment.email)) == null ? void 0 : _intel_get.gateway) != null ? _ref : null;
91
+ // A domain that could not be looked up is credited on a later run, when it can be.
92
+ if (!domain || !gateway || gateway === 'none') continue;
93
+ const fresh = delivered - Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0);
94
+ await recordOutreachGatewayOutcome(firestore, input.orgId, {
95
+ domain,
96
+ gateway,
97
+ outcome: 'delivered',
98
+ count: fresh,
99
+ atMs: input.nowMs
100
+ });
101
+ try {
102
+ await enrollments.doc(enrollment.id).update({
103
+ gatewayDeliveredSteps: delivered
104
+ });
105
+ counted += fresh;
106
+ } catch (error) {
107
+ console.error('[outreach] the delivery credit could not be marked on the enrollment', error);
108
+ }
109
+ }
110
+ return counted;
111
+ }
112
+
113
+ //# sourceMappingURL=gateway-ledger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/gateway-ledger.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 * WHAT THE RUNTIME TEACHES THE GATEWAY LEDGER (AGL-3326).\n *\n * The ledger learns from outcomes, and the runtime is where outcomes\n * happen: the send job counts a SEND the moment an email leaves, the sync\n * counts a BLOCK when a hard bounce reads as the gateway refusing the\n * sender, and the sync counts a DELIVERY for every email step that is a\n * day old with no bounce against it — the one verdict a small sender never\n * hears in words, so it is inferred from silence, and only after\n * {@link OUTREACH_GATEWAY_DELIVERED_AFTER_MS} of it.\n *\n * Which gateway a block is filed under is read from the bounce itself\n * where it can be: a DSN's `Remote-MTA` names the server that refused,\n * and `*.ess.barracudanetworks.com` is Barracuda whatever the MX says\n * today. A bounce that names no known gateway is filed under the domain's\n * own intel, looked up if it never was.\n */\n\nimport { outreachEmailDomain } from '../engine/do-not-contact-domain'\nimport { OUTREACH_GATEWAY_DELIVERED_AFTER_MS, outreachMailGatewayOfHost } from '../engine/mail-gateway'\nimport type { OutreachEnrollment } from '../model/outreach.types'\nimport {\n type OutreachResolveMx,\n readOutreachDomainIntel,\n recordOutreachGatewayOutcome,\n} from '../storage/domain-intel-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** A gateway block, counted against the gateway the bounce names — see the module note. */\nexport async function recordOutreachGatewayBlock(\n firestore: Firestore,\n input: { orgId: string; email: string; remoteMta: string | null; resolveMx: OutreachResolveMx; nowMs: number },\n): Promise<void> {\n const domain = outreachEmailDomain(input.email)\n if (!domain) return\n let gateway = outreachMailGatewayOfHost(input.remoteMta)\n if (!gateway) {\n const intel = await readOutreachDomainIntel(firestore, input.orgId, [input.email], {\n resolveMx: input.resolveMx,\n nowMs: input.nowMs,\n })\n gateway = intel.get(input.email)?.gateway ?? null\n }\n if (!gateway || gateway === 'none') return\n await recordOutreachGatewayOutcome(firestore, input.orgId, {\n domain,\n gateway,\n outcome: 'blocked',\n atMs: input.nowMs,\n })\n}\n\n/**\n * The email steps of an enrollment that count as delivered now: those a\n * day old, less the last one sent when the enrollment bounced — a bounce\n * answers the last send — and less the ones already credited.\n */\nexport function outreachDeliveredStepCount(\n enrollment: Pick<OutreachEnrollment, 'status' | 'stepRecords'>,\n nowMs: number,\n): number {\n const emails = (enrollment.stepRecords ?? []).filter((record) => record?.kind === 'email')\n const bounced = enrollment.status === 'bounced' ? emails.slice(0, -1) : emails\n return bounced.filter((record) => Number(record.atMs) <= nowMs - OUTREACH_GATEWAY_DELIVERED_AFTER_MS).length\n}\n\n/**\n * Credits every watched enrollment's day-old, unbounced email steps to the\n * ledger as delivered — see the module note — and marks each enrollment\n * with how many it has credited, so the next run credits only what is new.\n * Answers how many deliveries it counted.\n */\nexport async function creditOutreachGatewayDeliveries(\n firestore: Firestore,\n input: { orgId: string; enrollments: readonly OutreachEnrollment[]; resolveMx: OutreachResolveMx; nowMs: number },\n): Promise<number> {\n const due = input.enrollments\n .map((enrollment) => ({\n enrollment,\n delivered: outreachDeliveredStepCount(enrollment, input.nowMs),\n }))\n .filter(({ enrollment, delivered }) => delivered > Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0))\n if (!due.length) return 0\n const intel = await readOutreachDomainIntel(\n firestore,\n input.orgId,\n due.map(({ enrollment }) => enrollment.email),\n { resolveMx: input.resolveMx, nowMs: input.nowMs },\n )\n const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments')\n let counted = 0\n for (const { enrollment, delivered } of due) {\n const domain = outreachEmailDomain(enrollment.email)\n const gateway = intel.get(enrollment.email)?.gateway ?? null\n // A domain that could not be looked up is credited on a later run, when it can be.\n if (!domain || !gateway || gateway === 'none') continue\n const fresh = delivered - Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0)\n await recordOutreachGatewayOutcome(firestore, input.orgId, {\n domain,\n gateway,\n outcome: 'delivered',\n count: fresh,\n atMs: input.nowMs,\n })\n try {\n await enrollments.doc(enrollment.id).update({ gatewayDeliveredSteps: delivered })\n counted += fresh\n } catch (error) {\n console.error('[outreach] the delivery credit could not be marked on the enrollment', error)\n }\n }\n return counted\n}\n"],"names":["outreachEmailDomain","OUTREACH_GATEWAY_DELIVERED_AFTER_MS","outreachMailGatewayOfHost","readOutreachDomainIntel","recordOutreachGatewayOutcome","outreachOrgCollection","recordOutreachGatewayBlock","firestore","input","domain","email","gateway","remoteMta","intel","orgId","resolveMx","nowMs","get","outcome","atMs","outreachDeliveredStepCount","enrollment","emails","stepRecords","filter","record","kind","bounced","status","slice","Number","length","creditOutreachGatewayDeliveries","due","enrollments","map","delivered","Math","max","gatewayDeliveredSteps","counted","fresh","count","doc","id","update","error","console"],"mappings":"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"}
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { EMAIL_TOPIC_SALES } from "@aglyn/aglyn/app-utils/email-topics";
17
+ import { resolveMx } from "node:dns/promises";
17
18
  import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
19
  import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
19
20
  import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
@@ -94,6 +95,8 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
94
95
  openMailbox: (mailboxId)=>openOutreachMailboxClient(firestore(), {
95
96
  mailboxId
96
97
  }),
98
+ // The domain's MX (AGL-3326), from the console's own resolver.
99
+ resolveMx: (domain)=>resolveMx(domain),
97
100
  async orgRefusal (orgId, org) {
98
101
  if (!isPluginEnabled(org, OUTREACH_PLUGIN_ID)) return 'plugin-disabled';
99
102
  if (!checkEntitlement(org, 'outreach')) return 'not-entitled';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { resolveTrackingLinkOrigin } from '@aglyn/tenant-data-admin/server/tracking-hosts'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachShortLinkUrl } from './click-link'\nimport { createOutreachClickRoute, createOutreachShortLinkRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, orgId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, orgId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n // The sending domain's own `links.` host once the organization has\n // verified it (AGL-3306). A static import like every other\n // `tenant-data-admin` module here: a dynamic one marks the whole library\n // lazy-loaded, and `@nx/enforce-module-boundaries` then refuses every\n // static import of it across the plugin and the console.\n clickLinkOrigin({ orgId, senderAddress }) {\n return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress)\n },\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The short tracking link's redirect on the platform's own reach (AGL-3297). */\nexport function platformOutreachShortLinkRoute(): PluginWebApiHandler {\n return createOutreachShortLinkRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","resolveTrackingLinkOrigin","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachShortLinkUrl","createOutreachClickRoute","createOutreachShortLinkRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","orgId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","orgRefusal","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickLinkUrl","linkId","trackingOrigin","clickLinkOrigin","senderAddress","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { resolveMx } from 'node:dns/promises'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { resolveTrackingLinkOrigin } from '@aglyn/tenant-data-admin/server/tracking-hosts'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachShortLinkUrl } from './click-link'\nimport { createOutreachClickRoute, createOutreachShortLinkRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, orgId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, orgId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n // The domain's MX (AGL-3326), from the console's own resolver.\n resolveMx: (domain) => resolveMx(domain),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n // The sending domain's own `links.` host once the organization has\n // verified it (AGL-3306). A static import like every other\n // `tenant-data-admin` module here: a dynamic one marks the whole library\n // lazy-loaded, and `@nx/enforce-module-boundaries` then refuses every\n // static import of it across the plugin and the console.\n clickLinkOrigin({ orgId, senderAddress }) {\n return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress)\n },\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The short tracking link's redirect on the platform's own reach (AGL-3297). */\nexport function platformOutreachShortLinkRoute(): PluginWebApiHandler {\n return createOutreachShortLinkRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","resolveMx","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","resolveTrackingLinkOrigin","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachShortLinkUrl","createOutreachClickRoute","createOutreachShortLinkRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","orgId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","domain","orgRefusal","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickLinkUrl","linkId","trackingOrigin","clickLinkOrigin","senderAddress","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}
@@ -18,6 +18,7 @@ import type { EmailState } from '@aglyn/aglyn/app-utils/email-state';
18
18
  import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
19
19
  import type { OutreachMailboxNotice } from '../engine/mailbox-notice';
20
20
  import type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport';
21
+ import type { OutreachResolveMx } from '../storage/domain-intel-store';
21
22
  import type { OutreachUnsubscribeTarget } from './unsubscribe-link';
22
23
  /**
23
24
  * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).
@@ -125,6 +126,11 @@ export interface OutreachRuntimeDeps {
125
126
  random(): number;
126
127
  /** Opens a mailbox's Gmail client from its sealed grant. */
127
128
  openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>;
129
+ /**
130
+ * The MX resolver a domain's mail gateway is classified with (AGL-3326)
131
+ * — Node's `dns.promises.resolveMx` on the platform, a table in a spec.
132
+ */
133
+ resolveMx: OutreachResolveMx;
128
134
  /**
129
135
  * Why Outreach may not act for this workspace now, or `null` when it may:
130
136
  * the plugin switched on and entitled, and its release flag on for the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OutreachMailboxNotice } from '../engine/mailbox-notice'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: string\n}\n\n/**\n * The verdict on an address, for the record the person is (AGL-3245): what\n * the runtime wrote on a list, said on the lead and the contact that carry\n * the address. The platform finds the records; the runtime names none.\n */\nexport interface OutreachRecordEmailStamp {\n orgId: string\n email: string\n state: EmailState\n}\n\n/**\n * What a sequence produced, credited to the campaigns it is in (AGL-3254).\n *\n * Three doors into the platform's campaign attribution, all on the\n * platform's own join and none on the marketing plugin, which Outreach may\n * not import. Each is called once per enrollment per outcome, from the\n * state change that happens once; none throws, because the send, the reply\n * and the click have already happened and a lost credit understates a\n * campaign while a thrown one loses a step.\n */\nexport interface OutreachCampaignCredit {\n /**\n * Adds one to an outcome under every campaign named, in the campaign's\n * sequences report. `enrolled`, `sent`, `replied` and `converted` are the\n * runtime's to credit; `meetings` is credited by the booking door from\n * the touch {@link OutreachCampaignCredit.recordTouch} stamps.\n */\n credit(input: {\n hostId: string\n /**\n * The org whose campaigns these are, when the caller holds it — an\n * enrollment is an org record, so its routes do. The platform resolves\n * it from the site otherwise.\n */\n orgId?: string | null\n campaignIds: readonly string[]\n outcome: 'enrolled' | 'sent' | 'replied' | 'converted'\n atMs: number\n }): Promise<void>\n /**\n * Writes the conversion record the person's own page reads as \"where\n * this came from\": the sequence's campaign as the touch, on the lead or\n * the contact the enrollment names. Once per record, and never over a\n * record that already carries a web or email touch — the join's own\n * `create()` decides that.\n */\n attributeRecord(input: {\n hostId: string\n kind: 'lead' | 'contact'\n /** The lead's person key, or the contact's id. */\n refId: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n /**\n * Stamps a click on a sequence email as the person's last campaign touch\n * on the site, so a booking or a form they go on to submit is credited to\n * the sequence's campaign by the door that credits every other one.\n */\n recordTouch(input: {\n hostId: string\n email: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n /** The platform's campaign attribution, for what a sequence produced (AGL-3254). */\n campaignCredit: OutreachCampaignCredit\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /**\n * Stamps the verdict on the lead and the contact that carry the address\n * (AGL-3245), beside every write the runtime makes to a list. Never\n * throws: the list is the control, the stamp is what a person reads.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The short tracking link for a stored link's id (AGL-3239, AGL-3297) —\n * on `trackingOrigin` when one is given (AGL-3306) — or `null` when none\n * can be made: no HTTPS origin. The sending\n * runtime writes the `outreachLinks` document the id names before the\n * email leaves.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /**\n * The click-tracking origin for mail sent as `senderAddress` —\n * `https://links.<its domain>` once the organization has verified that\n * host (AGL-3306) — or `null` for the console's own address. Asked once\n * per mailbox per run; a failure reads as `null`, never as a held send.\n * Absent means no deployment-level hosts at all.\n */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAgHD,WAoEC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OutreachMailboxNotice } from '../engine/mailbox-notice'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachResolveMx } from '../storage/domain-intel-store'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: string\n}\n\n/**\n * The verdict on an address, for the record the person is (AGL-3245): what\n * the runtime wrote on a list, said on the lead and the contact that carry\n * the address. The platform finds the records; the runtime names none.\n */\nexport interface OutreachRecordEmailStamp {\n orgId: string\n email: string\n state: EmailState\n}\n\n/**\n * What a sequence produced, credited to the campaigns it is in (AGL-3254).\n *\n * Three doors into the platform's campaign attribution, all on the\n * platform's own join and none on the marketing plugin, which Outreach may\n * not import. Each is called once per enrollment per outcome, from the\n * state change that happens once; none throws, because the send, the reply\n * and the click have already happened and a lost credit understates a\n * campaign while a thrown one loses a step.\n */\nexport interface OutreachCampaignCredit {\n /**\n * Adds one to an outcome under every campaign named, in the campaign's\n * sequences report. `enrolled`, `sent`, `replied` and `converted` are the\n * runtime's to credit; `meetings` is credited by the booking door from\n * the touch {@link OutreachCampaignCredit.recordTouch} stamps.\n */\n credit(input: {\n hostId: string\n /**\n * The org whose campaigns these are, when the caller holds it — an\n * enrollment is an org record, so its routes do. The platform resolves\n * it from the site otherwise.\n */\n orgId?: string | null\n campaignIds: readonly string[]\n outcome: 'enrolled' | 'sent' | 'replied' | 'converted'\n atMs: number\n }): Promise<void>\n /**\n * Writes the conversion record the person's own page reads as \"where\n * this came from\": the sequence's campaign as the touch, on the lead or\n * the contact the enrollment names. Once per record, and never over a\n * record that already carries a web or email touch — the join's own\n * `create()` decides that.\n */\n attributeRecord(input: {\n hostId: string\n kind: 'lead' | 'contact'\n /** The lead's person key, or the contact's id. */\n refId: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n /**\n * Stamps a click on a sequence email as the person's last campaign touch\n * on the site, so a booking or a form they go on to submit is credited to\n * the sequence's campaign by the door that credits every other one.\n */\n recordTouch(input: {\n hostId: string\n email: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * The MX resolver a domain's mail gateway is classified with (AGL-3326)\n * — Node's `dns.promises.resolveMx` on the platform, a table in a spec.\n */\n resolveMx: OutreachResolveMx\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n /** The platform's campaign attribution, for what a sequence produced (AGL-3254). */\n campaignCredit: OutreachCampaignCredit\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /**\n * Stamps the verdict on the lead and the contact that carry the address\n * (AGL-3245), beside every write the runtime makes to a list. Never\n * throws: the list is the control, the stamp is what a person reads.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The short tracking link for a stored link's id (AGL-3239, AGL-3297) —\n * on `trackingOrigin` when one is given (AGL-3306) — or `null` when none\n * can be made: no HTTPS origin. The sending\n * runtime writes the `outreachLinks` document the id names before the\n * email leaves.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /**\n * The click-tracking origin for mail sent as `senderAddress` —\n * `https://links.<its domain>` once the organization has verified that\n * host (AGL-3306) — or `null` for the console's own address. Asked once\n * per mailbox per run; a failure reads as `null`, never as a held send.\n * Absent means no deployment-level hosts at all.\n */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAiHD,WAyEC"}
@@ -20,6 +20,7 @@ import { normalizeCrmEmailTemplate } from "@aglyn/aglyn/app-utils/crm-email-temp
20
20
  import { FieldValue } from "firebase-admin/firestore";
21
21
  import { randomUUID } from "node:crypto";
22
22
  import { composeOutreachEmail } from "../engine/compose.js";
23
+ import { outreachEmailDomain } from "../engine/do-not-contact-domain.js";
23
24
  import { planOutreachStepCompletion } from "../engine/enrollment-state.js";
24
25
  import { evaluateOutreachGates } from "../engine/gates.js";
25
26
  import { outreachSentToday, outreachTickAllowance, selectDueOutreachEnrollments } from "../engine/sending-capacity.js";
@@ -30,6 +31,7 @@ import { outreachEffectiveDailyCap, outreachLocalDay } from "../mailboxes/mailbo
30
31
  import { effectiveOutreachAllowedCountries } from "../model/compliance-settings.js";
31
32
  import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
32
33
  import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
34
+ import { recordOutreachGatewayOutcome } from "../storage/domain-intel-store.js";
33
35
  import { outreachEnrollmentLink, outreachEnrollmentPerson, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
34
36
  import { GmailTransportError, isReconnectRequired } from "../transport/gmail-errors.js";
35
37
  import { Rfc5322MessageError } from "../transport/rfc5322.js";
@@ -264,6 +266,10 @@ async function runMailbox(deps, firestore, input) {
264
266
  hostId,
265
267
  consentHostIds: consentGroup.hostIds,
266
268
  consentAwaitsConfirmation: consentGroup.awaitsConfirmation,
269
+ gateway: {
270
+ resolveMx: deps.resolveMx,
271
+ nowMs
272
+ },
267
273
  people: enrollments.map((enrollment)=>{
268
274
  const person = outreachEnrollmentPerson(enrollment);
269
275
  return {
@@ -585,6 +591,7 @@ async function openClient(deps, run) {
585
591
  }
586
592
  async function runEmailStep(deps, firestore, run, input) {
587
593
  var _ref, _composed_email_inReplyTo;
594
+ var _enrollment_gatewayHold;
588
595
  const { enrollment, sequence, person } = input;
589
596
  const { report, mailbox } = run;
590
597
  const nowMs = deps.now();
@@ -638,9 +645,38 @@ async function runEmailStep(deps, firestore, run, input) {
638
645
  personalLine: enrollment.personalLine,
639
646
  attestations: enrollment.attestations,
640
647
  enrollmentId: enrollment.id,
648
+ gatewayHoldReleased: typeof ((_enrollment_gatewayHold = enrollment.gatewayHold) == null ? void 0 : _enrollment_gatewayHold.releasedAtMs) === 'number',
641
649
  lookups: input.lookups
642
650
  });
643
651
  if (!gate.allowed) return stop(gateEvent(gate.blocks, nowMs), 'stopped');
652
+ if (gate.hold) {
653
+ // The gateway refused this organization twice lately and delivered
654
+ // nothing (AGL-3326): the send is held, as a pause a member resumes
655
+ // from the enrollment row, and nothing goes into it until they do.
656
+ const hold = gate.hold;
657
+ const outcome = await applyOutreachEvent(firestore, {
658
+ orgId: run.orgId,
659
+ enrollmentId: enrollment.id,
660
+ event: {
661
+ type: 'pause',
662
+ atMs: nowMs,
663
+ byUid: null,
664
+ reason: 'gateway_blocked_here',
665
+ detail: hold.reason
666
+ },
667
+ extra: ()=>({
668
+ gatewayHold: {
669
+ gateway: hold.gateway,
670
+ heldAtMs: nowMs,
671
+ releasedByUid: null,
672
+ releasedAtMs: null
673
+ }
674
+ }),
675
+ nowMs
676
+ });
677
+ if (outcome.changed) report.held += 1;
678
+ return;
679
+ }
644
680
  /*
645
681
  * The email, with its way out. The footer's "reply 'no'" line and the
646
682
  * postal address are always there (compose refuses without them). The
@@ -846,6 +882,18 @@ async function runEmailStep(deps, firestore, run, input) {
846
882
  claimMessageId: messageId
847
883
  });
848
884
  report.sent += 1;
885
+ // The gateway ledger (AGL-3326): one more send into the domain's gateway,
886
+ // which the sync credits as delivered a day from now if nothing bounces.
887
+ const standing = input.lookups.gateway;
888
+ const recipientDomain = outreachEmailDomain(enrollment.email);
889
+ if (standing && recipientDomain) {
890
+ await recordOutreachGatewayOutcome(firestore, run.orgId, {
891
+ domain: recipientDomain,
892
+ gateway: standing.gateway,
893
+ outcome: 'sent',
894
+ atMs: record.atMs
895
+ });
896
+ }
849
897
  await fileOutreachEmail(deps, {
850
898
  orgId: run.orgId,
851
899
  hostId: enrollment.hostId,