@aglyn/plugins-outreach 1.0.0-beta.217 → 1.0.0-beta.219
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 +14 -14
- package/src/lib/components/compliance-section.js +72 -34
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/enroll-dialog.d.ts +12 -2
- package/src/lib/components/enroll-dialog.js +72 -18
- package/src/lib/components/enroll-dialog.js.map +1 -1
- package/src/lib/components/enrollment-detail.d.ts +5 -1
- package/src/lib/components/enrollment-detail.js +12 -6
- package/src/lib/components/enrollment-detail.js.map +1 -1
- package/src/lib/components/outreach-console-sections.d.ts +1 -1
- package/src/lib/components/outreach-console-sections.js +1 -1
- package/src/lib/components/outreach-console-sections.js.map +1 -1
- package/src/lib/components/sequence-editor.js +85 -40
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/use-outreach-api.d.ts +2 -1
- package/src/lib/components/use-outreach-api.js +5 -3
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/engine/compose.d.ts +1 -1
- package/src/lib/engine/compose.js +8 -4
- package/src/lib/engine/compose.js.map +1 -1
- package/src/lib/engine/enrollment-activity.d.ts +9 -1
- package/src/lib/engine/enrollment-activity.js +15 -2
- package/src/lib/engine/enrollment-activity.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +33 -2
- package/src/lib/engine/enrollment-state.js +66 -7
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/engine/sending-capacity.d.ts +1 -1
- package/src/lib/engine/sending-capacity.js +1 -1
- package/src/lib/engine/sending-capacity.js.map +1 -1
- package/src/lib/engine/sequence-validation.d.ts +20 -3
- package/src/lib/engine/sequence-validation.js +30 -4
- package/src/lib/engine/sequence-validation.js.map +1 -1
- package/src/lib/enrollment/enroll-people.js +6 -4
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/step-overrides.d.ts +4 -2
- package/src/lib/enrollment/step-overrides.js +11 -4
- package/src/lib/enrollment/step-overrides.js.map +1 -1
- package/src/lib/mailboxes/mailbox-api.d.ts +5 -1
- package/src/lib/mailboxes/mailbox-api.js.map +1 -1
- package/src/lib/mailboxes/mailbox-routes.d.ts +5 -2
- package/src/lib/mailboxes/mailbox-routes.js +10 -7
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
- package/src/lib/mailboxes/mailbox-settings.d.ts +2 -2
- package/src/lib/mailboxes/mailbox-settings.js +1 -1
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +1 -1
- package/src/lib/model/enrollment-timeline.js +22 -3
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +19 -1
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +25 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/step-preview.d.ts +2 -0
- package/src/lib/model/step-preview.js +11 -7
- package/src/lib/model/step-preview.js.map +1 -1
- package/src/lib/model/stored-records.js +26 -0
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/plugin.js +1 -1
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/routes/enroll-routes.d.ts +6 -0
- package/src/lib/routes/enroll-routes.js +21 -6
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/preview-routes.js +14 -5
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/step-test-routes.js +3 -3
- package/src/lib/routes/step-test-routes.js.map +1 -1
- package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
- package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
- package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
- package/src/lib/transport/gmail-errors.d.ts +27 -0
- package/src/lib/transport/gmail-errors.js +56 -0
- package/src/lib/transport/gmail-errors.js.map +1 -1
|
@@ -1 +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 * A third entry (AGL-3324) says a member curated one step for the person,\n * and who wrote the words, so the send that follows can be read for what\n * it was.\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
|
|
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 * A third entry (AGL-3324) says a member curated one step for the person,\n * and who wrote the words, so the send that follows can be read for what\n * it was.\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 * The line that says an enrollment began past the first step (AGL-3228),\n * and which steps it skipped: \"Began at step 3 — steps 1–2 skipped, already\n * done by hand\". `''` for one that began at step 1.\n */\nexport function outreachStartedAtLine(startStepIndex: number | null | undefined): string {\n const start = Number(startStepIndex)\n if (!Number.isInteger(start) || start < 1) return ''\n const skipped = start === 1 ? 'step 1' : `steps 1–${start}`\n return `Began at step ${start + 1} — ${skipped} skipped, already done by hand`\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\". An enrollment\n * that began past the first step says so on a line of its own (AGL-3228).\n */\nexport function outreachEnrolledEntry(input: {\n enrollmentId: string\n sequenceName: string\n campaignNames: readonly string[]\n startStepIndex?: number\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'a sequence'\n const campaigns = input.campaignNames.map((name) => name.trim()).filter(Boolean)\n const enrolled = campaigns.length\n ? `Enrolled in ${sequence}\\nFiled under ${campaigns.join(', ')}`\n : `Enrolled in ${sequence}`\n const startedAt = outreachStartedAtLine(input.startStepIndex)\n return {\n dedupeKey: outreachEnrolledEntryKey(input.enrollmentId),\n body: startedAt ? `${enrolled}\\n${startedAt}` : enrolled,\n }\n}\n\n/**\n * The key one curated step's entry is filed under (AGL-3324): once per\n * confirmation, so a step curated again after a reply files again — each is\n * a fact about what went out, and a second line is a second fact.\n */\nexport function outreachCuratedEntryKey(enrollmentId: string, stepIndex: number, atMs: number): string {\n return `curated:${enrollmentId}:${stepIndex}:${atMs}`\n}\n\n/**\n * \"Curated step 2 — AI draft, edited by Casey\" (AGL-3324): who wrote this\n * person's copy of the step. An AI draft the member kept as it came reads\n * \"AI draft, confirmed by\"; words the member wrote themselves read \"written\n * by\". A member with no name is named by their address, and one with\n * neither is \"a member\".\n */\nexport function outreachCuratedEntry(input: {\n enrollmentId: string\n stepIndex: number\n source: 'ai' | 'member'\n edited: boolean\n memberName: string | null | undefined\n atMs: number\n}): OutreachTimelineEntry {\n const member = String(input.memberName ?? '').trim() || 'a member'\n const how =\n input.source === 'ai'\n ? input.edited\n ? `AI draft, edited by ${member}`\n : `AI draft, confirmed by ${member}`\n : `written by ${member}`\n return {\n dedupeKey: outreachCuratedEntryKey(input.enrollmentId, input.stepIndex, input.atMs),\n body: `Curated step ${input.stepIndex + 1} — ${how}`,\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","outreachStartedAtLine","startStepIndex","start","Number","isInteger","skipped","outreachEnrolledEntry","input","sequence","sequenceName","trim","campaigns","campaignNames","map","name","filter","Boolean","enrolled","length","join","startedAt","dedupeKey","body","outreachCuratedEntryKey","stepIndex","atMs","outreachCuratedEntry","member","String","memberName","how","source","edited","outreachStoppedEntry","reason"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC,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,cAAyC;IAC7E,MAAMC,QAAQC,OAAOF;IACrB,IAAI,CAACE,OAAOC,SAAS,CAACF,UAAUA,QAAQ,GAAG,OAAO;IAClD,MAAMG,UAAUH,UAAU,IAAI,WAAW,CAAC,QAAQ,EAAEA,OAAO;IAC3D,OAAO,CAAC,cAAc,EAAEA,QAAQ,EAAE,GAAG,EAAEG,QAAQ,8BAA8B,CAAC;AAChF;AAEA;;;;;CAKC,GACD,OAAO,SAASC,sBAAsBC,KAKrC;IACC,MAAMC,WAAWD,MAAME,YAAY,CAACC,IAAI,MAAM;IAC9C,MAAMC,YAAYJ,MAAMK,aAAa,CAACC,GAAG,CAAC,CAACC,OAASA,KAAKJ,IAAI,IAAIK,MAAM,CAACC;IACxE,MAAMC,WAAWN,UAAUO,MAAM,GAC7B,CAAC,YAAY,EAAEV,SAAS,cAAc,EAAEG,UAAUQ,IAAI,CAAC,OAAO,GAC9D,CAAC,YAAY,EAAEX,UAAU;IAC7B,MAAMY,YAAYpB,sBAAsBO,MAAMN,cAAc;IAC5D,OAAO;QACLoB,WAAWvB,yBAAyBS,MAAMR,YAAY;QACtDuB,MAAMF,YAAY,GAAGH,SAAS,EAAE,EAAEG,WAAW,GAAGH;IAClD;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASM,wBAAwBxB,YAAoB,EAAEyB,SAAiB,EAAEC,IAAY;IAC3F,OAAO,CAAC,QAAQ,EAAE1B,aAAa,CAAC,EAAEyB,UAAU,CAAC,EAAEC,MAAM;AACvD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,qBAAqBnB,KAOpC;QACuBA;IAAtB,MAAMoB,SAASC,QAAOrB,oBAAAA,MAAMsB,UAAU,YAAhBtB,oBAAoB,IAAIG,IAAI,MAAM;IACxD,MAAMoB,MACJvB,MAAMwB,MAAM,KAAK,OACbxB,MAAMyB,MAAM,GACV,CAAC,oBAAoB,EAAEL,QAAQ,GAC/B,CAAC,uBAAuB,EAAEA,QAAQ,GACpC,CAAC,WAAW,EAAEA,QAAQ;IAC5B,OAAO;QACLN,WAAWE,wBAAwBhB,MAAMR,YAAY,EAAEQ,MAAMiB,SAAS,EAAEjB,MAAMkB,IAAI;QAClFH,MAAM,CAAC,aAAa,EAAEf,MAAMiB,SAAS,GAAG,EAAE,GAAG,EAAEM,KAAK;IACtD;AACF;AAKA,uEAAuE,GACvE,OAAO,SAASG,qBAAqB1B,KAIpC;IACC,MAAMC,WAAWD,MAAME,YAAY,CAACC,IAAI,MAAM;IAC9C,OAAO;QACLW,WAAW,CAAC,QAAQ,EAAEd,MAAMR,YAAY,EAAE;QAC1CuB,MAAM,GAAGd,SAAS,UAAU,EAAED,MAAM2B,MAAM,KAAK,YAAY,iBAAiB,kBAAkB;IAChG;AACF"}
|
|
@@ -77,12 +77,29 @@ export interface OutreachEnrollmentEventResult {
|
|
|
77
77
|
export declare function applyOutreachEnrollmentEvent(enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>, event: OutreachEnrollmentEvent): OutreachEnrollmentEventResult;
|
|
78
78
|
type ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>;
|
|
79
79
|
type ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>;
|
|
80
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* The step an enrollment begins at (AGL-3228), read from a request: the
|
|
82
|
+
* first when the request names none, or the zero-based index it names —
|
|
83
|
+
* or, as `refusal`, why that is not a step of this sequence.
|
|
84
|
+
*/
|
|
85
|
+
export declare function readOutreachStartStepIndex(raw: unknown, steps: readonly unknown[] | null | undefined): {
|
|
86
|
+
startStepIndex: number;
|
|
87
|
+
refusal: null;
|
|
88
|
+
} | {
|
|
89
|
+
startStepIndex: null;
|
|
90
|
+
refusal: string;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* When a new enrollment's first step comes due, or `null` when it cannot be
|
|
94
|
+
* placed: the step it begins at (AGL-3228; the first unless it says so),
|
|
95
|
+
* that step's own delay counted from the moment of enrollment.
|
|
96
|
+
*/
|
|
81
97
|
export declare function planOutreachFirstDue(input: {
|
|
82
98
|
sequence: ScheduleSequence;
|
|
83
99
|
mailbox: ScheduleMailbox;
|
|
84
100
|
enrolledAtMs: number;
|
|
85
101
|
random: OutreachRandom;
|
|
102
|
+
startStepIndex?: number;
|
|
86
103
|
}): number | null;
|
|
87
104
|
/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */
|
|
88
105
|
export declare function outreachAttestationsFrom(kinds: readonly unknown[], uid: string, atMs: number): OutreachAttestations;
|
|
@@ -107,13 +124,27 @@ export interface OutreachEnrollmentDraft {
|
|
|
107
124
|
enrolledByUid: string;
|
|
108
125
|
nowMs: number;
|
|
109
126
|
random: OutreachRandom;
|
|
127
|
+
/**
|
|
128
|
+
* The step to begin at (AGL-3228), zero-based; the first when absent.
|
|
129
|
+
* The caller has checked it names a step — {@link readOutreachStartStepIndex}.
|
|
130
|
+
*/
|
|
131
|
+
startStepIndex?: number;
|
|
110
132
|
}
|
|
111
133
|
/**
|
|
112
134
|
* A new enrollment document, first step scheduled — one builder for every
|
|
113
135
|
* door that enrolls, so an enrollment made from a contact and one made from
|
|
114
136
|
* a list are the same shape. Call it after the gates allowed the person.
|
|
137
|
+
*
|
|
138
|
+
* An enrollment that begins past the first step (AGL-3228) is for someone
|
|
139
|
+
* who already had the earlier steps by hand: those are marked skipped, the
|
|
140
|
+
* step it begins at is scheduled by its own delay from now, and
|
|
141
|
+
* `lastSentAtMs` is the enrollment — the moment the earlier steps are
|
|
142
|
+
* accounted for — so the sync watches this mailbox for the person's reply,
|
|
143
|
+
* bounce or opt-out from the start, as it does after a send.
|
|
115
144
|
*/
|
|
116
145
|
export declare function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment;
|
|
146
|
+
/** The step an enrollment began at (AGL-3228): the first, unless it began later. */
|
|
147
|
+
export declare function outreachEnrollmentStartStepIndex(enrollment: Pick<OutreachEnrollment, 'startStepIndex'> | null | undefined): number;
|
|
117
148
|
/** What the provider reported for an email step that was sent. */
|
|
118
149
|
export interface OutreachSentEmail {
|
|
119
150
|
/** The `Message-ID` header the message went out with, angle brackets included. */
|
|
@@ -124,7 +155,7 @@ export interface OutreachSentEmail {
|
|
|
124
155
|
subject: string;
|
|
125
156
|
}
|
|
126
157
|
export interface OutreachStepCompletionInput {
|
|
127
|
-
enrollment: Pick<OutreachEnrollment, 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'
|
|
158
|
+
enrollment: Pick<OutreachEnrollment, 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'> & Partial<Pick<OutreachEnrollment, 'startStepIndex'>>;
|
|
128
159
|
sequence: ScheduleSequence;
|
|
129
160
|
mailbox: ScheduleMailbox;
|
|
130
161
|
/** When the step ran. */
|
|
@@ -160,9 +160,43 @@ const detailOf = (value)=>{
|
|
|
160
160
|
error: null
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* The step an enrollment begins at (AGL-3228), read from a request: the
|
|
165
|
+
* first when the request names none, or the zero-based index it names —
|
|
166
|
+
* or, as `refusal`, why that is not a step of this sequence.
|
|
167
|
+
*/ export function readOutreachStartStepIndex(raw, steps) {
|
|
168
|
+
var _ref;
|
|
169
|
+
if (raw === undefined || raw === null) return {
|
|
170
|
+
startStepIndex: 0,
|
|
171
|
+
refusal: null
|
|
172
|
+
};
|
|
173
|
+
const count = (_ref = steps == null ? void 0 : steps.length) != null ? _ref : 0;
|
|
174
|
+
const index = typeof raw === 'number' ? raw : Number.NaN;
|
|
175
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
176
|
+
return {
|
|
177
|
+
startStepIndex: null,
|
|
178
|
+
refusal: 'Name the step to start at by its number in the sequence.'
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (index >= count) {
|
|
182
|
+
return {
|
|
183
|
+
startStepIndex: null,
|
|
184
|
+
refusal: `This sequence has ${count} ${count === 1 ? 'step' : 'steps'}, so enrolling can't start at step ${index + 1}.`
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
startStepIndex: index,
|
|
189
|
+
refusal: null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* When a new enrollment's first step comes due, or `null` when it cannot be
|
|
194
|
+
* placed: the step it begins at (AGL-3228; the first unless it says so),
|
|
195
|
+
* that step's own delay counted from the moment of enrollment.
|
|
196
|
+
*/ export function planOutreachFirstDue(input) {
|
|
197
|
+
var _input_startStepIndex;
|
|
164
198
|
var _input_sequence_steps, _input_sequence_settings;
|
|
165
|
-
const first = (_input_sequence_steps = input.sequence.steps) == null ? void 0 : _input_sequence_steps[0];
|
|
199
|
+
const first = (_input_sequence_steps = input.sequence.steps) == null ? void 0 : _input_sequence_steps[(_input_startStepIndex = input.startStepIndex) != null ? _input_startStepIndex : 0];
|
|
166
200
|
if (!first) return null;
|
|
167
201
|
return scheduleOutreachDue({
|
|
168
202
|
fromMs: input.enrolledAtMs,
|
|
@@ -188,9 +222,18 @@ const detailOf = (value)=>{
|
|
|
188
222
|
* A new enrollment document, first step scheduled — one builder for every
|
|
189
223
|
* door that enrolls, so an enrollment made from a contact and one made from
|
|
190
224
|
* a list are the same shape. Call it after the gates allowed the person.
|
|
225
|
+
*
|
|
226
|
+
* An enrollment that begins past the first step (AGL-3228) is for someone
|
|
227
|
+
* who already had the earlier steps by hand: those are marked skipped, the
|
|
228
|
+
* step it begins at is scheduled by its own delay from now, and
|
|
229
|
+
* `lastSentAtMs` is the enrollment — the moment the earlier steps are
|
|
230
|
+
* accounted for — so the sync watches this mailbox for the person's reply,
|
|
231
|
+
* bounce or opt-out from the start, as it does after a send.
|
|
191
232
|
*/ export function buildOutreachEnrollment(draft) {
|
|
192
|
-
var _draft_target, _draft_leadId, _draft_contactName, _draft_email, _draft_attestations;
|
|
193
|
-
|
|
233
|
+
var _draft_startStepIndex, _draft_sequence_steps, _draft_target, _draft_leadId, _draft_contactName, _draft_email, _draft_attestations;
|
|
234
|
+
const start = (_draft_startStepIndex = draft.startStepIndex) != null ? _draft_startStepIndex : 0;
|
|
235
|
+
const steps = (_draft_sequence_steps = draft.sequence.steps) != null ? _draft_sequence_steps : [];
|
|
236
|
+
const enrollment = {
|
|
194
237
|
id: draft.id,
|
|
195
238
|
sequenceId: draft.sequence.id,
|
|
196
239
|
target: (_draft_target = draft.target) != null ? _draft_target : 'contact',
|
|
@@ -200,12 +243,13 @@ const detailOf = (value)=>{
|
|
|
200
243
|
email: String((_draft_email = draft.email) != null ? _draft_email : '').trim().toLowerCase(),
|
|
201
244
|
hostId: draft.sequence.hostId,
|
|
202
245
|
mailboxId: draft.sequence.mailboxId,
|
|
203
|
-
stepIndex:
|
|
246
|
+
stepIndex: start,
|
|
204
247
|
nextDueAtMs: planOutreachFirstDue({
|
|
205
248
|
sequence: draft.sequence,
|
|
206
249
|
mailbox: draft.mailbox,
|
|
207
250
|
enrolledAtMs: draft.nowMs,
|
|
208
|
-
random: draft.random
|
|
251
|
+
random: draft.random,
|
|
252
|
+
startStepIndex: start
|
|
209
253
|
}),
|
|
210
254
|
status: 'active',
|
|
211
255
|
stopReason: null,
|
|
@@ -227,6 +271,21 @@ const detailOf = (value)=>{
|
|
|
227
271
|
createdAtMs: draft.nowMs,
|
|
228
272
|
updatedAtMs: draft.nowMs
|
|
229
273
|
};
|
|
274
|
+
if (start > 0) {
|
|
275
|
+
enrollment.startStepIndex = start;
|
|
276
|
+
enrollment.skippedSteps = steps.slice(0, start).map((step, stepIndex)=>({
|
|
277
|
+
stepIndex,
|
|
278
|
+
stepId: step.id,
|
|
279
|
+
kind: step.kind,
|
|
280
|
+
atMs: draft.nowMs
|
|
281
|
+
}));
|
|
282
|
+
enrollment.lastSentAtMs = draft.nowMs;
|
|
283
|
+
}
|
|
284
|
+
return enrollment;
|
|
285
|
+
}
|
|
286
|
+
/** The step an enrollment began at (AGL-3228): the first, unless it began later. */ export function outreachEnrollmentStartStepIndex(enrollment) {
|
|
287
|
+
const start = Number(enrollment == null ? void 0 : enrollment.startStepIndex);
|
|
288
|
+
return Number.isInteger(start) && start > 0 ? start : 0;
|
|
230
289
|
}
|
|
231
290
|
/**
|
|
232
291
|
* What running the current step writes: the send recorded on the thread
|
|
@@ -271,7 +330,7 @@ const detailOf = (value)=>{
|
|
|
271
330
|
if (!threadIds.includes(threadId)) threadIds.push(threadId);
|
|
272
331
|
patch.gmailThreadId = threadId;
|
|
273
332
|
patch.gmailThreadIds = threadIds;
|
|
274
|
-
if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {
|
|
333
|
+
if (isInThreadEmailStep(steps, enrollment.stepIndex, outreachEnrollmentStartStepIndex(enrollment)) && enrollment.threadSubject) {
|
|
275
334
|
var _enrollment_messageIds;
|
|
276
335
|
patch.messageIds = [
|
|
277
336
|
...(_enrollment_messageIds = enrollment.messageIds) != null ? _enrollment_messageIds : [],
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.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 * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | {\n type: 'pause'\n atMs: number\n byUid: string | null\n /** `manual` unless the engine held the send on its gateway (AGL-3326). */\n reason?: Extract<OutreachStopReason, 'manual' | 'gateway_blocked_here'>\n detail?: string | null\n }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? (event.reason ?? 'manual')\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings' | 'campaignIds'>\n mailbox: ScheduleMailbox\n /** The record the person is (AGL-3234); a contact when absent. */\n target?: OutreachEnrollmentTarget\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key, for an enrollment made on a lead. */\n leadId?: string | null\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n target: draft.target ?? 'contact',\n contactId: draft.contactId,\n leadId: draft.leadId ?? null,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n // The sequence's campaigns as they stand now (AGL-3254): what this\n // enrollment's outcomes are credited to, whatever the sequence joins later.\n campaignIds: normalizeCampaignIds(draft.sequence.campaignIds),\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeCampaignIds","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.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 * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | {\n type: 'pause'\n atMs: number\n byUid: string | null\n /** `manual` unless the engine held the send on its gateway (AGL-3326). */\n reason?: Extract<OutreachStopReason, 'manual' | 'gateway_blocked_here'>\n detail?: string | null\n }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? (event.reason ?? 'manual')\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/**\n * The step an enrollment begins at (AGL-3228), read from a request: the\n * first when the request names none, or the zero-based index it names —\n * or, as `refusal`, why that is not a step of this sequence.\n */\nexport function readOutreachStartStepIndex(\n raw: unknown,\n steps: readonly unknown[] | null | undefined,\n): { startStepIndex: number; refusal: null } | { startStepIndex: null; refusal: string } {\n if (raw === undefined || raw === null) return { startStepIndex: 0, refusal: null }\n const count = steps?.length ?? 0\n const index = typeof raw === 'number' ? raw : Number.NaN\n if (!Number.isInteger(index) || index < 0) {\n return { startStepIndex: null, refusal: 'Name the step to start at by its number in the sequence.' }\n }\n if (index >= count) {\n return {\n startStepIndex: null,\n refusal: `This sequence has ${count} ${count === 1 ? 'step' : 'steps'}, so enrolling can't start at step ${index + 1}.`,\n }\n }\n return { startStepIndex: index, refusal: null }\n}\n\n/**\n * When a new enrollment's first step comes due, or `null` when it cannot be\n * placed: the step it begins at (AGL-3228; the first unless it says so),\n * that step's own delay counted from the moment of enrollment.\n */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n startStepIndex?: number\n}): number | null {\n const first = input.sequence.steps?.[input.startStepIndex ?? 0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings' | 'campaignIds'>\n mailbox: ScheduleMailbox\n /** The record the person is (AGL-3234); a contact when absent. */\n target?: OutreachEnrollmentTarget\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key, for an enrollment made on a lead. */\n leadId?: string | null\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n /**\n * The step to begin at (AGL-3228), zero-based; the first when absent.\n * The caller has checked it names a step — {@link readOutreachStartStepIndex}.\n */\n startStepIndex?: number\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n *\n * An enrollment that begins past the first step (AGL-3228) is for someone\n * who already had the earlier steps by hand: those are marked skipped, the\n * step it begins at is scheduled by its own delay from now, and\n * `lastSentAtMs` is the enrollment — the moment the earlier steps are\n * accounted for — so the sync watches this mailbox for the person's reply,\n * bounce or opt-out from the start, as it does after a send.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n const start = draft.startStepIndex ?? 0\n const steps = draft.sequence.steps ?? []\n const enrollment: OutreachEnrollment = {\n id: draft.id,\n sequenceId: draft.sequence.id,\n target: draft.target ?? 'contact',\n contactId: draft.contactId,\n leadId: draft.leadId ?? null,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: start,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n startStepIndex: start,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n // The sequence's campaigns as they stand now (AGL-3254): what this\n // enrollment's outcomes are credited to, whatever the sequence joins later.\n campaignIds: normalizeCampaignIds(draft.sequence.campaignIds),\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n if (start > 0) {\n enrollment.startStepIndex = start\n enrollment.skippedSteps = steps.slice(0, start).map((step, stepIndex) => ({\n stepIndex,\n stepId: step.id,\n kind: step.kind,\n atMs: draft.nowMs,\n }))\n enrollment.lastSentAtMs = draft.nowMs\n }\n return enrollment\n}\n\n/** The step an enrollment began at (AGL-3228): the first, unless it began later. */\nexport function outreachEnrollmentStartStepIndex(\n enrollment: Pick<OutreachEnrollment, 'startStepIndex'> | null | undefined,\n): number {\n const start = Number(enrollment?.startStepIndex)\n return Number.isInteger(start) && start > 0 ? start : 0\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n > &\n Partial<Pick<OutreachEnrollment, 'startStepIndex'>>\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (\n isInThreadEmailStep(steps, enrollment.stepIndex, outreachEnrollmentStartStepIndex(enrollment)) &&\n enrollment.threadSubject\n ) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeCampaignIds","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","readOutreachStartStepIndex","raw","steps","undefined","startStepIndex","refusal","count","length","index","Number","NaN","isInteger","planOutreachFirstDue","input","first","sequence","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","start","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","createdAtMs","updatedAtMs","skippedSteps","map","step","stepId","outreachEnrollmentStartStepIndex","planOutreachStepCompletion","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
|
|
@@ -18,7 +18,7 @@ import type { OutreachEnrollment, OutreachMailbox, OutreachSendWindow, OutreachS
|
|
|
18
18
|
/** The ramp's daily caps: week one, week two, and every week after. */
|
|
19
19
|
export declare const OUTREACH_RAMP_DAILY_CAPS: readonly number[];
|
|
20
20
|
/** The most any mailbox sends in one day, whatever is configured. */
|
|
21
|
-
export declare const OUTREACH_DAILY_CAP_MAX =
|
|
21
|
+
export declare const OUTREACH_DAILY_CAP_MAX = 100;
|
|
22
22
|
/** How often the sending runtime runs, in minutes. */
|
|
23
23
|
export declare const OUTREACH_TICK_MINUTES = 15;
|
|
24
24
|
/** The most one mailbox sends in one run. */
|
|
@@ -47,7 +47,7 @@ import { firstEmailStepIndex } from "./sequence-validation.js";
|
|
|
47
47
|
20,
|
|
48
48
|
30
|
|
49
49
|
];
|
|
50
|
-
/** The most any mailbox sends in one day, whatever is configured. */ export const OUTREACH_DAILY_CAP_MAX =
|
|
50
|
+
/** The most any mailbox sends in one day, whatever is configured. */ export const OUTREACH_DAILY_CAP_MAX = 100;
|
|
51
51
|
/** How often the sending runtime runs, in minutes. */ export const OUTREACH_TICK_MINUTES = 15;
|
|
52
52
|
/** The most one mailbox sends in one run. */ export const OUTREACH_SENDS_PER_TICK_MAX = 5;
|
|
53
53
|
const MINUTE_MS = 60000;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sending-capacity.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 * HOW MUCH A MAILBOX MAY SEND (AGL-2979).\n *\n * ## The daily cap\n *\n * A mailbox sends at most the least of three numbers in one of its own\n * days: the cap its owner configured; the warm-up ramp — 10 a day in its\n * first week, 20 in its second, 30 from its third — while a ramp is running;\n * and {@link OUTREACH_DAILY_CAP_MAX}, which nothing lifts. Mailbox providers\n * judge a new sender by how its volume grows, and a mailbox that goes from\n * nothing to fifty on its first morning is judged accordingly.\n *\n * ## The tick\n *\n * The runtime runs every {@link OUTREACH_TICK_MINUTES} minutes. What is left\n * of the day's cap is spread over the ticks left in the window, so thirty\n * emails go out across the working day rather than in its first half hour,\n * and no tick sends more than {@link OUTREACH_SENDS_PER_TICK_MAX}.\n *\n * ## Which due enrollments go first\n *\n * Follow-ups before first emails: a thread already started is a\n * conversation a delay would break, and a first email can start tomorrow\n * just as well. Then the longest-waiting, then the id, so two runs over the\n * same enrollments agree. One email per address per tick, whatever the data\n * says.\n *==========================================*/\n\nimport {\n isValidTimeZone,\n nextWeeklyOpening,\n zonedCalendarDaysBetween,\n zonedDayKey,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport type {\n OutreachEnrollment,\n OutreachMailbox,\n OutreachSendWindow,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { effectiveOutreachWindow, isOutreachWindowOpen, outreachWindowSchedule } from './schedule'\nimport { firstEmailStepIndex } from './sequence-validation'\n\n/** The ramp's daily caps: week one, week two, and every week after. */\nexport const OUTREACH_RAMP_DAILY_CAPS: readonly number[] = [10, 20, 30]\n\n/** The most any mailbox sends in one day, whatever is configured. */\nexport const OUTREACH_DAILY_CAP_MAX = 50\n\n/** How often the sending runtime runs, in minutes. */\nexport const OUTREACH_TICK_MINUTES = 15\n\n/** The most one mailbox sends in one run. */\nexport const OUTREACH_SENDS_PER_TICK_MAX = 5\n\nconst MINUTE_MS = 60_000\n\n/**\n * The ramp's cap for the day `nowMs` falls in, or `null` when no ramp is\n * running. Weeks are counted in calendar days of the mailbox's zone from the\n * day the ramp began, which is day one of week one; a start in the future\n * reads as week one.\n */\nexport function outreachRampCap(\n rampStartedAtMs: number | null | undefined,\n nowMs: number,\n timeZone: string,\n): number | null {\n if (typeof rampStartedAtMs !== 'number' || !Number.isFinite(rampStartedAtMs)) return null\n const days = zonedCalendarDaysBetween(rampStartedAtMs, nowMs, timeZone)\n const week = Math.max(0, Math.floor(days / 7))\n return OUTREACH_RAMP_DAILY_CAPS[Math.min(week, OUTREACH_RAMP_DAILY_CAPS.length - 1)]\n}\n\n/** The day's cap: the least of the configured cap, the ramp and the ceiling. */\nexport function outreachDailyCap(input: {\n configuredCap: number\n rampStartedAtMs: number | null | undefined\n nowMs: number\n timeZone: string\n}): number {\n const configured = Number.isFinite(input.configuredCap)\n ? Math.max(0, Math.floor(input.configuredCap))\n : 0\n const ramp = outreachRampCap(input.rampStartedAtMs, input.nowMs, input.timeZone)\n return Math.min(configured, ramp ?? OUTREACH_DAILY_CAP_MAX, OUTREACH_DAILY_CAP_MAX)\n}\n\n/**\n * How many emails the mailbox has sent today: its counter when the counter\n * belongs to today in the mailbox's zone, and `0` when it belongs to an\n * earlier day.\n */\nexport function outreachSentToday(\n health: Pick<OutreachMailbox['health'], 'sentToday' | 'sentOnDay'> | null | undefined,\n nowMs: number,\n timeZone: string,\n): number {\n if (!health || health.sentOnDay !== zonedDayKey(nowMs, timeZone)) return 0\n const sent = Number(health.sentToday)\n return Number.isFinite(sent) ? Math.max(0, Math.floor(sent)) : 0\n}\n\n/** Why a mailbox may send nothing this run. */\nexport type OutreachTickHold = 'mailbox_not_connected' | 'invalid_timezone' | 'daily_cap_reached'\n\nexport interface OutreachTickAllowanceInput {\n mailbox: Pick<\n OutreachMailbox,\n 'status' | 'dailyCap' | 'rampStartedAtMs' | 'timezone' | 'window' | 'health'\n >\n nowMs: number\n /**\n * The window the day's sends are spread across. The mailbox's own by\n * default; when it is closed but a sequence's own window is open, the\n * remainder is not paced and only the per-run maximum holds.\n */\n paceWindow?: OutreachSendWindow | null\n tickMinutes?: number\n perTickMax?: number\n}\n\nexport interface OutreachTickAllowance {\n /** How many emails this run may send from the mailbox. */\n allowance: number\n dailyCap: number\n sentToday: number\n remainingToday: number\n /** Why the allowance is zero, when a rule rather than an empty queue made it so. */\n hold: OutreachTickHold | null\n}\n\n/** How many emails a mailbox may send in this run — see the module note. */\nexport function outreachTickAllowance(input: OutreachTickAllowanceInput): OutreachTickAllowance {\n const { mailbox, nowMs } = input\n const none = (hold: OutreachTickHold, dailyCap = 0, sentToday = 0): OutreachTickAllowance => ({\n allowance: 0,\n dailyCap,\n sentToday,\n remainingToday: Math.max(0, dailyCap - sentToday),\n hold,\n })\n if (mailbox?.status !== 'connected') return none('mailbox_not_connected')\n if (!isValidTimeZone(mailbox.timezone)) return none('invalid_timezone')\n const dailyCap = outreachDailyCap({\n configuredCap: mailbox.dailyCap,\n rampStartedAtMs: mailbox.rampStartedAtMs,\n nowMs,\n timeZone: mailbox.timezone,\n })\n const sentToday = outreachSentToday(mailbox.health, nowMs, mailbox.timezone)\n const remainingToday = Math.max(0, dailyCap - sentToday)\n if (remainingToday === 0) return none('daily_cap_reached', dailyCap, sentToday)\n const tickMinutes = Math.max(1, input.tickMinutes ?? OUTREACH_TICK_MINUTES)\n const perTickMax = Math.max(0, Math.floor(input.perTickMax ?? OUTREACH_SENDS_PER_TICK_MAX))\n const paceWindow = input.paceWindow === undefined ? mailbox.window : input.paceWindow\n const opening = nextWeeklyOpening(nowMs, outreachWindowSchedule(paceWindow), mailbox.timezone)\n let paced = remainingToday\n if (opening && opening.startMs <= nowMs) {\n const ticksLeft = Math.max(1, Math.ceil((opening.endMs - nowMs) / (tickMinutes * MINUTE_MS)))\n paced = Math.ceil(remainingToday / ticksLeft)\n }\n return {\n allowance: Math.min(remainingToday, paced, perTickMax),\n dailyCap,\n sentToday,\n remainingToday,\n hold: null,\n }\n}\n\n/** One due enrollment beside the sequence it runs through. */\nexport interface OutreachDueCandidate {\n enrollment: Pick<\n OutreachEnrollment,\n 'id' | 'status' | 'nextDueAtMs' | 'stepIndex' | 'email' | 'mailboxId' | 'sequenceId'\n >\n sequence: Pick<OutreachSequence, 'id' | 'status' | 'steps' | 'settings'>\n}\n\nexport interface OutreachDueSelection {\n /** Email steps to send this run, in the order to send them. */\n emails: OutreachDueCandidate[]\n /** Task steps due now. Each becomes a CRM task; none spends the allowance. */\n tasks: OutreachDueCandidate[]\n /** Email steps that were due inside their window and did not fit this run. */\n deferred: OutreachDueCandidate[]\n}\n\nexport interface OutreachDueSelectionInput {\n candidates: readonly OutreachDueCandidate[]\n mailbox: Pick<OutreachMailbox, 'id' | 'timezone' | 'window'>\n nowMs: number\n /** {@link outreachTickAllowance}'s `allowance`. */\n allowance: number\n}\n\nconst byDueThenId = (a: OutreachDueCandidate, b: OutreachDueCandidate) =>\n (a.enrollment.nextDueAtMs ?? 0) - (b.enrollment.nextDueAtMs ?? 0) ||\n (a.enrollment.id < b.enrollment.id ? -1 : a.enrollment.id > b.enrollment.id ? 1 : 0)\n\n/**\n * The due enrollments one mailbox runs now, in order — see the module note.\n * An enrollment is due when it and its sequence are active, it belongs to\n * this mailbox, its time has come, and — for an email — its window is open.\n */\nexport function selectDueOutreachEnrollments(input: OutreachDueSelectionInput): OutreachDueSelection {\n const { mailbox, nowMs } = input\n const emails: Array<{ candidate: OutreachDueCandidate; followUp: boolean }> = []\n const tasks: OutreachDueCandidate[] = []\n for (const candidate of input.candidates ?? []) {\n const { enrollment, sequence } = candidate ?? ({} as OutreachDueCandidate)\n if (enrollment?.status !== 'active' || sequence?.status !== 'active') continue\n if (enrollment.sequenceId !== sequence.id || enrollment.mailboxId !== mailbox.id) continue\n const due = enrollment.nextDueAtMs\n if (typeof due !== 'number' || !Number.isFinite(due) || due > nowMs) continue\n const step = sequence.steps?.[enrollment.stepIndex]\n if (step?.kind === 'task') {\n tasks.push(candidate)\n } else if (step?.kind === 'email') {\n const window = effectiveOutreachWindow(sequence.settings?.window, mailbox.window)\n if (!isOutreachWindowOpen(nowMs, window, mailbox.timezone)) continue\n emails.push({\n candidate,\n followUp: enrollment.stepIndex > firstEmailStepIndex(sequence.steps),\n })\n }\n }\n emails.sort(\n (a, b) => Number(b.followUp) - Number(a.followUp) || byDueThenId(a.candidate, b.candidate),\n )\n const allowance = Math.max(0, Math.floor(Number(input.allowance) || 0))\n const selected: OutreachDueCandidate[] = []\n const deferred: OutreachDueCandidate[] = []\n const addresses = new Set<string>()\n for (const { candidate } of emails) {\n const address = String(candidate.enrollment.email ?? '').trim().toLowerCase()\n if (selected.length < allowance && !addresses.has(address)) {\n selected.push(candidate)\n addresses.add(address)\n } else {\n deferred.push(candidate)\n }\n }\n return { emails: selected, tasks: tasks.sort(byDueThenId), deferred }\n}\n"],"names":["isValidTimeZone","nextWeeklyOpening","zonedCalendarDaysBetween","zonedDayKey","effectiveOutreachWindow","isOutreachWindowOpen","outreachWindowSchedule","firstEmailStepIndex","OUTREACH_RAMP_DAILY_CAPS","OUTREACH_DAILY_CAP_MAX","OUTREACH_TICK_MINUTES","OUTREACH_SENDS_PER_TICK_MAX","MINUTE_MS","outreachRampCap","rampStartedAtMs","nowMs","timeZone","Number","isFinite","days","week","Math","max","floor","min","length","outreachDailyCap","input","configured","configuredCap","ramp","outreachSentToday","health","sentOnDay","sent","sentToday","outreachTickAllowance","mailbox","none","hold","dailyCap","allowance","remainingToday","status","timezone","tickMinutes","perTickMax","paceWindow","undefined","window","opening","paced","startMs","ticksLeft","ceil","endMs","byDueThenId","a","b","enrollment","nextDueAtMs","id","selectDueOutreachEnrollments","emails","tasks","candidate","candidates","sequence","sequenceId","mailboxId","due","step","steps","stepIndex","kind","push","settings","followUp","sort","selected","deferred","addresses","Set","address","String","email","trim","toLowerCase","has","add"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sending-capacity.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 * HOW MUCH A MAILBOX MAY SEND (AGL-2979).\n *\n * ## The daily cap\n *\n * A mailbox sends at most the least of three numbers in one of its own\n * days: the cap its owner configured; the warm-up ramp — 10 a day in its\n * first week, 20 in its second, 30 from its third — while a ramp is running;\n * and {@link OUTREACH_DAILY_CAP_MAX}, which nothing lifts. Mailbox providers\n * judge a new sender by how its volume grows, and a mailbox that goes from\n * nothing to fifty on its first morning is judged accordingly.\n *\n * ## The tick\n *\n * The runtime runs every {@link OUTREACH_TICK_MINUTES} minutes. What is left\n * of the day's cap is spread over the ticks left in the window, so thirty\n * emails go out across the working day rather than in its first half hour,\n * and no tick sends more than {@link OUTREACH_SENDS_PER_TICK_MAX}.\n *\n * ## Which due enrollments go first\n *\n * Follow-ups before first emails: a thread already started is a\n * conversation a delay would break, and a first email can start tomorrow\n * just as well. Then the longest-waiting, then the id, so two runs over the\n * same enrollments agree. One email per address per tick, whatever the data\n * says.\n *==========================================*/\n\nimport {\n isValidTimeZone,\n nextWeeklyOpening,\n zonedCalendarDaysBetween,\n zonedDayKey,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport type {\n OutreachEnrollment,\n OutreachMailbox,\n OutreachSendWindow,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { effectiveOutreachWindow, isOutreachWindowOpen, outreachWindowSchedule } from './schedule'\nimport { firstEmailStepIndex } from './sequence-validation'\n\n/** The ramp's daily caps: week one, week two, and every week after. */\nexport const OUTREACH_RAMP_DAILY_CAPS: readonly number[] = [10, 20, 30]\n\n/** The most any mailbox sends in one day, whatever is configured. */\nexport const OUTREACH_DAILY_CAP_MAX = 100\n\n/** How often the sending runtime runs, in minutes. */\nexport const OUTREACH_TICK_MINUTES = 15\n\n/** The most one mailbox sends in one run. */\nexport const OUTREACH_SENDS_PER_TICK_MAX = 5\n\nconst MINUTE_MS = 60_000\n\n/**\n * The ramp's cap for the day `nowMs` falls in, or `null` when no ramp is\n * running. Weeks are counted in calendar days of the mailbox's zone from the\n * day the ramp began, which is day one of week one; a start in the future\n * reads as week one.\n */\nexport function outreachRampCap(\n rampStartedAtMs: number | null | undefined,\n nowMs: number,\n timeZone: string,\n): number | null {\n if (typeof rampStartedAtMs !== 'number' || !Number.isFinite(rampStartedAtMs)) return null\n const days = zonedCalendarDaysBetween(rampStartedAtMs, nowMs, timeZone)\n const week = Math.max(0, Math.floor(days / 7))\n return OUTREACH_RAMP_DAILY_CAPS[Math.min(week, OUTREACH_RAMP_DAILY_CAPS.length - 1)]\n}\n\n/** The day's cap: the least of the configured cap, the ramp and the ceiling. */\nexport function outreachDailyCap(input: {\n configuredCap: number\n rampStartedAtMs: number | null | undefined\n nowMs: number\n timeZone: string\n}): number {\n const configured = Number.isFinite(input.configuredCap)\n ? Math.max(0, Math.floor(input.configuredCap))\n : 0\n const ramp = outreachRampCap(input.rampStartedAtMs, input.nowMs, input.timeZone)\n return Math.min(configured, ramp ?? OUTREACH_DAILY_CAP_MAX, OUTREACH_DAILY_CAP_MAX)\n}\n\n/**\n * How many emails the mailbox has sent today: its counter when the counter\n * belongs to today in the mailbox's zone, and `0` when it belongs to an\n * earlier day.\n */\nexport function outreachSentToday(\n health: Pick<OutreachMailbox['health'], 'sentToday' | 'sentOnDay'> | null | undefined,\n nowMs: number,\n timeZone: string,\n): number {\n if (!health || health.sentOnDay !== zonedDayKey(nowMs, timeZone)) return 0\n const sent = Number(health.sentToday)\n return Number.isFinite(sent) ? Math.max(0, Math.floor(sent)) : 0\n}\n\n/** Why a mailbox may send nothing this run. */\nexport type OutreachTickHold = 'mailbox_not_connected' | 'invalid_timezone' | 'daily_cap_reached'\n\nexport interface OutreachTickAllowanceInput {\n mailbox: Pick<\n OutreachMailbox,\n 'status' | 'dailyCap' | 'rampStartedAtMs' | 'timezone' | 'window' | 'health'\n >\n nowMs: number\n /**\n * The window the day's sends are spread across. The mailbox's own by\n * default; when it is closed but a sequence's own window is open, the\n * remainder is not paced and only the per-run maximum holds.\n */\n paceWindow?: OutreachSendWindow | null\n tickMinutes?: number\n perTickMax?: number\n}\n\nexport interface OutreachTickAllowance {\n /** How many emails this run may send from the mailbox. */\n allowance: number\n dailyCap: number\n sentToday: number\n remainingToday: number\n /** Why the allowance is zero, when a rule rather than an empty queue made it so. */\n hold: OutreachTickHold | null\n}\n\n/** How many emails a mailbox may send in this run — see the module note. */\nexport function outreachTickAllowance(input: OutreachTickAllowanceInput): OutreachTickAllowance {\n const { mailbox, nowMs } = input\n const none = (hold: OutreachTickHold, dailyCap = 0, sentToday = 0): OutreachTickAllowance => ({\n allowance: 0,\n dailyCap,\n sentToday,\n remainingToday: Math.max(0, dailyCap - sentToday),\n hold,\n })\n if (mailbox?.status !== 'connected') return none('mailbox_not_connected')\n if (!isValidTimeZone(mailbox.timezone)) return none('invalid_timezone')\n const dailyCap = outreachDailyCap({\n configuredCap: mailbox.dailyCap,\n rampStartedAtMs: mailbox.rampStartedAtMs,\n nowMs,\n timeZone: mailbox.timezone,\n })\n const sentToday = outreachSentToday(mailbox.health, nowMs, mailbox.timezone)\n const remainingToday = Math.max(0, dailyCap - sentToday)\n if (remainingToday === 0) return none('daily_cap_reached', dailyCap, sentToday)\n const tickMinutes = Math.max(1, input.tickMinutes ?? OUTREACH_TICK_MINUTES)\n const perTickMax = Math.max(0, Math.floor(input.perTickMax ?? OUTREACH_SENDS_PER_TICK_MAX))\n const paceWindow = input.paceWindow === undefined ? mailbox.window : input.paceWindow\n const opening = nextWeeklyOpening(nowMs, outreachWindowSchedule(paceWindow), mailbox.timezone)\n let paced = remainingToday\n if (opening && opening.startMs <= nowMs) {\n const ticksLeft = Math.max(1, Math.ceil((opening.endMs - nowMs) / (tickMinutes * MINUTE_MS)))\n paced = Math.ceil(remainingToday / ticksLeft)\n }\n return {\n allowance: Math.min(remainingToday, paced, perTickMax),\n dailyCap,\n sentToday,\n remainingToday,\n hold: null,\n }\n}\n\n/** One due enrollment beside the sequence it runs through. */\nexport interface OutreachDueCandidate {\n enrollment: Pick<\n OutreachEnrollment,\n 'id' | 'status' | 'nextDueAtMs' | 'stepIndex' | 'email' | 'mailboxId' | 'sequenceId'\n >\n sequence: Pick<OutreachSequence, 'id' | 'status' | 'steps' | 'settings'>\n}\n\nexport interface OutreachDueSelection {\n /** Email steps to send this run, in the order to send them. */\n emails: OutreachDueCandidate[]\n /** Task steps due now. Each becomes a CRM task; none spends the allowance. */\n tasks: OutreachDueCandidate[]\n /** Email steps that were due inside their window and did not fit this run. */\n deferred: OutreachDueCandidate[]\n}\n\nexport interface OutreachDueSelectionInput {\n candidates: readonly OutreachDueCandidate[]\n mailbox: Pick<OutreachMailbox, 'id' | 'timezone' | 'window'>\n nowMs: number\n /** {@link outreachTickAllowance}'s `allowance`. */\n allowance: number\n}\n\nconst byDueThenId = (a: OutreachDueCandidate, b: OutreachDueCandidate) =>\n (a.enrollment.nextDueAtMs ?? 0) - (b.enrollment.nextDueAtMs ?? 0) ||\n (a.enrollment.id < b.enrollment.id ? -1 : a.enrollment.id > b.enrollment.id ? 1 : 0)\n\n/**\n * The due enrollments one mailbox runs now, in order — see the module note.\n * An enrollment is due when it and its sequence are active, it belongs to\n * this mailbox, its time has come, and — for an email — its window is open.\n */\nexport function selectDueOutreachEnrollments(input: OutreachDueSelectionInput): OutreachDueSelection {\n const { mailbox, nowMs } = input\n const emails: Array<{ candidate: OutreachDueCandidate; followUp: boolean }> = []\n const tasks: OutreachDueCandidate[] = []\n for (const candidate of input.candidates ?? []) {\n const { enrollment, sequence } = candidate ?? ({} as OutreachDueCandidate)\n if (enrollment?.status !== 'active' || sequence?.status !== 'active') continue\n if (enrollment.sequenceId !== sequence.id || enrollment.mailboxId !== mailbox.id) continue\n const due = enrollment.nextDueAtMs\n if (typeof due !== 'number' || !Number.isFinite(due) || due > nowMs) continue\n const step = sequence.steps?.[enrollment.stepIndex]\n if (step?.kind === 'task') {\n tasks.push(candidate)\n } else if (step?.kind === 'email') {\n const window = effectiveOutreachWindow(sequence.settings?.window, mailbox.window)\n if (!isOutreachWindowOpen(nowMs, window, mailbox.timezone)) continue\n emails.push({\n candidate,\n followUp: enrollment.stepIndex > firstEmailStepIndex(sequence.steps),\n })\n }\n }\n emails.sort(\n (a, b) => Number(b.followUp) - Number(a.followUp) || byDueThenId(a.candidate, b.candidate),\n )\n const allowance = Math.max(0, Math.floor(Number(input.allowance) || 0))\n const selected: OutreachDueCandidate[] = []\n const deferred: OutreachDueCandidate[] = []\n const addresses = new Set<string>()\n for (const { candidate } of emails) {\n const address = String(candidate.enrollment.email ?? '').trim().toLowerCase()\n if (selected.length < allowance && !addresses.has(address)) {\n selected.push(candidate)\n addresses.add(address)\n } else {\n deferred.push(candidate)\n }\n }\n return { emails: selected, tasks: tasks.sort(byDueThenId), deferred }\n}\n"],"names":["isValidTimeZone","nextWeeklyOpening","zonedCalendarDaysBetween","zonedDayKey","effectiveOutreachWindow","isOutreachWindowOpen","outreachWindowSchedule","firstEmailStepIndex","OUTREACH_RAMP_DAILY_CAPS","OUTREACH_DAILY_CAP_MAX","OUTREACH_TICK_MINUTES","OUTREACH_SENDS_PER_TICK_MAX","MINUTE_MS","outreachRampCap","rampStartedAtMs","nowMs","timeZone","Number","isFinite","days","week","Math","max","floor","min","length","outreachDailyCap","input","configured","configuredCap","ramp","outreachSentToday","health","sentOnDay","sent","sentToday","outreachTickAllowance","mailbox","none","hold","dailyCap","allowance","remainingToday","status","timezone","tickMinutes","perTickMax","paceWindow","undefined","window","opening","paced","startMs","ticksLeft","ceil","endMs","byDueThenId","a","b","enrollment","nextDueAtMs","id","selectDueOutreachEnrollments","emails","tasks","candidate","candidates","sequence","sequenceId","mailboxId","due","step","steps","stepIndex","kind","push","settings","followUp","sort","selected","deferred","addresses","Set","address","String","email","trim","toLowerCase","has","add"],"mappings":"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"}
|
|
@@ -44,14 +44,31 @@ export interface OutreachValidationIssue {
|
|
|
44
44
|
}
|
|
45
45
|
/** Whether any issue in the list stops a save or an activation. */
|
|
46
46
|
export declare function hasOutreachValidationErrors(issues: readonly OutreachValidationIssue[]): boolean;
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The index of the first email step at or after `fromIndex` — the first
|
|
49
|
+
* email an enrollment that began there sends (AGL-3228) — or `-1` when
|
|
50
|
+
* there is none.
|
|
51
|
+
*/
|
|
52
|
+
export declare function firstEmailStepIndex(steps: readonly OutreachSequenceStep[] | null | undefined, fromIndex?: number): number;
|
|
49
53
|
/**
|
|
50
54
|
* Whether the email at `index` is sent as a reply in the thread an earlier
|
|
51
55
|
* email started: every email after the first unless it says otherwise. The
|
|
52
56
|
* first email starts the thread whatever its `replyInThread` says.
|
|
57
|
+
*
|
|
58
|
+
* `startStepIndex` is where the enrollment began (AGL-3228): the first email
|
|
59
|
+
* it sends from there starts the thread, because the steps before it were
|
|
60
|
+
* sent by hand, if at all, and there is no email of ours to answer.
|
|
61
|
+
*/
|
|
62
|
+
export declare function isInThreadEmailStep(steps: readonly OutreachSequenceStep[] | null | undefined, index: number, startStepIndex?: number): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* The subject an email step starts a thread with, before merge fields
|
|
65
|
+
* (AGL-3228). A step written to start a thread carries its own. One written
|
|
66
|
+
* as a reply may start the thread only for an enrollment that began at it,
|
|
67
|
+
* and its subject field is not what its author wrote for it — so it takes
|
|
68
|
+
* the sequence's first email's subject, the one the person already had by
|
|
69
|
+
* hand.
|
|
53
70
|
*/
|
|
54
|
-
export declare function
|
|
71
|
+
export declare function outreachThreadStartSubject(steps: readonly OutreachSequenceStep[] | null | undefined, index: number): string;
|
|
55
72
|
/**
|
|
56
73
|
* Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,
|
|
57
74
|
* `AW:` — which on an email that answers nothing tells the recipient a
|
|
@@ -65,19 +65,45 @@ const warning = (path, code, message)=>({
|
|
|
65
65
|
});
|
|
66
66
|
const asText = (value)=>typeof value === 'string' ? value : '';
|
|
67
67
|
const isEmailStep = (step)=>(step == null ? void 0 : step.kind) === 'email';
|
|
68
|
-
/**
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The index of the first email step at or after `fromIndex` — the first
|
|
70
|
+
* email an enrollment that began there sends (AGL-3228) — or `-1` when
|
|
71
|
+
* there is none.
|
|
72
|
+
*/ export function firstEmailStepIndex(steps, fromIndex = 0) {
|
|
73
|
+
const list = steps != null ? steps : [];
|
|
74
|
+
for(let index = Math.max(0, fromIndex); index < list.length; index += 1){
|
|
75
|
+
if (isEmailStep(list[index])) return index;
|
|
76
|
+
}
|
|
77
|
+
return -1;
|
|
70
78
|
}
|
|
71
79
|
/**
|
|
72
80
|
* Whether the email at `index` is sent as a reply in the thread an earlier
|
|
73
81
|
* email started: every email after the first unless it says otherwise. The
|
|
74
82
|
* first email starts the thread whatever its `replyInThread` says.
|
|
75
|
-
|
|
83
|
+
*
|
|
84
|
+
* `startStepIndex` is where the enrollment began (AGL-3228): the first email
|
|
85
|
+
* it sends from there starts the thread, because the steps before it were
|
|
86
|
+
* sent by hand, if at all, and there is no email of ours to answer.
|
|
87
|
+
*/ export function isInThreadEmailStep(steps, index, startStepIndex = 0) {
|
|
76
88
|
const step = steps == null ? void 0 : steps[index];
|
|
77
89
|
if (!isEmailStep(step)) return false;
|
|
78
|
-
const first = firstEmailStepIndex(steps);
|
|
90
|
+
const first = firstEmailStepIndex(steps, startStepIndex);
|
|
79
91
|
return index > first && step.replyInThread !== false;
|
|
80
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* The subject an email step starts a thread with, before merge fields
|
|
95
|
+
* (AGL-3228). A step written to start a thread carries its own. One written
|
|
96
|
+
* as a reply may start the thread only for an enrollment that began at it,
|
|
97
|
+
* and its subject field is not what its author wrote for it — so it takes
|
|
98
|
+
* the sequence's first email's subject, the one the person already had by
|
|
99
|
+
* hand.
|
|
100
|
+
*/ export function outreachThreadStartSubject(steps, index) {
|
|
101
|
+
const step = steps == null ? void 0 : steps[index];
|
|
102
|
+
if (!isEmailStep(step)) return '';
|
|
103
|
+
if (!isInThreadEmailStep(steps, index)) return asText(step.subject);
|
|
104
|
+
const first = steps == null ? void 0 : steps[firstEmailStepIndex(steps)];
|
|
105
|
+
return isEmailStep(first) ? asText(first.subject) : '';
|
|
106
|
+
}
|
|
81
107
|
/**
|
|
82
108
|
* Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,
|
|
83
109
|
* `AW:` — which on an email that answers nothing tells the recipient a
|