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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/compliance-section.d.ts +3 -1
  3. package/src/lib/components/compliance-section.js +110 -45
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.d.ts +7 -1
  6. package/src/lib/components/enroll-dialog.js +47 -3
  7. package/src/lib/components/enroll-dialog.js.map +1 -1
  8. package/src/lib/components/outreach-ui.js +1 -0
  9. package/src/lib/components/outreach-ui.js.map +1 -1
  10. package/src/lib/components/sequence-editor.js +25 -24
  11. package/src/lib/components/sequence-editor.js.map +1 -1
  12. package/src/lib/engine/delivery-status.d.ts +8 -0
  13. package/src/lib/engine/delivery-status.js +10 -5
  14. package/src/lib/engine/delivery-status.js.map +1 -1
  15. package/src/lib/engine/enrollment-state.d.ts +2 -0
  16. package/src/lib/engine/enrollment-state.js +2 -2
  17. package/src/lib/engine/enrollment-state.js.map +1 -1
  18. package/src/lib/engine/gates.d.ts +28 -1
  19. package/src/lib/engine/gates.js +28 -1
  20. package/src/lib/engine/gates.js.map +1 -1
  21. package/src/lib/engine/mail-gateway.d.ts +97 -0
  22. package/src/lib/engine/mail-gateway.js +226 -0
  23. package/src/lib/engine/mail-gateway.js.map +1 -0
  24. package/src/lib/engine/mailbox-health.d.ts +3 -3
  25. package/src/lib/engine/mailbox-health.js +17 -11
  26. package/src/lib/engine/mailbox-health.js.map +1 -1
  27. package/src/lib/engine/thread-classification.d.ts +2 -0
  28. package/src/lib/engine/thread-classification.js +2 -1
  29. package/src/lib/engine/thread-classification.js.map +1 -1
  30. package/src/lib/enrollment/enroll-people.d.ts +6 -1
  31. package/src/lib/enrollment/enroll-people.js +18 -4
  32. package/src/lib/enrollment/enroll-people.js.map +1 -1
  33. package/src/lib/enrollment/gate-lookups.d.ts +7 -0
  34. package/src/lib/enrollment/gate-lookups.js +11 -2
  35. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  36. package/src/lib/model/outreach-api.d.ts +14 -0
  37. package/src/lib/model/outreach-api.js.map +1 -1
  38. package/src/lib/model/outreach.types.d.ts +79 -3
  39. package/src/lib/model/outreach.types.js +21 -2
  40. package/src/lib/model/outreach.types.js.map +1 -1
  41. package/src/lib/routes/enroll-routes.js +23 -4
  42. package/src/lib/routes/enroll-routes.js.map +1 -1
  43. package/src/lib/routes/enrollment-routes.js +15 -1
  44. package/src/lib/routes/enrollment-routes.js.map +1 -1
  45. package/src/lib/routes/register-routes.js +3 -0
  46. package/src/lib/routes/register-routes.js.map +1 -1
  47. package/src/lib/routes/route-deps.d.ts +6 -0
  48. package/src/lib/routes/route-deps.js.map +1 -1
  49. package/src/lib/runtime/gateway-ledger.d.ts +46 -0
  50. package/src/lib/runtime/gateway-ledger.js +113 -0
  51. package/src/lib/runtime/gateway-ledger.js.map +1 -0
  52. package/src/lib/runtime/platform-runtime-deps.js +3 -0
  53. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  54. package/src/lib/runtime/runtime-deps.d.ts +6 -0
  55. package/src/lib/runtime/runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +48 -0
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/runtime/sync-job.d.ts +2 -0
  59. package/src/lib/runtime/sync-job.js +40 -3
  60. package/src/lib/runtime/sync-job.js.map +1 -1
  61. package/src/lib/storage/domain-intel-store.d.ts +107 -0
  62. package/src/lib/storage/domain-intel-store.js +320 -0
  63. package/src/lib/storage/domain-intel-store.js.map +1 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * WHAT THE RUNTIME TEACHES THE GATEWAY LEDGER (AGL-3326).
