@aglyn/plugins-outreach 1.0.0-beta.161 → 1.0.0-beta.162

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-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 { OutreachRouteGateDeps } from './route-gate'\n\n/**\n * What the settings, sequence and enrollment routes reach outside this\n * plugin (AGL-2980). The platform's own are `register-routes.ts`; specs\n * build their own.\n */\n\n/**\n * The org activity target a sequence's rows are filed under: Outreach's own\n * namespaced resource (`pluginId:noun`), which the feed reads as \"Sequence\".\n * Core's target list names only core's resources, so a plugin's never joins\n * it.\n */\nexport const OUTREACH_SEQUENCE_ACTIVITY_TARGET = 'outreach:sequence'\n\n/** What an org activity line written by these routes points at. */\nexport type OutreachActivityTarget =\n | { type: 'org'; id: string }\n | { type: typeof OUTREACH_SEQUENCE_ACTIVITY_TARGET; id: string; name: string }\n\nexport interface OutreachRouteDeps {\n firestore(): FirebaseFirestore.Firestore\n gate: OutreachRouteGateDeps\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n logOrgActivity(\n orgId: string,\n actor: { uid: string; email?: string | null },\n action: string,\n target: OutreachActivityTarget,\n ): Promise<void>\n}\n"],"names":["OUTREACH_SEQUENCE_ACTIVITY_TARGET"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;CAIC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,oCAAoC,oBAAmB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-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 { OutreachRecordEmailStamp } from '../runtime/runtime-deps'\nimport type { OutreachRouteGateDeps } from './route-gate'\n\n/**\n * What the settings, sequence and enrollment routes reach outside this\n * plugin (AGL-2980). The platform's own are `register-routes.ts`; specs\n * build their own.\n */\n\n/**\n * The org activity target a sequence's rows are filed under: Outreach's own\n * namespaced resource (`pluginId:noun`), which the feed reads as \"Sequence\".\n * Core's target list names only core's resources, so a plugin's never joins\n * it.\n */\nexport const OUTREACH_SEQUENCE_ACTIVITY_TARGET = 'outreach:sequence'\n\n/** What an org activity line written by these routes points at. */\nexport type OutreachActivityTarget =\n | { type: 'org'; id: string }\n | { type: typeof OUTREACH_SEQUENCE_ACTIVITY_TARGET; id: string; name: string }\n\nexport interface OutreachRouteDeps {\n firestore(): FirebaseFirestore.Firestore\n gate: OutreachRouteGateDeps\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n logOrgActivity(\n orgId: string,\n actor: { uid: string; email?: string | null },\n action: string,\n target: OutreachActivityTarget,\n ): Promise<void>\n /**\n * Stamps a member's do-not-contact mark on the lead and the contact that\n * carry the address (AGL-3245); the runtime's twin is on its own deps.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n}\n"],"names":["OUTREACH_SEQUENCE_ACTIVITY_TARGET"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAKD;;;;CAIC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,oCAAoC,oBAAmB"}
@@ -67,12 +67,14 @@ export type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_
67
67
  * 2. the `sales` topic of each site named, which the site's own sales
68
68
  * email reads too;
69
69
  * 3. every open enrollment of the address in the organization: the one it
70
- * came from with the reason given, the others as `do_not_contact`.
70
+ * came from with the reason given, the others as `do_not_contact`;
71
+ * 4. the lead and the contact that carry the address (AGL-3245), so the
72
+ * record a person reads says what the lists hold.
71
73
  *
72
74
  * Each is idempotent, so a second request — the link used twice, a reply
73
75
  * read by two runs — changes nothing.
74
76
  */
75
- export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic'>, input: {
77
+ export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>, input: {
76
78
  orgId: string;
77
79
  email: string;
78
80
  source: OutreachOptOutSource;
@@ -80,6 +82,8 @@ export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'fi
80
82
  hostIds: readonly string[];
81
83
  detail: string | null;
82
84
  nowMs: number;
85
+ /** The reply called the email spam (AGL-3245): the record says so. */
86
+ complaint?: boolean;
83
87
  }): Promise<{
84
88
  stopped: number;
85
89
  }>;
