@aglyn/plugins-outreach 1.0.0-beta.163 → 1.0.0-beta.165
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/enrollment-activity.d.ts +61 -0
- package/src/lib/engine/enrollment-activity.js +57 -0
- package/src/lib/engine/enrollment-activity.js.map +1 -0
- package/src/lib/routes/enroll-routes.d.ts +7 -0
- package/src/lib/routes/enroll-routes.js +39 -1
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +10 -1
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/runtime/enrollment-events.d.ts +5 -3
- package/src/lib/runtime/enrollment-events.js +27 -3
- package/src/lib/runtime/enrollment-events.js.map +1 -1
- package/src/lib/runtime/sync-job.js +23 -1
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/runtime/timeline.d.ts +14 -0
- package/src/lib/runtime/timeline.js +28 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-outreach",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.165",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.165",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.165",
|
|
30
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.165",
|
|
31
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.165",
|
|
32
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.165",
|
|
33
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.165",
|
|
34
|
+
"@aglyn/shared-util-http": "1.0.0-beta.165",
|
|
35
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.165",
|
|
36
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.165",
|
|
37
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.165",
|
|
38
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.165",
|
|
39
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.165",
|
|
40
40
|
"@swc/helpers": "0.5.23"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* WHAT A SEQUENCE SAYS ON THE PERSON'S TIMELINE ABOUT ITSELF (AGL-3274).
|
|
19
|
+
*
|
|
20
|
+
* The sends and the replies were already there (AGL-2981); the two facts
|
|
21
|
+
* that frame them were not. A reader of a lead's Activity saw an email go
|
|
22
|
+
* out with no line saying the person had been put in a sequence, and saw
|
|
23
|
+
* the sequence's last email with no line saying why there was no next one.
|
|
24
|
+
* Two entries close that: "Enrolled in <sequence>" when the enroll route
|
|
25
|
+
* takes the person, with the campaigns the enrollment carried them into,
|
|
26
|
+
* and "<sequence> stopped" when a reply or an opt-out ends it. A bounce
|
|
27
|
+
* files nothing here — the send's own entry already reads Bounced with
|
|
28
|
+
* the server's words (AGL-3245), and a second line would say it twice.
|
|
29
|
+
*
|
|
30
|
+
* Pure, so the route and the runtime compose the same words and the
|
|
31
|
+
* backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can
|
|
32
|
+
* restate them and be pinned against this file. Each entry has ONE key
|
|
33
|
+
* per enrollment, so a run that files again finds what it wrote.
|
|
34
|
+
*/
|
|
35
|
+
/** The author every entry the runtime files is signed with. */
|
|
36
|
+
export declare const OUTREACH_TIMELINE_BY_NAME = "Sequences";
|
|
37
|
+
/** An entry as the timeline seam takes it: the caller's key, the words. */
|
|
38
|
+
export interface OutreachTimelineEntry {
|
|
39
|
+
dedupeKey: string;
|
|
40
|
+
body: string;
|
|
41
|
+
}
|
|
42
|
+
/** The key the enrollment's own entry is filed under, restated by the backfill. */
|
|
43
|
+
export declare function outreachEnrolledEntryKey(enrollmentId: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* "Enrolled in <sequence>", and the campaigns it carried the person into
|
|
46
|
+
* when the sequence was in any, named. A sequence with no name — never
|
|
47
|
+
* stored, but a document can lose one — reads "a sequence".
|
|
48
|
+
*/
|
|
49
|
+
export declare function outreachEnrolledEntry(input: {
|
|
50
|
+
enrollmentId: string;
|
|
51
|
+
sequenceName: string;
|
|
52
|
+
campaignNames: readonly string[];
|
|
53
|
+
}): OutreachTimelineEntry;
|
|
54
|
+
/** Why a sequence stopped, in the two ways a person ends one. */
|
|
55
|
+
export type OutreachStoppedReason = 'replied' | 'opted_out';
|
|
56
|
+
/** "<sequence> stopped: they replied" — the line under the last send. */
|
|
57
|
+
export declare function outreachStoppedEntry(input: {
|
|
58
|
+
enrollmentId: string;
|
|
59
|
+
sequenceName: string;
|
|
60
|
+
reason: OutreachStoppedReason;
|
|
61
|
+
}): OutreachTimelineEntry;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* WHAT A SEQUENCE SAYS ON THE PERSON'S TIMELINE ABOUT ITSELF (AGL-3274).
|
|
18
|
+
*
|
|
19
|
+
* The sends and the replies were already there (AGL-2981); the two facts
|
|
20
|
+
* that frame them were not. A reader of a lead's Activity saw an email go
|
|
21
|
+
* out with no line saying the person had been put in a sequence, and saw
|
|
22
|
+
* the sequence's last email with no line saying why there was no next one.
|
|
23
|
+
* Two entries close that: "Enrolled in <sequence>" when the enroll route
|
|
24
|
+
* takes the person, with the campaigns the enrollment carried them into,
|
|
25
|
+
* and "<sequence> stopped" when a reply or an opt-out ends it. A bounce
|
|
26
|
+
* files nothing here — the send's own entry already reads Bounced with
|
|
27
|
+
* the server's words (AGL-3245), and a second line would say it twice.
|
|
28
|
+
*
|
|
29
|
+
* Pure, so the route and the runtime compose the same words and the
|
|
30
|
+
* backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can
|
|
31
|
+
* restate them and be pinned against this file. Each entry has ONE key
|
|
32
|
+
* per enrollment, so a run that files again finds what it wrote.
|
|
33
|
+
*/ /** The author every entry the runtime files is signed with. */ export const OUTREACH_TIMELINE_BY_NAME = 'Sequences';
|
|
34
|
+
/** The key the enrollment's own entry is filed under, restated by the backfill. */ export function outreachEnrolledEntryKey(enrollmentId) {
|
|
35
|
+
return `enrolled:${enrollmentId}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* "Enrolled in <sequence>", and the campaigns it carried the person into
|
|
39
|
+
* when the sequence was in any, named. A sequence with no name — never
|
|
40
|
+
* stored, but a document can lose one — reads "a sequence".
|
|
41
|
+
*/ export function outreachEnrolledEntry(input) {
|
|
42
|
+
const sequence = input.sequenceName.trim() || 'a sequence';
|
|
43
|
+
const campaigns = input.campaignNames.map((name)=>name.trim()).filter(Boolean);
|
|
44
|
+
return {
|
|
45
|
+
dedupeKey: outreachEnrolledEntryKey(input.enrollmentId),
|
|
46
|
+
body: campaigns.length ? `Enrolled in ${sequence}\nFiled under ${campaigns.join(', ')}` : `Enrolled in ${sequence}`
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** "<sequence> stopped: they replied" — the line under the last send. */ export function outreachStoppedEntry(input) {
|
|
50
|
+
const sequence = input.sequenceName.trim() || 'The sequence';
|
|
51
|
+
return {
|
|
52
|
+
dedupeKey: `stopped:${input.enrollmentId}`,
|
|
53
|
+
body: `${sequence} stopped: ${input.reason === 'replied' ? 'they replied' : 'they opted out'}`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//# sourceMappingURL=enrollment-activity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A SEQUENCE SAYS ON THE PERSON'S TIMELINE ABOUT ITSELF (AGL-3274).\n *\n * The sends and the replies were already there (AGL-2981); the two facts\n * that frame them were not. A reader of a lead's Activity saw an email go\n * out with no line saying the person had been put in a sequence, and saw\n * the sequence's last email with no line saying why there was no next one.\n * Two entries close that: \"Enrolled in <sequence>\" when the enroll route\n * takes the person, with the campaigns the enrollment carried them into,\n * and \"<sequence> stopped\" when a reply or an opt-out ends it. A bounce\n * files nothing here — the send's own entry already reads Bounced with\n * the server's words (AGL-3245), and a second line would say it twice.\n *\n * Pure, so the route and the runtime compose the same words and the\n * backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can\n * restate them and be pinned against this file. Each entry has ONE key\n * per enrollment, so a run that files again finds what it wrote.\n */\n\n/** The author every entry the runtime files is signed with. */\nexport const OUTREACH_TIMELINE_BY_NAME = 'Sequences'\n\n/** An entry as the timeline seam takes it: the caller's key, the words. */\nexport interface OutreachTimelineEntry {\n dedupeKey: string\n body: string\n}\n\n/** The key the enrollment's own entry is filed under, restated by the backfill. */\nexport function outreachEnrolledEntryKey(enrollmentId: string): string {\n return `enrolled:${enrollmentId}`\n}\n\n/**\n * \"Enrolled in <sequence>\", and the campaigns it carried the person into\n * when the sequence was in any, named. A sequence with no name — never\n * stored, but a document can lose one — reads \"a sequence\".\n */\nexport function outreachEnrolledEntry(input: {\n enrollmentId: string\n sequenceName: string\n campaignNames: readonly string[]\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'a sequence'\n const campaigns = input.campaignNames.map((name) => name.trim()).filter(Boolean)\n return {\n dedupeKey: outreachEnrolledEntryKey(input.enrollmentId),\n body: campaigns.length\n ? `Enrolled in ${sequence}\\nFiled under ${campaigns.join(', ')}`\n : `Enrolled in ${sequence}`,\n }\n}\n\n/** Why a sequence stopped, in the two ways a person ends one. */\nexport type OutreachStoppedReason = 'replied' | 'opted_out'\n\n/** \"<sequence> stopped: they replied\" — the line under the last send. */\nexport function outreachStoppedEntry(input: {\n enrollmentId: string\n sequenceName: string\n reason: OutreachStoppedReason\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'The sequence'\n return {\n dedupeKey: `stopped:${input.enrollmentId}`,\n body: `${sequence} stopped: ${input.reason === 'replied' ? 'they replied' : 'they opted out'}`,\n }\n}\n"],"names":["OUTREACH_TIMELINE_BY_NAME","outreachEnrolledEntryKey","enrollmentId","outreachEnrolledEntry","input","sequence","sequenceName","trim","campaigns","campaignNames","map","name","filter","Boolean","dedupeKey","body","length","join","outreachStoppedEntry","reason"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;CAiBC,GAED,6DAA6D,GAC7D,OAAO,MAAMA,4BAA4B,YAAW;AAQpD,iFAAiF,GACjF,OAAO,SAASC,yBAAyBC,YAAoB;IAC3D,OAAO,CAAC,SAAS,EAAEA,cAAc;AACnC;AAEA;;;;CAIC,GACD,OAAO,SAASC,sBAAsBC,KAIrC;IACC,MAAMC,WAAWD,MAAME,YAAY,CAACC,IAAI,MAAM;IAC9C,MAAMC,YAAYJ,MAAMK,aAAa,CAACC,GAAG,CAAC,CAACC,OAASA,KAAKJ,IAAI,IAAIK,MAAM,CAACC;IACxE,OAAO;QACLC,WAAWb,yBAAyBG,MAAMF,YAAY;QACtDa,MAAMP,UAAUQ,MAAM,GAClB,CAAC,YAAY,EAAEX,SAAS,cAAc,EAAEG,UAAUS,IAAI,CAAC,OAAO,GAC9D,CAAC,YAAY,EAAEZ,UAAU;IAC/B;AACF;AAKA,uEAAuE,GACvE,OAAO,SAASa,qBAAqBd,KAIpC;IACC,MAAMC,WAAWD,MAAME,YAAY,CAACC,IAAI,MAAM;IAC9C,OAAO;QACLO,WAAW,CAAC,QAAQ,EAAEV,MAAMF,YAAY,EAAE;QAC1Ca,MAAM,GAAGV,SAAS,UAAU,EAAED,MAAMe,MAAM,KAAK,YAAY,iBAAiB,kBAAkB;IAChG;AACF"}
|
|
@@ -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 { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
|
|
17
18
|
import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
|
|
18
19
|
import { type OutreachRouteDeps } from './route-deps';
|
|
19
20
|
import { type OutreachRouteExtraPermission } from './route-gate';
|
|
@@ -54,6 +55,12 @@ export interface OutreachEnrollRouteDeps extends OutreachRouteDeps {
|
|
|
54
55
|
emails: string[];
|
|
55
56
|
complete: boolean;
|
|
56
57
|
}>;
|
|
58
|
+
/**
|
|
59
|
+
* The workspace's record system on the timeline seam (AGL-3274), when a
|
|
60
|
+
* plugin keeps one: the enroll files "Enrolled in" on the person's record
|
|
61
|
+
* — the runtime's `timeline`, reached the same way.
|
|
62
|
+
*/
|
|
63
|
+
timeline(): PluginRecordTimelineWriter | null;
|
|
57
64
|
}
|
|
58
65
|
export interface OutreachEnrollRoutes {
|
|
59
66
|
preview: PluginWebApiHandler;
|
|
@@ -20,13 +20,15 @@ import { CRM_COLLECTIONS, crmLeadStatus, crmViewIsListed, isCrmLeadOpen, normali
|
|
|
20
20
|
import { dynamicListDimensionsForCrmView } from "@aglyn/aglyn/app-utils/dynamic-list-rule";
|
|
21
21
|
import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
22
22
|
import { FieldValue } from "firebase-admin/firestore";
|
|
23
|
+
import { outreachEnrolledEntry } from "../engine/enrollment-activity.js";
|
|
23
24
|
import { buildOutreachEnrollment, planOutreachFirstDue } from "../engine/enrollment-state.js";
|
|
24
25
|
import { decideOutreachEnrollment, previewOutreachPerson, readOutreachEnrollCandidates } from "../enrollment/enroll-people.js";
|
|
25
26
|
import { readOutreachGateLookups } from "../enrollment/gate-lookups.js";
|
|
26
27
|
import { effectiveOutreachAllowedCountries } from "../model/compliance-settings.js";
|
|
27
28
|
import { OUTREACH_ENROLL_BATCH_MAX } from "../model/outreach-api.js";
|
|
28
29
|
import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
|
|
29
|
-
import {
|
|
30
|
+
import { fileOutreachNote } from "../runtime/timeline.js";
|
|
31
|
+
import { outreachEnrollmentId, outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
|
|
30
32
|
import { OUTREACH_SEQUENCE_ACTIVITY_TARGET } from "./route-deps.js";
|
|
31
33
|
import { outreachRouteGate } from "./route-gate.js";
|
|
32
34
|
import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocumentId, readOutreachJsonBody } from "./route-http.js";
|
|
@@ -209,6 +211,26 @@ export function createOutreachEnrollRoutes(deps) {
|
|
|
209
211
|
}
|
|
210
212
|
}
|
|
211
213
|
/**
|
|
214
|
+
* The names of the sequence's campaigns as they stand, for the entry the
|
|
215
|
+
* enroll files on the person's record (AGL-3274). Read once per request,
|
|
216
|
+
* not per person; a container that is gone answers nothing and the entry
|
|
217
|
+
* names the rest.
|
|
218
|
+
*/ async function sequenceCampaignNames(firestore, sequence) {
|
|
219
|
+
const campaignIds = normalizeCampaignIds(sequence.campaignIds);
|
|
220
|
+
if (!campaignIds.length) return [];
|
|
221
|
+
try {
|
|
222
|
+
const containers = firestore.collection('hosts').doc(sequence.hostId).collection('emailCampaigns');
|
|
223
|
+
const found = await firestore.getAll(...campaignIds.map((id)=>containers.doc(id)));
|
|
224
|
+
return found.map((snapshot)=>{
|
|
225
|
+
var _snapshot_get;
|
|
226
|
+
return String((_snapshot_get = snapshot.get('name')) != null ? _snapshot_get : '').trim();
|
|
227
|
+
}).filter(Boolean);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error('[outreach] the sequence’s campaigns could not be named for the record', error);
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
212
234
|
* The people a source names, the batch limit applied: every contact a
|
|
213
235
|
* search picked, every lead picked from the sequence's site, or the
|
|
214
236
|
* people a saved Contacts or Leads view selects there — contacts found by
|
|
@@ -364,6 +386,7 @@ export function createOutreachEnrollRoutes(deps) {
|
|
|
364
386
|
...requested.values()
|
|
365
387
|
].map((entry)=>entry.ref));
|
|
366
388
|
const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments');
|
|
389
|
+
const campaignNames = await sequenceCampaignNames(firestore, sequence);
|
|
367
390
|
const results = await Promise.all(candidates.map(async (candidate)=>{
|
|
368
391
|
var _requested_get, _lookups_get;
|
|
369
392
|
const named = {
|
|
@@ -430,6 +453,21 @@ export function createOutreachEnrollRoutes(deps) {
|
|
|
430
453
|
});
|
|
431
454
|
}
|
|
432
455
|
await joinSequenceCampaigns(firestore, caller.orgId, sequence, context.contactGroupId, candidate, nowMs);
|
|
456
|
+
// The person's record says so (AGL-3274): "Enrolled in <sequence>",
|
|
457
|
+
// with the campaigns it carried them into, once per enrollment.
|
|
458
|
+
const entry = outreachEnrolledEntry({
|
|
459
|
+
enrollmentId: id,
|
|
460
|
+
sequenceName: sequence.name,
|
|
461
|
+
campaignNames
|
|
462
|
+
});
|
|
463
|
+
await fileOutreachNote(deps, {
|
|
464
|
+
orgId: caller.orgId,
|
|
465
|
+
hostId: sequence.hostId,
|
|
466
|
+
link: outreachEnrollmentLink(enrollment),
|
|
467
|
+
dedupeKey: entry.dedupeKey,
|
|
468
|
+
body: entry.body,
|
|
469
|
+
atMs: nowMs
|
|
470
|
+
});
|
|
433
471
|
return _extends({}, named, {
|
|
434
472
|
email: decision.email,
|
|
435
473
|
outcome: 'enrolled',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/enroll-routes.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 {\n CAMPAIGN_MEMBERSHIP_FIELD,\n contactCampaignFieldPath,\n normalizeCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n CRM_COLLECTIONS,\n crmLeadStatus,\n crmViewIsListed,\n isCrmLeadOpen,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { buildOutreachEnrollment, planOutreachFirstDue } from '../engine/enrollment-state'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport {\n decideOutreachEnrollment,\n previewOutreachPerson,\n readOutreachEnrollCandidates,\n type OutreachEnrollCandidate,\n type OutreachGateQuestion,\n} from '../enrollment/enroll-people'\nimport { readOutreachGateLookups } from '../enrollment/gate-lookups'\nimport { effectiveOutreachAllowedCountries } from '../model/compliance-settings'\nimport {\n OUTREACH_ENROLL_BATCH_MAX,\n type OutreachEnrollOutcome,\n type OutreachEnrollPreviewResponse,\n type OutreachEnrollResponse,\n type OutreachPersonRef,\n} from '../model/outreach-api'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachEnrollmentId,\n outreachOrgCollection,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller, type OutreachRouteExtraPermission } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ENROLLING PEOPLE IN A SEQUENCE (AGL-2980): `outreach/enroll/preview` and\n * `outreach/enroll`.\n *\n * The preview reads the people a saved Contacts view or a search named —\n * at most {@link OUTREACH_ENROLL_BATCH_MAX} — and says where each stands:\n * eligible, blocked with the gate's own sentence, or waiting on the rep for\n * a personal line and the attestations only they can make. Confirm reads\n * everything again and asks the gates again with what the rep supplied,\n * because a preview is a picture of a moment: someone can unsubscribe\n * between the two, and a request can claim anything. Nothing the preview\n * answered is trusted by the enroll.\n *\n * Both read the CRM's people, so both ask for `data.manage` beside\n * `outreach.use` — the permission the contacts' own rules read with, so\n * Outreach is no side door into the address book.\n *\n * Only an ACTIVE sequence takes people: enrolling schedules the first step\n * in the mailbox's hours, and a draft's steps and a paused sequence's timing\n * are not yet what will be sent.\n */\n\n/** The permission enrolling reads the CRM with. */\nexport const OUTREACH_READS_PEOPLE: OutreachRouteExtraPermission = {\n key: 'data.manage',\n refusal: \"Enrolling reads your CRM's contacts, and your role does not include Manage data.\",\n}\n\n/** Everything enrolling reaches beyond the routes' own dependencies. */\nexport interface OutreachEnrollRouteDeps extends OutreachRouteDeps {\n /**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read the way the dynamic-list sweep reads one, and whether the read\n * reached the whole view before its budget.\n */\n crmViewEmails(input: { hostId: string; viewId: string }): Promise<{ emails: string[]; complete: boolean }>\n}\n\nexport interface OutreachEnrollRoutes {\n preview: PluginWebApiHandler\n confirm: PluginWebApiHandler\n}\n\n/** The activity line an enroll writes. */\nexport function outreachEnrolledActivity(count: number): string {\n return count === 1\n ? 'Enrolled 1 person in a sequence'\n : `Enrolled ${count} people in a sequence`\n}\n\n/** Lookups for a person the gates will never be asked about: every one unchecked. */\nconst UNCHECKED: OutreachGateLookups = {\n platformSuppressed: null,\n hostSuppressed: null,\n salesTopicState: null,\n doNotContact: null,\n doNotContactDomain: null,\n workspaceMembers: null,\n openEnrollments: null,\n hasInboundEmail: null,\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\nasync function loadActiveSequence(\n firestore: Firestore,\n orgId: string,\n rawSequenceId: unknown,\n): Promise<Response | OutreachSequence> {\n const sequenceId = readOutreachDocumentId(rawSequenceId)\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to enroll people in.')\n const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get()\n const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n if (sequence.status === 'archived') {\n return outreachRefusal(409, 'sequence-archived', 'This sequence is archived and takes no one new.')\n }\n if (sequence.status !== 'active') {\n return outreachRefusal(409, 'sequence-not-active', 'Activate this sequence before enrolling people.')\n }\n return sequence\n}\n\n/** What every person in one request is judged against. */\ninterface EnrollContext {\n settings: OutreachGateQuestion['settings']\n contactGroupId: string\n siteName: string\n}\n\nasync function enrollContext(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n): Promise<EnrollContext> {\n const [orgSettings, host] = await Promise.all([\n readOutreachComplianceSettingsDoc(firestore, caller.orgId),\n firestore.collection('hosts').doc(sequence.hostId).get(),\n ])\n return {\n // The organization's countries are a ceiling over the sequence's.\n settings: {\n ...sequence.settings,\n allowedCountries: effectiveOutreachAllowedCountries(\n sequence.settings.allowedCountries,\n orgSettings.allowedCountries,\n ),\n },\n contactGroupId: consentGroupForHost(caller.org, sequence.hostId).groupId,\n siteName: host.exists ? String(host.get('name') ?? '') : '',\n }\n}\n\n/**\n * The candidates, the enrollments they already have here, and the gates'\n * lookups — every map keyed by the person's own id.\n *\n * \"Already in this sequence\" is answered two ways (AGL-3234): by the id the\n * person's enrollment would have, and by any enrollment in this sequence\n * that carries the person's ADDRESS. The second is what keeps a contact\n * from being enrolled beside the lead they were converted from: the lead's\n * enrollment followed them, under the lead's key, and still names them.\n */\nasync function readPeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n context: EnrollContext,\n people: readonly OutreachPersonRef[],\n): Promise<{\n candidates: OutreachEnrollCandidate[]\n enrolledHere: Set<string>\n lookups: Map<string, OutreachGateLookups>\n}> {\n const candidates = await readOutreachEnrollCandidates(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n contactGroupId: context.contactGroupId,\n people,\n })\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const existing = candidates.length\n ? await firestore.getAll(\n ...candidates.map((candidate) => enrollments.doc(outreachEnrollmentId(sequence.id, candidate.personId))),\n )\n : []\n const enrolledHere = new Set(\n candidates.filter((_candidate, index) => existing[index]?.exists).map((candidate) => candidate.personId),\n )\n const askable = candidates.filter((candidate) => candidate.contact && candidate.visible && candidate.email)\n const lookups = askable.length\n ? await readOutreachGateLookups(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n people: askable.map((candidate) => ({\n personId: candidate.personId,\n contactId: candidate.contactId || null,\n leadId: candidate.leadId,\n email: String(candidate.email),\n })),\n })\n : new Map<string, OutreachGateLookups>()\n for (const candidate of askable) {\n const open = lookups.get(candidate.personId)?.openEnrollments ?? []\n if (open.some((entry) => entry.sequenceId === sequence.id)) enrolledHere.add(candidate.personId)\n }\n return { candidates, enrolledHere, lookups }\n}\n\nconst uniqueIds = (values: unknown): string[] =>\n Array.isArray(values)\n ? [...new Set(values.map(readOutreachDocumentId).filter((id): id is string => id !== null))]\n : []\n\n/** The most leads a saved Leads view reads — the Leads list's own window. */\nconst LEADS_VIEW_WINDOW = 200\n\n/**\n * The people a saved LEADS view selects (AGL-3234): the sequence's site's\n * most recently seen leads, narrowed by the view's status clause the way\n * the Leads list narrows its window — open leads when the view names no\n * status, one status when it does, everything for `all`. A lead is the\n * site's own, so there is no address to resolve: the person key IS the id.\n */\nasync function leadsViewPeople(\n firestore: Firestore,\n sequence: OutreachSequence,\n filters: ReturnType<typeof normalizeCrmViewFilters>,\n): Promise<{ people: OutreachPersonRef[]; total: number; truncated: boolean }> {\n const statusClause = filters.find((clause) => clause.field === 'status' && clause.op === 'equals')\n const wanted = String(statusClause?.value ?? 'open')\n const window = await firestore\n .collection('hosts')\n .doc(sequence.hostId)\n .collection('leads')\n .orderBy('lastSeenAtMs', 'desc')\n .limit(LEADS_VIEW_WINDOW + 1)\n .get()\n const matching = window.docs.slice(0, LEADS_VIEW_WINDOW).filter((doc) => {\n const lead = doc.data() as Record<string, unknown>\n if (wanted === 'all') return true\n if (wanted === 'open') return isCrmLeadOpen(lead as never) && !lead['convertedContactId']\n return crmLeadStatus(lead as never) === wanted\n })\n return {\n people: matching.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((doc) => ({ kind: 'lead', id: doc.id })),\n total: matching.length,\n truncated: matching.length > OUTREACH_ENROLL_BATCH_MAX || window.docs.length > LEADS_VIEW_WINDOW,\n }\n}\n\nexport function createOutreachEnrollRoutes(deps: OutreachEnrollRouteDeps): OutreachEnrollRoutes {\n /**\n * The person joins the sequence's campaigns (AGL-3254), the moment they\n * are enrolled: `arrayUnion` on the lead's own `campaignIds`, or on the\n * contact's facet for the sequence's site — the same field, the same\n * shape, every other campaign member carries — and `enrolled` credited\n * to each campaign. After the enrollment is created and never before:\n * a person the create refused joins nothing. Neither write may fail the\n * enrollment, which already exists; a membership that could not be\n * stamped is one the list's bulk bar can add by hand.\n */\n async function joinSequenceCampaigns(\n firestore: Firestore,\n orgId: string,\n sequence: OutreachSequence,\n contactGroupId: string,\n candidate: OutreachEnrollCandidate,\n nowMs: number,\n ): Promise<void> {\n const campaignIds = normalizeCampaignIds(sequence.campaignIds)\n if (!campaignIds.length) return\n try {\n const ref =\n candidate.target === 'lead' && candidate.leadId\n ? firestore.collection('hosts').doc(sequence.hostId).collection('leads').doc(candidate.leadId)\n : candidate.contactId\n ? firestore.collection('orgs').doc(orgId).collection('contacts').doc(candidate.contactId)\n : null\n if (ref) {\n const field =\n candidate.target === 'lead' ? CAMPAIGN_MEMBERSHIP_FIELD : contactCampaignFieldPath(contactGroupId)\n await ref.update({ [field]: FieldValue.arrayUnion(...campaignIds), updatedAt: FieldValue.serverTimestamp() })\n }\n } catch (error) {\n console.error('[outreach] the enrolled person could not join the sequence’s campaigns', error)\n }\n try {\n await deps.creditCampaign({ hostId: sequence.hostId, campaignIds, outcome: 'enrolled', atMs: nowMs })\n } catch (error) {\n console.error('[outreach] the enrollment could not be credited to the sequence’s campaigns', error)\n }\n }\n\n /**\n * The people a source names, the batch limit applied: every contact a\n * search picked, every lead picked from the sequence's site, or the\n * people a saved Contacts or Leads view selects there — contacts found by\n * address, leads by their own key.\n */\n async function sourcePeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n source: Record<string, unknown>,\n ): Promise<Response | { people: OutreachPersonRef[]; total: number; truncated: boolean }> {\n if (source['kind'] === 'contacts' || source['kind'] === 'leads') {\n const kind = source['kind'] === 'leads' ? 'lead' : 'contact'\n const ids = uniqueIds(kind === 'lead' ? source['leadIds'] : source['contactIds'])\n if (!ids.length) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n return {\n people: ids.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((id) => ({ kind, id })),\n total: ids.length,\n truncated: ids.length > OUTREACH_ENROLL_BATCH_MAX,\n }\n }\n if (source['kind'] !== 'view') {\n return outreachRefusal(400, 'invalid-request', 'Enroll people from a saved view or a search.')\n }\n const viewId = readOutreachDocumentId(source['viewId'])\n const view = viewId\n ? await firestore.collection('orgs').doc(caller.orgId).collection(CRM_COLLECTIONS.views).doc(viewId).get()\n : null\n const data = view?.exists ? (view.data() as Record<string, unknown>) : null\n // A colleague's private view is theirs: it is not listed for this\n // reader, so it does not exist for them here either.\n if (\n !viewId ||\n !data ||\n !crmViewIsListed({ shared: data['shared'] === true, ownerUid: String(data['ownerUid'] ?? '') }, caller.uid)\n ) {\n return outreachRefusal(404, 'view-not-found', 'That saved view no longer exists.')\n }\n if (data['section'] === 'leads') {\n return leadsViewPeople(firestore, sequence, normalizeCrmViewFilters(data['filters']))\n }\n if (data['section'] !== 'contacts') {\n return outreachRefusal(400, 'view-unsupported', 'Enroll from a saved view of Contacts or Leads.')\n }\n const { unsupported } = dynamicListDimensionsForCrmView(normalizeCrmViewFilters(data['filters']))\n if (unsupported.length) {\n // Dropping the filter enrolling cannot read would enroll more people\n // than the view shows, so the view is refused whole.\n return outreachRefusal(\n 400,\n 'view-unsupported',\n `This view filters on ${unsupported.map((clause) => clause.label || clause.field).join(', ')}, which enrolling can't apply. Pick another view, or search.`,\n )\n }\n const { emails, complete } = await deps.crmViewEmails({ hostId: sequence.hostId, viewId })\n const ordered = [...new Set(emails)].sort()\n const contacts = firestore.collection('orgs').doc(caller.orgId).collection('contacts')\n const found = await Promise.all(\n ordered\n .slice(0, OUTREACH_ENROLL_BATCH_MAX)\n .map((email) => findContactByEmail(contacts, email, { hostId: sequence.hostId })),\n )\n return {\n people: [...new Set(found.filter((snapshot) => snapshot).map((snapshot) => String(snapshot?.id)))].map(\n (id) => ({ kind: 'contact', id }),\n ),\n total: ordered.length,\n truncated: ordered.length > OUTREACH_ENROLL_BATCH_MAX || !complete,\n }\n }\n\n const preview: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n const source = body['source'] && typeof body['source'] === 'object' ? (body['source'] as Record<string, unknown>) : {}\n const named = await sourcePeople(firestore, caller, sequence, source)\n if (named instanceof Response) return named\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n named.people,\n )\n const people = candidates.map((candidate) =>\n previewOutreachPerson(\n {\n candidate,\n lookups: lookups.get(candidate.personId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.personId) },\n ),\n )\n return outreachOk({\n ok: true,\n people,\n total: named.total,\n truncated: named.truncated,\n } satisfies OutreachEnrollPreviewResponse)\n }\n\n const confirm: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n\n // Each person once, by whichever record they are: a contact by id, or a\n // lead by its key (AGL-3234). A person named both ways is a contact.\n const requested = new Map<\n string,\n { ref: OutreachPersonRef; personalLine: string; attestations: unknown[] }\n >()\n for (const entry of Array.isArray(body['people']) ? body['people'] : []) {\n const person = (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>\n const contactId = readOutreachDocumentId(person['contactId'])\n const leadId = readOutreachDocumentId(person['leadId'])\n const ref: OutreachPersonRef | null = contactId\n ? { kind: 'contact', id: contactId }\n : leadId\n ? { kind: 'lead', id: leadId }\n : null\n if (!ref || requested.has(ref.id)) continue\n requested.set(ref.id, {\n ref,\n personalLine: typeof person['personalLine'] === 'string' ? person['personalLine'] : '',\n attestations: Array.isArray(person['attestations']) ? person['attestations'] : [],\n })\n }\n if (!requested.size) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n if (requested.size > OUTREACH_ENROLL_BATCH_MAX) {\n return outreachRefusal(\n 400,\n 'invalid-request',\n `Enroll at most ${OUTREACH_ENROLL_BATCH_MAX} people at a time.`,\n )\n }\n\n const mailboxSnapshot = sequence.mailboxId\n ? await outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get()\n : null\n const mailbox = readStoredOutreachMailbox(\n sequence.mailboxId,\n mailboxSnapshot?.exists ? mailboxSnapshot.data() : undefined,\n )\n if (!mailbox || mailbox.status === 'disconnected') {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's mailbox is no longer connected. Choose another mailbox for it before enrolling people.\",\n )\n }\n const nowMs = deps.now()\n if (planOutreachFirstDue({ sequence, mailbox, enrolledAtMs: nowMs, random: deps.random }) === null) {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's sending hours never open in its mailbox's timezone. Fix the hours before enrolling people.\",\n )\n }\n\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n [...requested.values()].map((entry) => entry.ref),\n )\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const results: OutreachEnrollOutcome[] = await Promise.all(\n candidates.map(async (candidate): Promise<OutreachEnrollOutcome> => {\n const named = {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n const supplied = requested.get(candidate.personId) ?? { personalLine: '', attestations: [] }\n const decision = decideOutreachEnrollment(\n {\n candidate,\n lookups: lookups.get(candidate.personId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.personId) },\n { personalLine: supplied.personalLine, attestations: supplied.attestations, uid: caller.uid, nowMs },\n )\n if (!decision.allowed || !decision.email) {\n return { ...named, email: candidate.email, outcome: 'blocked', blocks: decision.blocks }\n }\n const id = outreachEnrollmentId(sequence.id, candidate.personId)\n const enrollment = buildOutreachEnrollment({\n id,\n sequence,\n mailbox,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n contactName: candidate.name,\n email: decision.email,\n cold: decision.cold,\n personalLine: supplied.personalLine,\n attestations: decision.attestations,\n enrolledByUid: caller.uid,\n nowMs,\n random: deps.random,\n })\n try {\n await enrollments.doc(id).create(enrollment)\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n return {\n ...named,\n email: candidate.email,\n outcome: 'blocked',\n blocks: [\n {\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n },\n ],\n }\n }\n await joinSequenceCampaigns(firestore, caller.orgId, sequence, context.contactGroupId, candidate, nowMs)\n return { ...named, email: decision.email, outcome: 'enrolled', enrollmentId: id }\n }),\n )\n const enrolled = results.filter((result) => result.outcome === 'enrolled').length\n if (enrolled) {\n await deps.logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n outreachEnrolledActivity(enrolled),\n { type: OUTREACH_SEQUENCE_ACTIVITY_TARGET, id: sequence.id, name: sequence.name },\n )\n }\n return outreachOk({ ok: true, results, enrolled } satisfies OutreachEnrollResponse)\n }\n\n return { preview, confirm }\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","contactCampaignFieldPath","normalizeCampaignIds","consentGroupForHost","CRM_COLLECTIONS","crmLeadStatus","crmViewIsListed","isCrmLeadOpen","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","findContactByEmail","FieldValue","buildOutreachEnrollment","planOutreachFirstDue","decideOutreachEnrollment","previewOutreachPerson","readOutreachEnrollCandidates","readOutreachGateLookups","effectiveOutreachAllowedCountries","OUTREACH_ENROLL_BATCH_MAX","readOutreachComplianceSettingsDoc","outreachEnrollmentId","outreachOrgCollection","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","OUTREACH_READS_PEOPLE","key","refusal","outreachEnrolledActivity","count","UNCHECKED","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","doNotContactDomain","workspaceMembers","openEnrollments","hasInboundEmail","ALREADY_EXISTS","loadActiveSequence","firestore","orgId","rawSequenceId","sequenceId","snapshot","doc","get","sequence","exists","data","undefined","status","enrollContext","caller","host","orgSettings","Promise","all","collection","hostId","settings","allowedCountries","contactGroupId","org","groupId","siteName","String","readPeople","context","people","candidates","enrollments","existing","length","getAll","map","candidate","id","personId","enrolledHere","Set","filter","_candidate","index","askable","contact","visible","email","lookups","contactId","leadId","Map","open","some","entry","add","uniqueIds","values","Array","isArray","LEADS_VIEW_WINDOW","leadsViewPeople","filters","statusClause","find","clause","field","op","wanted","value","window","orderBy","limit","matching","docs","slice","lead","kind","total","truncated","createOutreachEnrollRoutes","deps","joinSequenceCampaigns","nowMs","campaignIds","ref","target","update","arrayUnion","updatedAt","serverTimestamp","error","console","creditCampaign","outcome","atMs","sourcePeople","source","ids","viewId","view","views","shared","ownerUid","uid","unsupported","label","join","emails","complete","crmViewEmails","ordered","sort","contacts","found","preview","request","method","body","gate","Response","named","alreadyInSequence","has","ok","confirm","requested","person","set","personalLine","attestations","size","mailboxSnapshot","mailboxId","mailbox","now","enrolledAtMs","random","results","supplied","decision","allowed","blocks","enrollment","contactName","name","cold","enrolledByUid","create","code","reason","enrollmentId","enrolled","result","logOrgActivity","type"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/enroll-routes.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 {\n CAMPAIGN_MEMBERSHIP_FIELD,\n contactCampaignFieldPath,\n normalizeCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n CRM_COLLECTIONS,\n crmLeadStatus,\n crmViewIsListed,\n isCrmLeadOpen,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachEnrolledEntry } from '../engine/enrollment-activity'\nimport { buildOutreachEnrollment, planOutreachFirstDue } from '../engine/enrollment-state'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport {\n decideOutreachEnrollment,\n previewOutreachPerson,\n readOutreachEnrollCandidates,\n type OutreachEnrollCandidate,\n type OutreachGateQuestion,\n} from '../enrollment/enroll-people'\nimport { readOutreachGateLookups } from '../enrollment/gate-lookups'\nimport { effectiveOutreachAllowedCountries } from '../model/compliance-settings'\nimport {\n OUTREACH_ENROLL_BATCH_MAX,\n type OutreachEnrollOutcome,\n type OutreachEnrollPreviewResponse,\n type OutreachEnrollResponse,\n type OutreachPersonRef,\n} from '../model/outreach-api'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport { fileOutreachNote } from '../runtime/timeline'\nimport {\n outreachEnrollmentId,\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller, type OutreachRouteExtraPermission } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ENROLLING PEOPLE IN A SEQUENCE (AGL-2980): `outreach/enroll/preview` and\n * `outreach/enroll`.\n *\n * The preview reads the people a saved Contacts view or a search named —\n * at most {@link OUTREACH_ENROLL_BATCH_MAX} — and says where each stands:\n * eligible, blocked with the gate's own sentence, or waiting on the rep for\n * a personal line and the attestations only they can make. Confirm reads\n * everything again and asks the gates again with what the rep supplied,\n * because a preview is a picture of a moment: someone can unsubscribe\n * between the two, and a request can claim anything. Nothing the preview\n * answered is trusted by the enroll.\n *\n * Both read the CRM's people, so both ask for `data.manage` beside\n * `outreach.use` — the permission the contacts' own rules read with, so\n * Outreach is no side door into the address book.\n *\n * Only an ACTIVE sequence takes people: enrolling schedules the first step\n * in the mailbox's hours, and a draft's steps and a paused sequence's timing\n * are not yet what will be sent.\n */\n\n/** The permission enrolling reads the CRM with. */\nexport const OUTREACH_READS_PEOPLE: OutreachRouteExtraPermission = {\n key: 'data.manage',\n refusal: \"Enrolling reads your CRM's contacts, and your role does not include Manage data.\",\n}\n\n/** Everything enrolling reaches beyond the routes' own dependencies. */\nexport interface OutreachEnrollRouteDeps extends OutreachRouteDeps {\n /**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read the way the dynamic-list sweep reads one, and whether the read\n * reached the whole view before its budget.\n */\n crmViewEmails(input: { hostId: string; viewId: string }): Promise<{ emails: string[]; complete: boolean }>\n /**\n * The workspace's record system on the timeline seam (AGL-3274), when a\n * plugin keeps one: the enroll files \"Enrolled in\" on the person's record\n * — the runtime's `timeline`, reached the same way.\n */\n timeline(): PluginRecordTimelineWriter | null\n}\n\nexport interface OutreachEnrollRoutes {\n preview: PluginWebApiHandler\n confirm: PluginWebApiHandler\n}\n\n/** The activity line an enroll writes. */\nexport function outreachEnrolledActivity(count: number): string {\n return count === 1\n ? 'Enrolled 1 person in a sequence'\n : `Enrolled ${count} people in a sequence`\n}\n\n/** Lookups for a person the gates will never be asked about: every one unchecked. */\nconst UNCHECKED: OutreachGateLookups = {\n platformSuppressed: null,\n hostSuppressed: null,\n salesTopicState: null,\n doNotContact: null,\n doNotContactDomain: null,\n workspaceMembers: null,\n openEnrollments: null,\n hasInboundEmail: null,\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\nasync function loadActiveSequence(\n firestore: Firestore,\n orgId: string,\n rawSequenceId: unknown,\n): Promise<Response | OutreachSequence> {\n const sequenceId = readOutreachDocumentId(rawSequenceId)\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to enroll people in.')\n const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get()\n const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n if (sequence.status === 'archived') {\n return outreachRefusal(409, 'sequence-archived', 'This sequence is archived and takes no one new.')\n }\n if (sequence.status !== 'active') {\n return outreachRefusal(409, 'sequence-not-active', 'Activate this sequence before enrolling people.')\n }\n return sequence\n}\n\n/** What every person in one request is judged against. */\ninterface EnrollContext {\n settings: OutreachGateQuestion['settings']\n contactGroupId: string\n siteName: string\n}\n\nasync function enrollContext(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n): Promise<EnrollContext> {\n const [orgSettings, host] = await Promise.all([\n readOutreachComplianceSettingsDoc(firestore, caller.orgId),\n firestore.collection('hosts').doc(sequence.hostId).get(),\n ])\n return {\n // The organization's countries are a ceiling over the sequence's.\n settings: {\n ...sequence.settings,\n allowedCountries: effectiveOutreachAllowedCountries(\n sequence.settings.allowedCountries,\n orgSettings.allowedCountries,\n ),\n },\n contactGroupId: consentGroupForHost(caller.org, sequence.hostId).groupId,\n siteName: host.exists ? String(host.get('name') ?? '') : '',\n }\n}\n\n/**\n * The candidates, the enrollments they already have here, and the gates'\n * lookups — every map keyed by the person's own id.\n *\n * \"Already in this sequence\" is answered two ways (AGL-3234): by the id the\n * person's enrollment would have, and by any enrollment in this sequence\n * that carries the person's ADDRESS. The second is what keeps a contact\n * from being enrolled beside the lead they were converted from: the lead's\n * enrollment followed them, under the lead's key, and still names them.\n */\nasync function readPeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n context: EnrollContext,\n people: readonly OutreachPersonRef[],\n): Promise<{\n candidates: OutreachEnrollCandidate[]\n enrolledHere: Set<string>\n lookups: Map<string, OutreachGateLookups>\n}> {\n const candidates = await readOutreachEnrollCandidates(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n contactGroupId: context.contactGroupId,\n people,\n })\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const existing = candidates.length\n ? await firestore.getAll(\n ...candidates.map((candidate) => enrollments.doc(outreachEnrollmentId(sequence.id, candidate.personId))),\n )\n : []\n const enrolledHere = new Set(\n candidates.filter((_candidate, index) => existing[index]?.exists).map((candidate) => candidate.personId),\n )\n const askable = candidates.filter((candidate) => candidate.contact && candidate.visible && candidate.email)\n const lookups = askable.length\n ? await readOutreachGateLookups(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n people: askable.map((candidate) => ({\n personId: candidate.personId,\n contactId: candidate.contactId || null,\n leadId: candidate.leadId,\n email: String(candidate.email),\n })),\n })\n : new Map<string, OutreachGateLookups>()\n for (const candidate of askable) {\n const open = lookups.get(candidate.personId)?.openEnrollments ?? []\n if (open.some((entry) => entry.sequenceId === sequence.id)) enrolledHere.add(candidate.personId)\n }\n return { candidates, enrolledHere, lookups }\n}\n\nconst uniqueIds = (values: unknown): string[] =>\n Array.isArray(values)\n ? [...new Set(values.map(readOutreachDocumentId).filter((id): id is string => id !== null))]\n : []\n\n/** The most leads a saved Leads view reads — the Leads list's own window. */\nconst LEADS_VIEW_WINDOW = 200\n\n/**\n * The people a saved LEADS view selects (AGL-3234): the sequence's site's\n * most recently seen leads, narrowed by the view's status clause the way\n * the Leads list narrows its window — open leads when the view names no\n * status, one status when it does, everything for `all`. A lead is the\n * site's own, so there is no address to resolve: the person key IS the id.\n */\nasync function leadsViewPeople(\n firestore: Firestore,\n sequence: OutreachSequence,\n filters: ReturnType<typeof normalizeCrmViewFilters>,\n): Promise<{ people: OutreachPersonRef[]; total: number; truncated: boolean }> {\n const statusClause = filters.find((clause) => clause.field === 'status' && clause.op === 'equals')\n const wanted = String(statusClause?.value ?? 'open')\n const window = await firestore\n .collection('hosts')\n .doc(sequence.hostId)\n .collection('leads')\n .orderBy('lastSeenAtMs', 'desc')\n .limit(LEADS_VIEW_WINDOW + 1)\n .get()\n const matching = window.docs.slice(0, LEADS_VIEW_WINDOW).filter((doc) => {\n const lead = doc.data() as Record<string, unknown>\n if (wanted === 'all') return true\n if (wanted === 'open') return isCrmLeadOpen(lead as never) && !lead['convertedContactId']\n return crmLeadStatus(lead as never) === wanted\n })\n return {\n people: matching.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((doc) => ({ kind: 'lead', id: doc.id })),\n total: matching.length,\n truncated: matching.length > OUTREACH_ENROLL_BATCH_MAX || window.docs.length > LEADS_VIEW_WINDOW,\n }\n}\n\nexport function createOutreachEnrollRoutes(deps: OutreachEnrollRouteDeps): OutreachEnrollRoutes {\n /**\n * The person joins the sequence's campaigns (AGL-3254), the moment they\n * are enrolled: `arrayUnion` on the lead's own `campaignIds`, or on the\n * contact's facet for the sequence's site — the same field, the same\n * shape, every other campaign member carries — and `enrolled` credited\n * to each campaign. After the enrollment is created and never before:\n * a person the create refused joins nothing. Neither write may fail the\n * enrollment, which already exists; a membership that could not be\n * stamped is one the list's bulk bar can add by hand.\n */\n async function joinSequenceCampaigns(\n firestore: Firestore,\n orgId: string,\n sequence: OutreachSequence,\n contactGroupId: string,\n candidate: OutreachEnrollCandidate,\n nowMs: number,\n ): Promise<void> {\n const campaignIds = normalizeCampaignIds(sequence.campaignIds)\n if (!campaignIds.length) return\n try {\n const ref =\n candidate.target === 'lead' && candidate.leadId\n ? firestore.collection('hosts').doc(sequence.hostId).collection('leads').doc(candidate.leadId)\n : candidate.contactId\n ? firestore.collection('orgs').doc(orgId).collection('contacts').doc(candidate.contactId)\n : null\n if (ref) {\n const field =\n candidate.target === 'lead' ? CAMPAIGN_MEMBERSHIP_FIELD : contactCampaignFieldPath(contactGroupId)\n await ref.update({ [field]: FieldValue.arrayUnion(...campaignIds), updatedAt: FieldValue.serverTimestamp() })\n }\n } catch (error) {\n console.error('[outreach] the enrolled person could not join the sequence’s campaigns', error)\n }\n try {\n await deps.creditCampaign({ hostId: sequence.hostId, campaignIds, outcome: 'enrolled', atMs: nowMs })\n } catch (error) {\n console.error('[outreach] the enrollment could not be credited to the sequence’s campaigns', error)\n }\n }\n\n /**\n * The names of the sequence's campaigns as they stand, for the entry the\n * enroll files on the person's record (AGL-3274). Read once per request,\n * not per person; a container that is gone answers nothing and the entry\n * names the rest.\n */\n async function sequenceCampaignNames(firestore: Firestore, sequence: OutreachSequence): Promise<string[]> {\n const campaignIds = normalizeCampaignIds(sequence.campaignIds)\n if (!campaignIds.length) return []\n try {\n const containers = firestore.collection('hosts').doc(sequence.hostId).collection('emailCampaigns')\n const found = await firestore.getAll(...campaignIds.map((id) => containers.doc(id)))\n return found.map((snapshot) => String(snapshot.get('name') ?? '').trim()).filter(Boolean)\n } catch (error) {\n console.error('[outreach] the sequence’s campaigns could not be named for the record', error)\n return []\n }\n }\n\n /**\n * The people a source names, the batch limit applied: every contact a\n * search picked, every lead picked from the sequence's site, or the\n * people a saved Contacts or Leads view selects there — contacts found by\n * address, leads by their own key.\n */\n async function sourcePeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n source: Record<string, unknown>,\n ): Promise<Response | { people: OutreachPersonRef[]; total: number; truncated: boolean }> {\n if (source['kind'] === 'contacts' || source['kind'] === 'leads') {\n const kind = source['kind'] === 'leads' ? 'lead' : 'contact'\n const ids = uniqueIds(kind === 'lead' ? source['leadIds'] : source['contactIds'])\n if (!ids.length) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n return {\n people: ids.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((id) => ({ kind, id })),\n total: ids.length,\n truncated: ids.length > OUTREACH_ENROLL_BATCH_MAX,\n }\n }\n if (source['kind'] !== 'view') {\n return outreachRefusal(400, 'invalid-request', 'Enroll people from a saved view or a search.')\n }\n const viewId = readOutreachDocumentId(source['viewId'])\n const view = viewId\n ? await firestore.collection('orgs').doc(caller.orgId).collection(CRM_COLLECTIONS.views).doc(viewId).get()\n : null\n const data = view?.exists ? (view.data() as Record<string, unknown>) : null\n // A colleague's private view is theirs: it is not listed for this\n // reader, so it does not exist for them here either.\n if (\n !viewId ||\n !data ||\n !crmViewIsListed({ shared: data['shared'] === true, ownerUid: String(data['ownerUid'] ?? '') }, caller.uid)\n ) {\n return outreachRefusal(404, 'view-not-found', 'That saved view no longer exists.')\n }\n if (data['section'] === 'leads') {\n return leadsViewPeople(firestore, sequence, normalizeCrmViewFilters(data['filters']))\n }\n if (data['section'] !== 'contacts') {\n return outreachRefusal(400, 'view-unsupported', 'Enroll from a saved view of Contacts or Leads.')\n }\n const { unsupported } = dynamicListDimensionsForCrmView(normalizeCrmViewFilters(data['filters']))\n if (unsupported.length) {\n // Dropping the filter enrolling cannot read would enroll more people\n // than the view shows, so the view is refused whole.\n return outreachRefusal(\n 400,\n 'view-unsupported',\n `This view filters on ${unsupported.map((clause) => clause.label || clause.field).join(', ')}, which enrolling can't apply. Pick another view, or search.`,\n )\n }\n const { emails, complete } = await deps.crmViewEmails({ hostId: sequence.hostId, viewId })\n const ordered = [...new Set(emails)].sort()\n const contacts = firestore.collection('orgs').doc(caller.orgId).collection('contacts')\n const found = await Promise.all(\n ordered\n .slice(0, OUTREACH_ENROLL_BATCH_MAX)\n .map((email) => findContactByEmail(contacts, email, { hostId: sequence.hostId })),\n )\n return {\n people: [...new Set(found.filter((snapshot) => snapshot).map((snapshot) => String(snapshot?.id)))].map(\n (id) => ({ kind: 'contact', id }),\n ),\n total: ordered.length,\n truncated: ordered.length > OUTREACH_ENROLL_BATCH_MAX || !complete,\n }\n }\n\n const preview: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n const source = body['source'] && typeof body['source'] === 'object' ? (body['source'] as Record<string, unknown>) : {}\n const named = await sourcePeople(firestore, caller, sequence, source)\n if (named instanceof Response) return named\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n named.people,\n )\n const people = candidates.map((candidate) =>\n previewOutreachPerson(\n {\n candidate,\n lookups: lookups.get(candidate.personId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.personId) },\n ),\n )\n return outreachOk({\n ok: true,\n people,\n total: named.total,\n truncated: named.truncated,\n } satisfies OutreachEnrollPreviewResponse)\n }\n\n const confirm: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n\n // Each person once, by whichever record they are: a contact by id, or a\n // lead by its key (AGL-3234). A person named both ways is a contact.\n const requested = new Map<\n string,\n { ref: OutreachPersonRef; personalLine: string; attestations: unknown[] }\n >()\n for (const entry of Array.isArray(body['people']) ? body['people'] : []) {\n const person = (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>\n const contactId = readOutreachDocumentId(person['contactId'])\n const leadId = readOutreachDocumentId(person['leadId'])\n const ref: OutreachPersonRef | null = contactId\n ? { kind: 'contact', id: contactId }\n : leadId\n ? { kind: 'lead', id: leadId }\n : null\n if (!ref || requested.has(ref.id)) continue\n requested.set(ref.id, {\n ref,\n personalLine: typeof person['personalLine'] === 'string' ? person['personalLine'] : '',\n attestations: Array.isArray(person['attestations']) ? person['attestations'] : [],\n })\n }\n if (!requested.size) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n if (requested.size > OUTREACH_ENROLL_BATCH_MAX) {\n return outreachRefusal(\n 400,\n 'invalid-request',\n `Enroll at most ${OUTREACH_ENROLL_BATCH_MAX} people at a time.`,\n )\n }\n\n const mailboxSnapshot = sequence.mailboxId\n ? await outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get()\n : null\n const mailbox = readStoredOutreachMailbox(\n sequence.mailboxId,\n mailboxSnapshot?.exists ? mailboxSnapshot.data() : undefined,\n )\n if (!mailbox || mailbox.status === 'disconnected') {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's mailbox is no longer connected. Choose another mailbox for it before enrolling people.\",\n )\n }\n const nowMs = deps.now()\n if (planOutreachFirstDue({ sequence, mailbox, enrolledAtMs: nowMs, random: deps.random }) === null) {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's sending hours never open in its mailbox's timezone. Fix the hours before enrolling people.\",\n )\n }\n\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n [...requested.values()].map((entry) => entry.ref),\n )\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const campaignNames = await sequenceCampaignNames(firestore, sequence)\n const results: OutreachEnrollOutcome[] = await Promise.all(\n candidates.map(async (candidate): Promise<OutreachEnrollOutcome> => {\n const named = {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n const supplied = requested.get(candidate.personId) ?? { personalLine: '', attestations: [] }\n const decision = decideOutreachEnrollment(\n {\n candidate,\n lookups: lookups.get(candidate.personId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.personId) },\n { personalLine: supplied.personalLine, attestations: supplied.attestations, uid: caller.uid, nowMs },\n )\n if (!decision.allowed || !decision.email) {\n return { ...named, email: candidate.email, outcome: 'blocked', blocks: decision.blocks }\n }\n const id = outreachEnrollmentId(sequence.id, candidate.personId)\n const enrollment = buildOutreachEnrollment({\n id,\n sequence,\n mailbox,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n contactName: candidate.name,\n email: decision.email,\n cold: decision.cold,\n personalLine: supplied.personalLine,\n attestations: decision.attestations,\n enrolledByUid: caller.uid,\n nowMs,\n random: deps.random,\n })\n try {\n await enrollments.doc(id).create(enrollment)\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n return {\n ...named,\n email: candidate.email,\n outcome: 'blocked',\n blocks: [\n {\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n },\n ],\n }\n }\n await joinSequenceCampaigns(firestore, caller.orgId, sequence, context.contactGroupId, candidate, nowMs)\n // The person's record says so (AGL-3274): \"Enrolled in <sequence>\",\n // with the campaigns it carried them into, once per enrollment.\n const entry = outreachEnrolledEntry({ enrollmentId: id, sequenceName: sequence.name, campaignNames })\n await fileOutreachNote(deps, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n link: outreachEnrollmentLink(enrollment),\n dedupeKey: entry.dedupeKey,\n body: entry.body,\n atMs: nowMs,\n })\n return { ...named, email: decision.email, outcome: 'enrolled', enrollmentId: id }\n }),\n )\n const enrolled = results.filter((result) => result.outcome === 'enrolled').length\n if (enrolled) {\n await deps.logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n outreachEnrolledActivity(enrolled),\n { type: OUTREACH_SEQUENCE_ACTIVITY_TARGET, id: sequence.id, name: sequence.name },\n )\n }\n return outreachOk({ ok: true, results, enrolled } satisfies OutreachEnrollResponse)\n }\n\n return { preview, confirm }\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","contactCampaignFieldPath","normalizeCampaignIds","consentGroupForHost","CRM_COLLECTIONS","crmLeadStatus","crmViewIsListed","isCrmLeadOpen","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","findContactByEmail","FieldValue","outreachEnrolledEntry","buildOutreachEnrollment","planOutreachFirstDue","decideOutreachEnrollment","previewOutreachPerson","readOutreachEnrollCandidates","readOutreachGateLookups","effectiveOutreachAllowedCountries","OUTREACH_ENROLL_BATCH_MAX","readOutreachComplianceSettingsDoc","fileOutreachNote","outreachEnrollmentId","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","OUTREACH_READS_PEOPLE","key","refusal","outreachEnrolledActivity","count","UNCHECKED","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","doNotContactDomain","workspaceMembers","openEnrollments","hasInboundEmail","ALREADY_EXISTS","loadActiveSequence","firestore","orgId","rawSequenceId","sequenceId","snapshot","doc","get","sequence","exists","data","undefined","status","enrollContext","caller","host","orgSettings","Promise","all","collection","hostId","settings","allowedCountries","contactGroupId","org","groupId","siteName","String","readPeople","context","people","candidates","enrollments","existing","length","getAll","map","candidate","id","personId","enrolledHere","Set","filter","_candidate","index","askable","contact","visible","email","lookups","contactId","leadId","Map","open","some","entry","add","uniqueIds","values","Array","isArray","LEADS_VIEW_WINDOW","leadsViewPeople","filters","statusClause","find","clause","field","op","wanted","value","window","orderBy","limit","matching","docs","slice","lead","kind","total","truncated","createOutreachEnrollRoutes","deps","joinSequenceCampaigns","nowMs","campaignIds","ref","target","update","arrayUnion","updatedAt","serverTimestamp","error","console","creditCampaign","outcome","atMs","sequenceCampaignNames","containers","found","trim","Boolean","sourcePeople","source","ids","viewId","view","views","shared","ownerUid","uid","unsupported","label","join","emails","complete","crmViewEmails","ordered","sort","contacts","preview","request","method","body","gate","Response","named","alreadyInSequence","has","ok","confirm","requested","person","set","personalLine","attestations","size","mailboxSnapshot","mailboxId","mailbox","now","enrolledAtMs","random","campaignNames","results","supplied","decision","allowed","blocks","enrollment","contactName","name","cold","enrolledByUid","create","code","reason","enrollmentId","sequenceName","link","dedupeKey","enrolled","result","logOrgActivity","type"],"mappings":"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"}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
16
|
+
*/ import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
|
|
17
|
+
import { registerPluginApiRoute } from "@aglyn/aglyn/server";
|
|
17
18
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
18
19
|
import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
|
|
19
20
|
import { outreachOrgSubject } from "../mailboxes/register-mailbox-routes.js";
|
|
@@ -66,6 +67,14 @@ export function defaultOutreachRouteDeps() {
|
|
|
66
67
|
outcome,
|
|
67
68
|
atMs
|
|
68
69
|
}, firebaseAdmin.app().firestore());
|
|
70
|
+
},
|
|
71
|
+
// The record system's writer (AGL-3274), resolved per call the way the
|
|
72
|
+
// runtime resolves it: the CRM registers at boot, and a request that
|
|
73
|
+
// arrives before it did files nothing rather than caching a `null`.
|
|
74
|
+
timeline: ()=>{
|
|
75
|
+
var _ref;
|
|
76
|
+
var _pluginRecordTimelineWriter;
|
|
77
|
+
return (_ref = (_pluginRecordTimelineWriter = pluginRecordTimelineWriter()) == null ? void 0 : _pluginRecordTimelineWriter.writer) != null ? _ref : null;
|
|
69
78
|
}
|
|
70
79
|
};
|
|
71
80
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.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 { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { createOutreachEnrollmentActionRoute } from './enrollment-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n}\n"],"names":["registerPluginApiRoute","firebaseAdmin","OUTREACH_API_ROUTES","outreachOrgSubject","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","registerOutreachRoutes","deps","sequences","enroll","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","remove","enrollPreview","preview","confirm","enrollmentsAction","doNotContactDomains"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,sBAAqB;AAC5D,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,6BAAyB;AAC7D,SAASC,kBAAkB,QAAQ,0CAAsC;AACzE,SAASC,sCAAsC,QAAQ,6BAAyB;AAChF,SAASC,0BAA0B,QAAsC,qBAAiB;AAC1F,SAASC,mCAAmC,QAAQ,yBAAqB;AACzE,SAASC,0BAA0B,QAAQ,sBAAkB;AAE7D,SAASC,4BAA4B,QAAQ,uBAAmB;AAChE,SAASC,2BAA2B,QAAQ,uBAAmB;AAE/D;;;;;;;;;;;CAWC,GAED,+EAA+E,GAC/E,MAAMC,WAAW,IAAM,MAAM,CAAC;AAE9B,wCAAwC,GACxC,OAAO,SAASC;IACd,OAAO;QACLC,eAAe,CAACC,UAAYZ,cAAca,GAAG,GAAGC,IAAI,GAAGH,aAAa,CAACC;QACrEG,uBAAuB,OAAOC,KAAKC,UAAY,AAAC,CAAA,MAAMR,UAAS,EAAGM,qBAAqB,CAACC,KAAKC;QAC7FC,2BAA2B,OAAOF,KAAKG,OAAOC,MAC5C,AAAC,CAAA,MAAMX,UAAS,EAAGS,yBAAyB,CAACF,KAAKG,OAAOC;QAC3DC,SAAS,OAAOF;gBAEYG;YAD1B,MAAMA,WAAW,MAAMtB,cAAca,GAAG,GAAGU,SAAS,GAAGC,UAAU,CAAC,QAAQC,GAAG,CAACN,OAAOO,GAAG;YACxF,OAAOJ,SAASK,MAAM,IAAIL,iBAAAA,SAASM,IAAI,cAAbN,iBAAmB,CAAC,IAAK;QACrD;QACAO,iBAAiB,OAAOC,UAAY,AAAC,CAAA,MAAMrB,UAAS,EAAGoB,eAAe,CAACC;IACzE;AACF;AAEA,OAAO,SAASC;IACd,OAAO;QACLR,WAAW,IAAMvB,cAAca,GAAG,GAAGU,SAAS;QAC9CS,MAAMtB;QACNuB,KAAKC,KAAKD,GAAG;QACbE,QAAQC,KAAKD,MAAM;QACnBE,gBAAgB,OAAOlB,OAAOmB,OAAOC,QAAQC,SAC3C,AAAC,CAAA,MAAM/B,UAAS,EAAG4B,cAAc,CAAClB,OAAOmB,OAAOC,QAAQC;QAC1DC,eAAe,OAAOC,QAAU,AAAC,CAAA,MAAMjC,UAAS,EAAGgC,aAAa,CAACC;QACjEC,uBAAuB,OAAOC;YAC5B,MAAM,AAAC,CAAA,MAAMnC,UAAS,EAAGkC,qBAAqB,CAACC;QACjD;QACAC,gBAAgB,OAAO,EAAEC,MAAM,EAAEC,WAAW,EAAEC,OAAO,EAAEC,IAAI,EAAE;YAC3D,MAAM,AAAC,CAAA,MAAMxC,UAAS,EAAGyC,6BAA6B,CACpD;gBAAEJ;gBAAQC;gBAAaC;gBAASC;YAAK,GACrCjD,cAAca,GAAG,GAAGU,SAAS;QAEjC;
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.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 { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { createOutreachEnrollmentActionRoute } from './enrollment-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","registerPluginApiRoute","firebaseAdmin","OUTREACH_API_ROUTES","outreachOrgSubject","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","registerOutreachRoutes","deps","sequences","enroll","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","remove","enrollPreview","preview","confirm","enrollmentsAction","doNotContactDomains"],"mappings":"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"}
|
|
@@ -69,16 +69,18 @@ export type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_
|
|
|
69
69
|
* 3. every open enrollment of the address in the organization: the one it
|
|
70
70
|
* came from with the reason given, the others as `do_not_contact`;
|
|
71
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
|
|
72
|
+
* record a person reads says what the lists hold;
|
|
73
|
+
* 5. the person's Activity (AGL-3274): "<sequence> stopped: they opted
|
|
74
|
+
* out", once per enrollment, under the last email it sent.
|
|
73
75
|
*
|
|
74
76
|
* Each is idempotent, so a second request — the link used twice, a reply
|
|
75
77
|
* read by two runs — changes nothing.
|
|
76
78
|
*/
|
|
77
|
-
export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>, input: {
|
|
79
|
+
export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState' | 'timeline'>, input: {
|
|
78
80
|
orgId: string;
|
|
79
81
|
email: string;
|
|
80
82
|
source: OutreachOptOutSource;
|
|
81
|
-
enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId'> | null;
|
|
83
|
+
enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId' | 'target' | 'contactId' | 'leadId'> | null;
|
|
82
84
|
hostIds: readonly string[];
|
|
83
85
|
detail: string | null;
|
|
84
86
|
nowMs: number;
|
|
@@ -15,10 +15,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
18
|
+
import { outreachStoppedEntry } from "../engine/enrollment-activity.js";
|
|
18
19
|
import { applyOutreachEnrollmentEvent } from "../engine/enrollment-state.js";
|
|
19
20
|
import { OUTREACH_OPEN_ENROLLMENT_STATUSES } from "../engine/gates.js";
|
|
20
21
|
import { addOutreachDoNotContact } from "../storage/do-not-contact-store.js";
|
|
21
|
-
import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
|
|
22
|
+
import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachSequence } from "../storage/outreach-records.js";
|
|
23
|
+
import { fileOutreachNote } from "./timeline.js";
|
|
22
24
|
/**
|
|
23
25
|
* Applies one event to one enrollment, with any fields the caller writes
|
|
24
26
|
* beside it — its handled message ids, a postponed due time. A transition
|
|
@@ -85,7 +87,9 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
|
|
|
85
87
|
* 3. every open enrollment of the address in the organization: the one it
|
|
86
88
|
* came from with the reason given, the others as `do_not_contact`;
|
|
87
89
|
* 4. the lead and the contact that carry the address (AGL-3245), so the
|
|
88
|
-
* record a person reads says what the lists hold
|
|
90
|
+
* record a person reads says what the lists hold;
|
|
91
|
+
* 5. the person's Activity (AGL-3274): "<sequence> stopped: they opted
|
|
92
|
+
* out", once per enrollment, under the last email it sent.
|
|
89
93
|
*
|
|
90
94
|
* Each is idempotent, so a second request — the link used twice, a reply
|
|
91
95
|
* read by two runs — changes nothing.
|
|
@@ -141,7 +145,27 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
|
|
|
141
145
|
},
|
|
142
146
|
nowMs: input.nowMs
|
|
143
147
|
});
|
|
144
|
-
if (own.changed)
|
|
148
|
+
if (own.changed) {
|
|
149
|
+
var _ref3;
|
|
150
|
+
stopped += 1;
|
|
151
|
+
// 5. the person's record says the sequence ended (AGL-3274), once,
|
|
152
|
+
// after the status moved — so the link used twice files once.
|
|
153
|
+
const sequenceSnapshot = await outreachOrgCollection(firestore, input.orgId, 'sequences').doc(input.enrollment.sequenceId).get();
|
|
154
|
+
const sequence = readStoredOutreachSequence(input.enrollment.sequenceId, sequenceSnapshot.exists ? sequenceSnapshot.data() : undefined);
|
|
155
|
+
const entry = outreachStoppedEntry({
|
|
156
|
+
enrollmentId: input.enrollment.id,
|
|
157
|
+
sequenceName: (_ref3 = sequence == null ? void 0 : sequence.name) != null ? _ref3 : '',
|
|
158
|
+
reason: 'opted_out'
|
|
159
|
+
});
|
|
160
|
+
await fileOutreachNote(deps, {
|
|
161
|
+
orgId: input.orgId,
|
|
162
|
+
hostId: input.enrollment.hostId,
|
|
163
|
+
link: outreachEnrollmentLink(input.enrollment),
|
|
164
|
+
dedupeKey: entry.dedupeKey,
|
|
165
|
+
body: entry.body,
|
|
166
|
+
atMs: input.nowMs
|
|
167
|
+
});
|
|
168
|
+
}
|
|
145
169
|
}
|
|
146
170
|
stopped += await optOutOtherOutreachEnrollments(firestore, {
|
|
147
171
|
orgId: input.orgId,
|
|
@@ -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 * 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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,QAAQ,kCAAiC;AACvE,SACEC,4BAA4B,QAEvB,gCAA4B;AACnC,SAASC,iCAAiC,QAAQ,qBAAiB;AAEnE,SAASC,uBAAuB,QAAQ,qCAAiC;AACzE,SAASC,qBAAqB,EAAEC,4BAA4B,QAAQ,iCAA6B;AAqBjG;;;;CAIC,GACD,OAAO,eAAeC,mBACpBC,SAAoB,EACpBC,KAMC;IAED,MAAMC,MAAML,sBAAsBG,WAAWC,MAAME,KAAK,EAAE,eAAeC,GAAG,CAACH,MAAMI,YAAY;IAC/F,OAAOL,UAAUM,cAAc,CAAC,OAAOC;;QACrC,MAAMC,WAAW,MAAMD,YAAYE,GAAG,CAACP;QACvC,MAAMQ,aAAaZ,6BAA6BG,MAAMI,YAAY,EAAEG,SAASG,MAAM,GAAGH,SAASI,IAAI,KAAKC;QACxG,IAAI,CAACH,YAAY,OAAO;YAAEA,YAAY;YAAMI,SAAS;QAAM;QAC3D,MAAMC,QAAQd,MAAMe,KAAK,GAAGtB,6BAA6BgB,YAAYT,MAAMe,KAAK,EAAED,KAAK,GAAG;QAC1F,MAAME,gBAAQhB,MAAMgB,KAAK,oBAAXhB,MAAMgB,KAAK,MAAXhB,OAAcS,8BAAe;QAC3C,IAAI,CAACK,SAAS,CAACE,OAAO,OAAO;YAAEP;YAAYI,SAAS;QAAM;QAC1D,MAAMI,UAAU,aAAMH,gBAAAA,QAAS,CAAC,GAAQE,gBAAAA,QAAS,CAAC;YAAIE,aAAalB,MAAMmB,KAAK;;QAC9Eb,YAAYc,MAAM,CAACnB,KAAKgB;QACxB,OAAO;YAAER,YAAY,aAAKA,YAAeQ;YAAiCJ,SAASC,UAAU;QAAK;IACpG;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeO,+BACpBtB,SAAoB,EACpBC,KAAyG;IAEzG,MAAMsB,QAAQ,MAAM1B,sBAAsBG,WAAWC,MAAME,KAAK,EAAE,eAC/DqB,KAAK,CAAC,SAAS,MAAMvB,MAAMwB,KAAK,EAChChB,GAAG;IACN,IAAIiB,QAAQ;IACZ,KAAK,MAAMtB,OAAOmB,MAAMI,IAAI,CAAE;QAC5B,IAAIvB,IAAIwB,EAAE,KAAK3B,MAAM4B,kBAAkB,EAAE;QACzC,IAAI,CAAClC,kCAAkCmC,QAAQ,CAAC1B,IAAIK,GAAG,CAAC,YAAY;QACpE,MAAMsB,UAAU,MAAMhC,mBAAmBC,WAAW;YAClDG,OAAOF,MAAME,KAAK;YAClBE,cAAcD,IAAIwB,EAAE;YACpBZ,OAAO;gBAAEgB,MAAM;gBAAWC,MAAMhC,MAAMmB,KAAK;gBAAEc,QAAQ;gBAAkBC,QAAQlC,MAAMkC,MAAM;YAAC;YAC5Ff,OAAOnB,MAAMmB,KAAK;QACpB;QACA,IAAIW,QAAQjB,OAAO,EAAEY,SAAS;IAChC;IACA,OAAOA;AACT;AAKA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,eAAeU,qBACpBC,IAA6F,EAC7FpC,KAUC;;QAWeA,mBACFA,oBAiBNA,oBAgBcA;IA3CtB,MAAMwB,QAAQhC,sBAAsBQ,MAAMwB,KAAK;IAC/C,IAAI,CAACA,OAAO,OAAO;QAAEa,SAAS;IAAE;IAChC,MAAMtC,YAAYqC,KAAKrC,SAAS;IAChC,MAAMJ,wBAAwBI,WAAW;QACvCG,OAAOF,MAAME,KAAK;QAClBsB;QACAS,QAAQjC,MAAMsC,MAAM;QACpBA,QAAQ;QACRnB,OAAOnB,MAAMmB,KAAK;QAClBf,YAAY,WAAEJ,oBAAAA,MAAMS,UAAU,qBAAhBT,kBAAkB2B,EAAE,mBAAI;QACtCY,UAAU,YAAEvC,qBAAAA,MAAMS,UAAU,qBAAhBT,mBAAkBuC,UAAU,oBAAI;QAC5CL,QAAQlC,MAAMkC,MAAM;IACtB;IACA,KAAK,MAAMM,UAAU,IAAIC,IAAIzC,MAAM0C,OAAO,CAACC,MAAM,CAACC,UAAW;QAC3D,MAAMR,KAAKS,kBAAkB,CAAC;YAAEL;YAAQhB;QAAM;IAChD;IACA,sEAAsE;IACtE,yEAAyE;IACzE,wDAAwD;IACxD,MAAMY,KAAKU,qBAAqB,CAAC;QAC/B5C,OAAOF,MAAME,KAAK;QAClBsB;QACAuB,OAAO;YACLC,QAAQhD,MAAMiD,SAAS,GAAG,eAAejD,MAAMsC,MAAM,KAAK,gBAAgB,iBAAiB;YAC3FN,MAAMhC,MAAMmB,KAAK;YACjBmB,QAAQ;YACRJ,QAAQlC,MAAMkC,MAAM;WAChBlC,EAAAA,qBAAAA,MAAMS,UAAU,qBAAhBT,mBAAkB2B,EAAE,IAAG;YAAEvB,cAAcJ,MAAMS,UAAU,CAACkB,EAAE;QAAC,IAAI,CAAC;IAExE;IACA,IAAIU,UAAU;IACd,IAAIrC,MAAMS,UAAU,EAAE;QACpB,MAAMyC,MAAM,MAAMpD,mBAAmBC,WAAW;YAC9CG,OAAOF,MAAME,KAAK;YAClBE,cAAcJ,MAAMS,UAAU,CAACkB,EAAE;YACjCZ,OAAO;gBAAEgB,MAAM;gBAAWC,MAAMhC,MAAMmB,KAAK;gBAAEc,QAAQjC,MAAMsC,MAAM;gBAAEJ,QAAQlC,MAAMkC,MAAM;YAAC;YACxFf,OAAOnB,MAAMmB,KAAK;QACpB;QACA,IAAI+B,IAAIrC,OAAO,EAAEwB,WAAW;IAC9B;IACAA,WAAW,MAAMhB,+BAA+BtB,WAAW;QACzDG,OAAOF,MAAME,KAAK;QAClBsB;QACAI,kBAAkB,YAAE5B,qBAAAA,MAAMS,UAAU,qBAAhBT,mBAAkB2B,EAAE,oBAAI;QAC5CR,OAAOnB,MAAMmB,KAAK;QAClBe,QACElC,MAAMsC,MAAM,KAAK,gBACb,wCACA;IACR;IACA,OAAO;QAAED;IAAQ;AACnB"}
|
|
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 { outreachStoppedEntry } from '../engine/enrollment-activity'\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 {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { fileOutreachNote } from './timeline'\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 * 5. the person's Activity (AGL-3274): \"<sequence> stopped: they opted\n * out\", once per enrollment, under the last email it sent.\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' | 'timeline'>,\n input: {\n orgId: string\n email: string\n source: OutreachOptOutSource\n enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId' | 'target' | 'contactId' | 'leadId'> | 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) {\n stopped += 1\n // 5. the person's record says the sequence ended (AGL-3274), once,\n // after the status moved — so the link used twice files once.\n const sequenceSnapshot = await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.enrollment.sequenceId)\n .get()\n const sequence = readStoredOutreachSequence(\n input.enrollment.sequenceId,\n sequenceSnapshot.exists ? sequenceSnapshot.data() : undefined,\n )\n const entry = outreachStoppedEntry({\n enrollmentId: input.enrollment.id,\n sequenceName: sequence?.name ?? '',\n reason: 'opted_out',\n })\n await fileOutreachNote(deps, {\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n dedupeKey: entry.dedupeKey,\n body: entry.body,\n atMs: input.nowMs,\n })\n }\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","outreachStoppedEntry","applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachSequence","fileOutreachNote","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","sequenceSnapshot","sequence","entry","sequenceName","name","link","dedupeKey","body"],"mappings":"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"}
|
|
@@ -19,6 +19,7 @@ import { crmInboundExcerpt, emailAddressOf } from "@aglyn/aglyn/app-utils/crm-in
|
|
|
19
19
|
import { outreachMessageIdHeader } from "../engine/compose.js";
|
|
20
20
|
import { readOutreachDeliveryReport } from "../engine/delivery-status.js";
|
|
21
21
|
import { outreachEmailDomain } from "../engine/do-not-contact-domain.js";
|
|
22
|
+
import { outreachStoppedEntry } from "../engine/enrollment-activity.js";
|
|
22
23
|
import { isOutreachGatewayBlock, outreachGatewayBlockDetail } from "../engine/gateway-block.js";
|
|
23
24
|
import { effectiveOutreachWindow, postponeOutreachForAutoReply } from "../engine/schedule.js";
|
|
24
25
|
import { decideOutreachThread } from "../engine/thread-classification.js";
|
|
@@ -33,7 +34,7 @@ import { applyOutreachEvent, recordOutreachOptOut } from "./enrollment-events.js
|
|
|
33
34
|
import { applyOutreachMailboxHealth, emptyOutreachHealthDelta } from "./mailbox-health-store.js";
|
|
34
35
|
import { noteOutreachMailboxReconnectRequired } from "./mailbox-notices.js";
|
|
35
36
|
import { markOutreachLeadWorking } from "./lead-records.js";
|
|
36
|
-
import { fileOutreachEmail, fileOutreachTask, markOutreachEmailDelivery } from "./timeline.js";
|
|
37
|
+
import { fileOutreachEmail, fileOutreachNote, fileOutreachTask, markOutreachEmailDelivery } from "./timeline.js";
|
|
37
38
|
import { outreachUnsubscribeMailbox } from "./unsubscribe-link.js";
|
|
38
39
|
/*==========================================
|
|
39
40
|
* THE SYNC JOB (AGL-2981): every fifteen minutes, on the console.
|
|
@@ -556,6 +557,27 @@ async function syncMailbox(deps, firestore, input) {
|
|
|
556
557
|
nowMs
|
|
557
558
|
});
|
|
558
559
|
if (postponedTo !== null && ((_outcome_enrollment = outcome.enrollment) == null ? void 0 : _outcome_enrollment.nextDueAtMs) === postponedTo) report.postponed += 1;
|
|
560
|
+
// The person's record says the sequence ended (AGL-3274), once, under the
|
|
561
|
+
// reply that ended it — after the transaction moved the status, so a run
|
|
562
|
+
// that reads the same reply again finds nothing to file. An opt-out's
|
|
563
|
+
// line is `recordOutreachOptOut`'s, beside the lists it writes.
|
|
564
|
+
if ((event == null ? void 0 : event.type) === 'reply' && outcome.changed) {
|
|
565
|
+
var _ref7;
|
|
566
|
+
const sequence = await sequenceOf(context, enrollment.sequenceId);
|
|
567
|
+
const entry = outreachStoppedEntry({
|
|
568
|
+
enrollmentId: enrollment.id,
|
|
569
|
+
sequenceName: (_ref7 = sequence == null ? void 0 : sequence.name) != null ? _ref7 : '',
|
|
570
|
+
reason: 'replied'
|
|
571
|
+
});
|
|
572
|
+
await fileOutreachNote(deps, {
|
|
573
|
+
orgId: context.orgId,
|
|
574
|
+
hostId: enrollment.hostId,
|
|
575
|
+
link: outreachEnrollmentLink(enrollment),
|
|
576
|
+
dedupeKey: entry.dedupeKey,
|
|
577
|
+
body: entry.body,
|
|
578
|
+
atMs: event.atMs
|
|
579
|
+
});
|
|
580
|
+
}
|
|
559
581
|
// The run's own view of the enrollment moves with it, so a second message
|
|
560
582
|
// of it in this run is judged against what this one did.
|
|
561
583
|
if (outcome.enrollment) Object.assign(enrollment, outcome.enrollment);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/sync-job.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 { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { crmInboundExcerpt, emailAddressOf } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { outreachMessageIdHeader } from '../engine/compose'\nimport { readOutreachDeliveryReport } from '../engine/delivery-status'\nimport { outreachEmailDomain } from '../engine/do-not-contact-domain'\nimport type { OutreachEnrollmentEvent } from '../engine/enrollment-state'\nimport { isOutreachGatewayBlock, outreachGatewayBlockDetail } from '../engine/gateway-block'\nimport { effectiveOutreachWindow, postponeOutreachForAutoReply } from '../engine/schedule'\nimport {\n decideOutreachThread,\n type OutreachMessageClassification,\n type OutreachThreadDecision,\n} from '../engine/thread-classification'\nimport { outreachHeader, outreachThreadMessageFromGmail, type OutreachThreadMessage } from '../engine/thread-message'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport {\n OUTREACH_COLLECTIONS,\n type OutreachEnrollment,\n type OutreachMailbox,\n type OutreachSequence,\n} from '../model/outreach.types'\nimport { addOutreachDoNotContact, addOutreachDoNotContactDomain } from '../storage/do-not-contact-store'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport type { GmailClient } from '../transport/gmail-client'\nimport { GmailTransportError, isReconnectRequired } from '../transport/gmail-errors'\nimport { creditOutreachReply } from './campaign-credit'\nimport { applyOutreachEvent, recordOutreachOptOut } from './enrollment-events'\nimport {\n applyOutreachMailboxHealth,\n emptyOutreachHealthDelta,\n type OutreachMailboxHealthDelta,\n} from './mailbox-health-store'\nimport { noteOutreachMailboxReconnectRequired } from './mailbox-notices'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { markOutreachLeadWorking } from './lead-records'\nimport { fileOutreachEmail, fileOutreachTask, markOutreachEmailDelivery } from './timeline'\nimport { outreachUnsubscribeMailbox } from './unsubscribe-link'\n\n/*==========================================\n * THE SYNC JOB (AGL-2981): every fifteen minutes, on the console.\n *\n * For each connected or paused mailbox, what came back since the last run —\n * read with `format=full`, turned into the engine's message shape and\n * classified by `decideOutreachThread`:\n *\n * - a REPLY stops the enrollment, files the message on the contact's\n * timeline (once per `Message-ID`, under the capture address's own id for\n * it) and gives the rep the task \"Reply from <name>\";\n * - an OUT-OF-OFFICE postpones the next step a working week from the reply;\n * - an OPT-OUT — a reply asking to be left alone, or a message to the\n * `+unsubscribe` address the `mailto:` names — puts the address on the\n * organization's do-not-contact list and off the site's `sales` topic,\n * and stops every open enrollment of it;\n * - a HARD BOUNCE stops the enrollment, files the address on the platform's\n * suppression list and the do-not-contact list — and, when its diagnostic\n * reads as the domain's mail gateway refusing the sender rather than one\n * address being unknown (`engine/gateway-block`, AGL-3244), files the\n * DOMAIN on the do-not-contact list too, and says so on the enrollment.\n *\n * Every verdict a list takes is said on the record the person is as well\n * (AGL-3245): the lead and the contact carry `emailState` through the\n * platform's stamp, and a bounce or a complaint lands on the send's own\n * timeline entry, so a member reading the record sees what the runtime saw.\n *\n * Four reads find those, each narrowed by Gmail rather than read in full:\n * the threads of this mailbox's enrollments that have new mail; delivery\n * reports outside those threads (a receiving server's own bounce), by\n * searching for the mail systems that send them; messages to the\n * unsubscribe address; and messages from the people this mailbox is\n * writing to, in a thread of their own.\n *\n * Every write it makes is idempotent — the do-not-contact list keeps its\n * first entry, a topic opt-out keeps its first moment, a timeline entry is\n * filed once per message — and the messages it handled are marked AFTER\n * their effects, on the enrollment and on the mailbox, so a run that dies\n * part-way repeats harmless work rather than skipping some. The mailbox's\n * health takes the run's bounces and complaints in one write at the end,\n * which is where it pauses itself.\n *==========================================*/\n\nexport { OUTREACH_SYNC_JOB_ID } from '../constants/runtime-jobs'\n\n/** The furthest back a run reads: a mailbox's first run, or one that fell behind. */\nexport const OUTREACH_SYNC_LOOKBACK_MS = 7 * 24 * 60 * 60_000\n\n/** How far a run reads back past the previous one, for mail Gmail indexed late. */\nexport const OUTREACH_SYNC_OVERLAP_MS = 2 * 60 * 60_000\n\n/** How long after its last send an enrollment's mail is still watched. */\nexport const OUTREACH_SYNC_WATCH_MS = 45 * 24 * 60 * 60_000\n\n/** The most pages of one search a run reads. */\nconst SEARCH_PAGES = 5\n/** Handled message ids a mailbox keeps, and an enrollment keeps. */\nconst MAILBOX_HANDLED_MAX = 500\nconst ENROLLMENT_HANDLED_MAX = 100\n/** Addresses in one `from:` search. */\nconst FROM_CHUNK = 15\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface OutreachSyncReport {\n [key: string]: number\n mailboxes: number\n threads: number\n replies: number\n optOuts: number\n bounces: number\n /** Hard bounces that read as a gateway block, each filing its domain (AGL-3244). */\n gatewayBlocks: number\n postponed: number\n paused: number\n}\n\ninterface SyncContext {\n deps: OutreachRuntimeDeps\n firestore: Firestore\n orgId: string\n mailbox: OutreachMailbox\n client: GmailClient\n selfAddresses: string[]\n unsubscribeAddress: string | null\n sequences: Map<string, OutreachSequence | null>\n delta: OutreachMailboxHealthDelta\n report: OutreachSyncReport\n nowMs: number\n}\n\n/** Runs the sync job once — see the module note. */\nexport async function runOutreachSyncJob(\n deps: OutreachRuntimeDeps,\n context: { nowMs: number; deadlineMs: number },\n): Promise<OutreachSyncReport> {\n const firestore = deps.firestore()\n const report: OutreachSyncReport = {\n mailboxes: 0,\n threads: 0,\n replies: 0,\n optOuts: 0,\n bounces: 0,\n gatewayBlocks: 0,\n postponed: 0,\n paused: 0,\n }\n const all = await firestore.collectionGroup(OUTREACH_COLLECTIONS.mailboxes).get()\n const byOrg = new Map<string, OutreachMailbox[]>()\n for (const doc of all.docs) {\n const orgId = doc.ref.parent.parent?.id\n const mailbox = readStoredOutreachMailbox(doc.id, doc.data())\n if (!orgId || !mailbox || (mailbox.status !== 'connected' && mailbox.status !== 'paused')) continue\n byOrg.set(orgId, [...(byOrg.get(orgId) ?? []), mailbox])\n }\n for (const [orgId, mailboxes] of byOrg) {\n if (deps.now() >= context.deadlineMs) break\n const orgSnapshot = await firestore.collection('orgs').doc(orgId).get()\n const org = (orgSnapshot.exists ? orgSnapshot.data() : null) as Record<string, unknown> | null\n if (!org || (await deps.orgRefusal(orgId, org))) continue\n for (const mailbox of mailboxes) {\n if (deps.now() >= context.deadlineMs) break\n try {\n await syncMailbox(deps, firestore, { orgId, mailbox, report, nowMs: context.nowMs })\n report.mailboxes += 1\n } catch (error) {\n // One mailbox's failure is its own; the next mailbox is read. A grant\n // Google refused is the mailbox's to reconnect.\n if (isReconnectRequired(error)) {\n await noteOutreachMailboxReconnectRequired(deps, {\n orgId,\n mailboxId: mailbox.id,\n errorCode: error instanceof GmailTransportError ? error.code : 'invalid_grant',\n nowMs: context.nowMs,\n })\n }\n console.error('[outreach] syncing a mailbox failed', error)\n }\n }\n }\n return report\n}\n\n/** Every stub a search finds, a few pages deep. */\nasync function search(\n client: GmailClient,\n q: string,\n): Promise<Array<{ id: string; threadId: string }>> {\n const found: Array<{ id: string; threadId: string }> = []\n let pageToken: string | null = null\n for (let page = 0; page < SEARCH_PAGES; page += 1) {\n const list = await client.listMessages({ q, maxResults: 100, pageToken, includeSpamTrash: true })\n found.push(...list.messages.filter((message) => message.id))\n pageToken = list.nextPageToken\n if (!pageToken) break\n }\n return found\n}\n\nasync function syncMailbox(\n deps: OutreachRuntimeDeps,\n firestore: Firestore,\n input: { orgId: string; mailbox: OutreachMailbox; report: OutreachSyncReport; nowMs: number },\n): Promise<void> {\n const { orgId, mailbox, report, nowMs } = input\n const opened = await deps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason !== 'not-configured') {\n await noteOutreachMailboxReconnectRequired(deps, {\n orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n }\n return\n }\n const unsubscribe = outreachUnsubscribeMailbox(mailbox.email)\n const context: SyncContext = {\n deps,\n firestore,\n orgId,\n mailbox,\n client: opened.client,\n selfAddresses: [\n mailbox.email,\n mailbox.sendAs,\n ...(mailbox.sendAsOptions ?? []).map((option) => option.email),\n ].filter(Boolean),\n unsubscribeAddress: unsubscribe?.address ?? null,\n sequences: new Map(),\n delta: emptyOutreachHealthDelta(),\n report,\n nowMs,\n }\n\n // The enrollments this mailbox is writing to, by thread and by address.\n const watched = await outreachOrgCollection(firestore, orgId, 'enrollments')\n .where('mailboxId', '==', mailbox.id)\n .where('lastSentAtMs', '>=', nowMs - OUTREACH_SYNC_WATCH_MS)\n .get()\n const byThread = new Map<string, OutreachEnrollment>()\n const byAddress = new Map<string, OutreachEnrollment>()\n for (const doc of watched.docs) {\n const enrollment = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!enrollment) continue\n for (const threadId of enrollment.gmailThreadIds) byThread.set(threadId, enrollment)\n const known = byAddress.get(enrollment.email)\n if (!known || (enrollment.lastSentAtMs ?? 0) > (known.lastSentAtMs ?? 0)) byAddress.set(enrollment.email, enrollment)\n }\n\n const sinceMs = Math.max(Number(mailbox.sync?.throughMs) || 0, nowMs - OUTREACH_SYNC_LOOKBACK_MS) - OUTREACH_SYNC_OVERLAP_MS\n const after = `after:${Math.floor(sinceMs / 1000)}`\n // Every message this run or an earlier one handled outside an enrollment's\n // thread; one message is handled once however many searches find it.\n const handled = new Set(mailbox.sync?.handledMessageIds ?? [])\n const newlyHandled: string[] = []\n const markHandled = (id: string) => {\n if (handled.has(id)) return\n handled.add(id)\n newlyHandled.push(id)\n }\n const fresh = (stub: { id: string; threadId: string }) => !handled.has(stub.id) && !byThread.has(stub.threadId)\n\n // 1. The enrollments' own threads that have new mail.\n const inbound = await search(context.client, `${after} -from:me`)\n for (const threadId of new Set(inbound.map((stub) => stub.threadId))) {\n const enrollment = byThread.get(threadId)\n if (!enrollment) continue\n const thread = await context.client.getFullThread(threadId)\n report.threads += 1\n await applyMessages(context, enrollment, thread.messages.map(outreachThreadMessageFromGmail))\n }\n\n // 2. Delivery reports outside those threads: a receiving server's bounce.\n for (const stub of await search(context.client, `${after} from:(mailer-daemon OR postmaster)`)) {\n if (!fresh(stub)) continue\n const message = outreachThreadMessageFromGmail(await context.client.getFullMessage(stub.id))\n const delivery = readOutreachDeliveryReport(message)\n if (delivery?.kind === 'hard') {\n for (const address of delivery.failedAddresses) {\n const enrollment = byAddress.get(address)\n if (enrollment) await applyMessages(context, enrollment, [message])\n }\n }\n markHandled(stub.id)\n }\n\n // 3. Messages to the unsubscribe address: whoever wrote asked to leave.\n if (unsubscribe) {\n for (const stub of await search(context.client, `${after} to:${unsubscribe.address}`)) {\n if (handled.has(stub.id)) continue\n const metadata = await context.client.getMessage(stub.id, { metadataHeaders: ['From'] })\n const from = emailAddressOf(metadata.headers.find((header) => header.name.toLowerCase() === 'from')?.value ?? '')\n const address = normalizeContactEmail(from)\n if (address && !context.selfAddresses.includes(address)) {\n const enrollment = byAddress.get(address) ?? null\n await recordOutreachOptOut(deps, {\n orgId,\n email: address,\n source: 'unsubscribe',\n enrollment,\n hostIds: enrollment ? [enrollment.hostId] : [],\n detail: 'Wrote to the unsubscribe address.',\n nowMs,\n })\n report.optOuts += 1\n }\n markHandled(stub.id)\n }\n }\n\n // 4. The people this mailbox writes to, answering in a thread of their own.\n const addresses = [...byAddress.keys()]\n for (let start = 0; start < addresses.length; start += FROM_CHUNK) {\n const chunk = addresses.slice(start, start + FROM_CHUNK)\n for (const stub of await search(context.client, `${after} from:(${chunk.join(' OR ')})`)) {\n if (!fresh(stub)) continue\n const message = outreachThreadMessageFromGmail(await context.client.getFullMessage(stub.id))\n const enrollment = byAddress.get(normalizeContactEmail(emailAddressOf(message.from)) ?? '')\n if (enrollment) await applyMessages(context, enrollment, [message])\n markHandled(stub.id)\n }\n }\n\n const paused = await applyOutreachMailboxHealth(deps, { orgId, mailboxId: mailbox.id, delta: context.delta })\n if (paused) report.paused += 1\n await mailboxRef(firestore, orgId, mailbox.id).update({\n sync: {\n throughMs: nowMs,\n handledMessageIds: [...(mailbox.sync?.handledMessageIds ?? []), ...newlyHandled].slice(-MAILBOX_HANDLED_MAX),\n },\n })\n}\n\n/** The sequence an enrollment runs through, read once per run. */\nasync function sequenceOf(context: SyncContext, sequenceId: string): Promise<OutreachSequence | null> {\n if (!context.sequences.has(sequenceId)) {\n const snapshot = await outreachOrgCollection(context.firestore, context.orgId, 'sequences').doc(sequenceId).get()\n context.sequences.set(sequenceId, readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined))\n }\n return context.sequences.get(sequenceId) ?? null\n}\n\n/** A message's `Message-ID` in header form, or `null` when it carries none. */\nconst messageIdOf = (message: OutreachThreadMessage) => outreachMessageIdHeader(outreachHeader(message, 'Message-ID'))\n\n/** A bounce's diagnostic with the recipient's address taken out of it. */\nexport function scrubbedOutreachDiagnostic(diagnostic: string | null, email: string): string | null {\n if (!diagnostic) return null\n const pattern = new RegExp(email.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'gi')\n return diagnostic.replace(pattern, 'the address')\n}\n\n/** Files a message the person wrote on their timeline. */\nasync function fileInbound(context: SyncContext, enrollment: OutreachEnrollment, message: OutreachThreadMessage) {\n const messageId = messageIdOf(message)\n if (!messageId) return\n await fileOutreachEmail(context.deps, {\n orgId: context.orgId,\n hostId: enrollment.hostId,\n link: outreachEnrollmentLink(enrollment),\n direction: 'inbound',\n subject: message.subject,\n from: emailAddressOf(message.from) ?? message.from,\n to: context.mailbox.sendAs || context.mailbox.email,\n messageId,\n inReplyTo: outreachMessageIdHeader(outreachHeader(message, 'In-Reply-To')),\n body: crmInboundExcerpt(message.textBody ?? message.snippet ?? '', message.htmlBody ?? ''),\n atMs: message.internalDateMs,\n byUid: '',\n })\n}\n\n/**\n * What the new messages of one enrollment mean, done: the effects first,\n * each idempotent, then the event and the handled ids in one transaction.\n */\nasync function applyMessages(\n context: SyncContext,\n enrollment: OutreachEnrollment,\n messages: readonly OutreachThreadMessage[],\n): Promise<void> {\n const { deps, firestore, report, nowMs } = context\n const decision: OutreachThreadDecision = decideOutreachThread({\n messages,\n recipient: enrollment.email,\n handledMessageIds: enrollment.syncedMessageIds ?? [],\n selfAddresses: context.selfAddresses,\n unsubscribeAddress: context.unsubscribeAddress,\n threadSubject: enrollment.threadSubject,\n })\n const freshIds = decision.classifications.map((classification) => classification.messageId).filter(Boolean)\n if (!freshIds.length) return\n const decidedBy: OutreachMessageClassification | null = decision.decidedBy\n const decidingMessage = decidedBy ? messages.find((message) => message.id === decidedBy.messageId) ?? null : null\n const lastSentMessageId = enrollment.messageIds[enrollment.messageIds.length - 1] ?? null\n\n for (const classification of decision.classifications) {\n const aboutRecipient =\n !classification.bounce?.recipients.length || classification.bounce.recipients.includes(enrollment.email)\n if (classification.kind === 'hard_bounce' && aboutRecipient) {\n context.delta.bounces.push({ atMs: classification.atMs || nowMs, messageId: lastSentMessageId })\n }\n if (classification.complaint) {\n context.delta.complaintAtMs = Math.max(context.delta.complaintAtMs ?? 0, classification.atMs || nowMs)\n }\n }\n\n let event: OutreachEnrollmentEvent | null = null\n let postponedTo: number | null = null\n switch (decision.outcome) {\n case 'opted_out': {\n if (decidingMessage) await fileInbound(context, enrollment, decidingMessage)\n const complaint = decidedBy?.complaint === true\n await recordOutreachOptOut(deps, {\n orgId: context.orgId,\n email: enrollment.email,\n source: decidedBy?.evidence?.startsWith('To:') ? 'unsubscribe' : 'opt_out_reply',\n enrollment,\n hostIds: [enrollment.hostId],\n detail: decidedBy?.evidence ?? null,\n nowMs,\n complaint,\n })\n // A reply that called the email spam marks the send it answered\n // (AGL-3245), as the campaign webhook would mark a reported one.\n if (complaint) {\n await markOutreachEmailDelivery(deps, {\n orgId: context.orgId,\n messageId: lastSentMessageId,\n state: 'complained',\n atMs: decidedBy?.atMs || nowMs,\n detail: decidedBy?.evidence ?? null,\n })\n }\n report.optOuts += 1\n break\n }\n case 'replied': {\n if (decidingMessage) {\n await fileInbound(context, enrollment, decidingMessage)\n try {\n await fileOutreachTask(deps, {\n orgId: context.orgId,\n hostId: enrollment.hostId,\n link: outreachEnrollmentLink(enrollment),\n dedupeKey: `reply:${messageIdOf(decidingMessage) ?? decidingMessage.id}`,\n title: `Reply from ${enrollment.contactName || enrollment.email}`,\n notes: crmInboundExcerpt(decidingMessage.textBody ?? decidingMessage.snippet ?? '', decidingMessage.htmlBody ?? '').slice(0, 500),\n kind: 'email',\n dueAtMs: nowMs,\n assigneeUid: context.mailbox.connectedByUid,\n })\n } catch (error) {\n console.error('[outreach] the reply task could not be filed', error)\n }\n }\n // A lead somebody wrote back to is being worked (AGL-3234).\n if (enrollment.target === 'lead' && enrollment.leadId) {\n await markOutreachLeadWorking(firestore, { hostId: enrollment.hostId, leadId: enrollment.leadId })\n }\n context.delta.replies += 1\n event = { type: 'reply', atMs: decidedBy?.atMs || nowMs, detail: decidedBy?.evidence ?? null }\n report.replies += 1\n // Once per enrollment (AGL-3254): a reply moves the status to\n // `replied`, and nothing is synced for it again.\n await creditOutreachReply(deps, { enrollment, atMs: event.atMs })\n break\n }\n case 'bounced': {\n const diagnostic = scrubbedOutreachDiagnostic(decidedBy?.bounce?.diagnostic ?? decidedBy?.bounce?.status ?? null, enrollment.email)\n await deps.suppressBouncedEmail({ email: enrollment.email, hostId: enrollment.hostId })\n await addOutreachDoNotContact(firestore, {\n orgId: context.orgId,\n email: enrollment.email,\n reason: 'hard_bounce',\n source: 'runtime',\n nowMs,\n enrollmentId: enrollment.id,\n sequenceId: enrollment.sequenceId,\n detail: diagnostic,\n })\n let detail = diagnostic\n // A gateway block is the domain's verdict on the sender (AGL-3244):\n // the domain is filed beside the address, and the enrollment says so.\n // Never a public mailbox provider's domain — Gmail refusing one\n // message on policy is not a reason to stop writing to Gmail.\n const domain = outreachEmailDomain(enrollment.email)\n const gateway = isOutreachGatewayBlock(decidedBy?.bounce ?? null)\n if (domain && !isPublicMailboxDomain(domain) && gateway) {\n await addOutreachDoNotContactDomain(firestore, {\n orgId: context.orgId,\n domain,\n reason: 'gateway_block',\n source: 'runtime',\n nowMs,\n enrollmentId: enrollment.id,\n sequenceId: enrollment.sequenceId,\n detail: diagnostic,\n })\n detail = outreachGatewayBlockDetail(domain, diagnostic)\n report.gatewayBlocks += 1\n }\n const bouncedAtMs = decidedBy?.atMs || nowMs\n // The record the person is says so too (AGL-3245): the verdict on the\n // lead and the contact, and the bounce on the send's own timeline\n // entry, so the record reads Sent, then Bounced.\n await deps.stampRecordEmailState({\n orgId: context.orgId,\n email: enrollment.email,\n state: {\n status: gateway ? 'blocked' : 'bounced',\n atMs: bouncedAtMs,\n source: 'sequence',\n detail: diagnostic,\n enrollmentId: enrollment.id,\n },\n })\n await markOutreachEmailDelivery(deps, {\n orgId: context.orgId,\n messageId: lastSentMessageId,\n state: 'bounced',\n atMs: bouncedAtMs,\n detail: diagnostic,\n })\n event = { type: 'bounce', atMs: bouncedAtMs, detail }\n report.bounces += 1\n break\n }\n case 'postpone': {\n const sequence = await sequenceOf(context, enrollment.sequenceId)\n postponedTo = postponeOutreachForAutoReply({\n nextDueAtMs: enrollment.nextDueAtMs,\n receivedAtMs: decidedBy?.atMs || nowMs,\n timeZone: context.mailbox.timezone,\n window: effectiveOutreachWindow(sequence?.settings.window, context.mailbox.window),\n random: deps.random,\n })\n break\n }\n default:\n break\n }\n\n const outcome = await applyOutreachEvent(firestore, {\n orgId: context.orgId,\n enrollmentId: enrollment.id,\n event,\n extra: (current) => {\n const extra: Record<string, unknown> = {\n syncedMessageIds: [...(current.syncedMessageIds ?? []), ...freshIds].slice(-ENROLLMENT_HANDLED_MAX),\n }\n const waiting = current.status === 'active' || current.status === 'paused'\n if (postponedTo !== null && waiting && current.nextDueAtMs !== null && postponedTo > current.nextDueAtMs) {\n extra['nextDueAtMs'] = postponedTo\n }\n return extra\n },\n nowMs,\n })\n if (postponedTo !== null && outcome.enrollment?.nextDueAtMs === postponedTo) report.postponed += 1\n // The run's own view of the enrollment moves with it, so a second message\n // of it in this run is judged against what this one did.\n if (outcome.enrollment) Object.assign(enrollment, outcome.enrollment)\n}\n"],"names":["normalizeContactEmail","isPublicMailboxDomain","crmInboundExcerpt","emailAddressOf","outreachMessageIdHeader","readOutreachDeliveryReport","outreachEmailDomain","isOutreachGatewayBlock","outreachGatewayBlockDetail","effectiveOutreachWindow","postponeOutreachForAutoReply","decideOutreachThread","outreachHeader","outreachThreadMessageFromGmail","mailboxRef","OUTREACH_COLLECTIONS","addOutreachDoNotContact","addOutreachDoNotContactDomain","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","readStoredOutreachSequence","GmailTransportError","isReconnectRequired","creditOutreachReply","applyOutreachEvent","recordOutreachOptOut","applyOutreachMailboxHealth","emptyOutreachHealthDelta","noteOutreachMailboxReconnectRequired","markOutreachLeadWorking","fileOutreachEmail","fileOutreachTask","markOutreachEmailDelivery","outreachUnsubscribeMailbox","OUTREACH_SYNC_JOB_ID","OUTREACH_SYNC_LOOKBACK_MS","OUTREACH_SYNC_OVERLAP_MS","OUTREACH_SYNC_WATCH_MS","SEARCH_PAGES","MAILBOX_HANDLED_MAX","ENROLLMENT_HANDLED_MAX","FROM_CHUNK","runOutreachSyncJob","deps","context","firestore","report","mailboxes","threads","replies","optOuts","bounces","gatewayBlocks","postponed","paused","all","collectionGroup","get","byOrg","Map","doc","docs","orgId","ref","parent","id","mailbox","data","status","set","now","deadlineMs","orgSnapshot","collection","org","exists","orgRefusal","syncMailbox","nowMs","error","mailboxId","errorCode","code","console","search","client","q","found","pageToken","page","list","listMessages","maxResults","includeSpamTrash","push","messages","filter","message","nextPageToken","input","opened","openMailbox","ok","reason","unsubscribe","email","selfAddresses","sendAs","sendAsOptions","map","option","Boolean","unsubscribeAddress","address","sequences","delta","watched","where","byThread","byAddress","enrollment","known","threadId","gmailThreadIds","lastSentAtMs","sinceMs","Math","max","Number","sync","throughMs","after","floor","handled","Set","handledMessageIds","newlyHandled","markHandled","has","add","fresh","stub","inbound","thread","getFullThread","applyMessages","getFullMessage","delivery","kind","failedAddresses","metadata","getMessage","metadataHeaders","from","headers","find","header","name","toLowerCase","value","includes","source","hostIds","hostId","detail","addresses","keys","start","length","chunk","slice","join","update","sequenceOf","sequenceId","snapshot","undefined","messageIdOf","scrubbedOutreachDiagnostic","diagnostic","pattern","RegExp","replace","fileInbound","messageId","link","direction","subject","to","inReplyTo","body","textBody","snippet","htmlBody","atMs","internalDateMs","byUid","outcome","decision","recipient","syncedMessageIds","threadSubject","freshIds","classifications","classification","decidedBy","decidingMessage","lastSentMessageId","messageIds","aboutRecipient","bounce","recipients","complaint","complaintAtMs","event","postponedTo","evidence","startsWith","state","dedupeKey","title","contactName","notes","dueAtMs","assigneeUid","connectedByUid","target","leadId","type","suppressBouncedEmail","enrollmentId","domain","gateway","bouncedAtMs","stampRecordEmailState","sequence","nextDueAtMs","receivedAtMs","timeZone","timezone","window","settings","random","extra","current","waiting","Object","assign"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/sync-job.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 { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { crmInboundExcerpt, emailAddressOf } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { outreachMessageIdHeader } from '../engine/compose'\nimport { readOutreachDeliveryReport } from '../engine/delivery-status'\nimport { outreachEmailDomain } from '../engine/do-not-contact-domain'\nimport { outreachStoppedEntry } from '../engine/enrollment-activity'\nimport type { OutreachEnrollmentEvent } from '../engine/enrollment-state'\nimport { isOutreachGatewayBlock, outreachGatewayBlockDetail } from '../engine/gateway-block'\nimport { effectiveOutreachWindow, postponeOutreachForAutoReply } from '../engine/schedule'\nimport {\n decideOutreachThread,\n type OutreachMessageClassification,\n type OutreachThreadDecision,\n} from '../engine/thread-classification'\nimport { outreachHeader, outreachThreadMessageFromGmail, type OutreachThreadMessage } from '../engine/thread-message'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport {\n OUTREACH_COLLECTIONS,\n type OutreachEnrollment,\n type OutreachMailbox,\n type OutreachSequence,\n} from '../model/outreach.types'\nimport { addOutreachDoNotContact, addOutreachDoNotContactDomain } from '../storage/do-not-contact-store'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport type { GmailClient } from '../transport/gmail-client'\nimport { GmailTransportError, isReconnectRequired } from '../transport/gmail-errors'\nimport { creditOutreachReply } from './campaign-credit'\nimport { applyOutreachEvent, recordOutreachOptOut } from './enrollment-events'\nimport {\n applyOutreachMailboxHealth,\n emptyOutreachHealthDelta,\n type OutreachMailboxHealthDelta,\n} from './mailbox-health-store'\nimport { noteOutreachMailboxReconnectRequired } from './mailbox-notices'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { markOutreachLeadWorking } from './lead-records'\nimport { fileOutreachEmail, fileOutreachNote, fileOutreachTask, markOutreachEmailDelivery } from './timeline'\nimport { outreachUnsubscribeMailbox } from './unsubscribe-link'\n\n/*==========================================\n * THE SYNC JOB (AGL-2981): every fifteen minutes, on the console.\n *\n * For each connected or paused mailbox, what came back since the last run —\n * read with `format=full`, turned into the engine's message shape and\n * classified by `decideOutreachThread`:\n *\n * - a REPLY stops the enrollment, files the message on the contact's\n * timeline (once per `Message-ID`, under the capture address's own id for\n * it) and gives the rep the task \"Reply from <name>\";\n * - an OUT-OF-OFFICE postpones the next step a working week from the reply;\n * - an OPT-OUT — a reply asking to be left alone, or a message to the\n * `+unsubscribe` address the `mailto:` names — puts the address on the\n * organization's do-not-contact list and off the site's `sales` topic,\n * and stops every open enrollment of it;\n * - a HARD BOUNCE stops the enrollment, files the address on the platform's\n * suppression list and the do-not-contact list — and, when its diagnostic\n * reads as the domain's mail gateway refusing the sender rather than one\n * address being unknown (`engine/gateway-block`, AGL-3244), files the\n * DOMAIN on the do-not-contact list too, and says so on the enrollment.\n *\n * Every verdict a list takes is said on the record the person is as well\n * (AGL-3245): the lead and the contact carry `emailState` through the\n * platform's stamp, and a bounce or a complaint lands on the send's own\n * timeline entry, so a member reading the record sees what the runtime saw.\n *\n * Four reads find those, each narrowed by Gmail rather than read in full:\n * the threads of this mailbox's enrollments that have new mail; delivery\n * reports outside those threads (a receiving server's own bounce), by\n * searching for the mail systems that send them; messages to the\n * unsubscribe address; and messages from the people this mailbox is\n * writing to, in a thread of their own.\n *\n * Every write it makes is idempotent — the do-not-contact list keeps its\n * first entry, a topic opt-out keeps its first moment, a timeline entry is\n * filed once per message — and the messages it handled are marked AFTER\n * their effects, on the enrollment and on the mailbox, so a run that dies\n * part-way repeats harmless work rather than skipping some. The mailbox's\n * health takes the run's bounces and complaints in one write at the end,\n * which is where it pauses itself.\n *==========================================*/\n\nexport { OUTREACH_SYNC_JOB_ID } from '../constants/runtime-jobs'\n\n/** The furthest back a run reads: a mailbox's first run, or one that fell behind. */\nexport const OUTREACH_SYNC_LOOKBACK_MS = 7 * 24 * 60 * 60_000\n\n/** How far a run reads back past the previous one, for mail Gmail indexed late. */\nexport const OUTREACH_SYNC_OVERLAP_MS = 2 * 60 * 60_000\n\n/** How long after its last send an enrollment's mail is still watched. */\nexport const OUTREACH_SYNC_WATCH_MS = 45 * 24 * 60 * 60_000\n\n/** The most pages of one search a run reads. */\nconst SEARCH_PAGES = 5\n/** Handled message ids a mailbox keeps, and an enrollment keeps. */\nconst MAILBOX_HANDLED_MAX = 500\nconst ENROLLMENT_HANDLED_MAX = 100\n/** Addresses in one `from:` search. */\nconst FROM_CHUNK = 15\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface OutreachSyncReport {\n [key: string]: number\n mailboxes: number\n threads: number\n replies: number\n optOuts: number\n bounces: number\n /** Hard bounces that read as a gateway block, each filing its domain (AGL-3244). */\n gatewayBlocks: number\n postponed: number\n paused: number\n}\n\ninterface SyncContext {\n deps: OutreachRuntimeDeps\n firestore: Firestore\n orgId: string\n mailbox: OutreachMailbox\n client: GmailClient\n selfAddresses: string[]\n unsubscribeAddress: string | null\n sequences: Map<string, OutreachSequence | null>\n delta: OutreachMailboxHealthDelta\n report: OutreachSyncReport\n nowMs: number\n}\n\n/** Runs the sync job once — see the module note. */\nexport async function runOutreachSyncJob(\n deps: OutreachRuntimeDeps,\n context: { nowMs: number; deadlineMs: number },\n): Promise<OutreachSyncReport> {\n const firestore = deps.firestore()\n const report: OutreachSyncReport = {\n mailboxes: 0,\n threads: 0,\n replies: 0,\n optOuts: 0,\n bounces: 0,\n gatewayBlocks: 0,\n postponed: 0,\n paused: 0,\n }\n const all = await firestore.collectionGroup(OUTREACH_COLLECTIONS.mailboxes).get()\n const byOrg = new Map<string, OutreachMailbox[]>()\n for (const doc of all.docs) {\n const orgId = doc.ref.parent.parent?.id\n const mailbox = readStoredOutreachMailbox(doc.id, doc.data())\n if (!orgId || !mailbox || (mailbox.status !== 'connected' && mailbox.status !== 'paused')) continue\n byOrg.set(orgId, [...(byOrg.get(orgId) ?? []), mailbox])\n }\n for (const [orgId, mailboxes] of byOrg) {\n if (deps.now() >= context.deadlineMs) break\n const orgSnapshot = await firestore.collection('orgs').doc(orgId).get()\n const org = (orgSnapshot.exists ? orgSnapshot.data() : null) as Record<string, unknown> | null\n if (!org || (await deps.orgRefusal(orgId, org))) continue\n for (const mailbox of mailboxes) {\n if (deps.now() >= context.deadlineMs) break\n try {\n await syncMailbox(deps, firestore, { orgId, mailbox, report, nowMs: context.nowMs })\n report.mailboxes += 1\n } catch (error) {\n // One mailbox's failure is its own; the next mailbox is read. A grant\n // Google refused is the mailbox's to reconnect.\n if (isReconnectRequired(error)) {\n await noteOutreachMailboxReconnectRequired(deps, {\n orgId,\n mailboxId: mailbox.id,\n errorCode: error instanceof GmailTransportError ? error.code : 'invalid_grant',\n nowMs: context.nowMs,\n })\n }\n console.error('[outreach] syncing a mailbox failed', error)\n }\n }\n }\n return report\n}\n\n/** Every stub a search finds, a few pages deep. */\nasync function search(\n client: GmailClient,\n q: string,\n): Promise<Array<{ id: string; threadId: string }>> {\n const found: Array<{ id: string; threadId: string }> = []\n let pageToken: string | null = null\n for (let page = 0; page < SEARCH_PAGES; page += 1) {\n const list = await client.listMessages({ q, maxResults: 100, pageToken, includeSpamTrash: true })\n found.push(...list.messages.filter((message) => message.id))\n pageToken = list.nextPageToken\n if (!pageToken) break\n }\n return found\n}\n\nasync function syncMailbox(\n deps: OutreachRuntimeDeps,\n firestore: Firestore,\n input: { orgId: string; mailbox: OutreachMailbox; report: OutreachSyncReport; nowMs: number },\n): Promise<void> {\n const { orgId, mailbox, report, nowMs } = input\n const opened = await deps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason !== 'not-configured') {\n await noteOutreachMailboxReconnectRequired(deps, {\n orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n }\n return\n }\n const unsubscribe = outreachUnsubscribeMailbox(mailbox.email)\n const context: SyncContext = {\n deps,\n firestore,\n orgId,\n mailbox,\n client: opened.client,\n selfAddresses: [\n mailbox.email,\n mailbox.sendAs,\n ...(mailbox.sendAsOptions ?? []).map((option) => option.email),\n ].filter(Boolean),\n unsubscribeAddress: unsubscribe?.address ?? null,\n sequences: new Map(),\n delta: emptyOutreachHealthDelta(),\n report,\n nowMs,\n }\n\n // The enrollments this mailbox is writing to, by thread and by address.\n const watched = await outreachOrgCollection(firestore, orgId, 'enrollments')\n .where('mailboxId', '==', mailbox.id)\n .where('lastSentAtMs', '>=', nowMs - OUTREACH_SYNC_WATCH_MS)\n .get()\n const byThread = new Map<string, OutreachEnrollment>()\n const byAddress = new Map<string, OutreachEnrollment>()\n for (const doc of watched.docs) {\n const enrollment = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!enrollment) continue\n for (const threadId of enrollment.gmailThreadIds) byThread.set(threadId, enrollment)\n const known = byAddress.get(enrollment.email)\n if (!known || (enrollment.lastSentAtMs ?? 0) > (known.lastSentAtMs ?? 0)) byAddress.set(enrollment.email, enrollment)\n }\n\n const sinceMs = Math.max(Number(mailbox.sync?.throughMs) || 0, nowMs - OUTREACH_SYNC_LOOKBACK_MS) - OUTREACH_SYNC_OVERLAP_MS\n const after = `after:${Math.floor(sinceMs / 1000)}`\n // Every message this run or an earlier one handled outside an enrollment's\n // thread; one message is handled once however many searches find it.\n const handled = new Set(mailbox.sync?.handledMessageIds ?? [])\n const newlyHandled: string[] = []\n const markHandled = (id: string) => {\n if (handled.has(id)) return\n handled.add(id)\n newlyHandled.push(id)\n }\n const fresh = (stub: { id: string; threadId: string }) => !handled.has(stub.id) && !byThread.has(stub.threadId)\n\n // 1. The enrollments' own threads that have new mail.\n const inbound = await search(context.client, `${after} -from:me`)\n for (const threadId of new Set(inbound.map((stub) => stub.threadId))) {\n const enrollment = byThread.get(threadId)\n if (!enrollment) continue\n const thread = await context.client.getFullThread(threadId)\n report.threads += 1\n await applyMessages(context, enrollment, thread.messages.map(outreachThreadMessageFromGmail))\n }\n\n // 2. Delivery reports outside those threads: a receiving server's bounce.\n for (const stub of await search(context.client, `${after} from:(mailer-daemon OR postmaster)`)) {\n if (!fresh(stub)) continue\n const message = outreachThreadMessageFromGmail(await context.client.getFullMessage(stub.id))\n const delivery = readOutreachDeliveryReport(message)\n if (delivery?.kind === 'hard') {\n for (const address of delivery.failedAddresses) {\n const enrollment = byAddress.get(address)\n if (enrollment) await applyMessages(context, enrollment, [message])\n }\n }\n markHandled(stub.id)\n }\n\n // 3. Messages to the unsubscribe address: whoever wrote asked to leave.\n if (unsubscribe) {\n for (const stub of await search(context.client, `${after} to:${unsubscribe.address}`)) {\n if (handled.has(stub.id)) continue\n const metadata = await context.client.getMessage(stub.id, { metadataHeaders: ['From'] })\n const from = emailAddressOf(metadata.headers.find((header) => header.name.toLowerCase() === 'from')?.value ?? '')\n const address = normalizeContactEmail(from)\n if (address && !context.selfAddresses.includes(address)) {\n const enrollment = byAddress.get(address) ?? null\n await recordOutreachOptOut(deps, {\n orgId,\n email: address,\n source: 'unsubscribe',\n enrollment,\n hostIds: enrollment ? [enrollment.hostId] : [],\n detail: 'Wrote to the unsubscribe address.',\n nowMs,\n })\n report.optOuts += 1\n }\n markHandled(stub.id)\n }\n }\n\n // 4. The people this mailbox writes to, answering in a thread of their own.\n const addresses = [...byAddress.keys()]\n for (let start = 0; start < addresses.length; start += FROM_CHUNK) {\n const chunk = addresses.slice(start, start + FROM_CHUNK)\n for (const stub of await search(context.client, `${after} from:(${chunk.join(' OR ')})`)) {\n if (!fresh(stub)) continue\n const message = outreachThreadMessageFromGmail(await context.client.getFullMessage(stub.id))\n const enrollment = byAddress.get(normalizeContactEmail(emailAddressOf(message.from)) ?? '')\n if (enrollment) await applyMessages(context, enrollment, [message])\n markHandled(stub.id)\n }\n }\n\n const paused = await applyOutreachMailboxHealth(deps, { orgId, mailboxId: mailbox.id, delta: context.delta })\n if (paused) report.paused += 1\n await mailboxRef(firestore, orgId, mailbox.id).update({\n sync: {\n throughMs: nowMs,\n handledMessageIds: [...(mailbox.sync?.handledMessageIds ?? []), ...newlyHandled].slice(-MAILBOX_HANDLED_MAX),\n },\n })\n}\n\n/** The sequence an enrollment runs through, read once per run. */\nasync function sequenceOf(context: SyncContext, sequenceId: string): Promise<OutreachSequence | null> {\n if (!context.sequences.has(sequenceId)) {\n const snapshot = await outreachOrgCollection(context.firestore, context.orgId, 'sequences').doc(sequenceId).get()\n context.sequences.set(sequenceId, readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined))\n }\n return context.sequences.get(sequenceId) ?? null\n}\n\n/** A message's `Message-ID` in header form, or `null` when it carries none. */\nconst messageIdOf = (message: OutreachThreadMessage) => outreachMessageIdHeader(outreachHeader(message, 'Message-ID'))\n\n/** A bounce's diagnostic with the recipient's address taken out of it. */\nexport function scrubbedOutreachDiagnostic(diagnostic: string | null, email: string): string | null {\n if (!diagnostic) return null\n const pattern = new RegExp(email.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'gi')\n return diagnostic.replace(pattern, 'the address')\n}\n\n/** Files a message the person wrote on their timeline. */\nasync function fileInbound(context: SyncContext, enrollment: OutreachEnrollment, message: OutreachThreadMessage) {\n const messageId = messageIdOf(message)\n if (!messageId) return\n await fileOutreachEmail(context.deps, {\n orgId: context.orgId,\n hostId: enrollment.hostId,\n link: outreachEnrollmentLink(enrollment),\n direction: 'inbound',\n subject: message.subject,\n from: emailAddressOf(message.from) ?? message.from,\n to: context.mailbox.sendAs || context.mailbox.email,\n messageId,\n inReplyTo: outreachMessageIdHeader(outreachHeader(message, 'In-Reply-To')),\n body: crmInboundExcerpt(message.textBody ?? message.snippet ?? '', message.htmlBody ?? ''),\n atMs: message.internalDateMs,\n byUid: '',\n })\n}\n\n/**\n * What the new messages of one enrollment mean, done: the effects first,\n * each idempotent, then the event and the handled ids in one transaction.\n */\nasync function applyMessages(\n context: SyncContext,\n enrollment: OutreachEnrollment,\n messages: readonly OutreachThreadMessage[],\n): Promise<void> {\n const { deps, firestore, report, nowMs } = context\n const decision: OutreachThreadDecision = decideOutreachThread({\n messages,\n recipient: enrollment.email,\n handledMessageIds: enrollment.syncedMessageIds ?? [],\n selfAddresses: context.selfAddresses,\n unsubscribeAddress: context.unsubscribeAddress,\n threadSubject: enrollment.threadSubject,\n })\n const freshIds = decision.classifications.map((classification) => classification.messageId).filter(Boolean)\n if (!freshIds.length) return\n const decidedBy: OutreachMessageClassification | null = decision.decidedBy\n const decidingMessage = decidedBy ? messages.find((message) => message.id === decidedBy.messageId) ?? null : null\n const lastSentMessageId = enrollment.messageIds[enrollment.messageIds.length - 1] ?? null\n\n for (const classification of decision.classifications) {\n const aboutRecipient =\n !classification.bounce?.recipients.length || classification.bounce.recipients.includes(enrollment.email)\n if (classification.kind === 'hard_bounce' && aboutRecipient) {\n context.delta.bounces.push({ atMs: classification.atMs || nowMs, messageId: lastSentMessageId })\n }\n if (classification.complaint) {\n context.delta.complaintAtMs = Math.max(context.delta.complaintAtMs ?? 0, classification.atMs || nowMs)\n }\n }\n\n let event: OutreachEnrollmentEvent | null = null\n let postponedTo: number | null = null\n switch (decision.outcome) {\n case 'opted_out': {\n if (decidingMessage) await fileInbound(context, enrollment, decidingMessage)\n const complaint = decidedBy?.complaint === true\n await recordOutreachOptOut(deps, {\n orgId: context.orgId,\n email: enrollment.email,\n source: decidedBy?.evidence?.startsWith('To:') ? 'unsubscribe' : 'opt_out_reply',\n enrollment,\n hostIds: [enrollment.hostId],\n detail: decidedBy?.evidence ?? null,\n nowMs,\n complaint,\n })\n // A reply that called the email spam marks the send it answered\n // (AGL-3245), as the campaign webhook would mark a reported one.\n if (complaint) {\n await markOutreachEmailDelivery(deps, {\n orgId: context.orgId,\n messageId: lastSentMessageId,\n state: 'complained',\n atMs: decidedBy?.atMs || nowMs,\n detail: decidedBy?.evidence ?? null,\n })\n }\n report.optOuts += 1\n break\n }\n case 'replied': {\n if (decidingMessage) {\n await fileInbound(context, enrollment, decidingMessage)\n try {\n await fileOutreachTask(deps, {\n orgId: context.orgId,\n hostId: enrollment.hostId,\n link: outreachEnrollmentLink(enrollment),\n dedupeKey: `reply:${messageIdOf(decidingMessage) ?? decidingMessage.id}`,\n title: `Reply from ${enrollment.contactName || enrollment.email}`,\n notes: crmInboundExcerpt(decidingMessage.textBody ?? decidingMessage.snippet ?? '', decidingMessage.htmlBody ?? '').slice(0, 500),\n kind: 'email',\n dueAtMs: nowMs,\n assigneeUid: context.mailbox.connectedByUid,\n })\n } catch (error) {\n console.error('[outreach] the reply task could not be filed', error)\n }\n }\n // A lead somebody wrote back to is being worked (AGL-3234).\n if (enrollment.target === 'lead' && enrollment.leadId) {\n await markOutreachLeadWorking(firestore, { hostId: enrollment.hostId, leadId: enrollment.leadId })\n }\n context.delta.replies += 1\n event = { type: 'reply', atMs: decidedBy?.atMs || nowMs, detail: decidedBy?.evidence ?? null }\n report.replies += 1\n // Once per enrollment (AGL-3254): a reply moves the status to\n // `replied`, and nothing is synced for it again.\n await creditOutreachReply(deps, { enrollment, atMs: event.atMs })\n break\n }\n case 'bounced': {\n const diagnostic = scrubbedOutreachDiagnostic(decidedBy?.bounce?.diagnostic ?? decidedBy?.bounce?.status ?? null, enrollment.email)\n await deps.suppressBouncedEmail({ email: enrollment.email, hostId: enrollment.hostId })\n await addOutreachDoNotContact(firestore, {\n orgId: context.orgId,\n email: enrollment.email,\n reason: 'hard_bounce',\n source: 'runtime',\n nowMs,\n enrollmentId: enrollment.id,\n sequenceId: enrollment.sequenceId,\n detail: diagnostic,\n })\n let detail = diagnostic\n // A gateway block is the domain's verdict on the sender (AGL-3244):\n // the domain is filed beside the address, and the enrollment says so.\n // Never a public mailbox provider's domain — Gmail refusing one\n // message on policy is not a reason to stop writing to Gmail.\n const domain = outreachEmailDomain(enrollment.email)\n const gateway = isOutreachGatewayBlock(decidedBy?.bounce ?? null)\n if (domain && !isPublicMailboxDomain(domain) && gateway) {\n await addOutreachDoNotContactDomain(firestore, {\n orgId: context.orgId,\n domain,\n reason: 'gateway_block',\n source: 'runtime',\n nowMs,\n enrollmentId: enrollment.id,\n sequenceId: enrollment.sequenceId,\n detail: diagnostic,\n })\n detail = outreachGatewayBlockDetail(domain, diagnostic)\n report.gatewayBlocks += 1\n }\n const bouncedAtMs = decidedBy?.atMs || nowMs\n // The record the person is says so too (AGL-3245): the verdict on the\n // lead and the contact, and the bounce on the send's own timeline\n // entry, so the record reads Sent, then Bounced.\n await deps.stampRecordEmailState({\n orgId: context.orgId,\n email: enrollment.email,\n state: {\n status: gateway ? 'blocked' : 'bounced',\n atMs: bouncedAtMs,\n source: 'sequence',\n detail: diagnostic,\n enrollmentId: enrollment.id,\n },\n })\n await markOutreachEmailDelivery(deps, {\n orgId: context.orgId,\n messageId: lastSentMessageId,\n state: 'bounced',\n atMs: bouncedAtMs,\n detail: diagnostic,\n })\n event = { type: 'bounce', atMs: bouncedAtMs, detail }\n report.bounces += 1\n break\n }\n case 'postpone': {\n const sequence = await sequenceOf(context, enrollment.sequenceId)\n postponedTo = postponeOutreachForAutoReply({\n nextDueAtMs: enrollment.nextDueAtMs,\n receivedAtMs: decidedBy?.atMs || nowMs,\n timeZone: context.mailbox.timezone,\n window: effectiveOutreachWindow(sequence?.settings.window, context.mailbox.window),\n random: deps.random,\n })\n break\n }\n default:\n break\n }\n\n const outcome = await applyOutreachEvent(firestore, {\n orgId: context.orgId,\n enrollmentId: enrollment.id,\n event,\n extra: (current) => {\n const extra: Record<string, unknown> = {\n syncedMessageIds: [...(current.syncedMessageIds ?? []), ...freshIds].slice(-ENROLLMENT_HANDLED_MAX),\n }\n const waiting = current.status === 'active' || current.status === 'paused'\n if (postponedTo !== null && waiting && current.nextDueAtMs !== null && postponedTo > current.nextDueAtMs) {\n extra['nextDueAtMs'] = postponedTo\n }\n return extra\n },\n nowMs,\n })\n if (postponedTo !== null && outcome.enrollment?.nextDueAtMs === postponedTo) report.postponed += 1\n // The person's record says the sequence ended (AGL-3274), once, under the\n // reply that ended it — after the transaction moved the status, so a run\n // that reads the same reply again finds nothing to file. An opt-out's\n // line is `recordOutreachOptOut`'s, beside the lists it writes.\n if (event?.type === 'reply' && outcome.changed) {\n const sequence = await sequenceOf(context, enrollment.sequenceId)\n const entry = outreachStoppedEntry({\n enrollmentId: enrollment.id,\n sequenceName: sequence?.name ?? '',\n reason: 'replied',\n })\n await fileOutreachNote(deps, {\n orgId: context.orgId,\n hostId: enrollment.hostId,\n link: outreachEnrollmentLink(enrollment),\n dedupeKey: entry.dedupeKey,\n body: entry.body,\n atMs: event.atMs,\n })\n }\n // The run's own view of the enrollment moves with it, so a second message\n // of it in this run is judged against what this one did.\n if (outcome.enrollment) Object.assign(enrollment, outcome.enrollment)\n}\n"],"names":["normalizeContactEmail","isPublicMailboxDomain","crmInboundExcerpt","emailAddressOf","outreachMessageIdHeader","readOutreachDeliveryReport","outreachEmailDomain","outreachStoppedEntry","isOutreachGatewayBlock","outreachGatewayBlockDetail","effectiveOutreachWindow","postponeOutreachForAutoReply","decideOutreachThread","outreachHeader","outreachThreadMessageFromGmail","mailboxRef","OUTREACH_COLLECTIONS","addOutreachDoNotContact","addOutreachDoNotContactDomain","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","readStoredOutreachSequence","GmailTransportError","isReconnectRequired","creditOutreachReply","applyOutreachEvent","recordOutreachOptOut","applyOutreachMailboxHealth","emptyOutreachHealthDelta","noteOutreachMailboxReconnectRequired","markOutreachLeadWorking","fileOutreachEmail","fileOutreachNote","fileOutreachTask","markOutreachEmailDelivery","outreachUnsubscribeMailbox","OUTREACH_SYNC_JOB_ID","OUTREACH_SYNC_LOOKBACK_MS","OUTREACH_SYNC_OVERLAP_MS","OUTREACH_SYNC_WATCH_MS","SEARCH_PAGES","MAILBOX_HANDLED_MAX","ENROLLMENT_HANDLED_MAX","FROM_CHUNK","runOutreachSyncJob","deps","context","firestore","report","mailboxes","threads","replies","optOuts","bounces","gatewayBlocks","postponed","paused","all","collectionGroup","get","byOrg","Map","doc","docs","orgId","ref","parent","id","mailbox","data","status","set","now","deadlineMs","orgSnapshot","collection","org","exists","orgRefusal","syncMailbox","nowMs","error","mailboxId","errorCode","code","console","search","client","q","found","pageToken","page","list","listMessages","maxResults","includeSpamTrash","push","messages","filter","message","nextPageToken","input","opened","openMailbox","ok","reason","unsubscribe","email","selfAddresses","sendAs","sendAsOptions","map","option","Boolean","unsubscribeAddress","address","sequences","delta","watched","where","byThread","byAddress","enrollment","known","threadId","gmailThreadIds","lastSentAtMs","sinceMs","Math","max","Number","sync","throughMs","after","floor","handled","Set","handledMessageIds","newlyHandled","markHandled","has","add","fresh","stub","inbound","thread","getFullThread","applyMessages","getFullMessage","delivery","kind","failedAddresses","metadata","getMessage","metadataHeaders","from","headers","find","header","name","toLowerCase","value","includes","source","hostIds","hostId","detail","addresses","keys","start","length","chunk","slice","join","update","sequenceOf","sequenceId","snapshot","undefined","messageIdOf","scrubbedOutreachDiagnostic","diagnostic","pattern","RegExp","replace","fileInbound","messageId","link","direction","subject","to","inReplyTo","body","textBody","snippet","htmlBody","atMs","internalDateMs","byUid","outcome","decision","recipient","syncedMessageIds","threadSubject","freshIds","classifications","classification","decidedBy","decidingMessage","lastSentMessageId","messageIds","aboutRecipient","bounce","recipients","complaint","complaintAtMs","event","postponedTo","evidence","startsWith","state","dedupeKey","title","contactName","notes","dueAtMs","assigneeUid","connectedByUid","target","leadId","type","suppressBouncedEmail","enrollmentId","domain","gateway","bouncedAtMs","stampRecordEmailState","sequence","nextDueAtMs","receivedAtMs","timeZone","timezone","window","settings","random","extra","current","waiting","changed","entry","sequenceName","Object","assign"],"mappings":"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"}
|
|
@@ -50,6 +50,20 @@ export declare function fileOutreachEmail(deps: Pick<OutreachRuntimeDeps, 'timel
|
|
|
50
50
|
byUid: string;
|
|
51
51
|
byName?: string | null;
|
|
52
52
|
}): Promise<PluginRecordWrite | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Files one of the runtime's own lines on a person's record (AGL-3274) —
|
|
55
|
+
* "Enrolled in", "stopped" — as a note by "Sequences", once per the key
|
|
56
|
+
* given; never throws. The words and the keys are `enrollment-activity.ts`'s.
|
|
57
|
+
*/
|
|
58
|
+
export declare function fileOutreachNote(deps: Pick<OutreachRuntimeDeps, 'timeline'>, input: {
|
|
59
|
+
orgId: string;
|
|
60
|
+
hostId: string;
|
|
61
|
+
/** The record the entry lands on: `{ contactId }` or `{ leadId }`. */
|
|
62
|
+
link: PluginRecordLink;
|
|
63
|
+
dedupeKey: string;
|
|
64
|
+
body: string;
|
|
65
|
+
atMs: number;
|
|
66
|
+
}): Promise<PluginRecordWrite | null>;
|
|
53
67
|
/**
|
|
54
68
|
* Marks a sent email bounced or reported on the record it was filed on
|
|
55
69
|
* (AGL-3245), so the timeline reads Sent, then Bounced, with what the
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
17
|
import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
|
|
18
|
+
import { OUTREACH_TIMELINE_BY_NAME } from "../engine/enrollment-activity.js";
|
|
18
19
|
/**
|
|
19
20
|
* OUTREACH ON THE CONTACT'S TIMELINE (AGL-2981).
|
|
20
21
|
*
|
|
@@ -64,6 +65,33 @@ import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
|
|
|
64
65
|
return null;
|
|
65
66
|
}
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Files one of the runtime's own lines on a person's record (AGL-3274) —
|
|
70
|
+
* "Enrolled in", "stopped" — as a note by "Sequences", once per the key
|
|
71
|
+
* given; never throws. The words and the keys are `enrollment-activity.ts`'s.
|
|
72
|
+
*/ export async function fileOutreachNote(deps, input) {
|
|
73
|
+
const writer = deps.timeline();
|
|
74
|
+
if (!writer) return null;
|
|
75
|
+
try {
|
|
76
|
+
const written = await writer.logActivity({
|
|
77
|
+
orgId: input.orgId,
|
|
78
|
+
hostId: input.hostId,
|
|
79
|
+
link: input.link,
|
|
80
|
+
sourcePluginId: OUTREACH_PLUGIN_ID,
|
|
81
|
+
kind: 'note',
|
|
82
|
+
atMs: input.atMs,
|
|
83
|
+
body: input.body,
|
|
84
|
+
byUid: '',
|
|
85
|
+
byName: OUTREACH_TIMELINE_BY_NAME,
|
|
86
|
+
dedupeKey: input.dedupeKey
|
|
87
|
+
});
|
|
88
|
+
if (written.ok === false) console.warn(`[outreach] the record system did not file a note: ${written.error}`);
|
|
89
|
+
return written;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error('[outreach] filing a note on the record failed', error);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
67
95
|
/**
|
|
68
96
|
* Marks a sent email bounced or reported on the record it was filed on
|
|
69
97
|
* (AGL-3245), so the timeline reads Sent, then Bounced, with what the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/timeline.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginRecordDeliveryRequest,\n PluginRecordLink,\n PluginRecordTaskKind,\n PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport type { OutreachTaskKind } from '../model/outreach.types'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * OUTREACH ON THE CONTACT'S TIMELINE (AGL-2981).\n *\n * Every email a sequence sends, every reply it reads, and every task it\n * asks of the rep goes onto the contact's record through the core's\n * record-timeline seam — whichever plugin keeps the workspace's records —\n * never by writing that plugin's documents. An email is filed once per\n * `Message-ID`, under the id the capture address would file a forwarded copy\n * by; a task once per the key given here.\n *\n * The timeline is bookkeeping beside the act: an email has already left\n * when it is filed, so a writer that refuses or fails is logged and the\n * send stands. A workspace with no record system files nothing.\n */\n\n/** The record system's word for a step's task kind. */\nexport const OUTREACH_TASK_KIND_TO_RECORD: Record<OutreachTaskKind, PluginRecordTaskKind> = {\n call: 'call',\n linkedin: 'todo',\n todo: 'todo',\n}\n\n/** Files one email on a person's record — the contact, or the lead while they are one (AGL-3234); never throws. */\nexport async function fileOutreachEmail(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: {\n orgId: string\n hostId: string\n /** The record the entry lands on: `{ contactId }` or `{ leadId }`. */\n link: PluginRecordLink\n direction: 'outbound' | 'inbound'\n subject: string\n from: string\n to: string | null\n messageId: string\n inReplyTo?: string | null\n body: string\n atMs: number\n byUid: string\n byName?: string | null\n },\n): Promise<PluginRecordWrite | null> {\n const writer = deps.timeline()\n if (!writer) return null\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.hostId,\n link: input.link,\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'email',\n atMs: input.atMs,\n body: input.body,\n byUid: input.byUid,\n byName: input.byName ?? null,\n email: {\n direction: input.direction,\n subject: input.subject,\n from: input.from,\n to: input.to,\n messageId: input.messageId,\n inReplyTo: input.inReplyTo ?? null,\n },\n })\n if (written.ok === false) console.warn(`[outreach] the record system did not file an email: ${written.error}`)\n return written\n } catch (error) {\n console.error('[outreach] filing an email on the record failed', error)\n return null\n }\n}\n\n/**\n * Marks a sent email bounced or reported on the record it was filed on\n * (AGL-3245), so the timeline reads Sent, then Bounced, with what the\n * server said. Answers `null` when the workspace keeps no records or its\n * record system keeps no delivery state; never throws.\n */\nexport async function markOutreachEmailDelivery(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: Omit<PluginRecordDeliveryRequest, 'messageId'> & { messageId: string | null },\n): Promise<PluginRecordWrite | null> {\n const writer = deps.timeline()\n if (!writer?.recordEmailDelivery || !input.messageId) return null\n try {\n const written = await writer.recordEmailDelivery({ ...input, messageId: input.messageId })\n if (written.ok === false && written.status !== 404) {\n console.warn(`[outreach] the record system did not mark the email ${input.state}: ${written.error}`)\n }\n return written\n } catch (error) {\n console.error('[outreach] marking an email on the record failed', error)\n return null\n }\n}\n\n/**\n * Files one task on a contact. Answers the record system's answer, `null`\n * when the workspace keeps no records; a writer that THROWS is the caller's,\n * which a step waits out rather than completing without its task.\n */\nexport async function fileOutreachTask(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: {\n orgId: string\n hostId: string\n /** The record the task lands on: `{ contactId }` or `{ leadId }`. */\n link: PluginRecordLink\n dedupeKey: string\n title: string\n notes?: string | null\n kind: PluginRecordTaskKind\n dueAtMs: number\n assigneeUid: string\n },\n): Promise<PluginRecordWrite | null> {\n const writer = deps.timeline()\n if (!writer) return null\n return writer.createTask({\n orgId: input.orgId,\n hostId: input.hostId,\n link: input.link,\n sourcePluginId: OUTREACH_PLUGIN_ID,\n dedupeKey: input.dedupeKey,\n title: input.title,\n notes: input.notes ?? null,\n kind: input.kind,\n dueAtMs: input.dueAtMs,\n assigneeUid: input.assigneeUid,\n createdByUid: '',\n })\n}\n"],"names":["OUTREACH_PLUGIN_ID","OUTREACH_TASK_KIND_TO_RECORD","call","linkedin","todo","fileOutreachEmail","deps","input","writer","timeline","written","logActivity","orgId","hostId","link","sourcePluginId","kind","atMs","body","byUid","byName","email","direction","subject","from","to","messageId","inReplyTo","ok","console","warn","error","markOutreachEmailDelivery","recordEmailDelivery","status","state","fileOutreachTask","createTask","dedupeKey","title","notes","dueAtMs","assigneeUid","createdByUid"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAQD,SAASA,kBAAkB,QAAQ,gCAA4B;AAI/D;;;;;;;;;;;;;CAaC,GAED,qDAAqD,GACrD,OAAO,MAAMC,+BAA+E;IAC1FC,MAAM;IACNC,UAAU;IACVC,MAAM;AACR,EAAC;AAED,iHAAiH,GACjH,OAAO,eAAeC,kBACpBC,IAA2C,EAC3CC,KAeC;IAED,MAAMC,SAASF,KAAKG,QAAQ;IAC5B,IAAI,CAACD,QAAQ,OAAO;IACpB,IAAI;YAUQD,eAOKA;QAhBf,MAAMG,UAAU,MAAMF,OAAOG,WAAW,CAAC;YACvCC,OAAOL,MAAMK,KAAK;YAClBC,QAAQN,MAAMM,MAAM;YACpBC,MAAMP,MAAMO,IAAI;YAChBC,gBAAgBf;YAChBgB,MAAM;YACNC,MAAMV,MAAMU,IAAI;YAChBC,MAAMX,MAAMW,IAAI;YAChBC,OAAOZ,MAAMY,KAAK;YAClBC,MAAM,GAAEb,gBAAAA,MAAMa,MAAM,YAAZb,gBAAgB;YACxBc,OAAO;gBACLC,WAAWf,MAAMe,SAAS;gBAC1BC,SAAShB,MAAMgB,OAAO;gBACtBC,MAAMjB,MAAMiB,IAAI;gBAChBC,IAAIlB,MAAMkB,EAAE;gBACZC,WAAWnB,MAAMmB,SAAS;gBAC1BC,SAAS,GAAEpB,mBAAAA,MAAMoB,SAAS,YAAfpB,mBAAmB;YAChC;QACF;QACA,IAAIG,QAAQkB,EAAE,KAAK,OAAOC,QAAQC,IAAI,CAAC,CAAC,oDAAoD,EAAEpB,QAAQqB,KAAK,EAAE;QAC7G,OAAOrB;IACT,EAAE,OAAOqB,OAAO;QACdF,QAAQE,KAAK,CAAC,mDAAmDA;QACjE,OAAO;IACT;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeC,0BACpB1B,IAA2C,EAC3CC,KAAoF;IAEpF,MAAMC,SAASF,KAAKG,QAAQ;IAC5B,IAAI,EAACD,0BAAAA,OAAQyB,mBAAmB,KAAI,CAAC1B,MAAMmB,SAAS,EAAE,OAAO;IAC7D,IAAI;QACF,MAAMhB,UAAU,MAAMF,OAAOyB,mBAAmB,CAAC,aAAK1B;YAAOmB,WAAWnB,MAAMmB,SAAS;;QACvF,IAAIhB,QAAQkB,EAAE,KAAK,SAASlB,QAAQwB,MAAM,KAAK,KAAK;YAClDL,QAAQC,IAAI,CAAC,CAAC,oDAAoD,EAAEvB,MAAM4B,KAAK,CAAC,EAAE,EAAEzB,QAAQqB,KAAK,EAAE;QACrG;QACA,OAAOrB;IACT,EAAE,OAAOqB,OAAO;QACdF,QAAQE,KAAK,CAAC,oDAAoDA;QAClE,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeK,iBACpB9B,IAA2C,EAC3CC,KAWC;QAWQA;IATT,MAAMC,SAASF,KAAKG,QAAQ;IAC5B,IAAI,CAACD,QAAQ,OAAO;IACpB,OAAOA,OAAO6B,UAAU,CAAC;QACvBzB,OAAOL,MAAMK,KAAK;QAClBC,QAAQN,MAAMM,MAAM;QACpBC,MAAMP,MAAMO,IAAI;QAChBC,gBAAgBf;QAChBsC,WAAW/B,MAAM+B,SAAS;QAC1BC,OAAOhC,MAAMgC,KAAK;QAClBC,KAAK,GAAEjC,eAAAA,MAAMiC,KAAK,YAAXjC,eAAe;QACtBS,MAAMT,MAAMS,IAAI;QAChByB,SAASlC,MAAMkC,OAAO;QACtBC,aAAanC,MAAMmC,WAAW;QAC9BC,cAAc;IAChB;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/timeline.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginRecordDeliveryRequest,\n PluginRecordLink,\n PluginRecordTaskKind,\n PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { OUTREACH_TIMELINE_BY_NAME } from '../engine/enrollment-activity'\nimport type { OutreachTaskKind } from '../model/outreach.types'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * OUTREACH ON THE CONTACT'S TIMELINE (AGL-2981).\n *\n * Every email a sequence sends, every reply it reads, and every task it\n * asks of the rep goes onto the contact's record through the core's\n * record-timeline seam — whichever plugin keeps the workspace's records —\n * never by writing that plugin's documents. An email is filed once per\n * `Message-ID`, under the id the capture address would file a forwarded copy\n * by; a task once per the key given here.\n *\n * The timeline is bookkeeping beside the act: an email has already left\n * when it is filed, so a writer that refuses or fails is logged and the\n * send stands. A workspace with no record system files nothing.\n */\n\n/** The record system's word for a step's task kind. */\nexport const OUTREACH_TASK_KIND_TO_RECORD: Record<OutreachTaskKind, PluginRecordTaskKind> = {\n call: 'call',\n linkedin: 'todo',\n todo: 'todo',\n}\n\n/** Files one email on a person's record — the contact, or the lead while they are one (AGL-3234); never throws. */\nexport async function fileOutreachEmail(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: {\n orgId: string\n hostId: string\n /** The record the entry lands on: `{ contactId }` or `{ leadId }`. */\n link: PluginRecordLink\n direction: 'outbound' | 'inbound'\n subject: string\n from: string\n to: string | null\n messageId: string\n inReplyTo?: string | null\n body: string\n atMs: number\n byUid: string\n byName?: string | null\n },\n): Promise<PluginRecordWrite | null> {\n const writer = deps.timeline()\n if (!writer) return null\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.hostId,\n link: input.link,\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'email',\n atMs: input.atMs,\n body: input.body,\n byUid: input.byUid,\n byName: input.byName ?? null,\n email: {\n direction: input.direction,\n subject: input.subject,\n from: input.from,\n to: input.to,\n messageId: input.messageId,\n inReplyTo: input.inReplyTo ?? null,\n },\n })\n if (written.ok === false) console.warn(`[outreach] the record system did not file an email: ${written.error}`)\n return written\n } catch (error) {\n console.error('[outreach] filing an email on the record failed', error)\n return null\n }\n}\n\n/**\n * Files one of the runtime's own lines on a person's record (AGL-3274) —\n * \"Enrolled in\", \"stopped\" — as a note by \"Sequences\", once per the key\n * given; never throws. The words and the keys are `enrollment-activity.ts`'s.\n */\nexport async function fileOutreachNote(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: {\n orgId: string\n hostId: string\n /** The record the entry lands on: `{ contactId }` or `{ leadId }`. */\n link: PluginRecordLink\n dedupeKey: string\n body: string\n atMs: number\n },\n): Promise<PluginRecordWrite | null> {\n const writer = deps.timeline()\n if (!writer) return null\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.hostId,\n link: input.link,\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.atMs,\n body: input.body,\n byUid: '',\n byName: OUTREACH_TIMELINE_BY_NAME,\n dedupeKey: input.dedupeKey,\n })\n if (written.ok === false) console.warn(`[outreach] the record system did not file a note: ${written.error}`)\n return written\n } catch (error) {\n console.error('[outreach] filing a note on the record failed', error)\n return null\n }\n}\n\n/**\n * Marks a sent email bounced or reported on the record it was filed on\n * (AGL-3245), so the timeline reads Sent, then Bounced, with what the\n * server said. Answers `null` when the workspace keeps no records or its\n * record system keeps no delivery state; never throws.\n */\nexport async function markOutreachEmailDelivery(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: Omit<PluginRecordDeliveryRequest, 'messageId'> & { messageId: string | null },\n): Promise<PluginRecordWrite | null> {\n const writer = deps.timeline()\n if (!writer?.recordEmailDelivery || !input.messageId) return null\n try {\n const written = await writer.recordEmailDelivery({ ...input, messageId: input.messageId })\n if (written.ok === false && written.status !== 404) {\n console.warn(`[outreach] the record system did not mark the email ${input.state}: ${written.error}`)\n }\n return written\n } catch (error) {\n console.error('[outreach] marking an email on the record failed', error)\n return null\n }\n}\n\n/**\n * Files one task on a contact. Answers the record system's answer, `null`\n * when the workspace keeps no records; a writer that THROWS is the caller's,\n * which a step waits out rather than completing without its task.\n */\nexport async function fileOutreachTask(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: {\n orgId: string\n hostId: string\n /** The record the task lands on: `{ contactId }` or `{ leadId }`. */\n link: PluginRecordLink\n dedupeKey: string\n title: string\n notes?: string | null\n kind: PluginRecordTaskKind\n dueAtMs: number\n assigneeUid: string\n },\n): Promise<PluginRecordWrite | null> {\n const writer = deps.timeline()\n if (!writer) return null\n return writer.createTask({\n orgId: input.orgId,\n hostId: input.hostId,\n link: input.link,\n sourcePluginId: OUTREACH_PLUGIN_ID,\n dedupeKey: input.dedupeKey,\n title: input.title,\n notes: input.notes ?? null,\n kind: input.kind,\n dueAtMs: input.dueAtMs,\n assigneeUid: input.assigneeUid,\n createdByUid: '',\n })\n}\n"],"names":["OUTREACH_PLUGIN_ID","OUTREACH_TIMELINE_BY_NAME","OUTREACH_TASK_KIND_TO_RECORD","call","linkedin","todo","fileOutreachEmail","deps","input","writer","timeline","written","logActivity","orgId","hostId","link","sourcePluginId","kind","atMs","body","byUid","byName","email","direction","subject","from","to","messageId","inReplyTo","ok","console","warn","error","fileOutreachNote","dedupeKey","markOutreachEmailDelivery","recordEmailDelivery","status","state","fileOutreachTask","createTask","title","notes","dueAtMs","assigneeUid","createdByUid"],"mappings":"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"}
|
|
@@ -21,4 +21,4 @@ export declare function outreachUnsubscribePage(input: {
|
|
|
21
21
|
title: string;
|
|
22
22
|
body: string;
|
|
23
23
|
}, status?: number): Response;
|
|
24
|
-
export declare function createOutreachUnsubscribeRoute(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>): PluginWebApiHandler;
|
|
24
|
+
export declare function createOutreachUnsubscribeRoute(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'optOutOfSalesTopic' | 'stampRecordEmailState' | 'timeline'>): PluginWebApiHandler;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/unsubscribe-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport { recordOutreachOptOut } from './enrollment-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { readOutreachUnsubscribeToken } from './unsubscribe-link'\n\n/**\n * ONE-CLICK UNSUBSCRIBE (AGL-2981): `GET` and `POST /api/outreach/unsubscribe?t=…`.\n *\n * The link in every Outreach email's `List-Unsubscribe` header. A mailbox\n * provider POSTs it with `List-Unsubscribe=One-Click` and no person present\n * (RFC 8058); a mail client that opens it for a person sends a GET. BOTH\n * act, and both answer a plain page saying it is done.\n *\n * That GET acts is deliberate, and the opposite of the platform's newsletter\n * links. A person who used their mail client's Unsubscribe command has asked\n * already, and a second button would be a way out with a hurdle in it. The\n * one caller that could fetch the link without asking — a link scanner — can\n * only make a cold sequence stop writing to somebody, which is the direction\n * cold email should err in.\n *\n * No sign-in: the signature in the token is the authority, and a token that\n * does not verify changes nothing. It is registered as a recipient link, so\n * it answers whether or not Outreach is released to the organization now —\n * an opt-out outlives a paused rollout. Each act is idempotent: the link\n * used twice changes nothing the first use did not.\n *\n * What it records is what a reply saying \"stop\" records: the organization's\n * do-not-contact list, the site's `sales` topic, and every open enrollment\n * of the address stopped. An enrollment that no longer exists belongs to a\n * person or a workspace already erased, whose every site already refuses\n * them; the page says the same thing either way, and reveals nothing.\n */\n\nconst PAGE_HEADERS = {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Cache-Control': 'no-store',\n 'X-Robots-Tag': 'noindex',\n 'Referrer-Policy': 'no-referrer',\n}\n\nconst escapeHtml = (value: string) =>\n value.replace(/[&<>\"']/g, (character) => `&#${character.charCodeAt(0)};`)\n\n/** The plain page both methods answer with. */\nexport function outreachUnsubscribePage(input: { title: string; body: string }, status = 200): Response {\n const html =\n '<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">' +\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n // The reader's own colors, light or dark: the page has no theme to take.\n '<meta name=\"color-scheme\" content=\"light dark\">' +\n `<title>${escapeHtml(input.title)}</title>` +\n '<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5}h1{font-size:1.5rem}</style>' +\n `</head><body><main><h1>${escapeHtml(input.title)}</h1><p>${escapeHtml(input.body)}</p></main></body></html>`\n return new Response(html, { status, headers: PAGE_HEADERS })\n}\n\nexport function createOutreachUnsubscribeRoute(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'POST') {\n return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, POST' } })\n }\n const target = readOutreachUnsubscribeToken(new URL(request.url).searchParams.get('t'))\n if (!target) {\n return outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This unsubscribe link isn’t valid. To stop these emails, reply to one of them with “no”.',\n },\n 400,\n )\n }\n const firestore = deps.firestore()\n const snapshot = await outreachOrgCollection(firestore, target.orgId, 'enrollments').doc(target.enrollmentId).get()\n const enrollment = readStoredOutreachEnrollment(target.enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (enrollment) {\n await recordOutreachOptOut(deps, {\n orgId: target.orgId,\n email: enrollment.email,\n source: 'unsubscribe',\n enrollment,\n hostIds: [enrollment.hostId],\n detail: request.method === 'POST' ? 'One-click unsubscribe.' : 'Used the unsubscribe link.',\n nowMs: deps.now(),\n })\n }\n const settings = await readOutreachComplianceSettingsDoc(firestore, target.orgId).catch(() => null)\n const sender = settings?.brandName || settings?.legalName || 'The sender'\n return outreachUnsubscribePage({\n title: 'You’re unsubscribed',\n body: `${sender} won’t send you any more of these emails.`,\n })\n }\n}\n"],"names":["readOutreachComplianceSettingsDoc","outreachOrgCollection","readStoredOutreachEnrollment","recordOutreachOptOut","readOutreachUnsubscribeToken","PAGE_HEADERS","escapeHtml","value","replace","character","charCodeAt","outreachUnsubscribePage","input","status","html","title","body","Response","headers","createOutreachUnsubscribeRoute","deps","request","method","Allow","target","URL","url","searchParams","get","firestore","snapshot","orgId","doc","enrollmentId","enrollment","exists","data","undefined","email","source","hostIds","hostId","detail","nowMs","now","settings","catch","sender","brandName","legalName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAASA,iCAAiC,QAAQ,0CAAsC;AACxF,SAASC,qBAAqB,EAAEC,4BAA4B,QAAQ,iCAA6B;AACjG,SAASC,oBAAoB,QAAQ,yBAAqB;AAE1D,SAASC,4BAA4B,QAAQ,wBAAoB;AAEjE;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,MAAMC,eAAe;IACnB,gBAAgB;IAChB,iBAAiB;IACjB,gBAAgB;IAChB,mBAAmB;AACrB;AAEA,MAAMC,aAAa,CAACC,QAClBA,MAAMC,OAAO,CAAC,YAAY,CAACC,YAAc,CAAC,EAAE,EAAEA,UAAUC,UAAU,CAAC,GAAG,CAAC,CAAC;AAE1E,6CAA6C,GAC7C,OAAO,SAASC,wBAAwBC,KAAsC,EAAEC,SAAS,GAAG;IAC1F,MAAMC,OACJ,gEACA,yEACA,yEAAyE;IACzE,oDACA,CAAC,OAAO,EAAER,WAAWM,MAAMG,KAAK,EAAE,QAAQ,CAAC,GAC3C,8IACA,CAAC,uBAAuB,EAAET,WAAWM,MAAMG,KAAK,EAAE,QAAQ,EAAET,WAAWM,MAAMI,IAAI,EAAE,yBAAyB,CAAC;IAC/G,OAAO,IAAIC,SAASH,MAAM;QAAED;QAAQK,SAASb;IAAa;AAC5D;AAEA,OAAO,SAASc,+BACdC,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/unsubscribe-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport { recordOutreachOptOut } from './enrollment-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { readOutreachUnsubscribeToken } from './unsubscribe-link'\n\n/**\n * ONE-CLICK UNSUBSCRIBE (AGL-2981): `GET` and `POST /api/outreach/unsubscribe?t=…`.\n *\n * The link in every Outreach email's `List-Unsubscribe` header. A mailbox\n * provider POSTs it with `List-Unsubscribe=One-Click` and no person present\n * (RFC 8058); a mail client that opens it for a person sends a GET. BOTH\n * act, and both answer a plain page saying it is done.\n *\n * That GET acts is deliberate, and the opposite of the platform's newsletter\n * links. A person who used their mail client's Unsubscribe command has asked\n * already, and a second button would be a way out with a hurdle in it. The\n * one caller that could fetch the link without asking — a link scanner — can\n * only make a cold sequence stop writing to somebody, which is the direction\n * cold email should err in.\n *\n * No sign-in: the signature in the token is the authority, and a token that\n * does not verify changes nothing. It is registered as a recipient link, so\n * it answers whether or not Outreach is released to the organization now —\n * an opt-out outlives a paused rollout. Each act is idempotent: the link\n * used twice changes nothing the first use did not.\n *\n * What it records is what a reply saying \"stop\" records: the organization's\n * do-not-contact list, the site's `sales` topic, and every open enrollment\n * of the address stopped. An enrollment that no longer exists belongs to a\n * person or a workspace already erased, whose every site already refuses\n * them; the page says the same thing either way, and reveals nothing.\n */\n\nconst PAGE_HEADERS = {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Cache-Control': 'no-store',\n 'X-Robots-Tag': 'noindex',\n 'Referrer-Policy': 'no-referrer',\n}\n\nconst escapeHtml = (value: string) =>\n value.replace(/[&<>\"']/g, (character) => `&#${character.charCodeAt(0)};`)\n\n/** The plain page both methods answer with. */\nexport function outreachUnsubscribePage(input: { title: string; body: string }, status = 200): Response {\n const html =\n '<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">' +\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n // The reader's own colors, light or dark: the page has no theme to take.\n '<meta name=\"color-scheme\" content=\"light dark\">' +\n `<title>${escapeHtml(input.title)}</title>` +\n '<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5}h1{font-size:1.5rem}</style>' +\n `</head><body><main><h1>${escapeHtml(input.title)}</h1><p>${escapeHtml(input.body)}</p></main></body></html>`\n return new Response(html, { status, headers: PAGE_HEADERS })\n}\n\nexport function createOutreachUnsubscribeRoute(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'optOutOfSalesTopic' | 'stampRecordEmailState' | 'timeline'>,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'POST') {\n return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, POST' } })\n }\n const target = readOutreachUnsubscribeToken(new URL(request.url).searchParams.get('t'))\n if (!target) {\n return outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This unsubscribe link isn’t valid. To stop these emails, reply to one of them with “no”.',\n },\n 400,\n )\n }\n const firestore = deps.firestore()\n const snapshot = await outreachOrgCollection(firestore, target.orgId, 'enrollments').doc(target.enrollmentId).get()\n const enrollment = readStoredOutreachEnrollment(target.enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (enrollment) {\n await recordOutreachOptOut(deps, {\n orgId: target.orgId,\n email: enrollment.email,\n source: 'unsubscribe',\n enrollment,\n hostIds: [enrollment.hostId],\n detail: request.method === 'POST' ? 'One-click unsubscribe.' : 'Used the unsubscribe link.',\n nowMs: deps.now(),\n })\n }\n const settings = await readOutreachComplianceSettingsDoc(firestore, target.orgId).catch(() => null)\n const sender = settings?.brandName || settings?.legalName || 'The sender'\n return outreachUnsubscribePage({\n title: 'You’re unsubscribed',\n body: `${sender} won’t send you any more of these emails.`,\n })\n }\n}\n"],"names":["readOutreachComplianceSettingsDoc","outreachOrgCollection","readStoredOutreachEnrollment","recordOutreachOptOut","readOutreachUnsubscribeToken","PAGE_HEADERS","escapeHtml","value","replace","character","charCodeAt","outreachUnsubscribePage","input","status","html","title","body","Response","headers","createOutreachUnsubscribeRoute","deps","request","method","Allow","target","URL","url","searchParams","get","firestore","snapshot","orgId","doc","enrollmentId","enrollment","exists","data","undefined","email","source","hostIds","hostId","detail","nowMs","now","settings","catch","sender","brandName","legalName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAASA,iCAAiC,QAAQ,0CAAsC;AACxF,SAASC,qBAAqB,EAAEC,4BAA4B,QAAQ,iCAA6B;AACjG,SAASC,oBAAoB,QAAQ,yBAAqB;AAE1D,SAASC,4BAA4B,QAAQ,wBAAoB;AAEjE;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,MAAMC,eAAe;IACnB,gBAAgB;IAChB,iBAAiB;IACjB,gBAAgB;IAChB,mBAAmB;AACrB;AAEA,MAAMC,aAAa,CAACC,QAClBA,MAAMC,OAAO,CAAC,YAAY,CAACC,YAAc,CAAC,EAAE,EAAEA,UAAUC,UAAU,CAAC,GAAG,CAAC,CAAC;AAE1E,6CAA6C,GAC7C,OAAO,SAASC,wBAAwBC,KAAsC,EAAEC,SAAS,GAAG;IAC1F,MAAMC,OACJ,gEACA,yEACA,yEAAyE;IACzE,oDACA,CAAC,OAAO,EAAER,WAAWM,MAAMG,KAAK,EAAE,QAAQ,CAAC,GAC3C,8IACA,CAAC,uBAAuB,EAAET,WAAWM,MAAMG,KAAK,EAAE,QAAQ,EAAET,WAAWM,MAAMI,IAAI,EAAE,yBAAyB,CAAC;IAC/G,OAAO,IAAIC,SAASH,MAAM;QAAED;QAAQK,SAASb;IAAa;AAC5D;AAEA,OAAO,SAASc,+BACdC,IAAkH;IAElH,OAAO,OAAOC;QACZ,IAAIA,QAAQC,MAAM,KAAK,SAASD,QAAQC,MAAM,KAAK,QAAQ;YACzD,OAAO,IAAIL,SAAS,sBAAsB;gBAAEJ,QAAQ;gBAAKK,SAAS;oBAAEK,OAAO;gBAAY;YAAE;QAC3F;QACA,MAAMC,SAASpB,6BAA6B,IAAIqB,IAAIJ,QAAQK,GAAG,EAAEC,YAAY,CAACC,GAAG,CAAC;QAClF,IAAI,CAACJ,QAAQ;YACX,OAAOb,wBACL;gBACEI,OAAO;gBACPC,MAAM;YACR,GACA;QAEJ;QACA,MAAMa,YAAYT,KAAKS,SAAS;QAChC,MAAMC,WAAW,MAAM7B,sBAAsB4B,WAAWL,OAAOO,KAAK,EAAE,eAAeC,GAAG,CAACR,OAAOS,YAAY,EAAEL,GAAG;QACjH,MAAMM,aAAahC,6BAA6BsB,OAAOS,YAAY,EAAEH,SAASK,MAAM,GAAGL,SAASM,IAAI,KAAKC;QACzG,IAAIH,YAAY;YACd,MAAM/B,qBAAqBiB,MAAM;gBAC/BW,OAAOP,OAAOO,KAAK;gBACnBO,OAAOJ,WAAWI,KAAK;gBACvBC,QAAQ;gBACRL;gBACAM,SAAS;oBAACN,WAAWO,MAAM;iBAAC;gBAC5BC,QAAQrB,QAAQC,MAAM,KAAK,SAAS,2BAA2B;gBAC/DqB,OAAOvB,KAAKwB,GAAG;YACjB;QACF;QACA,MAAMC,WAAW,MAAM7C,kCAAkC6B,WAAWL,OAAOO,KAAK,EAAEe,KAAK,CAAC,IAAM;QAC9F,MAAMC,SAASF,CAAAA,4BAAAA,SAAUG,SAAS,MAAIH,4BAAAA,SAAUI,SAAS,KAAI;QAC7D,OAAOtC,wBAAwB;YAC7BI,OAAO;YACPC,MAAM,GAAG+B,OAAO,yCAAyC,CAAC;QAC5D;IACF;AACF"}
|