18
+ *
19
+ * The ledger learns from outcomes, and the runtime is where outcomes
20
+ * happen: the send job counts a SEND the moment an email leaves, the sync
21
+ * counts a BLOCK when a hard bounce reads as the gateway refusing the
22
+ * sender, and the sync counts a DELIVERY for every email step that is a
23
+ * day old with no bounce against it — the one verdict a small sender never
24
+ * hears in words, so it is inferred from silence, and only after
25
+ * {@link OUTREACH_GATEWAY_DELIVERED_AFTER_MS} of it.
26
+ *
27
+ * Which gateway a block is filed under is read from the bounce itself
28
+ * where it can be: a DSN's `Remote-MTA` names the server that refused,
29
+ * and `*.ess.barracudanetworks.com` is Barracuda whatever the MX says
30
+ * today. A bounce that names no known gateway is filed under the domain's
31
+ * own intel, looked up if it never was.
32
+ */ import { outreachEmailDomain } from "../engine/do-not-contact-domain.js";
33
+ import { OUTREACH_GATEWAY_DELIVERED_AFTER_MS, outreachMailGatewayOfHost } from "../engine/mail-gateway.js";
34
+ import { readOutreachDomainIntel, recordOutreachGatewayOutcome } from "../storage/domain-intel-store.js";
35
+ import { outreachOrgCollection } from "../storage/outreach-records.js";
36
+ /** A gateway block, counted against the gateway the bounce names — see the module note. */ export async function recordOutreachGatewayBlock(firestore, input) {
37
+ const domain = outreachEmailDomain(input.email);
38
+ if (!domain) return;
39
+ let gateway = outreachMailGatewayOfHost(input.remoteMta);
40
+ if (!gateway) {
41
+ var _ref;
42
+ var _intel_get;
43
+ const intel = await readOutreachDomainIntel(firestore, input.orgId, [
44
+ input.email
45
+ ], {
46
+ resolveMx: input.resolveMx,
47
+ nowMs: input.nowMs
48
+ });
49
+ gateway = (_ref = (_intel_get = intel.get(input.email)) == null ? void 0 : _intel_get.gateway) != null ? _ref : null;
50
+ }
51
+ if (!gateway || gateway === 'none') return;
52
+ await recordOutreachGatewayOutcome(firestore, input.orgId, {
53
+ domain,
54
+ gateway,
55
+ outcome: 'blocked',
56
+ atMs: input.nowMs
57
+ });
58
+ }
59
+ /**
60
+ * The email steps of an enrollment that count as delivered now: those a
61
+ * day old, less the last one sent when the enrollment bounced — a bounce
62
+ * answers the last send — and less the ones already credited.
63
+ */ export function outreachDeliveredStepCount(enrollment, nowMs) {
64
+ var _enrollment_stepRecords;
65
+ const emails = ((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).filter((record)=>(record == null ? void 0 : record.kind) === 'email');
66
+ const bounced = enrollment.status === 'bounced' ? emails.slice(0, -1) : emails;
67
+ return bounced.filter((record)=>Number(record.atMs) <= nowMs - OUTREACH_GATEWAY_DELIVERED_AFTER_MS).length;
68
+ }
69
+ /**
70
+ * Credits every watched enrollment's day-old, unbounced email steps to the
71
+ * ledger as delivered — see the module note — and marks each enrollment
72
+ * with how many it has credited, so the next run credits only what is new.
73
+ * Answers how many deliveries it counted.
74
+ */ export async function creditOutreachGatewayDeliveries(firestore, input) {
75
+ const due = input.enrollments.map((enrollment)=>({
76
+ enrollment,
77
+ delivered: outreachDeliveredStepCount(enrollment, input.nowMs)
78
+ })).filter(({ enrollment, delivered })=>delivered > Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0));
79
+ if (!due.length) return 0;
80
+ const intel = await readOutreachDomainIntel(firestore, input.orgId, due.map(({ enrollment })=>enrollment.email), {
81
+ resolveMx: input.resolveMx,
82
+ nowMs: input.nowMs
83
+ });
84
+ const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments');
85
+ let counted = 0;
86
+ for (const { enrollment, delivered } of due){
87
+ var _ref;
88
+ var _intel_get;
89
+ const domain = outreachEmailDomain(enrollment.email);
90
+ const gateway = (_ref = (_intel_get = intel.get(enrollment.email)) == null ? void 0 : _intel_get.gateway) != null ? _ref : null;
91
+ // A domain that could not be looked up is credited on a later run, when it can be.
92
+ if (!domain || !gateway || gateway === 'none') continue;
93
+ const fresh = delivered - Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0);
94
+ await recordOutreachGatewayOutcome(firestore, input.orgId, {
95
+ domain,
96
+ gateway,
97
+ outcome: 'delivered',
98
+ count: fresh,
99
+ atMs: input.nowMs
100
+ });
101
+ try {
102
+ await enrollments.doc(enrollment.id).update({
103
+ gatewayDeliveredSteps: delivered
104
+ });
105
+ counted += fresh;
106
+ } catch (error) {
107
+ console.error('[outreach] the delivery credit could not be marked on the enrollment', error);
108
+ }
109
+ }
110
+ return counted;
111
+ }
112
+
113
+ //# sourceMappingURL=gateway-ledger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/gateway-ledger.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT THE RUNTIME TEACHES THE GATEWAY LEDGER (AGL-3326).\n *\n * The ledger learns from outcomes, and the runtime is where outcomes\n * happen: the send job counts a SEND the moment an email leaves, the sync\n * counts a BLOCK when a hard bounce reads as the gateway refusing the\n * sender, and the sync counts a DELIVERY for every email step that is a\n * day old with no bounce against it — the one verdict a small sender never\n * hears in words, so it is inferred from silence, and only after\n * {@link OUTREACH_GATEWAY_DELIVERED_AFTER_MS} of it.\n *\n * Which gateway a block is filed under is read from the bounce itself\n * where it can be: a DSN's `Remote-MTA` names the server that refused,\n * and `*.ess.barracudanetworks.com` is Barracuda whatever the MX says\n * today. A bounce that names no known gateway is filed under the domain's\n * own intel, looked up if it never was.\n */\n\nimport { outreachEmailDomain } from '../engine/do-not-contact-domain'\nimport { OUTREACH_GATEWAY_DELIVERED_AFTER_MS, outreachMailGatewayOfHost } from '../engine/mail-gateway'\nimport type { OutreachEnrollment } from '../model/outreach.types'\nimport {\n type OutreachResolveMx,\n readOutreachDomainIntel,\n recordOutreachGatewayOutcome,\n} from '../storage/domain-intel-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** A gateway block, counted against the gateway the bounce names — see the module note. */\nexport async function recordOutreachGatewayBlock(\n firestore: Firestore,\n input: { orgId: string; email: string; remoteMta: string | null; resolveMx: OutreachResolveMx; nowMs: number },\n): Promise<void> {\n const domain = outreachEmailDomain(input.email)\n if (!domain) return\n let gateway = outreachMailGatewayOfHost(input.remoteMta)\n if (!gateway) {\n const intel = await readOutreachDomainIntel(firestore, input.orgId, [input.email], {\n resolveMx: input.resolveMx,\n nowMs: input.nowMs,\n })\n gateway = intel.get(input.email)?.gateway ?? null\n }\n if (!gateway || gateway === 'none') return\n await recordOutreachGatewayOutcome(firestore, input.orgId, {\n domain,\n gateway,\n outcome: 'blocked',\n atMs: input.nowMs,\n })\n}\n\n/**\n * The email steps of an enrollment that count as delivered now: those a\n * day old, less the last one sent when the enrollment bounced — a bounce\n * answers the last send — and less the ones already credited.\n */\nexport function outreachDeliveredStepCount(\n enrollment: Pick<OutreachEnrollment, 'status' | 'stepRecords'>,\n nowMs: number,\n): number {\n const emails = (enrollment.stepRecords ?? []).filter((record) => record?.kind === 'email')\n const bounced = enrollment.status === 'bounced' ? emails.slice(0, -1) : emails\n return bounced.filter((record) => Number(record.atMs) <= nowMs - OUTREACH_GATEWAY_DELIVERED_AFTER_MS).length\n}\n\n/**\n * Credits every watched enrollment's day-old, unbounced email steps to the\n * ledger as delivered — see the module note — and marks each enrollment\n * with how many it has credited, so the next run credits only what is new.\n * Answers how many deliveries it counted.\n */\nexport async function creditOutreachGatewayDeliveries(\n firestore: Firestore,\n input: { orgId: string; enrollments: readonly OutreachEnrollment[]; resolveMx: OutreachResolveMx; nowMs: number },\n): Promise<number> {\n const due = input.enrollments\n .map((enrollment) => ({\n enrollment,\n delivered: outreachDeliveredStepCount(enrollment, input.nowMs),\n }))\n .filter(({ enrollment, delivered }) => delivered > Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0))\n if (!due.length) return 0\n const intel = await readOutreachDomainIntel(\n firestore,\n input.orgId,\n due.map(({ enrollment }) => enrollment.email),\n { resolveMx: input.resolveMx, nowMs: input.nowMs },\n )\n const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments')\n let counted = 0\n for (const { enrollment, delivered } of due) {\n const domain = outreachEmailDomain(enrollment.email)\n const gateway = intel.get(enrollment.email)?.gateway ?? null\n // A domain that could not be looked up is credited on a later run, when it can be.\n if (!domain || !gateway || gateway === 'none') continue\n const fresh = delivered - Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0)\n await recordOutreachGatewayOutcome(firestore, input.orgId, {\n domain,\n gateway,\n outcome: 'delivered',\n count: fresh,\n atMs: input.nowMs,\n })\n try {\n await enrollments.doc(enrollment.id).update({ gatewayDeliveredSteps: delivered })\n counted += fresh\n } catch (error) {\n console.error('[outreach] the delivery credit could not be marked on the enrollment', error)\n }\n }\n return counted\n}\n"],"names":["outreachEmailDomain","OUTREACH_GATEWAY_DELIVERED_AFTER_MS","outreachMailGatewayOfHost","readOutreachDomainIntel","recordOutreachGatewayOutcome","outreachOrgCollection","recordOutreachGatewayBlock","firestore","input","domain","email","gateway","remoteMta","intel","orgId","resolveMx","nowMs","get","outcome","atMs","outreachDeliveredStepCount","enrollment","emails","stepRecords","filter","record","kind","bounced","status","slice","Number","length","creditOutreachGatewayDeliveries","due","enrollments","map","delivered","Math","max","gatewayDeliveredSteps","counted","fresh","count","doc","id","update","error","console"],"mappings":"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"}
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { EMAIL_TOPIC_SALES } from "@aglyn/aglyn/app-utils/email-topics";
17
+ import { resolveMx } from "node:dns/promises";
17
18
  import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