@@ -83,13 +83,15 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
83
83
  * 2. the `sales` topic of each site named, which the site's own sales
84
84
  * email reads too;
85
85
  * 3. every open enrollment of the address in the organization: the one it
86
- * came from with the reason given, the others as `do_not_contact`.
86
+ * came from with the reason given, the others as `do_not_contact`;
87
+ * 4. the lead and the contact that carry the address (AGL-3245), so the
88
+ * record a person reads says what the lists hold.
87
89
  *
88
90
  * Each is idempotent, so a second request — the link used twice, a reply
89
91
  * read by two runs — changes nothing.
90
92
  */ export async function recordOutreachOptOut(deps, input) {
91
93
  var _ref, _ref1, _ref2;
92
- var _input_enrollment, _input_enrollment1, _input_enrollment2;
94
+ var _input_enrollment, _input_enrollment1, _input_enrollment2, _input_enrollment3;
93
95
  const email = normalizeContactEmail(input.email);
94
96
  if (!email) return {
95
97
  stopped: 0
@@ -111,6 +113,21 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
111
113
  email
112
114
  });
113
115
  }
116
+ // 4. the record the person is (AGL-3245): the link and the header say
117
+ // "unsubscribed"; a reply that asked is the runtime's own list entry,
118
+ // and one that called the email spam is a complaint.
119
+ await deps.stampRecordEmailState({
120
+ orgId: input.orgId,
121
+ email,
122
+ state: _extends({
123
+ status: input.complaint ? 'complained' : input.source === 'unsubscribe' ? 'unsubscribed' : 'do_not_contact',
124
+ atMs: input.nowMs,
125
+ source: 'sequence',
126
+ detail: input.detail
127
+ }, ((_input_enrollment2 = input.enrollment) == null ? void 0 : _input_enrollment2.id) ? {
128
+ enrollmentId: input.enrollment.id
129
+ } : {})
130
+ });
114
131
  let stopped = 0;
