@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.
- package/package.json +13 -13
- package/src/lib/engine/gates.d.ts +1 -1
- package/src/lib/engine/gates.js +6 -1
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/enrollment/enroll-people.js +6 -3
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/routes/enrollment-routes.js +13 -0
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +2 -1
- package/src/lib/routes/platform-deps.js +2 -1
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/register-routes.js +4 -1
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +6 -0
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/runtime/enrollment-events.d.ts +6 -2
- package/src/lib/runtime/enrollment-events.js +20 -3
- package/src/lib/runtime/enrollment-events.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.js +7 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +17 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/sync-job.js +52 -10
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/runtime/timeline.d.ts +10 -1
- package/src/lib/runtime/timeline.js +23 -1
- package/src/lib/runtime/timeline.js.map +1 -1
- package/src/lib/runtime/unsubscribe-route.d.ts +1 -1
- package/src/lib/runtime/unsubscribe-route.js.map +1 -1
|
@@ -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,
|
|
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 = (
|
|
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,
|
|
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
|
|
410
|
+
var _ref2;
|
|
392
411
|
if (decidingMessage) {
|
|
393
412
|
await fileInbound(context, enrollment, decidingMessage);
|
|
394
413
|
try {
|
|
395
|
-
var _messageIdOf,
|
|
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((
|
|
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: (
|
|
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
|
|
448
|
+
var _ref4, _ref5, _ref6;
|
|
430
449
|
var _decidedBy_bounce, _decidedBy_bounce1;
|
|
431
|
-
const diagnostic = scrubbedOutreachDiagnostic((
|
|
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
|
-
|
|
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:
|
|
510
|
+
atMs: bouncedAtMs,
|
|
469
511
|
detail
|
|
470
512
|
};
|
|
471
513
|
report.bounces += 1;
|