@aglyn/plugins-outreach 1.0.0-beta.200 → 1.0.0-beta.202

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 (90) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/enrollment-actions.d.ts +33 -0
  3. package/src/lib/components/enrollment-actions.js +174 -0
  4. package/src/lib/components/enrollment-actions.js.map +1 -0
  5. package/src/lib/components/enrollment-detail.d.ts +21 -0
  6. package/src/lib/components/enrollment-detail.js +870 -0
  7. package/src/lib/components/enrollment-detail.js.map +1 -0
  8. package/src/lib/components/enrollment-filter-params.d.ts +58 -0
  9. package/src/lib/components/enrollment-filter-params.js +98 -0
  10. package/src/lib/components/enrollment-filter-params.js.map +1 -0
  11. package/src/lib/components/enrollments-table.d.ts +21 -0
  12. package/src/lib/components/enrollments-table.js +249 -210
  13. package/src/lib/components/enrollments-table.js.map +1 -1
  14. package/src/lib/components/outreach-console-page.js +10 -0
  15. package/src/lib/components/outreach-console-page.js.map +1 -1
  16. package/src/lib/components/sequence-detail.d.ts +2 -0
  17. package/src/lib/components/sequence-detail.js +70 -8
  18. package/src/lib/components/sequence-detail.js.map +1 -1
  19. package/src/lib/components/sequence-report-card.d.ts +17 -0
  20. package/src/lib/components/sequence-report-card.js +63 -15
  21. package/src/lib/components/sequence-report-card.js.map +1 -1
  22. package/src/lib/components/sequences-section.d.ts +4 -1
  23. package/src/lib/components/sequences-section.js +12 -1
  24. package/src/lib/components/sequences-section.js.map +1 -1
  25. package/src/lib/components/use-outreach-api.d.ts +3 -1
  26. package/src/lib/components/use-outreach-api.js +6 -0
  27. package/src/lib/components/use-outreach-api.js.map +1 -1
  28. package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
  29. package/src/lib/components/use-outreach-enrollment.js +133 -0
  30. package/src/lib/components/use-outreach-enrollment.js.map +1 -0
  31. package/src/lib/constants/api-routes.d.ts +2 -0
  32. package/src/lib/constants/api-routes.js +1 -0
  33. package/src/lib/constants/api-routes.js.map +1 -1
  34. package/src/lib/engine/gates.d.ts +4 -3
  35. package/src/lib/engine/gates.js.map +1 -1
  36. package/src/lib/engine/gateway-block.d.ts +4 -8
  37. package/src/lib/engine/gateway-block.js +6 -26
  38. package/src/lib/engine/gateway-block.js.map +1 -1
  39. package/src/lib/engine/mail-gateway.d.ts +18 -73
  40. package/src/lib/engine/mail-gateway.js +32 -206
  41. package/src/lib/engine/mail-gateway.js.map +1 -1
  42. package/src/lib/model/enrollment-engagement.d.ts +26 -2
  43. package/src/lib/model/enrollment-engagement.js +40 -3
  44. package/src/lib/model/enrollment-engagement.js.map +1 -1
  45. package/src/lib/model/enrollment-history.d.ts +40 -0
  46. package/src/lib/model/enrollment-history.js +95 -0
  47. package/src/lib/model/enrollment-history.js.map +1 -0
  48. package/src/lib/model/enrollment-list-query.d.ts +6 -0
  49. package/src/lib/model/enrollment-list-query.js +35 -1
  50. package/src/lib/model/enrollment-list-query.js.map +1 -1
  51. package/src/lib/model/enrollment-timeline.d.ts +78 -0
  52. package/src/lib/model/enrollment-timeline.js +430 -0
  53. package/src/lib/model/enrollment-timeline.js.map +1 -0
  54. package/src/lib/model/outreach-api.d.ts +24 -0
  55. package/src/lib/model/outreach-api.js.map +1 -1
  56. package/src/lib/model/outreach.types.d.ts +78 -29
  57. package/src/lib/model/outreach.types.js +33 -8
  58. package/src/lib/model/outreach.types.js.map +1 -1
  59. package/src/lib/routes/enroll-routes.js +26 -3
  60. package/src/lib/routes/enroll-routes.js.map +1 -1
  61. package/src/lib/routes/enrollment-routes.d.ts +10 -0
  62. package/src/lib/routes/enrollment-routes.js +82 -1
  63. package/src/lib/routes/enrollment-routes.js.map +1 -1
  64. package/src/lib/routes/register-routes.js +14 -4
  65. package/src/lib/routes/register-routes.js.map +1 -1
  66. package/src/lib/routes/route-deps.d.ts +3 -1
  67. package/src/lib/routes/route-deps.js.map +1 -1
  68. package/src/lib/runtime/click-events.d.ts +10 -5
  69. package/src/lib/runtime/click-events.js +45 -6
  70. package/src/lib/runtime/click-events.js.map +1 -1
  71. package/src/lib/runtime/gateway-ledger.d.ts +6 -0
  72. package/src/lib/runtime/gateway-ledger.js +9 -3
  73. package/src/lib/runtime/gateway-ledger.js.map +1 -1
  74. package/src/lib/runtime/host-suspension.d.ts +30 -0
  75. package/src/lib/runtime/host-suspension.js +35 -0
  76. package/src/lib/runtime/host-suspension.js.map +1 -0
  77. package/src/lib/runtime/person-erasure.d.ts +4 -1
  78. package/src/lib/runtime/person-erasure.js +14 -3
  79. package/src/lib/runtime/person-erasure.js.map +1 -1
  80. package/src/lib/runtime/platform-runtime-deps.js +34 -3
  81. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  82. package/src/lib/runtime/runtime-deps.d.ts +27 -1
  83. package/src/lib/runtime/runtime-deps.js.map +1 -1
  84. package/src/lib/runtime/send-job.js +46 -6
  85. package/src/lib/runtime/send-job.js.map +1 -1
  86. package/src/lib/runtime/sync-job.js +4 -0
  87. package/src/lib/runtime/sync-job.js.map +1 -1
  88. package/src/lib/storage/domain-intel-store.d.ts +61 -43
  89. package/src/lib/storage/domain-intel-store.js +138 -226
  90. package/src/lib/storage/domain-intel-store.js.map +1 -1