115
132
  if (input.enrollment) {
116
133
  const own = await applyOutreachEvent(firestore, {
@@ -129,7 +146,7 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
129
146
  stopped += await optOutOtherOutreachEnrollments(firestore, {
130
147
  orgId: input.orgId,
131
148
  email,
132
- exceptEnrollmentId: (_ref2 = (_input_enrollment2 = input.enrollment) == null ? void 0 : _input_enrollment2.id) != null ? _ref2 : null,
149
+ exceptEnrollmentId: (_ref2 = (_input_enrollment3 = input.enrollment) == null ? void 0 : _input_enrollment3.id) != null ? _ref2 : null,
133
150
  nowMs: input.nowMs,
134
151
  detail: input.source === 'unsubscribe' ? 'Unsubscribed from another sequence.' : 'Asked not to be emailed in a reply to another sequence.'
135
152
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/enrollment-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachDoNotContactReason, OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT THE RUNTIME DOES TO ENROLLMENTS, AND TO THE LISTS BESIDE THEM\n * (AGL-2981).\n *\n * Every status change is the engine's (`applyOutreachEnrollmentEvent`),\n * written in a transaction over the one enrollment so a member's pause and a\n * run's step completion never interleave. The runtime's events name no\n * member: `stoppedByUid` stays `null`.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The enrollment after an event, and whether the event changed it. */\nexport interface OutreachEventOutcome {\n enrollment: OutreachEnrollment | null\n changed: boolean\n}\n\n/**\n * Applies one event to one enrollment, with any fields the caller writes\n * beside it — its handled message ids, a postponed due time. A transition\n * the engine refuses writes only the extra fields.\n */\nexport async function applyOutreachEvent(\n firestore: Firestore,\n input: {\n orgId: string\n enrollmentId: string\n event: OutreachEnrollmentEvent | null\n extra?: (enrollment: OutreachEnrollment) => Record<string, unknown> | null\n nowMs: number\n },\n): Promise<OutreachEventOutcome> {\n const ref = outreachOrgCollection(firestore, input.orgId, 'enrollments').doc(input.enrollmentId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(input.enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return { enrollment: null, changed: false }\n const patch = input.event ? applyOutreachEnrollmentEvent(enrollment, input.event).patch : null\n const extra = input.extra?.(enrollment) ?? null\n if (!patch && !extra) return { enrollment, changed: false }\n const written = { ...(patch ?? {}), ...(extra ?? {}), updatedAtMs: input.nowMs }\n transaction.update(ref, written)\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, changed: patch !== null }\n })\n}\n\n/**\n * Opts every OTHER open enrollment of an address in the organization out,\n * as `do_not_contact`: the person asked not to be emailed, so no sequence\n * is next. Answers how many moved.\n */\nexport async function optOutOtherOutreachEnrollments(\n firestore: Firestore,\n input: { orgId: string; email: string; exceptEnrollmentId: string | null; nowMs: number; detail: string },\n): Promise<number> {\n const found = await outreachOrgCollection(firestore, input.orgId, 'enrollments')\n .where('email', '==', input.email)\n .get()\n let moved = 0\n for (const doc of found.docs) {\n if (doc.id === input.exceptEnrollmentId) continue\n if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(doc.get('status'))) continue\n const outcome = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: doc.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: 'do_not_contact', detail: input.detail },\n nowMs: input.nowMs,\n })\n if (outcome.changed) moved += 1\n }\n return moved\n}\n\n/** Where an opt-out came from. */\nexport type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_reply' | 'unsubscribe'>\n\n/**\n * A person asked not to be emailed — by the unsubscribe link, the `mailto:`\n * or a reply — and every list that has to remember it does:\n *\n * 1. the organization's do-not-contact list, which every sequence checks\n * before every send (its first entry for the address stands);\n * 2. the `sales` topic of each site named, which the site's own sales\n * email reads too;\n * 3. every open enrollment of the address in the organization: the one it\n * came from with the reason given, the others as `do_not_contact`.\n *\n * Each is idempotent, so a second request — the link used twice, a reply\n * read by two runs — changes nothing.\n */\nexport async function recordOutreachOptOut(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic'>,\n input: {\n orgId: string\n email: string\n source: OutreachOptOutSource\n enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId'> | null\n hostIds: readonly string[]\n detail: string | null\n nowMs: number\n },\n): Promise<{ stopped: number }> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { stopped: 0 }\n const firestore = deps.firestore()\n await addOutreachDoNotContact(firestore, {\n orgId: input.orgId,\n email,\n reason: input.source,\n source: 'runtime',\n nowMs: input.nowMs,\n enrollmentId: input.enrollment?.id ?? null,\n sequenceId: input.enrollment?.sequenceId ?? null,\n detail: input.detail,\n })\n for (const hostId of new Set(input.hostIds.filter(Boolean))) {\n await deps.optOutOfSalesTopic({ hostId, email })\n }\n let stopped = 0\n if (input.enrollment) {\n const own = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: input.enrollment.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: input.source, detail: input.detail },\n nowMs: input.nowMs,\n })\n if (own.changed) stopped += 1\n }\n stopped += await optOutOtherOutreachEnrollments(firestore, {\n orgId: input.orgId,\n email,\n exceptEnrollmentId: input.enrollment?.id ?? null,\n nowMs: input.nowMs,\n detail:\n input.source === 'unsubscribe'\n ? 'Unsubscribed from another sequence.'\n : 'Asked not to be emailed in a reply to another sequence.',\n })\n return { stopped }\n}\n"],"names":["normalizeContactEmail","applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachOrgCollection","readStoredOutreachEnrollment","applyOutreachEvent","firestore","input","ref","orgId","doc","enrollmentId","runTransaction","transaction","snapshot","get","enrollment","exists","data","undefined","changed","patch","event","extra","written","updatedAtMs","nowMs","update","optOutOtherOutreachEnrollments","found","where","email","moved","docs","id","exceptEnrollmentId","includes","outcome","type","atMs","reason","detail","recordOutreachOptOut","deps","stopped","source","sequenceId","hostId","Set","hostIds","filter","Boolean","optOutOfSalesTopic","own"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/enrollment-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachDoNotContactReason, OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT THE RUNTIME DOES TO ENROLLMENTS, AND TO THE LISTS BESIDE THEM\n * (AGL-2981).\n *\n * Every status change is the engine's (`applyOutreachEnrollmentEvent`),\n * written in a transaction over the one enrollment so a member's pause and a\n * run's step completion never interleave. The runtime's events name no\n * member: `stoppedByUid` stays `null`.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The enrollment after an event, and whether the event changed it. */\nexport interface OutreachEventOutcome {\n enrollment: OutreachEnrollment | null\n changed: boolean\n}\n\n/**\n * Applies one event to one enrollment, with any fields the caller writes\n * beside it — its handled message ids, a postponed due time. A transition\n * the engine refuses writes only the extra fields.\n */\nexport async function applyOutreachEvent(\n firestore: Firestore,\n input: {\n orgId: string\n enrollmentId: string\n event: OutreachEnrollmentEvent | null\n extra?: (enrollment: OutreachEnrollment) => Record<string, unknown> | null\n nowMs: number\n },\n): Promise<OutreachEventOutcome> {\n const ref = outreachOrgCollection(firestore, input.orgId, 'enrollments').doc(input.enrollmentId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(input.enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return { enrollment: null, changed: false }\n const patch = input.event ? applyOutreachEnrollmentEvent(enrollment, input.event).patch : null\n const extra = input.extra?.(enrollment) ?? null\n if (!patch && !extra) return { enrollment, changed: false }\n const written = { ...(patch ?? {}), ...(extra ?? {}), updatedAtMs: input.nowMs }\n transaction.update(ref, written)\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, changed: patch !== null }\n })\n}\n\n/**\n * Opts every OTHER open enrollment of an address in the organization out,\n * as `do_not_contact`: the person asked not to be emailed, so no sequence\n * is next. Answers how many moved.\n */\nexport async function optOutOtherOutreachEnrollments(\n firestore: Firestore,\n input: { orgId: string; email: string; exceptEnrollmentId: string | null; nowMs: number; detail: string },\n): Promise<number> {\n const found = await outreachOrgCollection(firestore, input.orgId, 'enrollments')\n .where('email', '==', input.email)\n .get()\n let moved = 0\n for (const doc of found.docs) {\n if (doc.id === input.exceptEnrollmentId) continue\n if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(doc.get('status'))) continue\n const outcome = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: doc.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: 'do_not_contact', detail: input.detail },\n nowMs: input.nowMs,\n })\n if (outcome.changed) moved += 1\n }\n return moved\n}\n\n/** Where an opt-out came from. */\nexport type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_reply' | 'unsubscribe'>\n\n/**\n * A person asked not to be emailed — by the unsubscribe link, the `mailto:`\n * or a reply — and every list that has to remember it does:\n *\n * 1. the organization's do-not-contact list, which every sequence checks\n * before every send (its first entry for the address stands);\n * 2. the `sales` topic of each site named, which the site's own sales\n * email reads too;\n * 3. every open enrollment of the address in the organization: the one it\n * came from with the reason given, the others as `do_not_contact`;\n * 4. the lead and the contact that carry the address (AGL-3245), so the\n * record a person reads says what the lists hold.\n *\n * Each is idempotent, so a second request — the link used twice, a reply\n * read by two runs — changes nothing.\n */\nexport async function recordOutreachOptOut(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>,\n input: {\n orgId: string\n email: string\n source: OutreachOptOutSource\n enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId'> | null\n hostIds: readonly string[]\n detail: string | null\n nowMs: number\n /** The reply called the email spam (AGL-3245): the record says so. */\n complaint?: boolean\n },\n): Promise<{ stopped: number }> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { stopped: 0 }\n const firestore = deps.firestore()\n await addOutreachDoNotContact(firestore, {\n orgId: input.orgId,\n email,\n reason: input.source,\n source: 'runtime',\n nowMs: input.nowMs,\n enrollmentId: input.enrollment?.id ?? null,\n sequenceId: input.enrollment?.sequenceId ?? null,\n detail: input.detail,\n })\n for (const hostId of new Set(input.hostIds.filter(Boolean))) {\n await deps.optOutOfSalesTopic({ hostId, email })\n }\n // 4. the record the person is (AGL-3245): the link and the header say\n // \"unsubscribed\"; a reply that asked is the runtime's own list entry,\n // and one that called the email spam is a complaint.\n await deps.stampRecordEmailState({\n orgId: input.orgId,\n email,\n state: {\n status: input.complaint ? 'complained' : input.source === 'unsubscribe' ? 'unsubscribed' : 'do_not_contact',\n atMs: input.nowMs,\n source: 'sequence',\n detail: input.detail,\n ...(input.enrollment?.id ? { enrollmentId: input.enrollment.id } : {}),\n },\n })\n let stopped = 0\n if (input.enrollment) {\n const own = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: input.enrollment.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: input.source, detail: input.detail },\n nowMs: input.nowMs,\n })\n if (own.changed) stopped += 1\n }\n stopped += await optOutOtherOutreachEnrollments(firestore, {\n orgId: input.orgId,\n email,\n exceptEnrollmentId: input.enrollment?.id ?? null,\n nowMs: input.nowMs,\n detail:\n input.source === 'unsubscribe'\n ? 'Unsubscribed from another sequence.'\n : 'Asked not to be emailed in a reply to another sequence.',\n })\n return { stopped }\n}\n"],"names":["normalizeContactEmail","applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachOrgCollection","readStoredOutreachEnrollment","applyOutreachEvent","firestore","input","ref","orgId","doc","enrollmentId","runTransaction","transaction","snapshot","get","enrollment","exists","data","undefined","changed","patch","event","extra","written","updatedAtMs","nowMs","update","optOutOtherOutreachEnrollments","found","where","email","moved","docs","id","exceptEnrollmentId","includes","outcome","type","atMs","reason","detail","recordOutreachOptOut","deps","stopped","source","sequenceId","hostId","Set","hostIds","filter","Boolean","optOutOfSalesTopic","stampRecordEmailState","state","status","complaint","own"],"mappings":"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"}
@@ -24,6 +24,7 @@ import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
24
24
  import { sendOrgMemberNotice } from "@aglyn/tenant-data-admin/server/org-member-notice";
25
25
  import { logOrgActivity } from "@aglyn/tenant-data-admin/server/organizations";
26
26
  import { filterEnabledPluginsByReleaseFlags } from "@aglyn/tenant-data-admin/server/release-flags";
27
+ import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
27
28
  import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
28
29
  import { composeOutreachMailboxNotice } from "../engine/mailbox-notice.js";
29
30
  import { openOutreachMailboxClient } from "../mailboxes/mailbox-transport.js";
@@ -76,14 +77,20 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
76
77
  });
77
78
  },