19
  import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
19
20
  import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
@@ -94,6 +95,8 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
94
95
  openMailbox: (mailboxId)=>openOutreachMailboxClient(firestore(), {
95
96
  mailboxId
96
97
  }),
98
+ // The domain's MX (AGL-3326), from the console's own resolver.
99
+ resolveMx: (domain)=>resolveMx(domain),
97
100
  async orgRefusal (orgId, org) {
98
101
  if (!isPluginEnabled(org, OUTREACH_PLUGIN_ID)) return 'plugin-disabled';
99
102
  if (!checkEntitlement(org, 'outreach')) return 'not-entitled';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { resolveTrackingLinkOrigin } from '@aglyn/tenant-data-admin/server/tracking-hosts'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachShortLinkUrl } from './click-link'\nimport { createOutreachClickRoute, createOutreachShortLinkRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, orgId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, orgId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n // The sending domain's own `links.` host once the organization has\n // verified it (AGL-3306). A static import like every other\n // `tenant-data-admin` module here: a dynamic one marks the whole library\n // lazy-loaded, and `@nx/enforce-module-boundaries` then refuses every\n // static import of it across the plugin and the console.\n clickLinkOrigin({ orgId, senderAddress }) {\n return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress)\n },\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The short tracking link's redirect on the platform's own reach (AGL-3297). */\nexport function platformOutreachShortLinkRoute(): PluginWebApiHandler {\n return createOutreachShortLinkRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","resolveTrackingLinkOrigin","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachShortLinkUrl","createOutreachClickRoute","createOutreachShortLinkRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","orgId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","orgRefusal","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickLinkUrl","linkId","trackingOrigin","clickLinkOrigin","senderAddress","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { resolveMx } from 'node:dns/promises'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { resolveTrackingLinkOrigin } from '@aglyn/tenant-data-admin/server/tracking-hosts'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachShortLinkUrl } from './click-link'\nimport { createOutreachClickRoute, createOutreachShortLinkRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, orgId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, orgId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n // The domain's MX (AGL-3326), from the console's own resolver.\n resolveMx: (domain) => resolveMx(domain),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n // The sending domain's own `links.` host once the organization has\n // verified it (AGL-3306). A static import like every other\n // `tenant-data-admin` module here: a dynamic one marks the whole library\n // lazy-loaded, and `@nx/enforce-module-boundaries` then refuses every\n // static import of it across the plugin and the console.\n clickLinkOrigin({ orgId, senderAddress }) {\n return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress)\n },\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The short tracking link's redirect on the platform's own reach (AGL-3297). */\nexport function platformOutreachShortLinkRoute(): PluginWebApiHandler {\n return createOutreachShortLinkRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","resolveMx","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","resolveTrackingLinkOrigin","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachShortLinkUrl","createOutreachClickRoute","createOutreachShortLinkRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","orgId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","domain","orgRefusal","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickLinkUrl","linkId","trackingOrigin","clickLinkOrigin","senderAddress","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}
@@ -18,6 +18,7 @@ import type { EmailState } from '@aglyn/aglyn/app-utils/email-state';
18
18
  import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
19
19
  import type { OutreachMailboxNotice } from '../engine/mailbox-notice';
20
20
  import type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport';
21
+ import type { OutreachResolveMx } from '../storage/domain-intel-store';
21
22
  import type { OutreachUnsubscribeTarget } from './unsubscribe-link';
22
23
  /**
23
24
  * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).
@@ -125,6 +126,11 @@ export interface OutreachRuntimeDeps {
125
126
  random(): number;
126
127
  /** Opens a mailbox's Gmail client from its sealed grant. */
127
128
  openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>;
129
+ /**
130
+ * The MX resolver a domain's mail gateway is classified with (AGL-3326)
131
+ * — Node's `dns.promises.resolveMx` on the platform, a table in a spec.
132
+ */
133
+ resolveMx: OutreachResolveMx;
128
134
  /**
129
135
  * Why Outreach may not act for this workspace now, or `null` when it may:
130
136
  * the plugin switched on and entitled, and its release flag on for the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OutreachMailboxNotice } from '../engine/mailbox-notice'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: string\n}\n\n/**\n * The verdict on an address, for the record the person is (AGL-3245): what\n * the runtime wrote on a list, said on the lead and the contact that carry\n * the address. The platform finds the records; the runtime names none.\n */\nexport interface OutreachRecordEmailStamp {\n orgId: string\n email: string\n state: EmailState\n}\n\n/**\n * What a sequence produced, credited to the campaigns it is in (AGL-3254).\n *\n * Three doors into the platform's campaign attribution, all on the\n * platform's own join and none on the marketing plugin, which Outreach may\n * not import. Each is called once per enrollment per outcome, from the\n * state change that happens once; none throws, because the send, the reply\n * and the click have already happened and a lost credit understates a\n * campaign while a thrown one loses a step.\n */\nexport interface OutreachCampaignCredit {\n /**\n * Adds one to an outcome under every campaign named, in the campaign's\n * sequences report. `enrolled`, `sent`, `replied` and `converted` are the\n * runtime's to credit; `meetings` is credited by the booking door from\n * the touch {@link OutreachCampaignCredit.recordTouch} stamps.\n */\n credit(input: {\n hostId: string\n /**\n * The org whose campaigns these are, when the caller holds it — an\n * enrollment is an org record, so its routes do. The platform resolves\n * it from the site otherwise.\n */\n orgId?: string | null\n campaignIds: readonly string[]\n outcome: 'enrolled' | 'sent' | 'replied' | 'converted'\n atMs: number\n }): Promise<void>\n /**\n * Writes the conversion record the person's own page reads as \"where\n * this came from\": the sequence's campaign as the touch, on the lead or\n * the contact the enrollment names. Once per record, and never over a\n * record that already carries a web or email touch — the join's own\n * `create()` decides that.\n */\n attributeRecord(input: {\n hostId: string\n kind: 'lead' | 'contact'\n /** The lead's person key, or the contact's id. */\n refId: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n /**\n * Stamps a click on a sequence email as the person's last campaign touch\n * on the site, so a booking or a form they go on to submit is credited to\n * the sequence's campaign by the door that credits every other one.\n */\n recordTouch(input: {\n hostId: string\n email: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n /** The platform's campaign attribution, for what a sequence produced (AGL-3254). */\n campaignCredit: OutreachCampaignCredit\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /**\n * Stamps the verdict on the lead and the contact that carry the address\n * (AGL-3245), beside every write the runtime makes to a list. Never\n * throws: the list is the control, the stamp is what a person reads.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The short tracking link for a stored link's id (AGL-3239, AGL-3297) —\n * on `trackingOrigin` when one is given (AGL-3306) — or `null` when none\n * can be made: no HTTPS origin. The sending\n * runtime writes the `outreachLinks` document the id names before the\n * email leaves.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /**\n * The click-tracking origin for mail sent as `senderAddress` —\n * `https://links.<its domain>` once the organization has verified that\n * host (AGL-3306) — or `null` for the console's own address. Asked once\n * per mailbox per run; a failure reads as `null`, never as a held send.\n * Absent means no deployment-level hosts at all.\n */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAgHD,WAoEC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OutreachMailboxNotice } from '../engine/mailbox-notice'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachResolveMx } from '../storage/domain-intel-store'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: string\n}\n\n/**\n * The verdict on an address, for the record the person is (AGL-3245): what\n * the runtime wrote on a list, said on the lead and the contact that carry\n * the address. The platform finds the records; the runtime names none.\n */\nexport interface OutreachRecordEmailStamp {\n orgId: string\n email: string\n state: EmailState\n}\n\n/**\n * What a sequence produced, credited to the campaigns it is in (AGL-3254).\n *\n * Three doors into the platform's campaign attribution, all on the\n * platform's own join and none on the marketing plugin, which Outreach may\n * not import. Each is called once per enrollment per outcome, from the\n * state change that happens once; none throws, because the send, the reply\n * and the click have already happened and a lost credit understates a\n * campaign while a thrown one loses a step.\n */\nexport interface OutreachCampaignCredit {\n /**\n * Adds one to an outcome under every campaign named, in the campaign's\n * sequences report. `enrolled`, `sent`, `replied` and `converted` are the\n * runtime's to credit; `meetings` is credited by the booking door from\n * the touch {@link OutreachCampaignCredit.recordTouch} stamps.\n */\n credit(input: {\n hostId: string\n /**\n * The org whose campaigns these are, when the caller holds it — an\n * enrollment is an org record, so its routes do. The platform resolves\n * it from the site otherwise.\n */\n orgId?: string | null\n campaignIds: readonly string[]\n outcome: 'enrolled' | 'sent' | 'replied' | 'converted'\n atMs: number\n }): Promise<void>\n /**\n * Writes the conversion record the person's own page reads as \"where\n * this came from\": the sequence's campaign as the touch, on the lead or\n * the contact the enrollment names. Once per record, and never over a\n * record that already carries a web or email touch — the join's own\n * `create()` decides that.\n */\n attributeRecord(input: {\n hostId: string\n kind: 'lead' | 'contact'\n /** The lead's person key, or the contact's id. */\n refId: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n /**\n * Stamps a click on a sequence email as the person's last campaign touch\n * on the site, so a booking or a form they go on to submit is credited to\n * the sequence's campaign by the door that credits every other one.\n */\n recordTouch(input: {\n hostId: string\n email: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * The MX resolver a domain's mail gateway is classified with (AGL-3326)\n * — Node's `dns.promises.resolveMx` on the platform, a table in a spec.\n */\n resolveMx: OutreachResolveMx\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n /** The platform's campaign attribution, for what a sequence produced (AGL-3254). */\n campaignCredit: OutreachCampaignCredit\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /**\n * Stamps the verdict on the lead and the contact that carry the address\n * (AGL-3245), beside every write the runtime makes to a list. Never\n * throws: the list is the control, the stamp is what a person reads.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The short tracking link for a stored link's id (AGL-3239, AGL-3297) —\n * on `trackingOrigin` when one is given (AGL-3306) — or `null` when none\n * can be made: no HTTPS origin. The sending\n * runtime writes the `outreachLinks` document the id names before the\n * email leaves.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /**\n * The click-tracking origin for mail sent as `senderAddress` —\n * `https://links.<its domain>` once the organization has verified that\n * host (AGL-3306) — or `null` for the console's own address. Asked once\n * per mailbox per run; a failure reads as `null`, never as a held send.\n * Absent means no deployment-level hosts at all.\n */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAiHD,WAyEC"}
@@ -20,6 +20,7 @@ import { normalizeCrmEmailTemplate } from "@aglyn/aglyn/app-utils/crm-email-temp
20
20
  import { FieldValue } from "firebase-admin/firestore";
21
21
  import { randomUUID } from "node:crypto";
22
22
  import { composeOutreachEmail } from "../engine/compose.js";
23
+ import { outreachEmailDomain } from "../engine/do-not-contact-domain.js";
23
24
  import { planOutreachStepCompletion } from "../engine/enrollment-state.js";
24
25
  import { evaluateOutreachGates } from "../engine/gates.js";
25
26
  import { outreachSentToday, outreachTickAllowance, selectDueOutreachEnrollments } from "../engine/sending-capacity.js";
@@ -30,6 +31,7 @@ import { outreachEffectiveDailyCap, outreachLocalDay } from "../mailboxes/mailbo
30
31
  import { effectiveOutreachAllowedCountries } from "../model/compliance-settings.js";
31
32
  import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
32
33
  import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
34
+ import { recordOutreachGatewayOutcome } from "../storage/domain-intel-store.js";
33
35
  import { outreachEnrollmentLink, outreachEnrollmentPerson, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
34
36
  import { GmailTransportError, isReconnectRequired } from "../transport/gmail-errors.js";
35
37
  import { Rfc5322MessageError } from "../transport/rfc5322.js";
@@ -264,6 +266,10 @@ async function runMailbox(deps, firestore, input) {
264
266
  hostId,
265
267
  consentHostIds: consentGroup.hostIds,
266
268
  consentAwaitsConfirmation: consentGroup.awaitsConfirmation,
269
+ gateway: {
270
+ resolveMx: deps.resolveMx,
271
+ nowMs
272
+ },
267
273
  people: enrollments.map((enrollment)=>{
268
274
  const person = outreachEnrollmentPerson(enrollment);
269
275
  return {
@@ -585,6 +591,7 @@ async function openClient(deps, run) {
585
591
  }
586
592
  async function runEmailStep(deps, firestore, run, input) {
587
593
  var _ref, _composed_email_inReplyTo;
594
+ var _enrollment_gatewayHold;
588
595
  const { enrollment, sequence, person } = input;
589
596
  const { report, mailbox } = run;
590
597
  const nowMs = deps.now();
@@ -638,9 +645,38 @@ async function runEmailStep(deps, firestore, run, input) {
638
645
  personalLine: enrollment.personalLine,
639
646
  attestations: enrollment.attestations,
640
647
  enrollmentId: enrollment.id,
648
+ gatewayHoldReleased: typeof ((_enrollment_gatewayHold = enrollment.gatewayHold) == null ? void 0 : _enrollment_gatewayHold.releasedAtMs) === 'number',
641
649
  lookups: input.lookups
642
650
  });
643
651
  if (!gate.allowed) return stop(gateEvent(gate.blocks, nowMs), 'stopped');
652
+ if (gate.hold) {
653
+ // The gateway refused this organization twice lately and delivered
654
+ // nothing (AGL-3326): the send is held, as a pause a member resumes
655
+ // from the enrollment row, and nothing goes into it until they do.
656
+ const hold = gate.hold;
657
+ const outcome = await applyOutreachEvent(firestore, {
658
+ orgId: run.orgId,
659
+ enrollmentId: enrollment.id,
660
+ event: {
661
+ type: 'pause',
662
+ atMs: nowMs,
663
+ byUid: null,
664
+ reason: 'gateway_blocked_here',
665
+ detail: hold.reason
666
+ },
667
+ extra: ()=>({
668
+ gatewayHold: {
669
+ gateway: hold.gateway,
670
+ heldAtMs: nowMs,
671
+ releasedByUid: null,
672
+ releasedAtMs: null
673
+ }
674
+ }),
675
+ nowMs
676
+ });
677
+ if (outcome.changed) report.held += 1;
678
+ return;
679
+ }
644
680
  /*
645
681
  * The email, with its way out. The footer's "reply 'no'" line and the
646
682
  * postal address are always there (compose refuses without them). The
@@ -846,6 +882,18 @@ async function runEmailStep(deps, firestore, run, input) {
846
882
  claimMessageId: messageId
847
883
  });
848
884
  report.sent += 1;
885
+ // The gateway ledger (AGL-3326): one more send into the domain's gateway,
886
+ // which the sync credits as delivered a day from now if nothing bounces.
887
+ const standing = input.lookups.gateway;
888
+ const recipientDomain = outreachEmailDomain(enrollment.email);
889
+ if (standing && recipientDomain) {
890
+ await recordOutreachGatewayOutcome(firestore, run.orgId, {
891
+ domain: recipientDomain,
892
+ gateway: standing.gateway,
893
+ outcome: 'sent',
894
+ atMs: record.atMs
895
+ });
896
+ }
849
897
  await fileOutreachEmail(deps, {
850
898
  orgId: run.orgId,
851
899
  hostId: enrollment.hostId,