@@ -24,6 +24,10 @@
24
24
  * hears in words, so it is inferred from silence, and only after
25
25
  * {@link OUTREACH_GATEWAY_DELIVERED_AFTER_MS} of it.
26
26
  *
27
+ * Every count is filed against the MAILBOX's sending domain (AGL-3328):
28
+ * a gateway refuses a sender, so the ledger that holds the next send is the
29
+ * one keyed `sendingDomain × gateway`.
30
+ *
27
31
  * Which gateway a block is filed under is read from the bounce itself
28
32
  * where it can be: a DSN's `Remote-MTA` names the server that refused,
29
33
  * and `*.ess.barracudanetworks.com` is Barracuda whatever the MX says
@@ -34,6 +38,7 @@ import { OUTREACH_GATEWAY_DELIVERED_AFTER_MS, outreachMailGatewayOfHost } from "
34
38
  import { readOutreachDomainIntel, recordOutreachGatewayOutcome } from "../storage/domain-intel-store.js";
35
39
  import { outreachOrgCollection } from "../storage/outreach-records.js";
36
40
  /** A gateway block, counted against the gateway the bounce names — see the module note. */ export async function recordOutreachGatewayBlock(firestore, input) {
41
+ var _input_diagnostic;
37
42
  const domain = outreachEmailDomain(input.email);
38
43
  if (!domain) return;
39
44
  let gateway = outreachMailGatewayOfHost(input.remoteMta);
@@ -50,10 +55,11 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
50
55
  }
51
56
  if (!gateway || gateway === 'none') return;
52
57
  await recordOutreachGatewayOutcome(firestore, input.orgId, {
53
- domain,
58
+ sendingDomain: input.sendingDomain,
54
59
  gateway,
55
60
  outcome: 'blocked',
56
- atMs: input.nowMs
61
+ atMs: input.nowMs,
62
+ detail: (_input_diagnostic = input.diagnostic) != null ? _input_diagnostic : null
57
63
  });
58
64
  }