78
79
  async suppressBouncedEmail ({ email, hostId }) {
80
+ // The runtime stamps the record itself, with the richer verdict — the
81
+ // domain block, the enrollment — so the list's own stamp stands down.
79
82
  await suppressEmail({
80
83
  email,
81
84
  reason: 'bounce',
82
85
  context: 'outreach',
83
86
  hostId,
87
+ stampRecord: false,
84
88
  firestore: firestore()
85
89
  });
86
90
  },
91
+ async stampRecordEmailState (stamp) {
92
+ await stampRecordEmailState(stamp);
93
+ },
87
94
  async notifyMailboxOwner (notice) {
88
95
  // The Mailboxes page, by the organization's slug — the page Resume and
89
96
  // Reconnect live on (AGL-3244). Without a slug or a console origin the
@@ -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 { 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 { 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 { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\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 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 await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, firestore: firestore() })\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 clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\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 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","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","suppressBouncedEmail","reason","context","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","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 { 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 { 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 { 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 { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\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 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 clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\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 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","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","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","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"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
  */
17
+ import type { EmailState } from '@aglyn/aglyn/app-utils/email-state';
17
18
  import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
18
19
  import type { OutreachMailboxNotice } from '../engine/mailbox-notice';
19
20
  import type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport';
@@ -48,6 +49,16 @@ export interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {
48
49
  /** The member whose mailbox it is. */
49
50
  connectedByUid: string;
50
51
  }
52
+ /**
53
+ * The verdict on an address, for the record the person is (AGL-3245): what
54
+ * the runtime wrote on a list, said on the lead and the contact that carry
55
+ * the address. The platform finds the records; the runtime names none.
56
+ */
57
+ export interface OutreachRecordEmailStamp {
58
+ orgId: string;
59
+ email: string;
60
+ state: EmailState;
61
+ }
51
62
  export interface OutreachRuntimeDeps {
52
63
  firestore(): FirebaseFirestore.Firestore;
53
64
  now(): number;
@@ -86,6 +97,12 @@ export interface OutreachRuntimeDeps {
86
97
  email: string;
87
98
  hostId: string | null;
88
99
  }): Promise<void>;
100
+ /**
101
+ * Stamps the verdict on the lead and the contact that carry the address
102
+ * (AGL-3245), beside every write the runtime makes to a list. Never
103
+ * throws: the list is the control, the stamp is what a person reads.
104
+ */
105
+ stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>;
89
106
  /**
90
107
  * Emails the mailbox's owner, and the organization's admins, that the
91
108
  * mailbox paused itself or needs reconnecting (AGL-3244), through 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 { 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 { OutreachClickTarget } from './click-link'\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\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 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 * 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 signed tracking link for one link in one email, or `null` when none\n * can be minted (AGL-3239) — no console origin, no signing secret, or a\n * destination we will not sign.\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 clickUrl(target: OutreachClickTarget): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAwCD,WAkDC"}
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 { OutreachClickTarget } from './click-link'\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\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 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 signed tracking link for one link in one email, or `null` when none\n * can be minted (AGL-3239) — no console origin, no signing secret, or a\n * destination we will not sign.\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 clickUrl(target: OutreachClickTarget): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAoDD,WAwDC"}
@@ -32,7 +32,7 @@ import { applyOutreachEvent, recordOutreachOptOut } from "./enrollment-events.js
32
32
  import { applyOutreachMailboxHealth, emptyOutreachHealthDelta } from "./mailbox-health-store.js";
33
33
  import { noteOutreachMailboxReconnectRequired } from "./mailbox-notices.js";
34
34
  import { markOutreachLeadWorking } from "./lead-records.js";
35
- import { fileOutreachEmail, fileOutreachTask } from "./timeline.js";
35
+ import { fileOutreachEmail, fileOutreachTask, markOutreachEmailDelivery } from "./timeline.js";
36
36
  import { outreachUnsubscribeMailbox } from "./unsubscribe-link.js";
37
37
  /*==========================================
38
38
  * THE SYNC JOB (AGL-2981): every fifteen minutes, on the console.
@@ -55,6 +55,11 @@ import { outreachUnsubscribeMailbox } from "./unsubscribe-link.js";
55
55
  * address being unknown (`engine/gateway-block`, AGL-3244), files the
56
56
  * DOMAIN on the do-not-contact list too, and says so on the enrollment.
57
57
  *
58
+ * Every verdict a list takes is said on the record the person is as well
59
+ * (AGL-3245): the lead and the contact carry `emailState` through the
60
+ * platform's stamp, and a bounce or a complaint lands on the send's own
61
+ * timeline entry, so a member reading the record sees what the runtime saw.
62
+ *
58
63
  * Four reads find those, each narrowed by Gmail rather than read in full:
59
64
  * the threads of this mailbox's enrollments that have new mail; delivery
60
65
  * reports outside those threads (a receiving server's own bounce), by
@@ -372,6 +377,7 @@ async function syncMailbox(deps, firestore, input) {
372
377
  var _ref;
373
378
  var _decidedBy_evidence;
374
379
  if (decidingMessage) await fileInbound(context, enrollment, decidingMessage);
380
+ const complaint = (decidedBy == null ? void 0 : decidedBy.complaint) === true;
375
381
  await recordOutreachOptOut(deps, {
376
382
  orgId: context.orgId,
377
383
  email: enrollment.email,
@@ -381,25 +387,38 @@ async function syncMailbox(deps, firestore, input) {
381
387
  enrollment.hostId
382
388
  ],
383
389
  detail: (_ref = decidedBy == null ? void 0 : decidedBy.evidence) != null ? _ref : null,
384
- nowMs
390
+ nowMs,
391
+ complaint
385
392
  });
393
+ // A reply that called the email spam marks the send it answered
394
+ // (AGL-3245), as the campaign webhook would mark a reported one.
395
+ if (complaint) {
396
+ var _ref1;
397
+ await markOutreachEmailDelivery(deps, {
398
+ orgId: context.orgId,
399
+ messageId: lastSentMessageId,
400
+ state: 'complained',
401
+ atMs: (decidedBy == null ? void 0 : decidedBy.atMs) || nowMs,
402
+ detail: (_ref1 = decidedBy == null ? void 0 : decidedBy.evidence) != null ? _ref1 : null
403
+ });
404
+ }
386
405
  report.optOuts += 1;
387
406
  break;
388
407
  }
389
408
  case 'replied':
390
409
  {
391
- var _ref1;
410
+ var _ref2;
392
411
  if (decidingMessage) {
393
412
  await fileInbound(context, enrollment, decidingMessage);
394
413
  try {
395
- var _messageIdOf, _ref2, _decidingMessage_textBody, _decidingMessage_htmlBody;
414
+ var _messageIdOf, _ref3, _decidingMessage_textBody, _decidingMessage_htmlBody;
396
415
  await fileOutreachTask(deps, {
397
416
  orgId: context.orgId,
398
417
  hostId: enrollment.hostId,
399
418
  link: outreachEnrollmentLink(enrollment),
400
419
  dedupeKey: `reply:${(_messageIdOf = messageIdOf(decidingMessage)) != null ? _messageIdOf : decidingMessage.id}`,
401
420
  title: `Reply from ${enrollment.contactName || enrollment.email}`,
402
- notes: crmInboundExcerpt((_ref2 = (_decidingMessage_textBody = decidingMessage.textBody) != null ? _decidingMessage_textBody : decidingMessage.snippet) != null ? _ref2 : '', (_decidingMessage_htmlBody = decidingMessage.htmlBody) != null ? _decidingMessage_htmlBody : '').slice(0, 500),
421
+ notes: crmInboundExcerpt((_ref3 = (_decidingMessage_textBody = decidingMessage.textBody) != null ? _decidingMessage_textBody : decidingMessage.snippet) != null ? _ref3 : '', (_decidingMessage_htmlBody = decidingMessage.htmlBody) != null ? _decidingMessage_htmlBody : '').slice(0, 500),
403
422
  kind: 'email',
404
423
  dueAtMs: nowMs,
405
424
  assigneeUid: context.mailbox.connectedByUid
@@ -419,16 +438,16 @@ async function syncMailbox(deps, firestore, input) {
419
438
  event = {
420
439
  type: 'reply',
421
440
  atMs: (decidedBy == null ? void 0 : decidedBy.atMs) || nowMs,
422
- detail: (_ref1 = decidedBy == null ? void 0 : decidedBy.evidence) != null ? _ref1 : null
441
+ detail: (_ref2 = decidedBy == null ? void 0 : decidedBy.evidence) != null ? _ref2 : null
423
442
  };
424
443
  report.replies += 1;
425
444
  break;
426
445
  }
427
446
  case 'bounced':
428
447
  {
429
- var _ref3, _ref4, _ref5;
448
+ var _ref4, _ref5, _ref6;
430
449
  var _decidedBy_bounce, _decidedBy_bounce1;
431
- const diagnostic = scrubbedOutreachDiagnostic((_ref3 = (_ref4 = decidedBy == null ? void 0 : (_decidedBy_bounce = decidedBy.bounce) == null ? void 0 : _decidedBy_bounce.diagnostic) != null ? _ref4 : decidedBy == null ? void 0 : (_decidedBy_bounce1 = decidedBy.bounce) == null ? void 0 : _decidedBy_bounce1.status) != null ? _ref3 : null, enrollment.email);
450
+ const diagnostic = scrubbedOutreachDiagnostic((_ref4 = (_ref5 = decidedBy == null ? void 0 : (_decidedBy_bounce = decidedBy.bounce) == null ? void 0 : _decidedBy_bounce.diagnostic) != null ? _ref5 : decidedBy == null ? void 0 : (_decidedBy_bounce1 = decidedBy.bounce) == null ? void 0 : _decidedBy_bounce1.status) != null ? _ref4 : null, enrollment.email);
432
451
  await deps.suppressBouncedEmail({
433
452
  email: enrollment.email,
434
453
  hostId: enrollment.hostId
@@ -449,7 +468,8 @@ async function syncMailbox(deps, firestore, input) {
449
468
  // Never a public mailbox provider's domain — Gmail refusing one
450
469
  // message on policy is not a reason to stop writing to Gmail.
451
470
  const domain = outreachEmailDomain(enrollment.email);
452
- if (domain && !isPublicMailboxDomain(domain) && isOutreachGatewayBlock((_ref5 = decidedBy == null ? void 0 : decidedBy.bounce) != null ? _ref5 : null)) {
471
+ const gateway = isOutreachGatewayBlock((_ref6 = decidedBy == null ? void 0 : decidedBy.bounce) != null ? _ref6 : null);
472
+ if (domain && !isPublicMailboxDomain(domain) && gateway) {
453
473
  await addOutreachDoNotContactDomain(firestore, {
454
474
  orgId: context.orgId,
455
475
  domain,
@@ -463,9 +483,31 @@ async function syncMailbox(deps, firestore, input) {
463
483
  detail = outreachGatewayBlockDetail(domain, diagnostic);
464
484
  report.gatewayBlocks += 1;
465
485
  }
486
+ const bouncedAtMs = (decidedBy == null ? void 0 : decidedBy.atMs) || nowMs;
487
+ // The record the person is says so too (AGL-3245): the verdict on the
488
+ // lead and the contact, and the bounce on the send's own timeline
489
+ // entry, so the record reads Sent, then Bounced.
490
+ await deps.stampRecordEmailState({
491
+ orgId: context.orgId,
492
+ email: enrollment.email,
493
+ state: {
494
+ status: gateway ? 'blocked' : 'bounced',
495
+ atMs: bouncedAtMs,
496
+ source: 'sequence',
497
+ detail: diagnostic,
498
+ enrollmentId: enrollment.id
499
+ }
500
+ });
501
+ await markOutreachEmailDelivery(deps, {
502
+ orgId: context.orgId,
503
+ messageId: lastSentMessageId,
504
+ state: 'bounced',
505
+ atMs: bouncedAtMs,
506
+ detail: diagnostic
507
+ });
466
508
  event = {
467
509
  type: 'bounce',
468
- atMs: (decidedBy == null ? void 0 : decidedBy.atMs) || nowMs,
510
+ atMs: bouncedAtMs,
469
511
  detail
470
512
  };
471
513
  report.bounces += 1;