59
65
  /**
@@ -92,7 +98,7 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
92
98
  if (!domain || !gateway || gateway === 'none') continue;
93
99
  const fresh = delivered - Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0);
94
100
  await recordOutreachGatewayOutcome(firestore, input.orgId, {
95
- domain,
101
+ sendingDomain: input.sendingDomain,
96
102
  gateway,
97
103
  outcome: 'delivered',
98
104
  count: fresh,
@@ -1 +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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAED,SAASA,mBAAmB,QAAQ,qCAAiC;AACrE,SAASC,mCAAmC,EAAEC,yBAAyB,QAAQ,4BAAwB;AAEvG,SAEEC,uBAAuB,EACvBC,4BAA4B,QACvB,mCAA+B;AACtC,SAASC,qBAAqB,QAAQ,iCAA6B;AAInE,yFAAyF,GACzF,OAAO,eAAeC,2BACpBC,SAAoB,EACpBC,KAA8G;IAE9G,MAAMC,SAAST,oBAAoBQ,MAAME,KAAK;IAC9C,IAAI,CAACD,QAAQ;IACb,IAAIE,UAAUT,0BAA0BM,MAAMI,SAAS;IACvD,IAAI,CAACD,SAAS;;YAKFE;QAJV,MAAMA,QAAQ,MAAMV,wBAAwBI,WAAWC,MAAMM,KAAK,EAAE;YAACN,MAAME,KAAK;SAAC,EAAE;YACjFK,WAAWP,MAAMO,SAAS;YAC1BC,OAAOR,MAAMQ,KAAK;QACpB;QACAL,mBAAUE,aAAAA,MAAMI,GAAG,CAACT,MAAME,KAAK,sBAArBG,WAAwBF,OAAO,mBAAI;IAC/C;IACA,IAAI,CAACA,WAAWA,YAAY,QAAQ;IACpC,MAAMP,6BAA6BG,WAAWC,MAAMM,KAAK,EAAE;QACzDL;QACAE;QACAO,SAAS;QACTC,MAAMX,MAAMQ,KAAK;IACnB;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASI,2BACdC,UAA8D,EAC9DL,KAAa;QAEGK;IAAhB,MAAMC,SAAS,EAACD,0BAAAA,WAAWE,WAAW,YAAtBF,0BAA0B,EAAE,EAAEG,MAAM,CAAC,CAACC,SAAWA,CAAAA,0BAAAA,OAAQC,IAAI,MAAK;IAClF,MAAMC,UAAUN,WAAWO,MAAM,KAAK,YAAYN,OAAOO,KAAK,CAAC,GAAG,CAAC,KAAKP;IACxE,OAAOK,QAAQH,MAAM,CAAC,CAACC,SAAWK,OAAOL,OAAON,IAAI,KAAKH,QAAQf,qCAAqC8B,MAAM;AAC9G;AAEA;;;;;CAKC,GACD,OAAO,eAAeC,gCACpBzB,SAAoB,EACpBC,KAAiH;IAEjH,MAAMyB,MAAMzB,MAAM0B,WAAW,CAC1BC,GAAG,CAAC,CAACd,aAAgB,CAAA;YACpBA;YACAe,WAAWhB,2BAA2BC,YAAYb,MAAMQ,KAAK;QAC/D,CAAA,GACCQ,MAAM,CAAC,CAAC,EAAEH,UAAU,EAAEe,SAAS,EAAE,GAAKA,YAAYC,KAAKC,GAAG,CAAC,GAAGR,OAAOT,WAAWkB,qBAAqB,KAAK;IAC7G,IAAI,CAACN,IAAIF,MAAM,EAAE,OAAO;IACxB,MAAMlB,QAAQ,MAAMV,wBAClBI,WACAC,MAAMM,KAAK,EACXmB,IAAIE,GAAG,CAAC,CAAC,EAAEd,UAAU,EAAE,GAAKA,WAAWX,KAAK,GAC5C;QAAEK,WAAWP,MAAMO,SAAS;QAAEC,OAAOR,MAAMQ,KAAK;IAAC;IAEnD,MAAMkB,cAAc7B,sBAAsBE,WAAWC,MAAMM,KAAK,EAAE;IAClE,IAAI0B,UAAU;IACd,KAAK,MAAM,EAAEnB,UAAU,EAAEe,SAAS,EAAE,IAAIH,IAAK;;YAE3BpB;QADhB,MAAMJ,SAAST,oBAAoBqB,WAAWX,KAAK;QACnD,MAAMC,mBAAUE,aAAAA,MAAMI,GAAG,CAACI,WAAWX,KAAK,sBAA1BG,WAA6BF,OAAO,mBAAI;QACxD,mFAAmF;QACnF,IAAI,CAACF,UAAU,CAACE,WAAWA,YAAY,QAAQ;QAC/C,MAAM8B,QAAQL,YAAYC,KAAKC,GAAG,CAAC,GAAGR,OAAOT,WAAWkB,qBAAqB,KAAK;QAClF,MAAMnC,6BAA6BG,WAAWC,MAAMM,KAAK,EAAE;YACzDL;YACAE;YACAO,SAAS;YACTwB,OAAOD;YACPtB,MAAMX,MAAMQ,KAAK;QACnB;QACA,IAAI;YACF,MAAMkB,YAAYS,GAAG,CAACtB,WAAWuB,EAAE,EAAEC,MAAM,CAAC;gBAAEN,uBAAuBH;YAAU;YAC/EI,WAAWC;QACb,EAAE,OAAOK,OAAO;YACdC,QAAQD,KAAK,CAAC,wEAAwEA;QACxF;IACF;IACA,OAAON;AACT"}
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 * Every count is filed against the MAILBOX's sending domain (AGL-3328):\n * a gateway refuses a sender, so the ledger that holds the next send is the\n * one keyed `sendingDomain × gateway`.\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: {\n orgId: string\n email: string\n remoteMta: string | null\n /** The domain the mailbox sends from, whose ledger the block is counted on (AGL-3328). */\n sendingDomain: string | null\n /** The bounce's diagnostic, kept scrubbed of addresses for the ledger. */\n diagnostic?: string | null\n resolveMx: OutreachResolveMx\n nowMs: number\n },\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 sendingDomain: input.sendingDomain,\n gateway,\n outcome: 'blocked',\n atMs: input.nowMs,\n detail: input.diagnostic ?? null,\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: {\n orgId: string\n enrollments: readonly OutreachEnrollment[]\n /** The domain the mailbox sends from, whose ledger the deliveries are credited to (AGL-3328). */\n sendingDomain: string | null\n resolveMx: OutreachResolveMx\n nowMs: number\n },\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 sendingDomain: input.sendingDomain,\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","sendingDomain","outcome","atMs","detail","diagnostic","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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"}
@@ -0,0 +1,30 @@
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
+ /**
18
+ * Is this site suspended, so its sequences hold (AGL-3356)?
19
+ *
20
+ * The send job refuses a locked WORKSPACE through `sendRefusal`, which asks
21
+ * the org and the member, but a sequence mails for a SITE of that workspace
22
+ * — its consent group, its name on the message — and a staff lock on that
23
+ * one site was never asked. The job already reads each site's document to
24
+ * name it, so the answer costs nothing.
25
+ *
26
+ * Any active lock holds, a read-only window included: a sequence step is a
27
+ * resumable sweep, so holding it is lossless and the enrollment sends on the
28
+ * first run after the lift.
29
+ */
30
+ export declare function outreachSiteHeld(host: Record<string, unknown> | null | undefined, nowMs: number): boolean;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { siteLockdownFromDocs } from "@aglyn/aglyn/app-utils/lockdown";
17
+ /**
18
+ * Is this site suspended, so its sequences hold (AGL-3356)?
19
+ *
20
+ * The send job refuses a locked WORKSPACE through `sendRefusal`, which asks
21
+ * the org and the member, but a sequence mails for a SITE of that workspace
22
+ * — its consent group, its name on the message — and a staff lock on that
23
+ * one site was never asked. The job already reads each site's document to
24
+ * name it, so the answer costs nothing.
25
+ *
26
+ * Any active lock holds, a read-only window included: a sequence step is a
27
+ * resumable sweep, so holding it is lossless and the enrollment sends on the
28
+ * first run after the lift.
29
+ */ export function outreachSiteHeld(host, nowMs) {
30
+ return siteLockdownFromDocs({
31
+ host: host != null ? host : null
32
+ }, nowMs) !== null;
33
+ }
34
+
35
+ //# sourceMappingURL=host-suspension.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/host-suspension.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 { siteLockdownFromDocs } from '@aglyn/aglyn/app-utils/lockdown'\n\n/**\n * Is this site suspended, so its sequences hold (AGL-3356)?\n *\n * The send job refuses a locked WORKSPACE through `sendRefusal`, which asks\n * the org and the member, but a sequence mails for a SITE of that workspace\n * — its consent group, its name on the message — and a staff lock on that\n * one site was never asked. The job already reads each site's document to\n * name it, so the answer costs nothing.\n *\n * Any active lock holds, a read-only window included: a sequence step is a\n * resumable sweep, so holding it is lossless and the enrollment sends on the\n * first run after the lift.\n */\nexport function outreachSiteHeld(\n host: Record<string, unknown> | null | undefined,\n nowMs: number,\n): boolean {\n return siteLockdownFromDocs({ host: (host ?? null) as never }, nowMs) !== null\n}\n"],"names":["siteLockdownFromDocs","outreachSiteHeld","host","nowMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,kCAAiC;AAEtE;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,iBACdC,IAAgD,EAChDC,KAAa;IAEb,OAAOH,qBAAqB;QAAEE,IAAI,EAAGA,eAAAA,OAAQ;IAAe,GAAGC,WAAW;AAC5E"}
@@ -21,7 +21,10 @@ import type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-pers
21
21
  * The person's ENROLLMENTS go: each names them — their address, their
22
22
  * contact, the personal line a rep wrote about them, the attestations — and
23
23
  * is about them. They are found by the address and by the contacts the
24
- * erasure is deleting, and deleted whatever their status.
24
+ * erasure is deleting, and deleted whatever their status. Each one's
25
+ * HISTORY (AGL-3332) — every link they followed, and when — goes first: a
26
+ * subcollection outlives the document above it, and would otherwise be a
27
+ * record of an erased person's clicks that nothing lists.
25
28
  *
26
29
  * Their DO-NOT-CONTACT entry STAYS, on the rule every suppression list
27
30
  * follows through an erasure: it is keyed by `personKey`, carries no
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { outreachDoNotContactCollection } from "../storage/do-not-contact-store.js";
16
+ */ import { OUTREACH_ENROLLMENT_HISTORY } from "../model/outreach.types.js";
17
+ import { outreachDoNotContactCollection } from "../storage/do-not-contact-store.js";
17
18
  import { outreachOrgCollection } from "../storage/outreach-records.js";
18
19
  /** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
19
20
  /** `in` takes this many values at most. */ const IN_LIMIT = 30;
@@ -34,9 +35,19 @@ export function createOutreachPersonEraser(deps) {
34
35
  doNotContactKept: listed,
35
36
  doNotContactScrubbed: null
36
37
  };
37
- const refs = [
38
+ const enrollmentRefs = [
38
39
  ...found.values()
39
40
  ];
41
+ const historyRefs = [];
42
+ for (const ref of enrollmentRefs){
43
+ for (const row of (await ref.collection(OUTREACH_ENROLLMENT_HISTORY).get()).docs)historyRefs.push(row.ref);
44
+ }
45
+ // The rows before the enrollments, so a run cut short leaves a history
46
+ // under a live enrollment rather than one under nothing.
47
+ const refs = [
48
+ ...historyRefs,
49
+ ...enrollmentRefs
50
+ ];
40
51
  for(let start = 0; start < refs.length; start += BATCH_LIMIT){
41
52
  const batch = firestore.batch();
42
53
  for (const ref of refs.slice(start, start + BATCH_LIMIT))batch.delete(ref);
@@ -47,7 +58,7 @@ export function createOutreachPersonEraser(deps) {
47
58
  enrollmentId: null
48
59
  });
49
60
  return {
50
- enrollments: refs.length,
61
+ enrollments: enrollmentRefs.length,
51
62
  doNotContactKept: listed,
52
63
  doNotContactScrubbed: listed
53
64
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/person-erasure.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 {\n PluginPersonEraser,\n PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachDoNotContactCollection } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A PERSON ERASURE (AGL-2981).\n *\n * The person's ENROLLMENTS go: each names them — their address, their\n * contact, the personal line a rep wrote about them, the attestations — and\n * is about them. They are found by the address and by the contacts the\n * erasure is deleting, and deleted whatever their status.\n *\n * Their DO-NOT-CONTACT entry STAYS, on the rule every suppression list\n * follows through an erasure: it is keyed by `personKey`, carries no\n * address, and is the one promise the workspace made the person that has to\n * outlive their data. What in it could point back at them is cleared: the\n * detail — a member's note, a bounce's diagnostic — and the enrollment it\n * came from, which no longer exists.\n *\n * The DOMAIN list (`outreachDoNotContactDomains`, AGL-3244) is not touched:\n * a domain names a company, not a person, and an entry there carries the\n * bounce's diagnostic already scrubbed of the address it named.\n *\n * A dry run counts both and writes nothing.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** `in` takes this many values at most. */\nconst IN_LIMIT = 30\n\nexport function createOutreachPersonEraser(deps: { firestore(): Firestore }): PluginPersonEraser {\n return async ({ orgId, email, key, contactIds, dryRun }): Promise<PluginPersonErasureReport> => {\n const firestore = deps.firestore()\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const found = new Map<string, FirebaseFirestore.DocumentReference>()\n for (const doc of (await enrollments.where('email', '==', email).get()).docs) found.set(doc.id, doc.ref)\n for (let start = 0; start < contactIds.length; start += IN_LIMIT) {\n const chunk = contactIds.slice(start, start + IN_LIMIT)\n for (const doc of (await enrollments.where('contactId', 'in', chunk).get()).docs) found.set(doc.id, doc.ref)\n }\n const entry = outreachDoNotContactCollection(firestore, orgId).doc(key)\n const listed = (await entry.get()).exists\n if (dryRun) return { enrollments: found.size, doNotContactKept: listed, doNotContactScrubbed: null }\n\n const refs = [...found.values()]\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) batch.delete(ref)\n await batch.commit()\n }\n if (listed) await entry.update({ detail: null, enrollmentId: null })\n return { enrollments: refs.length, doNotContactKept: listed, doNotContactScrubbed: listed }\n }\n}\n"],"names":["outreachDoNotContactCollection","outreachOrgCollection","BATCH_LIMIT","IN_LIMIT","createOutreachPersonEraser","deps","orgId","email","key","contactIds","dryRun","firestore","enrollments","found","Map","doc","where","get","docs","set","id","ref","start","length","chunk","slice","entry","listed","exists","size","doNotContactKept","doNotContactScrubbed","refs","values","batch","delete","commit","update","detail","enrollmentId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD,SAASA,8BAA8B,QAAQ,qCAAiC;AAChF,SAASC,qBAAqB,QAAQ,iCAA6B;AA0BnE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,yCAAyC,GACzC,MAAMC,WAAW;AAEjB,OAAO,SAASC,2BAA2BC,IAAgC;IACzE,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,GAAG,EAAEC,UAAU,EAAEC,MAAM,EAAE;QACrD,MAAMC,YAAYN,KAAKM,SAAS;QAChC,MAAMC,cAAcX,sBAAsBU,WAAWL,OAAO;QAC5D,MAAMO,QAAQ,IAAIC;QAClB,KAAK,MAAMC,OAAO,AAAC,CAAA,MAAMH,YAAYI,KAAK,CAAC,SAAS,MAAMT,OAAOU,GAAG,EAAC,EAAGC,IAAI,CAAEL,MAAMM,GAAG,CAACJ,IAAIK,EAAE,EAAEL,IAAIM,GAAG;QACvG,IAAK,IAAIC,QAAQ,GAAGA,QAAQb,WAAWc,MAAM,EAAED,SAASnB,SAAU;YAChE,MAAMqB,QAAQf,WAAWgB,KAAK,CAACH,OAAOA,QAAQnB;YAC9C,KAAK,MAAMY,OAAO,AAAC,CAAA,MAAMH,YAAYI,KAAK,CAAC,aAAa,MAAMQ,OAAOP,GAAG,EAAC,EAAGC,IAAI,CAAEL,MAAMM,GAAG,CAACJ,IAAIK,EAAE,EAAEL,IAAIM,GAAG;QAC7G;QACA,MAAMK,QAAQ1B,+BAA+BW,WAAWL,OAAOS,GAAG,CAACP;QACnE,MAAMmB,SAAS,AAAC,CAAA,MAAMD,MAAMT,GAAG,EAAC,EAAGW,MAAM;QACzC,IAAIlB,QAAQ,OAAO;YAAEE,aAAaC,MAAMgB,IAAI;YAAEC,kBAAkBH;YAAQI,sBAAsB;QAAK;QAEnG,MAAMC,OAAO;eAAInB,MAAMoB,MAAM;SAAG;QAChC,IAAK,IAAIX,QAAQ,GAAGA,QAAQU,KAAKT,MAAM,EAAED,SAASpB,YAAa;YAC7D,MAAMgC,QAAQvB,UAAUuB,KAAK;YAC7B,KAAK,MAAMb,OAAOW,KAAKP,KAAK,CAACH,OAAOA,QAAQpB,aAAcgC,MAAMC,MAAM,CAACd;YACvE,MAAMa,MAAME,MAAM;QACpB;QACA,IAAIT,QAAQ,MAAMD,MAAMW,MAAM,CAAC;YAAEC,QAAQ;YAAMC,cAAc;QAAK;QAClE,OAAO;YAAE3B,aAAaoB,KAAKT,MAAM;YAAEO,kBAAkBH;YAAQI,sBAAsBJ;QAAO;IAC5F;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/person-erasure.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 {\n PluginPersonEraser,\n PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { OUTREACH_ENROLLMENT_HISTORY } from '../model/outreach.types'\nimport { outreachDoNotContactCollection } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A PERSON ERASURE (AGL-2981).\n *\n * The person's ENROLLMENTS go: each names them — their address, their\n * contact, the personal line a rep wrote about them, the attestations — and\n * is about them. They are found by the address and by the contacts the\n * erasure is deleting, and deleted whatever their status. Each one's\n * HISTORY (AGL-3332) — every link they followed, and when — goes first: a\n * subcollection outlives the document above it, and would otherwise be a\n * record of an erased person's clicks that nothing lists.\n *\n * Their DO-NOT-CONTACT entry STAYS, on the rule every suppression list\n * follows through an erasure: it is keyed by `personKey`, carries no\n * address, and is the one promise the workspace made the person that has to\n * outlive their data. What in it could point back at them is cleared: the\n * detail — a member's note, a bounce's diagnostic — and the enrollment it\n * came from, which no longer exists.\n *\n * The DOMAIN list (`outreachDoNotContactDomains`, AGL-3244) is not touched:\n * a domain names a company, not a person, and an entry there carries the\n * bounce's diagnostic already scrubbed of the address it named.\n *\n * A dry run counts both and writes nothing.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** `in` takes this many values at most. */\nconst IN_LIMIT = 30\n\nexport function createOutreachPersonEraser(deps: { firestore(): Firestore }): PluginPersonEraser {\n return async ({ orgId, email, key, contactIds, dryRun }): Promise<PluginPersonErasureReport> => {\n const firestore = deps.firestore()\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const found = new Map<string, FirebaseFirestore.DocumentReference>()\n for (const doc of (await enrollments.where('email', '==', email).get()).docs) found.set(doc.id, doc.ref)\n for (let start = 0; start < contactIds.length; start += IN_LIMIT) {\n const chunk = contactIds.slice(start, start + IN_LIMIT)\n for (const doc of (await enrollments.where('contactId', 'in', chunk).get()).docs) found.set(doc.id, doc.ref)\n }\n const entry = outreachDoNotContactCollection(firestore, orgId).doc(key)\n const listed = (await entry.get()).exists\n if (dryRun) return { enrollments: found.size, doNotContactKept: listed, doNotContactScrubbed: null }\n\n const enrollmentRefs = [...found.values()]\n const historyRefs: FirebaseFirestore.DocumentReference[] = []\n for (const ref of enrollmentRefs) {\n for (const row of (await ref.collection(OUTREACH_ENROLLMENT_HISTORY).get()).docs) historyRefs.push(row.ref)\n }\n // The rows before the enrollments, so a run cut short leaves a history\n // under a live enrollment rather than one under nothing.\n const refs = [...historyRefs, ...enrollmentRefs]\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) batch.delete(ref)\n await batch.commit()\n }\n if (listed) await entry.update({ detail: null, enrollmentId: null })\n return { enrollments: enrollmentRefs.length, doNotContactKept: listed, doNotContactScrubbed: listed }\n }\n}\n"],"names":["OUTREACH_ENROLLMENT_HISTORY","outreachDoNotContactCollection","outreachOrgCollection","BATCH_LIMIT","IN_LIMIT","createOutreachPersonEraser","deps","orgId","email","key","contactIds","dryRun","firestore","enrollments","found","Map","doc","where","get","docs","set","id","ref","start","length","chunk","slice","entry","listed","exists","size","doNotContactKept","doNotContactScrubbed","enrollmentRefs","values","historyRefs","row","collection","push","refs","batch","delete","commit","update","detail","enrollmentId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD,SAASA,2BAA2B,QAAQ,6BAAyB;AACrE,SAASC,8BAA8B,QAAQ,qCAAiC;AAChF,SAASC,qBAAqB,QAAQ,iCAA6B;AA6BnE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,yCAAyC,GACzC,MAAMC,WAAW;AAEjB,OAAO,SAASC,2BAA2BC,IAAgC;IACzE,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,GAAG,EAAEC,UAAU,EAAEC,MAAM,EAAE;QACrD,MAAMC,YAAYN,KAAKM,SAAS;QAChC,MAAMC,cAAcX,sBAAsBU,WAAWL,OAAO;QAC5D,MAAMO,QAAQ,IAAIC;QAClB,KAAK,MAAMC,OAAO,AAAC,CAAA,MAAMH,YAAYI,KAAK,CAAC,SAAS,MAAMT,OAAOU,GAAG,EAAC,EAAGC,IAAI,CAAEL,MAAMM,GAAG,CAACJ,IAAIK,EAAE,EAAEL,IAAIM,GAAG;QACvG,IAAK,IAAIC,QAAQ,GAAGA,QAAQb,WAAWc,MAAM,EAAED,SAASnB,SAAU;YAChE,MAAMqB,QAAQf,WAAWgB,KAAK,CAACH,OAAOA,QAAQnB;YAC9C,KAAK,MAAMY,OAAO,AAAC,CAAA,MAAMH,YAAYI,KAAK,CAAC,aAAa,MAAMQ,OAAOP,GAAG,EAAC,EAAGC,IAAI,CAAEL,MAAMM,GAAG,CAACJ,IAAIK,EAAE,EAAEL,IAAIM,GAAG;QAC7G;QACA,MAAMK,QAAQ1B,+BAA+BW,WAAWL,OAAOS,GAAG,CAACP;QACnE,MAAMmB,SAAS,AAAC,CAAA,MAAMD,MAAMT,GAAG,EAAC,EAAGW,MAAM;QACzC,IAAIlB,QAAQ,OAAO;YAAEE,aAAaC,MAAMgB,IAAI;YAAEC,kBAAkBH;YAAQI,sBAAsB;QAAK;QAEnG,MAAMC,iBAAiB;eAAInB,MAAMoB,MAAM;SAAG;QAC1C,MAAMC,cAAqD,EAAE;QAC7D,KAAK,MAAMb,OAAOW,eAAgB;YAChC,KAAK,MAAMG,OAAO,AAAC,CAAA,MAAMd,IAAIe,UAAU,CAACrC,6BAA6BkB,GAAG,EAAC,EAAGC,IAAI,CAAEgB,YAAYG,IAAI,CAACF,IAAId,GAAG;QAC5G;QACA,uEAAuE;QACvE,yDAAyD;QACzD,MAAMiB,OAAO;eAAIJ;eAAgBF;SAAe;QAChD,IAAK,IAAIV,QAAQ,GAAGA,QAAQgB,KAAKf,MAAM,EAAED,SAASpB,YAAa;YAC7D,MAAMqC,QAAQ5B,UAAU4B,KAAK;YAC7B,KAAK,MAAMlB,OAAOiB,KAAKb,KAAK,CAACH,OAAOA,QAAQpB,aAAcqC,MAAMC,MAAM,CAACnB;YACvE,MAAMkB,MAAME,MAAM;QACpB;QACA,IAAId,QAAQ,MAAMD,MAAMgB,MAAM,CAAC;YAAEC,QAAQ;YAAMC,cAAc;QAAK;QAClE,OAAO;YAAEhC,aAAaoB,eAAeT,MAAM;YAAEO,kBAAkBH;YAAQI,sBAAsBJ;QAAO;IACtG;AACF"}
@@ -14,7 +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
+ import { platformMailDnsResolver } from "@aglyn/tenant-data-admin/server/email-deliverability";
18
18
  import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
19
19
  import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
20
20
  import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
@@ -28,6 +28,8 @@ import { sendOrgMemberNotice } from "@aglyn/tenant-data-admin/server/org-member-
28
28
  import { logOrgActivity } from "@aglyn/tenant-data-admin/server/organizations";
29
29
  import { filterEnabledPluginsByReleaseFlags } from "@aglyn/tenant-data-admin/server/release-flags";
30
30
  import { resolveTrackingLinkOrigin } from "@aglyn/tenant-data-admin/server/tracking-hosts";
31
+ import { sendingWorkspaceFor } from "@aglyn/tenant-data-admin/server/outbound-send-review";
32
+ import { screenTenantMessage } from "@aglyn/shared-util-email/outbound-screen-gate";
31
33
  import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
32
34
  import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
33
35
  import { composeOutreachMailboxNotice } from "../engine/mailbox-notice.js";
@@ -95,8 +97,15 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
95
97
  openMailbox: (mailboxId)=>openOutreachMailboxClient(firestore(), {
96
98
  mailboxId
97
99
  }),
98
- // The domain's MX (AGL-3326), from the console's own resolver.
99
- resolveMx: (domain)=>resolveMx(domain),
100
+ // The domain's MX (AGL-3326) and, for a domain with none, its address
101
+ // record — through the platform's pinned resolver (AGL-3328), which
102
+ // asks twice before it says a domain takes no mail.
103
+ resolveMx: (domain)=>platformMailDnsResolver().resolveMx(domain),
104
+ resolveAddress: (domain)=>{
105
+ var _ref;
106
+ var _platformMailDnsResolver_resolveAddress, _platformMailDnsResolver;
107
+ return (_ref = (_platformMailDnsResolver_resolveAddress = (_platformMailDnsResolver = platformMailDnsResolver()).resolveAddress) == null ? void 0 : _platformMailDnsResolver_resolveAddress.call(_platformMailDnsResolver, domain)) != null ? _ref : Promise.resolve(false);
108
+ },
100
109
  async orgRefusal (orgId, org) {
101
110
  if (!isPluginEnabled(org, OUTREACH_PLUGIN_ID)) return 'plugin-disabled';
102
111
  if (!checkEntitlement(org, 'outreach')) return 'not-entitled';
@@ -180,6 +189,28 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
180
189
  // static import of it across the plugin and the console.
181
190
  clickLinkOrigin ({ orgId, senderAddress }) {
182
191
  return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress);
192
+ },
193
+ // The one phishing screen every tenant message passes (AGL-3356). The
194
+ // review module's import installs its store, so a hold files the same
195
+ // abuse-queue row a held newsletter or campaign does.
196
+ async screenMessage ({ orgId, org, hostId, host, subject, text, fromName, fromAddress }) {
197
+ var _ref;
198
+ const refusal = await screenTenantMessage({
199
+ workspace: sendingWorkspaceFor({
200
+ hostId,
201
+ orgId,
202
+ org,
203
+ host
204
+ }),
205
+ subject,
206
+ fromName,
207
+ fromAddress: fromAddress != null ? fromAddress : null,
208
+ bodies: [
209
+ text
210
+ ],
211
+ context: 'outreach sequence'
212
+ }, 'outreach email');
213
+ return (_ref = refusal == null ? void 0 : refusal.detail) != null ? _ref : null;
183
214
  }
184
215
  };
185
216
  }
@@ -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 { 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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"}
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 { platformMailDnsResolver } from '@aglyn/tenant-data-admin/server/email-deliverability'\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 { sendingWorkspaceFor } from '@aglyn/tenant-data-admin/server/outbound-send-review'\nimport { screenTenantMessage } from '@aglyn/shared-util-email/outbound-screen-gate'\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) and, for a domain with none, its address\n // record — through the platform's pinned resolver (AGL-3328), which\n // asks twice before it says a domain takes no mail.\n resolveMx: (domain) => platformMailDnsResolver().resolveMx(domain),\n resolveAddress: (domain) => platformMailDnsResolver().resolveAddress?.(domain) ?? Promise.resolve(false),\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 // The one phishing screen every tenant message passes (AGL-3356). The\n // review module's import installs its store, so a hold files the same\n // abuse-queue row a held newsletter or campaign does.\n async screenMessage({ orgId, org, hostId, host, subject, text, fromName, fromAddress }) {\n const refusal = await screenTenantMessage(\n {\n workspace: sendingWorkspaceFor({ hostId, orgId, org, host }),\n subject,\n fromName,\n fromAddress: fromAddress ?? null,\n bodies: [text],\n context: 'outreach sequence',\n },\n 'outreach email',\n )\n return refusal?.detail ?? null\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","platformMailDnsResolver","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","resolveTrackingLinkOrigin","sendingWorkspaceFor","screenTenantMessage","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","resolveMx","domain","resolveAddress","Promise","resolve","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","screenMessage","host","fromName","fromAddress","refusal","workspace","bodies","detail","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}
@@ -128,9 +128,16 @@ export interface OutreachRuntimeDeps {
128
128
  openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>;
129
129
  /**
130
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.
131
+ * — the platform's pinned resolver (`platformMailDnsResolver`), a table
132
+ * in a spec.
132
133
  */
133
134
  resolveMx: OutreachResolveMx;
135
+ /**
136
+ * Whether a domain with no MX has an address record (AGL-3328) — the
137
+ * implicit MX mail is still delivered to. Omitted, a domain with no MX
138
+ * reads as having no mail server.
139
+ */
140
+ resolveAddress?: (domain: string) => Promise<boolean>;
134
141
  /**
135
142
  * Why Outreach may not act for this workspace now, or `null` when it may:
136
143
  * the plugin switched on and entitled, and its release flag on for the
@@ -203,4 +210,23 @@ export interface OutreachRuntimeDeps {
203
210
  orgId: string;
204
211
  senderAddress: string;
205
212
  }): Promise<string | null>;
213
+ /**
214
+ * The platform's outbound phishing screen (AGL-3356), asked for each
215
+ * composed email before anything is claimed or sent — the same screen,
216
+ * tiers and staff review every other tenant message passes, reached here
217
+ * because a connected mailbox is a transport `sendEmail` never sees. The
218
+ * sentence a member reads when the email is held or rejected, or `null`
219
+ * when it may go. Absent means unscreened.
220
+ */
221
+ screenMessage?(input: {
222
+ orgId: string;
223
+ org: Record<string, unknown>;
224
+ hostId: string;
225
+ host: Record<string, unknown> | null;
226
+ subject: string;
227
+ text: string;
228
+ fromName: string | null;
229
+ /** The mailbox address it leaves from — a lookalike domain holds (AGL-3362). */
230
+ fromAddress?: string | null;
231
+ }): Promise<string | null>;
206
232
  }
@@ -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 { 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"}
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 * — the platform's pinned resolver (`platformMailDnsResolver`), a table\n * in a spec.\n */\n resolveMx: OutreachResolveMx\n /**\n * Whether a domain with no MX has an address record (AGL-3328) — the\n * implicit MX mail is still delivered to. Omitted, a domain with no MX\n * reads as having no mail server.\n */\n resolveAddress?: (domain: string) => Promise<boolean>\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 * The platform's outbound phishing screen (AGL-3356), asked for each\n * composed email before anything is claimed or sent — the same screen,\n * tiers and staff review every other tenant message passes, reached here\n * because a connected mailbox is a transport `sendEmail` never sees. The\n * sentence a member reads when the email is held or rejected, or `null`\n * when it may go. Absent means unscreened.\n */\n screenMessage?(input: {\n orgId: string\n org: Record<string, unknown>\n hostId: string\n host: Record<string, unknown> | null\n subject: string\n text: string\n fromName: string | null\n /** The mailbox address it leaves from — a lookalike domain holds (AGL-3362). */\n fromAddress?: string | null\n }): Promise<string | null>\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAiHD,WAmGC"}
@@ -31,7 +31,7 @@ import { outreachEffectiveDailyCap, outreachLocalDay } from "../mailboxes/mailbo
31
31
  import { effectiveOutreachAllowedCountries } from "../model/compliance-settings.js";
32
32
  import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
33
33
  import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
34
- import { recordOutreachGatewayOutcome } from "../storage/domain-intel-store.js";
34
+ import { outreachMailboxSendingDomain, recordOutreachGatewayOutcome } from "../storage/domain-intel-store.js";
35
35
  import { outreachEnrollmentLink, outreachEnrollmentPerson, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
36
36
  import { GmailTransportError, isReconnectRequired } from "../transport/gmail-errors.js";
37
37
  import { Rfc5322MessageError } from "../transport/rfc5322.js";
@@ -39,6 +39,7 @@ import { sendComposedOutreachEmail } from "../transport/send-message.js";
39
39
  import { creditOutreachFirstSend } from "./campaign-credit.js";
40
40
  import { newOutreachLinkId, outreachStoredLink } from "./click-link.js";
41
41
  import { applyOutreachEvent } from "./enrollment-events.js";
42
+ import { outreachSiteHeld } from "./host-suspension.js";
42
43
  import { outreachSendDigest, withRecentSend } from "./mailbox-health-store.js";
43
44
  import { noteOutreachMailboxReconnectRequired } from "./mailbox-notices.js";
44
45
  import { fileOutreachEmail, fileOutreachTask, OUTREACH_TASK_KIND_TO_RECORD } from "./timeline.js";
@@ -248,8 +249,12 @@ async function runMailbox(deps, firestore, input) {
248
249
  ]);
249
250
  }
250
251
  const siteNames = new Map();
252
+ const siteDocs = new Map();
253
+ // Sites under a staff suspension (AGL-3356): their people are held, not
254
+ // stamped, so the step sends on the first run after the lift.
255
+ const heldSites = new Set();
251
256
  for (const [hostId, enrollments] of byHost){
252
- var _host_get;
257
+ var _host_get, _host_data;
253
258
  const consentGroup = consentGroupForHost(org, hostId);
254
259
  const contactGroupId = consentGroup.groupId;
255
260
  // Each by the record it names — the contact, or the lead while the
@@ -268,7 +273,9 @@ async function runMailbox(deps, firestore, input) {
268
273
  consentAwaitsConfirmation: consentGroup.awaitsConfirmation,
269
274
  gateway: {
270
275
  resolveMx: deps.resolveMx,
271
- nowMs
276
+ resolveAddress: deps.resolveAddress,
277
+ nowMs,
278
+ sendingDomain: outreachMailboxSendingDomain(mailbox)
272
279
  },
273
280
  people: enrollments.map((enrollment)=>{
274
281
  const person = outreachEnrollmentPerson(enrollment);
@@ -285,21 +292,30 @@ async function runMailbox(deps, firestore, input) {
285
292
  for (const person of candidatesRead)people.set(person.personId, person);
286
293
  for (const [personId, answer] of lookupsRead)lookups.set(personId, answer);
287
294
  siteNames.set(hostId, host.exists ? String((_host_get = host.get('name')) != null ? _host_get : '') : '');
295
+ siteDocs.set(hostId, host.exists ? (_host_data = host.data()) != null ? _host_data : null : null);
296
+ if (host.exists && outreachSiteHeld(host.data(), nowMs)) {
297
+ heldSites.add(hostId);
298
+ }
288
299
  }
289
300
  for (const candidate of selection.emails){
290
- var _people_get, _lookups_get, _siteNames_get;
301
+ var _people_get, _lookups_get, _siteNames_get, _siteDocs_get;
291
302
  if (run.stopped || deps.now() >= context.deadlineMs) {
292
303
  report.held += 1;
293
304
  continue;
294
305
  }
295
306
  const enrollment = candidate.enrollment;
307
+ if (heldSites.has(enrollment.hostId)) {
308
+ report.held += 1;
309
+ continue;
310
+ }
296
311
  const personId = outreachEnrollmentPerson(enrollment).id;
297
312
  await runEmailStep(deps, firestore, run, {
298
313
  enrollment,
299
314
  sequence: candidate.sequence,
300
315
  person: (_people_get = people.get(personId)) != null ? _people_get : null,
301
316
  lookups: (_lookups_get = lookups.get(personId)) != null ? _lookups_get : null,
302
- siteName: (_siteNames_get = siteNames.get(enrollment.hostId)) != null ? _siteNames_get : ''
317
+ siteName: (_siteNames_get = siteNames.get(enrollment.hostId)) != null ? _siteNames_get : '',
318
+ site: (_siteDocs_get = siteDocs.get(enrollment.hostId)) != null ? _siteDocs_get : null
303
319
  });
304
320
  }
305
321
  }
@@ -787,6 +803,30 @@ async function runEmailStep(deps, firestore, run, input) {
787
803
  }, 'failed');
788
804
  }
789
805
  /*
806
+ * THE PHISHING SCREEN (AGL-3356): the same one every tenant message
807
+ * passes, asked of the email as composed and before anything is claimed,
808
+ * stored or sent. A hold pauses this enrollment with the reason; staff
809
+ * decide it in the abuse queue, and a member resumes it once released.
810
+ */ if (deps.screenMessage) {
811
+ var _input_site, _mailbox_displayName;
812
+ const held = await deps.screenMessage({
813
+ orgId: run.orgId,
814
+ org: run.org,
815
+ hostId: enrollment.hostId,
816
+ host: (_input_site = input.site) != null ? _input_site : null,
817
+ subject: composed.email.subject,
818
+ text: composed.email.text,
819
+ fromName: (_mailbox_displayName = mailbox.displayName) != null ? _mailbox_displayName : null,
820
+ fromAddress: sender.address
821
+ });
822
+ if (held) return stop({
823
+ type: 'pause',
824
+ atMs: nowMs,
825
+ byUid: null,
826
+ detail: held
827
+ }, 'failed');
828
+ }
829
+ /*
790
830
  * The short links' documents, before anything is claimed or sent: a link
791
831
  * in an email that already left must resolve on the first click. A write
792
832
  * that fails holds the send for the next run, which mints new ones — an
@@ -888,7 +928,7 @@ async function runEmailStep(deps, firestore, run, input) {
888
928
  const recipientDomain = outreachEmailDomain(enrollment.email);
889
929
  if (standing && recipientDomain) {
890
930
  await recordOutreachGatewayOutcome(firestore, run.orgId, {
891
- domain: recipientDomain,
931
+ sendingDomain: outreachMailboxSendingDomain(mailbox),
892
932
  gateway: standing.gateway,
893
933
  outcome: 'sent',
894
934
  atMs: record.atMs