@aglyn/plugins-outreach 1.0.0-beta.195 → 1.0.0-beta.197
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/curate-step-dialog.d.ts +50 -0
- package/src/lib/components/curate-step-dialog.js +235 -0
- package/src/lib/components/curate-step-dialog.js.map +1 -0
- package/src/lib/components/curated-step-editor.d.ts +80 -0
- package/src/lib/components/curated-step-editor.js +255 -0
- package/src/lib/components/curated-step-editor.js.map +1 -0
- package/src/lib/components/enroll-dialog.d.ts +15 -0
- package/src/lib/components/enroll-dialog.js +214 -14
- package/src/lib/components/enroll-dialog.js.map +1 -1
- package/src/lib/components/enrollments-table.d.ts +2 -0
- package/src/lib/components/enrollments-table.js +62 -8
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/mailbox-card.js +11 -2
- package/src/lib/components/mailbox-card.js.map +1 -1
- package/src/lib/components/sequence-detail.js +1 -0
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-editor.d.ts +2 -0
- package/src/lib/components/sequence-editor.js +26 -2
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/sequence-step-card.d.ts +7 -0
- package/src/lib/components/sequence-step-card.js +37 -14
- package/src/lib/components/sequence-step-card.js.map +1 -1
- package/src/lib/components/sequences-section.js +12 -3
- package/src/lib/components/sequences-section.js.map +1 -1
- package/src/lib/components/step-test-dialog.d.ts +31 -0
- package/src/lib/components/step-test-dialog.js +247 -0
- package/src/lib/components/step-test-dialog.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +15 -1
- package/src/lib/components/use-outreach-api.js +16 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +6 -1
- package/src/lib/components/use-outreach-data.js +6 -3
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +15 -0
- package/src/lib/constants/api-routes.js +12 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/compose.d.ts +12 -2
- package/src/lib/engine/compose.js +32 -6
- package/src/lib/engine/compose.js.map +1 -1
- package/src/lib/engine/curation-prompt.d.ts +73 -0
- package/src/lib/engine/curation-prompt.js +151 -0
- package/src/lib/engine/curation-prompt.js.map +1 -0
- package/src/lib/engine/enrollment-activity.d.ts +24 -0
- package/src/lib/engine/enrollment-activity.js +25 -0
- package/src/lib/engine/enrollment-activity.js.map +1 -1
- package/src/lib/engine/sequence-validation.d.ts +10 -1
- package/src/lib/engine/sequence-validation.js +54 -0
- package/src/lib/engine/sequence-validation.js.map +1 -1
- package/src/lib/enrollment/step-overrides.d.ts +53 -0
- package/src/lib/enrollment/step-overrides.js +140 -0
- package/src/lib/enrollment/step-overrides.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +89 -2
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +42 -0
- package/src/lib/model/outreach.types.js +18 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/step-preview.d.ts +10 -2
- package/src/lib/model/step-preview.js +9 -5
- package/src/lib/model/step-preview.js.map +1 -1
- package/src/lib/model/stored-records.d.ts +9 -1
- package/src/lib/model/stored-records.js +47 -2
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/routes/curate-routes.d.ts +63 -0
- package/src/lib/routes/curate-routes.js +424 -0
- package/src/lib/routes/curate-routes.js.map +1 -0
- package/src/lib/routes/enroll-routes.d.ts +7 -0
- package/src/lib/routes/enroll-routes.js +58 -4
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +2 -2
- package/src/lib/routes/platform-deps.js +2 -2
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/preview-routes.d.ts +24 -0
- package/src/lib/routes/preview-routes.js +103 -85
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/register-routes.d.ts +7 -0
- package/src/lib/routes/register-routes.js +41 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/step-test-routes.d.ts +88 -0
- package/src/lib/routes/step-test-routes.js +275 -0
- package/src/lib/routes/step-test-routes.js.map +1 -0
- package/src/lib/runtime/click-events.js +10 -0
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-link.d.ts +11 -0
- package/src/lib/runtime/click-link.js +9 -4
- package/src/lib/runtime/click-link.js.map +1 -1
- package/src/lib/runtime/click-route.js +3 -0
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/send-job.js +2 -0
- package/src/lib/runtime/send-job.js.map +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /*==========================================
|
|
17
|
+
* WHAT THE AI IS ASKED, WHEN IT DRAFTS ONE PERSON'S EMAILS (AGL-3324).
|
|
18
|
+
*
|
|
19
|
+
* A sequence's step is one template for everyone plus the personal line.
|
|
20
|
+
* Curating asks the workspace's AI for this one person's copy of each email
|
|
21
|
+
* step: the step's own words as the skeleton, the person's record as the
|
|
22
|
+
* facts, the rep's personal line as the reason for writing now. What comes
|
|
23
|
+
* back is a draft the member reads, edits and confirms — never a send.
|
|
24
|
+
*
|
|
25
|
+
* ## The playbook rides in the standing instructions
|
|
26
|
+
*
|
|
27
|
+
* The outbound playbook's rules a sequence author is trusted with, an AI
|
|
28
|
+
* draft is not: list price and never a discount, a cohort, a slot count or a
|
|
29
|
+
* deadline; no invented anecdotes or statistics; one link at most, and only
|
|
30
|
+
* the link the step already carries; plain text; the footer left to the
|
|
31
|
+
* composer, which appends it to every email. They are stated here, and the
|
|
32
|
+
* validator (`sequence-validation.ts`) refuses a draft that breaks one
|
|
33
|
+
* anyway, because an instruction is not a guarantee.
|
|
34
|
+
*
|
|
35
|
+
* The standing block is byte-identical from one call to the next, so a
|
|
36
|
+
* provider that caches a prefix can; everything about the person rides in
|
|
37
|
+
* the prompt. Pure and client-safe: the route builds the prompt, the stored
|
|
38
|
+
* override keeps it for the audit log, and a spec pins the words.
|
|
39
|
+
*
|
|
40
|
+
* ## What the prompt does not carry
|
|
41
|
+
*
|
|
42
|
+
* No email address and no phone number: the person's address is what the
|
|
43
|
+
* email is SENT to, never something the draft needs to say, and an address
|
|
44
|
+
* or a number typed into a lead's notes or the personal line is replaced
|
|
45
|
+
* by a placeholder before the text leaves — the same line the CRM's own
|
|
46
|
+
* record facts hold (AGL-2917), so the published Anthropic row's words
|
|
47
|
+
* about CRM records stay true of this door.
|
|
48
|
+
*==========================================*/ /** The standing instructions. Kept as one string so it caches as a prefix. */ export const OUTREACH_CURATION_SYSTEM = [
|
|
49
|
+
'You draft one-to-one sales emails for a person on a sales team, one person at a time.',
|
|
50
|
+
'You are given the emails of a sequence as its author wrote them, the facts a CRM holds about one recipient, and a sentence the sender wrote about why they are writing to this recipient now.',
|
|
51
|
+
'Rewrite each email for this one recipient. Keep the author’s structure, intent, length and voice; make it read as though the sender wrote it for this person, using the facts given.',
|
|
52
|
+
'Rules, which the emails are checked against before anything is kept:',
|
|
53
|
+
'- Plain text only. No markdown, no HTML, no bold, no headings, no bullet symbols.',
|
|
54
|
+
'- One link at most in an email, and only a link the author’s email already carries, unchanged. Never add a link.',
|
|
55
|
+
'- List price only. Never a discount, a coupon, a promo code, a cohort, a slot count, a deadline or “limited time”.',
|
|
56
|
+
'- Never invent facts, anecdotes, statistics, customer names, results or claims. Use only the facts given; where a fact is missing, leave it out rather than guess. No placeholders in brackets.',
|
|
57
|
+
'- Do not write a signature block, a postal address, a company footer or an unsubscribe line. They are appended by the system.',
|
|
58
|
+
'- Keep any {{merge.field}} token the author used for the sender ({{sender.firstName}}, {{sender.name}}) exactly as written. You may write the recipient’s own name and company directly.',
|
|
59
|
+
'- An email marked as a reply in the thread has no subject: answer it with an empty subject.',
|
|
60
|
+
'- Never mention that an AI drafted the email.',
|
|
61
|
+
'Answer with JSON only, no code fence and no commentary: {"steps":[{"stepIndex":<number>,"subject":"<text>","body":"<text>"}]} — one entry per email given, in the order given, with the body’s line breaks as \\n.'
|
|
62
|
+
].join('\n');
|
|
63
|
+
/** The most of a lead's notes the prompt carries. */ export const OUTREACH_CURATION_NOTES_MAX = 600;
|
|
64
|
+
/** What an email address or a phone number in free text becomes before it leaves. */ export const OUTREACH_CURATION_EMAIL_PLACEHOLDER = '[email address]';
|
|
65
|
+
export const OUTREACH_CURATION_PHONE_PLACEHOLDER = '[phone number]';
|
|
66
|
+
const EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
67
|
+
/** Seven or more digits with the separators a typed number carries, an optional leading `+`. */ const PHONE_NUMBER = /(?:\+?\d[\d\s().-]{6,}\d)/g;
|
|
68
|
+
/** Free text a person typed, with every address and number replaced. */ export function redactOutreachCurationText(text) {
|
|
69
|
+
return String(text != null ? text : '').replace(EMAIL_ADDRESS, OUTREACH_CURATION_EMAIL_PLACEHOLDER).replace(PHONE_NUMBER, (match)=>match.replace(/\D/g, '').length >= 7 ? OUTREACH_CURATION_PHONE_PLACEHOLDER : match);
|
|
70
|
+
}
|
|
71
|
+
const clean = (value)=>String(value != null ? value : '').replace(/\s+/g, ' ').trim();
|
|
72
|
+
function factLines(facts) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
const fact = (label, value)=>{
|
|
75
|
+
const text = clean(value);
|
|
76
|
+
if (text) lines.push(`${label}: ${text}`);
|
|
77
|
+
};
|
|
78
|
+
fact('Name', facts.name);
|
|
79
|
+
fact('Company', facts.company);
|
|
80
|
+
fact('Title', facts.title);
|
|
81
|
+
fact('Website', facts.website);
|
|
82
|
+
if (facts.tags.length) lines.push(`Tags: ${facts.tags.map(clean).filter(Boolean).join(', ')}`);
|
|
83
|
+
if (facts.sources.length) lines.push(`How they came to us: ${facts.sources.map(clean).filter(Boolean).join(', ')}`);
|
|
84
|
+
if (facts.campaigns.length) lines.push(`Campaigns: ${facts.campaigns.map(clean).filter(Boolean).join(', ')}`);
|
|
85
|
+
const notes = redactOutreachCurationText(facts.notes).trim();
|
|
86
|
+
if (notes) lines.push(`Notes: ${notes.slice(0, OUTREACH_CURATION_NOTES_MAX)}`);
|
|
87
|
+
lines.push(`Record: ${facts.record === 'lead' ? 'a lead' : 'a contact'}`);
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
/** The prompt: the sender, the person, the reason, and each email as written. */ export function outreachCurationPrompt(facts, steps) {
|
|
91
|
+
var _ref;
|
|
92
|
+
var _steps_;
|
|
93
|
+
const parts = [];
|
|
94
|
+
parts.push(`Sender: ${clean(facts.senderName) || 'the sender'}${clean(facts.siteName) ? ` at ${clean(facts.siteName)}` : ''}`);
|
|
95
|
+
parts.push('');
|
|
96
|
+
parts.push('Recipient:');
|
|
97
|
+
parts.push(...factLines(facts));
|
|
98
|
+
parts.push('');
|
|
99
|
+
const line = clean(redactOutreachCurationText(facts.personalLine));
|
|
100
|
+
parts.push(`Why the sender is writing now: ${line || '(the sender wrote no personal line)'}`);
|
|
101
|
+
parts.push('');
|
|
102
|
+
parts.push(`Emails to rewrite (${steps.length} of the sequence’s ${(_ref = (_steps_ = steps[0]) == null ? void 0 : _steps_.count) != null ? _ref : steps.length}):`);
|
|
103
|
+
for (const step of steps){
|
|
104
|
+
parts.push('');
|
|
105
|
+
parts.push(`--- Email ${step.position} of ${step.count} · stepIndex ${step.stepIndex} · ${step.startsThread ? 'starts the thread' : 'a reply in the thread (no subject)'} ---`);
|
|
106
|
+
if (step.startsThread) parts.push(`Subject: ${step.subject}`);
|
|
107
|
+
parts.push('Body:');
|
|
108
|
+
parts.push(step.body);
|
|
109
|
+
}
|
|
110
|
+
return parts.join('\n');
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The drafts in the model's answer, one per step asked for, or `null` when
|
|
114
|
+
* the answer is not what was asked for. A code fence around the JSON is
|
|
115
|
+
* forgiven; a step the answer left out, or named twice, is not — the member
|
|
116
|
+
* would be shown a draft for one email and a template for another without
|
|
117
|
+
* knowing which the model skipped.
|
|
118
|
+
*/ export function parseOutreachCurationAnswer(text, steps) {
|
|
119
|
+
const raw = String(text != null ? text : '').trim();
|
|
120
|
+
const unfenced = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
121
|
+
const start = unfenced.indexOf('{');
|
|
122
|
+
const end = unfenced.lastIndexOf('}');
|
|
123
|
+
if (start < 0 || end <= start) return null;
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(unfenced.slice(start, end + 1));
|
|
127
|
+
} catch (unused) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const entries = parsed == null ? void 0 : parsed.steps;
|
|
131
|
+
if (!Array.isArray(entries)) return null;
|
|
132
|
+
const byIndex = new Map();
|
|
133
|
+
for (const entry of entries){
|
|
134
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
135
|
+
const record = entry;
|
|
136
|
+
const stepIndex = Number(record['stepIndex']);
|
|
137
|
+
const step = steps.find((candidate)=>candidate.stepIndex === stepIndex);
|
|
138
|
+
if (!step || byIndex.has(stepIndex)) return null;
|
|
139
|
+
const body = typeof record['body'] === 'string' ? record['body'].replace(/\r\n?/g, '\n').trim() : '';
|
|
140
|
+
const subject = step.startsThread && typeof record['subject'] === 'string' ? clean(record['subject']) : null;
|
|
141
|
+
byIndex.set(stepIndex, {
|
|
142
|
+
stepIndex,
|
|
143
|
+
subject,
|
|
144
|
+
body
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (byIndex.size !== steps.length) return null;
|
|
148
|
+
return steps.map((step)=>byIndex.get(step.stepIndex));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
//# sourceMappingURL=curation-prompt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/curation-prompt.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT THE AI IS ASKED, WHEN IT DRAFTS ONE PERSON'S EMAILS (AGL-3324).\n *\n * A sequence's step is one template for everyone plus the personal line.\n * Curating asks the workspace's AI for this one person's copy of each email\n * step: the step's own words as the skeleton, the person's record as the\n * facts, the rep's personal line as the reason for writing now. What comes\n * back is a draft the member reads, edits and confirms — never a send.\n *\n * ## The playbook rides in the standing instructions\n *\n * The outbound playbook's rules a sequence author is trusted with, an AI\n * draft is not: list price and never a discount, a cohort, a slot count or a\n * deadline; no invented anecdotes or statistics; one link at most, and only\n * the link the step already carries; plain text; the footer left to the\n * composer, which appends it to every email. They are stated here, and the\n * validator (`sequence-validation.ts`) refuses a draft that breaks one\n * anyway, because an instruction is not a guarantee.\n *\n * The standing block is byte-identical from one call to the next, so a\n * provider that caches a prefix can; everything about the person rides in\n * the prompt. Pure and client-safe: the route builds the prompt, the stored\n * override keeps it for the audit log, and a spec pins the words.\n *\n * ## What the prompt does not carry\n *\n * No email address and no phone number: the person's address is what the\n * email is SENT to, never something the draft needs to say, and an address\n * or a number typed into a lead's notes or the personal line is replaced\n * by a placeholder before the text leaves — the same line the CRM's own\n * record facts hold (AGL-2917), so the published Anthropic row's words\n * about CRM records stay true of this door.\n *==========================================*/\n\n/** The standing instructions. Kept as one string so it caches as a prefix. */\nexport const OUTREACH_CURATION_SYSTEM = [\n 'You draft one-to-one sales emails for a person on a sales team, one person at a time.',\n 'You are given the emails of a sequence as its author wrote them, the facts a CRM holds about one recipient, and a sentence the sender wrote about why they are writing to this recipient now.',\n 'Rewrite each email for this one recipient. Keep the author’s structure, intent, length and voice; make it read as though the sender wrote it for this person, using the facts given.',\n 'Rules, which the emails are checked against before anything is kept:',\n '- Plain text only. No markdown, no HTML, no bold, no headings, no bullet symbols.',\n '- One link at most in an email, and only a link the author’s email already carries, unchanged. Never add a link.',\n '- List price only. Never a discount, a coupon, a promo code, a cohort, a slot count, a deadline or “limited time”.',\n '- Never invent facts, anecdotes, statistics, customer names, results or claims. Use only the facts given; where a fact is missing, leave it out rather than guess. No placeholders in brackets.',\n '- Do not write a signature block, a postal address, a company footer or an unsubscribe line. They are appended by the system.',\n '- Keep any {{merge.field}} token the author used for the sender ({{sender.firstName}}, {{sender.name}}) exactly as written. You may write the recipient’s own name and company directly.',\n '- An email marked as a reply in the thread has no subject: answer it with an empty subject.',\n '- Never mention that an AI drafted the email.',\n 'Answer with JSON only, no code fence and no commentary: {\"steps\":[{\"stepIndex\":<number>,\"subject\":\"<text>\",\"body\":\"<text>\"}]} — one entry per email given, in the order given, with the body’s line breaks as \\\\n.',\n].join('\\n')\n\n/** The most of a lead's notes the prompt carries. */\nexport const OUTREACH_CURATION_NOTES_MAX = 600\n\n/** What an email address or a phone number in free text becomes before it leaves. */\nexport const OUTREACH_CURATION_EMAIL_PLACEHOLDER = '[email address]'\nexport const OUTREACH_CURATION_PHONE_PLACEHOLDER = '[phone number]'\n\nconst EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g\n/** Seven or more digits with the separators a typed number carries, an optional leading `+`. */\nconst PHONE_NUMBER = /(?:\\+?\\d[\\d\\s().-]{6,}\\d)/g\n\n/** Free text a person typed, with every address and number replaced. */\nexport function redactOutreachCurationText(text: unknown): string {\n return String(text ?? '')\n .replace(EMAIL_ADDRESS, OUTREACH_CURATION_EMAIL_PLACEHOLDER)\n .replace(PHONE_NUMBER, (match) => (match.replace(/\\D/g, '').length >= 7 ? OUTREACH_CURATION_PHONE_PLACEHOLDER : match))\n}\n\n/** What the CRM holds about the person, as the prompt states it. */\nexport interface OutreachCurationFacts {\n name: string\n company: string\n title: string\n website: string\n tags: readonly string[]\n /** How the person came to the workspace: a form, an import, a booking. */\n sources: readonly string[]\n /** The campaigns the person is filed under, by name. */\n campaigns: readonly string[]\n /** A lead's notes, when it has any. */\n notes: string\n /** Whether the person is a lead or a contact. */\n record: 'lead' | 'contact'\n siteName: string\n /** The sender, as the mailbox names them. */\n senderName: string\n personalLine: string\n}\n\n/** One email step as the prompt states it. */\nexport interface OutreachCurationStep {\n stepIndex: number\n /** Which email this is, one-based, of how many the sequence sends. */\n position: number\n count: number\n subject: string\n body: string\n /** Whether the email starts a thread; an in-thread email is answered with no subject. */\n startsThread: boolean\n}\n\nconst clean = (value: unknown): string => String(value ?? '').replace(/\\s+/g, ' ').trim()\n\nfunction factLines(facts: OutreachCurationFacts): string[] {\n const lines: string[] = []\n const fact = (label: string, value: string) => {\n const text = clean(value)\n if (text) lines.push(`${label}: ${text}`)\n }\n fact('Name', facts.name)\n fact('Company', facts.company)\n fact('Title', facts.title)\n fact('Website', facts.website)\n if (facts.tags.length) lines.push(`Tags: ${facts.tags.map(clean).filter(Boolean).join(', ')}`)\n if (facts.sources.length) lines.push(`How they came to us: ${facts.sources.map(clean).filter(Boolean).join(', ')}`)\n if (facts.campaigns.length) lines.push(`Campaigns: ${facts.campaigns.map(clean).filter(Boolean).join(', ')}`)\n const notes = redactOutreachCurationText(facts.notes).trim()\n if (notes) lines.push(`Notes: ${notes.slice(0, OUTREACH_CURATION_NOTES_MAX)}`)\n lines.push(`Record: ${facts.record === 'lead' ? 'a lead' : 'a contact'}`)\n return lines\n}\n\n/** The prompt: the sender, the person, the reason, and each email as written. */\nexport function outreachCurationPrompt(\n facts: OutreachCurationFacts,\n steps: readonly OutreachCurationStep[],\n): string {\n const parts: string[] = []\n parts.push(`Sender: ${clean(facts.senderName) || 'the sender'}${clean(facts.siteName) ? ` at ${clean(facts.siteName)}` : ''}`)\n parts.push('')\n parts.push('Recipient:')\n parts.push(...factLines(facts))\n parts.push('')\n const line = clean(redactOutreachCurationText(facts.personalLine))\n parts.push(`Why the sender is writing now: ${line || '(the sender wrote no personal line)'}`)\n parts.push('')\n parts.push(`Emails to rewrite (${steps.length} of the sequence’s ${steps[0]?.count ?? steps.length}):`)\n for (const step of steps) {\n parts.push('')\n parts.push(\n `--- Email ${step.position} of ${step.count} · stepIndex ${step.stepIndex} · ${\n step.startsThread ? 'starts the thread' : 'a reply in the thread (no subject)'\n } ---`,\n )\n if (step.startsThread) parts.push(`Subject: ${step.subject}`)\n parts.push('Body:')\n parts.push(step.body)\n }\n return parts.join('\\n')\n}\n\n/** One drafted email, as the answer named it. */\nexport interface OutreachCurationDraft {\n stepIndex: number\n /** `null` for an in-thread email, which has none. */\n subject: string | null\n body: string\n}\n\n/**\n * The drafts in the model's answer, one per step asked for, or `null` when\n * the answer is not what was asked for. A code fence around the JSON is\n * forgiven; a step the answer left out, or named twice, is not — the member\n * would be shown a draft for one email and a template for another without\n * knowing which the model skipped.\n */\nexport function parseOutreachCurationAnswer(\n text: string,\n steps: readonly OutreachCurationStep[],\n): OutreachCurationDraft[] | null {\n const raw = String(text ?? '').trim()\n const unfenced = raw.replace(/^```(?:json)?\\s*/i, '').replace(/\\s*```$/, '')\n const start = unfenced.indexOf('{')\n const end = unfenced.lastIndexOf('}')\n if (start < 0 || end <= start) return null\n let parsed: unknown\n try {\n parsed = JSON.parse(unfenced.slice(start, end + 1))\n } catch {\n return null\n }\n const entries = (parsed as { steps?: unknown } | null)?.steps\n if (!Array.isArray(entries)) return null\n const byIndex = new Map<number, OutreachCurationDraft>()\n for (const entry of entries) {\n if (!entry || typeof entry !== 'object') return null\n const record = entry as Record<string, unknown>\n const stepIndex = Number(record['stepIndex'])\n const step = steps.find((candidate) => candidate.stepIndex === stepIndex)\n if (!step || byIndex.has(stepIndex)) return null\n const body = typeof record['body'] === 'string' ? record['body'].replace(/\\r\\n?/g, '\\n').trim() : ''\n const subject = step.startsThread && typeof record['subject'] === 'string' ? clean(record['subject']) : null\n byIndex.set(stepIndex, { stepIndex, subject, body })\n }\n if (byIndex.size !== steps.length) return null\n return steps.map((step) => byIndex.get(step.stepIndex) as OutreachCurationDraft)\n}\n"],"names":["OUTREACH_CURATION_SYSTEM","join","OUTREACH_CURATION_NOTES_MAX","OUTREACH_CURATION_EMAIL_PLACEHOLDER","OUTREACH_CURATION_PHONE_PLACEHOLDER","EMAIL_ADDRESS","PHONE_NUMBER","redactOutreachCurationText","text","String","replace","match","length","clean","value","trim","factLines","facts","lines","fact","label","push","name","company","title","website","tags","map","filter","Boolean","sources","campaigns","notes","slice","record","outreachCurationPrompt","steps","parts","senderName","siteName","line","personalLine","count","step","position","stepIndex","startsThread","subject","body","parseOutreachCurationAnswer","raw","unfenced","start","indexOf","end","lastIndexOf","parsed","JSON","parse","entries","Array","isArray","byIndex","Map","entry","Number","find","candidate","has","set","size","get"],"mappings":"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"}
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
* and "<sequence> stopped" when a reply or an opt-out ends it. A bounce
|
|
27
27
|
* files nothing here — the send's own entry already reads Bounced with
|
|
28
28
|
* the server's words (AGL-3245), and a second line would say it twice.
|
|
29
|
+
* A third entry (AGL-3324) says a member curated one step for the person,
|
|
30
|
+
* and who wrote the words, so the send that follows can be read for what
|
|
31
|
+
* it was.
|
|
29
32
|
*
|
|
30
33
|
* Pure, so the route and the runtime compose the same words and the
|
|
31
34
|
* backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can
|
|
@@ -51,6 +54,27 @@ export declare function outreachEnrolledEntry(input: {
|
|
|
51
54
|
sequenceName: string;
|
|
52
55
|
campaignNames: readonly string[];
|
|
53
56
|
}): OutreachTimelineEntry;
|
|
57
|
+
/**
|
|
58
|
+
* The key one curated step's entry is filed under (AGL-3324): once per
|
|
59
|
+
* confirmation, so a step curated again after a reply files again — each is
|
|
60
|
+
* a fact about what went out, and a second line is a second fact.
|
|
61
|
+
*/
|
|
62
|
+
export declare function outreachCuratedEntryKey(enrollmentId: string, stepIndex: number, atMs: number): string;
|
|
63
|
+
/**
|
|
64
|
+
* "Curated step 2 — AI draft, edited by Casey" (AGL-3324): who wrote this
|
|
65
|
+
* person's copy of the step. An AI draft the member kept as it came reads
|
|
66
|
+
* "AI draft, confirmed by"; words the member wrote themselves read "written
|
|
67
|
+
* by". A member with no name is named by their address, and one with
|
|
68
|
+
* neither is "a member".
|
|
69
|
+
*/
|
|
70
|
+
export declare function outreachCuratedEntry(input: {
|
|
71
|
+
enrollmentId: string;
|
|
72
|
+
stepIndex: number;
|
|
73
|
+
source: 'ai' | 'member';
|
|
74
|
+
edited: boolean;
|
|
75
|
+
memberName: string | null | undefined;
|
|
76
|
+
atMs: number;
|
|
77
|
+
}): OutreachTimelineEntry;
|
|
54
78
|
/** Why a sequence stopped, in the two ways a person ends one. */
|
|
55
79
|
export type OutreachStoppedReason = 'replied' | 'opted_out';
|
|
56
80
|
/** "<sequence> stopped: they replied" — the line under the last send. */
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* and "<sequence> stopped" when a reply or an opt-out ends it. A bounce
|
|
26
26
|
* files nothing here — the send's own entry already reads Bounced with
|
|
27
27
|
* the server's words (AGL-3245), and a second line would say it twice.
|
|
28
|
+
* A third entry (AGL-3324) says a member curated one step for the person,
|
|
29
|
+
* and who wrote the words, so the send that follows can be read for what
|
|
30
|
+
* it was.
|
|
28
31
|
*
|
|
29
32
|
* Pure, so the route and the runtime compose the same words and the
|
|
30
33
|
* backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can
|
|
@@ -46,6 +49,28 @@
|
|
|
46
49
|
body: campaigns.length ? `Enrolled in ${sequence}\nFiled under ${campaigns.join(', ')}` : `Enrolled in ${sequence}`
|
|
47
50
|
};
|
|
48
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The key one curated step's entry is filed under (AGL-3324): once per
|
|
54
|
+
* confirmation, so a step curated again after a reply files again — each is
|
|
55
|
+
* a fact about what went out, and a second line is a second fact.
|
|
56
|
+
*/ export function outreachCuratedEntryKey(enrollmentId, stepIndex, atMs) {
|
|
57
|
+
return `curated:${enrollmentId}:${stepIndex}:${atMs}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* "Curated step 2 — AI draft, edited by Casey" (AGL-3324): who wrote this
|
|
61
|
+
* person's copy of the step. An AI draft the member kept as it came reads
|
|
62
|
+
* "AI draft, confirmed by"; words the member wrote themselves read "written
|
|
63
|
+
* by". A member with no name is named by their address, and one with
|
|
64
|
+
* neither is "a member".
|
|
65
|
+
*/ export function outreachCuratedEntry(input) {
|
|
66
|
+
var _input_memberName;
|
|
67
|
+
const member = String((_input_memberName = input.memberName) != null ? _input_memberName : '').trim() || 'a member';
|
|
68
|
+
const how = input.source === 'ai' ? input.edited ? `AI draft, edited by ${member}` : `AI draft, confirmed by ${member}` : `written by ${member}`;
|
|
69
|
+
return {
|
|
70
|
+
dedupeKey: outreachCuratedEntryKey(input.enrollmentId, input.stepIndex, input.atMs),
|
|
71
|
+
body: `Curated step ${input.stepIndex + 1} — ${how}`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
49
74
|
/** "<sequence> stopped: they replied" — the line under the last send. */ export function outreachStoppedEntry(input) {
|
|
50
75
|
const sequence = input.sequenceName.trim() || 'The sequence';
|
|
51
76
|
return {
|
|
@@ -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 *\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
|
|
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 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/**\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","outreachEnrolledEntry","input","sequence","sequenceName","trim","campaigns","campaignNames","map","name","filter","Boolean","dedupeKey","body","length","join","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,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;AAEA;;;;CAIC,GACD,OAAO,SAASa,wBAAwBhB,YAAoB,EAAEiB,SAAiB,EAAEC,IAAY;IAC3F,OAAO,CAAC,QAAQ,EAAElB,aAAa,CAAC,EAAEiB,UAAU,CAAC,EAAEC,MAAM;AACvD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,qBAAqBjB,KAOpC;QACuBA;IAAtB,MAAMkB,SAASC,QAAOnB,oBAAAA,MAAMoB,UAAU,YAAhBpB,oBAAoB,IAAIG,IAAI,MAAM;IACxD,MAAMkB,MACJrB,MAAMsB,MAAM,KAAK,OACbtB,MAAMuB,MAAM,GACV,CAAC,oBAAoB,EAAEL,QAAQ,GAC/B,CAAC,uBAAuB,EAAEA,QAAQ,GACpC,CAAC,WAAW,EAAEA,QAAQ;IAC5B,OAAO;QACLR,WAAWI,wBAAwBd,MAAMF,YAAY,EAAEE,MAAMe,SAAS,EAAEf,MAAMgB,IAAI;QAClFL,MAAM,CAAC,aAAa,EAAEX,MAAMe,SAAS,GAAG,EAAE,GAAG,EAAEM,KAAK;IACtD;AACF;AAKA,uEAAuE,GACvE,OAAO,SAASG,qBAAqBxB,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,MAAMyB,MAAM,KAAK,YAAY,iBAAiB,kBAAkB;IAChG;AACF"}
|
|
@@ -34,7 +34,7 @@ export declare const OUTREACH_SEQUENCE_NAME_MAX = 120;
|
|
|
34
34
|
/** The longest title a task step carries. */
|
|
35
35
|
export declare const OUTREACH_TASK_TITLE_MAX = 200;
|
|
36
36
|
export type OutreachValidationSeverity = 'error' | 'warning';
|
|
37
|
-
export type OutreachValidationCode = 'name_required' | 'name_too_long' | 'mailbox_required' | 'steps_required' | 'too_many_steps' | 'too_many_email_steps' | 'step_id_required' | 'duplicate_step_id' | 'unknown_step_kind' | 'delay_invalid' | 'subject_required' | 'subject_too_long' | 'subject_reply_prefix' | 'body_required' | 'body_and_template' | 'body_too_long' | 'unknown_merge_field' | 'personal_line_unused' | 'task_kind_invalid' | 'task_title_required' | 'task_title_too_long' | 'window_days_invalid' | 'window_hours_invalid' | 'countries_required' | 'country_invalid' | 'legal_name_required' | 'postal_address_required';
|
|
37
|
+
export type OutreachValidationCode = 'name_required' | 'name_too_long' | 'mailbox_required' | 'steps_required' | 'too_many_steps' | 'too_many_email_steps' | 'step_id_required' | 'duplicate_step_id' | 'unknown_step_kind' | 'delay_invalid' | 'subject_required' | 'subject_too_long' | 'subject_reply_prefix' | 'body_required' | 'body_and_template' | 'body_too_long' | 'unknown_merge_field' | 'personal_line_unused' | 'task_kind_invalid' | 'task_title_required' | 'task_title_too_long' | 'window_days_invalid' | 'window_hours_invalid' | 'countries_required' | 'country_invalid' | 'legal_name_required' | 'postal_address_required' | 'too_many_links' | 'discount_language' | 'not_plain_text';
|
|
38
38
|
export interface OutreachValidationIssue {
|
|
39
39
|
/** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */
|
|
40
40
|
path: string;
|
|
@@ -78,6 +78,15 @@ export declare function validateOutreachSequenceSettings(settings: OutreachSeque
|
|
|
78
78
|
export declare function validateOutreachSteps(steps: readonly OutreachSequenceStep[] | null | undefined, path?: string): OutreachValidationIssue[];
|
|
79
79
|
/** Everything a stored sequence has to be before it may be saved. */
|
|
80
80
|
export declare function validateOutreachSequence(sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined): OutreachValidationIssue[];
|
|
81
|
+
export interface OutreachCuratedStepInput {
|
|
82
|
+
/** The subject, when the copy carries one. Read only where the email starts a thread. */
|
|
83
|
+
subject?: string | null;
|
|
84
|
+
body?: string | null;
|
|
85
|
+
/** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */
|
|
86
|
+
startsThread: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** Everything one person's copy of a step has to be before it may be stored — see the section note. */
|
|
89
|
+
export declare function validateOutreachCuratedStep(input: OutreachCuratedStepInput, path?: string): OutreachValidationIssue[];
|
|
81
90
|
/**
|
|
82
91
|
* The organization's footer fields: a legal name and a postal address, the
|
|
83
92
|
* two things the law requires every commercial email to carry.
|
|
@@ -33,6 +33,7 @@ import { readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubs
|
|
|
33
33
|
import { CRM_MERGE_FIELDS, crmMergeFieldsIn } from "@aglyn/aglyn/app-utils/crm-email-templates";
|
|
34
34
|
import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
|
|
35
35
|
import { OUTREACH_DEFAULT_ALLOWED_COUNTRIES, OUTREACH_MAX_EMAIL_STEPS, OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS, OUTREACH_MAX_STEPS, OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS, OUTREACH_TASK_KINDS } from "../model/outreach.types.js";
|
|
36
|
+
import { outreachBodyLinks } from "./click-tracking.js";
|
|
36
37
|
/** The merge field the rep's signal sentence is written into a step with. */ export const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine';
|
|
37
38
|
/**
|
|
38
39
|
* Every field an Outreach step may use: the CRM's, which the composer fills
|
|
@@ -274,6 +275,59 @@ function emailStepIssues(steps, step, index, path) {
|
|
|
274
275
|
issues.push(...validateOutreachSequenceSettings(sequence == null ? void 0 : sequence.settings));
|
|
275
276
|
return issues;
|
|
276
277
|
}
|
|
278
|
+
/*==========================================
|
|
279
|
+
* A CURATED STEP (AGL-3324).
|
|
280
|
+
*
|
|
281
|
+
* One person's own copy of a step is held to everything a step is held to —
|
|
282
|
+
* a subject where the email starts a thread, a body, the lengths, the merge
|
|
283
|
+
* fields — and to the outbound playbook's rules the sequence editor trusts
|
|
284
|
+
* its author with but an AI draft cannot be trusted with: one link at most,
|
|
285
|
+
* list price only, plain text. Each is an ERROR: a draft that breaks one is
|
|
286
|
+
* refused rather than stored, whoever wrote it.
|
|
287
|
+
*==========================================*/ /**
|
|
288
|
+
* Words the playbook keeps out of every message: a discount, a coupon, a
|
|
289
|
+
* cohort, a slot count, a deadline. Matched on word boundaries, so "count"
|
|
290
|
+
* and "expired domain" pass and "coupon" and "20% off" do not.
|
|
291
|
+
*/ const DISCOUNT_LANGUAGE = /\b(discounts?|discounted|coupons?|promo(?:tional)?\s+codes?|promo\b|vouchers?|\d+\s*%\s*off|percent\s+off|money\s+off|special\s+offer|limited[-\s]time|expires?\s+(?:on|at|in|soon|today|tomorrow)|deadline|(?:only|last|first)\s+\d+\s+(?:spots?|slots?|seats?|places?)|cohorts?)\b/i;
|
|
292
|
+
/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */ const MARKDOWN = /\*\*[^*\n]+\*\*|\[[^\]\n]+\]\([^)\s]+\)|^#{1,6}\s|<\/?[a-z][^>]*>/im;
|
|
293
|
+
/** Everything one person's copy of a step has to be before it may be stored — see the section note. */ export function validateOutreachCuratedStep(input, path = 'override') {
|
|
294
|
+
const issues = [];
|
|
295
|
+
const subject = asText(input.subject);
|
|
296
|
+
if (input.startsThread && input.subject !== undefined && input.subject !== null) {
|
|
297
|
+
if (!subject.trim()) {
|
|
298
|
+
issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'));
|
|
299
|
+
} else if (outreachSubjectHasReplyPrefix(subject)) {
|
|
300
|
+
issues.push(error(`${path}.subject`, 'subject_reply_prefix', 'Remove the "Re:" or "Fwd:" — this email starts a conversation, it doesn\'t answer one.'));
|
|
301
|
+
}
|
|
302
|
+
if (subject.length > CRM_EMAIL_SUBJECT_MAX) {
|
|
303
|
+
issues.push(error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`));
|
|
304
|
+
}
|
|
305
|
+
if (DISCOUNT_LANGUAGE.test(subject)) {
|
|
306
|
+
issues.push(error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'));
|
|
307
|
+
}
|
|
308
|
+
issues.push(...mergeFieldWarnings(subject, `${path}.subject`));
|
|
309
|
+
}
|
|
310
|
+
const body = asText(input.body);
|
|
311
|
+
if (!body.trim()) {
|
|
312
|
+
issues.push(error(`${path}.body`, 'body_required', 'Write the email.'));
|
|
313
|
+
return issues;
|
|
314
|
+
}
|
|
315
|
+
if (body.length > CRM_EMAIL_BODY_MAX) {
|
|
316
|
+
issues.push(error(`${path}.body`, 'body_too_long', `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`));
|
|
317
|
+
}
|
|
318
|
+
const links = outreachBodyLinks(body);
|
|
319
|
+
if (links.length > 1) {
|
|
320
|
+
issues.push(error(`${path}.body`, 'too_many_links', `This email carries ${links.length} links. A sequence email carries one at most.`));
|
|
321
|
+
}
|
|
322
|
+
if (DISCOUNT_LANGUAGE.test(body)) {
|
|
323
|
+
issues.push(error(`${path}.body`, 'discount_language', 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.'));
|
|
324
|
+
}
|
|
325
|
+
if (MARKDOWN.test(body)) {
|
|
326
|
+
issues.push(error(`${path}.body`, 'not_plain_text', 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.'));
|
|
327
|
+
}
|
|
328
|
+
issues.push(...mergeFieldWarnings(body, `${path}.body`));
|
|
329
|
+
return issues;
|
|
330
|
+
}
|
|
277
331
|
/**
|
|
278
332
|
* The organization's footer fields: a legal name and a postal address, the
|
|
279
333
|
* two things the law requires every commercial email to carry.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.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 MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { readListUnsubscribeSetting } from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/** The index of the first email step, or `-1` for a sequence with none. */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n): number {\n return (steps ?? []).findIndex(isEmailStep)\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n // Absent is off (AGL-3239). Every sequence written before the setting\n // existed reads as untracked, which is what it was.\n trackClicks: data['trackClicks'] === true,\n // Absent is off (AGL-3296): a sequence is one-to-one mail, and every\n // sequence written before the setting existed reads as headerless.\n // Read by the shared reader campaigns use too (AGL-3307), with the\n // sequence's own default.\n listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false),\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","readListUnsubscribeSetting","CRM_MERGE_FIELDS","crmMergeFieldsIn","crmThreadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","findIndex","isInThreadEmailStep","index","first","replyInThread","outreachSubjectHasReplyPrefix","subject","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","length","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","trackClicks","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","list","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;4CAe4C,GAE5C,SAASA,kBAAkB,EAAEC,qBAAqB,QAAQ,6BAA4B;AACtF,SAASC,0BAA0B,QAAQ,4CAA2C;AACtF,SACEC,gBAAgB,EAEhBC,gBAAgB,QACX,6CAA4C;AACnD,SAASC,gBAAgB,QAAQ,qCAAoC;AACrE,SACEC,kCAAkC,EAClCC,wBAAwB,EACxBC,qCAAqC,EACrCC,kBAAkB,EAClBC,0CAA0C,EAC1CC,mBAAmB,QAOd,6BAAyB;AAEhC,2EAA2E,GAC3E,OAAO,MAAMC,+BAA+B,0BAAyB;AASrE;;;CAGC,GACD,OAAO,MAAMC,wBAAiE;OACzEV;IACH;QAAEW,KAAKF;QAA8BG,OAAO;QAAiBC,OAAO;IAAa;CAClF,CAAA;AAED,yCAAyC,GACzC,OAAO,MAAMC,6BAA6B,IAAG;AAE7C,2CAA2C,GAC3C,OAAO,MAAMC,0BAA0B,IAAG;AAyC1C,iEAAiE,GACjE,OAAO,SAASC,4BACdC,MAA0C;IAE1C,OAAOA,OAAOC,IAAI,CAAC,CAACC,QAAUA,MAAMC,QAAQ,KAAK;AACnD;AAEA,MAAMC,QAAQ,CACZC,MACAC,MACAC,UAC6B,CAAA;QAAEF;QAAMC;QAAMC;QAASJ,UAAU;IAAQ,CAAA;AAExE,MAAMK,UAAU,CACdH,MACAC,MACAC,UAC6B,CAAA;QAAEF;QAAMC;QAAMC;QAASJ,UAAU;IAAU,CAAA;AAE1E,MAAMM,SAAS,CAACC,QAA4B,OAAOA,UAAU,WAAWA,QAAQ;AAEhF,MAAMC,cAAc,CAACC,OACnBA,CAAAA,wBAAAA,KAAMC,IAAI,MAAK;AAEjB,yEAAyE,GACzE,OAAO,SAASC,oBACdC,KAAyD;IAEzD,OAAO,CAACA,gBAAAA,QAAS,EAAE,EAAEC,SAAS,CAACL;AACjC;AAEA;;;;CAIC,GACD,OAAO,SAASM,oBACdF,KAAyD,EACzDG,KAAa;IAEb,MAAMN,OAAOG,yBAAAA,KAAO,CAACG,MAAM;IAC3B,IAAI,CAACP,YAAYC,OAAO,OAAO;IAC/B,MAAMO,QAAQL,oBAAoBC;IAClC,OAAOG,QAAQC,SAASP,KAAKQ,aAAa,KAAK;AACjD;AAEA;;;;;CAKC,GACD,OAAO,SAASC,8BAA8BC,OAAgB;IAC5D,MAAMC,YAAYd,OAAOa,SAASE,OAAO,CAAC,QAAQ,KAAKC,IAAI;IAC3D,OAAOF,cAAc,MAAMtC,iBAAiBsC,eAAeA,UAAUG,KAAK,CAAC,GAAG7C;AAChF;AAEA,MAAM8C,gBAAgB;IAAC;IAAU;IAAU;IAAW;IAAa;IAAY;IAAU;CAAW;AAEpG,mFAAmF,GACnF,OAAO,SAASC,2BACdC,MAA6C,EAC7CxB,OAAO,QAAQ;IAEf,MAAML,SAAoC,EAAE;IAC5C,MAAM8B,OAAOC,MAAMC,OAAO,CAACH,0BAAAA,OAAQC,IAAI,IAAID,OAAOC,IAAI,GAAG,EAAE;IAC3D,MAAMG,YACJH,KAAKI,MAAM,GAAG,KACdJ,KAAKK,KAAK,CAAC,CAACC,MAAQC,OAAOC,SAAS,CAACF,QAAQA,OAAO,KAAKA,MAAMT,cAAcO,MAAM,KACnF,IAAIK,IAAIT,MAAMU,IAAI,KAAKV,KAAKI,MAAM;IACpC,IAAI,CAACD,WAAW;QACdjC,OAAOyC,IAAI,CACTrC,MAAM,GAAGC,KAAK,KAAK,CAAC,EAAE,uBAAuB;IAEjD;IACA,MAAMqC,QAAQb,0BAAAA,OAAQc,WAAW;IACjC,MAAMC,MAAMf,0BAAAA,OAAQgB,SAAS;IAC7B,MAAMC,aACJT,OAAOC,SAAS,CAACI,UACjBL,OAAOC,SAAS,CAACM,QACjBF,SAAS,KACTE,OAAO,KAAK,MACZF,QAAQE;IACV,IAAI,CAACE,YAAY;QACf9C,OAAOyC,IAAI,CACTrC,MACEC,MACA,wBACA;IAGN;IACA,OAAOL;AACT;AAEA;;;;CAIC,GACD,OAAO,SAAS+C,kCAAkCrC,KAAc;IAC9D,IAAI,CAACqB,MAAMC,OAAO,CAACtB,QAAQ,OAAO,EAAE;IACpC,MAAMsC,QAAkB,EAAE;IAC1B,KAAK,MAAMC,SAASvC,MAAO;QACzB,MAAMJ,OAAOG,OAAOwC,OAAOxB,IAAI,GAAGyB,WAAW;QAC7C,IAAI,aAAaC,IAAI,CAAC7C,SAAS,CAAC0C,MAAMI,QAAQ,CAAC9C,OAAO0C,MAAMP,IAAI,CAACnC;IACnE;IACA,OAAO0C;AACT;AAEA;;;;CAIC,GACD,OAAO,SAASK,6BACdC,GAAmF;IAEnF,MAAMC,OAAQD,cAAAA,MAAO,CAAC;IACtB,MAAME,YAAYT,kCAAkCQ,IAAI,CAAC,mBAAmB;IAC5E,MAAM1B,SAAS0B,IAAI,CAAC,SAAS;IAC7B,OAAO;QACL1B,QAAQA,UAAU,OAAOA,WAAW,WAAWA,SAAS;QACxD4B,kBAAkB1B,MAAMC,OAAO,CAACuB,IAAI,CAAC,mBAAmB,IACpDC,YACA;eAAItE;SAAmC;QAC3CwE,gBAAgBH,IAAI,CAAC,iBAAiB,KAAK;QAC3C,sEAAsE;QACtE,oDAAoD;QACpDI,aAAaJ,IAAI,CAAC,cAAc,KAAK;QACrC,qEAAqE;QACrE,mEAAmE;QACnE,mEAAmE;QACnE,0BAA0B;QAC1BK,iBAAiB9E,2BAA2ByE,IAAI,CAAC,kBAAkB,EAAE;IACvE;AACF;AAEA,OAAO,SAASM,iCACdC,QAAqD,EACrDzD,OAAO,UAAU;IAEjB,MAAML,SAAoC,EAAE;IAC5C,IAAI8D,4BAAAA,SAAUjC,MAAM,EAAE;QACpB7B,OAAOyC,IAAI,IAAIb,2BAA2BkC,SAASjC,MAAM,EAAE,GAAGxB,KAAK,OAAO,CAAC;IAC7E;IACA,MAAMiD,MAAMvB,MAAMC,OAAO,CAAC8B,4BAAAA,SAAUL,gBAAgB,IAAIK,SAASL,gBAAgB,GAAG,EAAE;IACtF,IAAI,CAACH,IAAIpB,MAAM,EAAE;QACflC,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,iBAAiB,CAAC,EAC1B,sBACA;IAGN;IACAiD,IAAIS,OAAO,CAAC,CAACd,OAAO/B;QAClB,IAAI,CAAC,aAAaiC,IAAI,CAAC1C,OAAOwC,SAAS;YACrCjD,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,kBAAkB,EAAEa,OAAO,EACnC,mBACA,CAAC,CAAC,EAAET,OAAOwC,OAAO,2CAA2C,CAAC;QAGpE;IACF;IACA,OAAOjD;AACT;AAEA,MAAMgE,qBAAqB,IAAIzB,IAAI9C,sBAAsBwE,GAAG,CAAC,CAACC,QAAUA,MAAMxE,GAAG;AAEjF,SAASyE,mBAAmBC,IAAY,EAAE/D,IAAY;IACpD,OAAOrB,iBAAiBoF,MACrBC,MAAM,CAAC,CAAC3E,MAAQ,CAACsE,mBAAmBM,GAAG,CAAC5E,MACxCuE,GAAG,CAAC,CAACvE,MACJc,QACEH,MACA,uBACA,CAAC,EAAE,EAAEX,IAAI,iDAAiD,CAAC;AAGnE;AAEA,SAAS6E,YACPxD,KAAsC,EACtCH,IAA0B,EAC1BM,KAAa,EACbb,IAAY;IAEZ,MAAMmE,QAAQ5D,KAAK6D,iBAAiB;IACpC,MAAMC,iBAAiB/D,YAAYC,SAASM,QAAQJ,oBAAoBC;IACxE,MAAM4D,MAAMD,iBAAiBpF,6CAA6C;IAC1E,IAAI+C,OAAOC,SAAS,CAACkC,UAAUA,SAASG,OAAOH,SAASpF,uCAAuC;QAC7F,OAAO,EAAE;IACX;IACA,MAAMmB,UACJoE,MAAM,IACF,CAAC,KAAK,EAAEA,IAAI,CAAC,EAAEvF,sCAAsC,iCAAiC,CAAC,GACvF,CAAC,OAAO,EAAEA,sCAAsC,gCAAgC,CAAC;IACvF,OAAO;QAACgB,MAAM,GAAGC,KAAK,kBAAkB,CAAC,EAAE,iBAAiBE;KAAS;AACvE;AAEA,SAASqE,gBACP7D,KAAsC,EACtCH,IAAuB,EACvBM,KAAa,EACbb,IAAY;IAEZ,MAAML,SAAoC,EAAE;IAC5C,MAAMmB,QAAQL,oBAAoBC,WAAWG;IAC7C,MAAM2D,eAAe,CAAC5D,oBAAoBF,OAAOG;IACjD,MAAMI,UAAUb,OAAOG,KAAKU,OAAO;IACnC,IAAIuD,cAAc;QAChB,IAAI,CAACvD,QAAQG,IAAI,IAAI;YACnBzB,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,QAAQ,CAAC,EACjB,oBACAc,QACI,qCACA;QAGV,OAAO,IAAIE,8BAA8BC,UAAU;YACjDtB,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,QAAQ,CAAC,EACjB,wBACA;QAGN;QACA,IAAIiB,QAAQY,MAAM,GAAGrD,uBAAuB;YAC1CmB,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,QAAQ,CAAC,EACjB,oBACA,CAAC,uBAAuB,EAAExB,sBAAsB,YAAY,CAAC;QAGnE;QACAmB,OAAOyC,IAAI,IAAI0B,mBAAmB7C,SAAS,GAAGjB,KAAK,QAAQ,CAAC;IAC9D;IACA,MAAMyE,OAAOrE,OAAOG,KAAKkE,IAAI;IAC7B,MAAMC,aAAatE,OAAOG,KAAKmE,UAAU,EAAEtD,IAAI;IAC/C,IAAIqD,KAAKrD,IAAI,MAAMsD,YAAY;QAC7B/E,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,KAAK,CAAC,EACd,qBACA;IAGN,OAAO,IAAI,CAACyE,KAAKrD,IAAI,MAAM,CAACsD,YAAY;QACtC/E,OAAOyC,IAAI,CAACrC,MAAM,GAAGC,KAAK,KAAK,CAAC,EAAE,iBAAiB;IACrD;IACA,IAAIyE,KAAK5C,MAAM,GAAGtD,oBAAoB;QACpCoB,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,KAAK,CAAC,EACd,iBACA,CAAC,qBAAqB,EAAEzB,mBAAmBoG,cAAc,CAAC,SAAS,YAAY,CAAC;IAGtF;IACA,IAAIF,KAAKrD,IAAI,IAAI;QACfzB,OAAOyC,IAAI,IAAI0B,mBAAmBW,MAAM,GAAGzE,KAAK,KAAK,CAAC;QACtD,IAAIc,SAAS,CAACnC,iBAAiB8F,MAAM1B,QAAQ,CAAC5D,+BAA+B;YAC3EQ,OAAOyC,IAAI,CACTjC,QACE,GAAGH,KAAK,KAAK,CAAC,EACd,wBACA,CAAC,8BAA8B,EAAEb,6BAA6B,qDAAqD,CAAC;QAG1H;IACF;IACA,OAAOQ;AACT;AAEA,gFAAgF,GAChF,OAAO,SAASiF,sBACdlE,KAAyD,EACzDV,OAAO,OAAO;IAEd,MAAM6E,OAAOnD,MAAMC,OAAO,CAACjB,SAASA,QAAQ,EAAE;IAC9C,MAAMf,SAAoC,EAAE;IAC5C,IAAI,CAACkF,KAAKhD,MAAM,EAAE;QAChBlC,OAAOyC,IAAI,CAACrC,MAAMC,MAAM,kBAAkB;QAC1C,OAAOL;IACT;IACA,IAAIkF,KAAKhD,MAAM,GAAG7C,oBAAoB;QACpCW,OAAOyC,IAAI,CACTrC,MAAMC,MAAM,kBAAkB,CAAC,yBAAyB,EAAEhB,mBAAmB,OAAO,CAAC;IAEzF;IACA,MAAM8F,aAAaD,KAAKb,MAAM,CAAC1D,aAAauB,MAAM;IAClD,IAAIiD,aAAahG,0BAA0B;QACzCa,OAAOyC,IAAI,CACTrC,MACEC,MACA,wBACA,CAAC,yBAAyB,EAAElB,yBAAyB,sBAAsB,CAAC;IAGlF;IACA,MAAMiG,OAAO,IAAI7C;IACjB2C,KAAKnB,OAAO,CAAC,CAACnD,MAAMM;QAClB,MAAMmE,WAAW,GAAGhF,KAAK,CAAC,EAAEa,OAAO;QACnC,MAAMoE,KAAK7E,OAAOG,wBAAAA,KAAM0E,EAAE,EAAE7D,IAAI;QAChC,IAAI,CAAC6D,IAAI;YACPtF,OAAOyC,IAAI,CAACrC,MAAM,GAAGiF,SAAS,GAAG,CAAC,EAAE,oBAAoB;QAC1D,OAAO,IAAID,KAAKd,GAAG,CAACgB,KAAK;YACvBtF,OAAOyC,IAAI,CAACrC,MAAM,GAAGiF,SAAS,GAAG,CAAC,EAAE,qBAAqB;QAC3D;QACAD,KAAKG,GAAG,CAACD;QACT,IAAI3E,YAAYC,OAAO;YACrBZ,OAAOyC,IAAI,IAAI8B,YAAYW,MAAMtE,MAAMM,OAAOmE;YAC9CrF,OAAOyC,IAAI,IAAImC,gBAAgBM,MAAMtE,MAAMM,OAAOmE;QACpD,OAAO,IAAIzE,CAAAA,wBAAAA,KAAMC,IAAI,MAAK,QAAQ;YAChCb,OAAOyC,IAAI,IAAI8B,YAAYW,MAAMtE,MAAMM,OAAOmE;YAC9C,IAAI,CAAC,AAAC9F,oBAA0C6D,QAAQ,CAACxC,KAAK4E,QAAQ,GAAG;gBACvExF,OAAOyC,IAAI,CACTrC,MAAM,GAAGiF,SAAS,SAAS,CAAC,EAAE,qBAAqB;YAEvD;YACA,MAAMI,QAAQhF,OAAOG,KAAK6E,KAAK;YAC/B,IAAI,CAACA,MAAMhE,IAAI,IAAI;gBACjBzB,OAAOyC,IAAI,CAACrC,MAAM,GAAGiF,SAAS,MAAM,CAAC,EAAE,uBAAuB;YAChE,OAAO,IAAII,MAAMvD,MAAM,GAAGpC,yBAAyB;gBACjDE,OAAOyC,IAAI,CACTrC,MACE,GAAGiF,SAAS,MAAM,CAAC,EACnB,uBACA,CAAC,qBAAqB,EAAEvF,wBAAwB,YAAY,CAAC;YAGnE;QACF,OAAO;YACLE,OAAOyC,IAAI,CACTrC,MAAMiF,UAAU,qBAAqB;QAEzC;IACF;IACA,OAAOrF;AACT;AAEA,mEAAmE,GACnE,OAAO,SAAS0F,yBACdC,QAAgG;;IAEhG,MAAM3F,SAAoC,EAAE;IAC5C,MAAM4F,OAAOnF,OAAOkF,4BAAAA,SAAUC,IAAI;IAClC,IAAI,CAACA,KAAKnE,IAAI,IAAI;QAChBzB,OAAOyC,IAAI,CAACrC,MAAM,QAAQ,iBAAiB;IAC7C,OAAO,IAAIwF,KAAK1D,MAAM,GAAGrC,4BAA4B;QACnDG,OAAOyC,IAAI,CACTrC,MAAM,QAAQ,iBAAiB,CAAC,oBAAoB,EAAEP,2BAA2B,YAAY,CAAC;IAElG;IACA,MAAMkB,gBAAQ4E,4BAAAA,SAAU5E,KAAK,mBAAI,EAAE;IACnC,IAAIA,MAAMd,IAAI,CAACU,gBAAgB,CAACF,OAAOkF,4BAAAA,SAAUE,SAAS,EAAEpE,IAAI,IAAI;QAClEzB,OAAOyC,IAAI,CACTrC,MAAM,aAAa,oBAAoB;IAE3C;IACAJ,OAAOyC,IAAI,IAAIwC,sBAAsBlE;IACrCf,OAAOyC,IAAI,IAAIoB,iCAAiC8B,4BAAAA,SAAU7B,QAAQ;IAClE,OAAO9D;AACT;AAEA;;;CAGC,GACD,OAAO,SAAS8F,4BACdhC,QAAqF,EACrFzD,OAAO,aAAa;IAEpB,MAAML,SAAoC,EAAE;IAC5C,IAAI,CAACS,OAAOqD,4BAAAA,SAAUiC,SAAS,EAAEtE,IAAI,IAAI;QACvCzB,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,UAAU,CAAC,EACnB,uBACA;IAGN;IACA,IAAI,CAACI,OAAOqD,4BAAAA,SAAUkC,aAAa,EAAEvE,IAAI,IAAI;QAC3CzB,OAAOyC,IAAI,CACTrC,MACE,GAAGC,KAAK,cAAc,CAAC,EACvB,2BACA;IAGN;IACA,OAAOL;AACT;AAEA;;;CAGC,GACD,OAAO,SAASiG,mCACdN,QAAgG,EAChGO,WAAwF;IAExF,OAAO;WAAIR,yBAAyBC;WAAcG,4BAA4BI;KAAa;AAC7F"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.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 MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { readListUnsubscribeSetting } from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\nimport { outreachBodyLinks } from './click-tracking'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n | 'too_many_links'\n | 'discount_language'\n | 'not_plain_text'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/** The index of the first email step, or `-1` for a sequence with none. */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n): number {\n return (steps ?? []).findIndex(isEmailStep)\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n // Absent is off (AGL-3239). Every sequence written before the setting\n // existed reads as untracked, which is what it was.\n trackClicks: data['trackClicks'] === true,\n // Absent is off (AGL-3296): a sequence is one-to-one mail, and every\n // sequence written before the setting existed reads as headerless.\n // Read by the shared reader campaigns use too (AGL-3307), with the\n // sequence's own default.\n listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false),\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/*==========================================\n * A CURATED STEP (AGL-3324).\n *\n * One person's own copy of a step is held to everything a step is held to —\n * a subject where the email starts a thread, a body, the lengths, the merge\n * fields — and to the outbound playbook's rules the sequence editor trusts\n * its author with but an AI draft cannot be trusted with: one link at most,\n * list price only, plain text. Each is an ERROR: a draft that breaks one is\n * refused rather than stored, whoever wrote it.\n *==========================================*/\n\n/**\n * Words the playbook keeps out of every message: a discount, a coupon, a\n * cohort, a slot count, a deadline. Matched on word boundaries, so \"count\"\n * and \"expired domain\" pass and \"coupon\" and \"20% off\" do not.\n */\nconst DISCOUNT_LANGUAGE =\n /\\b(discounts?|discounted|coupons?|promo(?:tional)?\\s+codes?|promo\\b|vouchers?|\\d+\\s*%\\s*off|percent\\s+off|money\\s+off|special\\s+offer|limited[-\\s]time|expires?\\s+(?:on|at|in|soon|today|tomorrow)|deadline|(?:only|last|first)\\s+\\d+\\s+(?:spots?|slots?|seats?|places?)|cohorts?)\\b/i\n\n/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */\nconst MARKDOWN = /\\*\\*[^*\\n]+\\*\\*|\\[[^\\]\\n]+\\]\\([^)\\s]+\\)|^#{1,6}\\s|<\\/?[a-z][^>]*>/im\n\nexport interface OutreachCuratedStepInput {\n /** The subject, when the copy carries one. Read only where the email starts a thread. */\n subject?: string | null\n body?: string | null\n /** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */\n startsThread: boolean\n}\n\n/** Everything one person's copy of a step has to be before it may be stored — see the section note. */\nexport function validateOutreachCuratedStep(\n input: OutreachCuratedStepInput,\n path = 'override',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const subject = asText(input.subject)\n if (input.startsThread && input.subject !== undefined && input.subject !== null) {\n if (!subject.trim()) {\n issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'))\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`),\n )\n }\n if (DISCOUNT_LANGUAGE.test(subject)) {\n issues.push(\n error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(input.body)\n if (!body.trim()) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email.'))\n return issues\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n const links = outreachBodyLinks(body)\n if (links.length > 1) {\n issues.push(\n error(\n `${path}.body`,\n 'too_many_links',\n `This email carries ${links.length} links. A sequence email carries one at most.`,\n ),\n )\n }\n if (DISCOUNT_LANGUAGE.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'discount_language',\n 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.',\n ),\n )\n }\n if (MARKDOWN.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'not_plain_text',\n 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.',\n ),\n )\n }\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","readListUnsubscribeSetting","CRM_MERGE_FIELDS","crmMergeFieldsIn","crmThreadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","outreachBodyLinks","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","findIndex","isInThreadEmailStep","index","first","replyInThread","outreachSubjectHasReplyPrefix","subject","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","length","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","trackClicks","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","list","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","DISCOUNT_LANGUAGE","MARKDOWN","validateOutreachCuratedStep","input","undefined","links","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"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"}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
import { type OutreachValidationIssue } from '../engine/sequence-validation';
|
|
18
|
+
import type { OutreachStepOverrideRequest } from '../model/outreach-api';
|
|
19
|
+
import { type OutreachSequenceStep, type OutreachStepOverride, type OutreachStepOverrides } from '../model/outreach.types';
|
|
20
|
+
/** The most of a prompt an override keeps for the audit log. */
|
|
21
|
+
export declare const OUTREACH_OVERRIDE_PROMPT_MAX = 12000;
|
|
22
|
+
export interface OutreachStepOverrideRead {
|
|
23
|
+
/** The overrides as they would be stored, keyed by step index, stamped by the caller. */
|
|
24
|
+
overrides: OutreachStepOverrides;
|
|
25
|
+
/** Every issue, each path prefixed `stepOverrides.<index>`. */
|
|
26
|
+
issues: OutreachValidationIssue[];
|
|
27
|
+
/** Why the request could not be read at all, in a sentence; `null` when it could. */
|
|
28
|
+
refusal: string | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One override request as a stored override, before the caller's stamp.
|
|
32
|
+
* `null` when the request is not one.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readOutreachStepOverrideRequest(raw: unknown, steps: readonly OutreachSequenceStep[]): {
|
|
35
|
+
stepIndex: number;
|
|
36
|
+
override: Omit<OutreachStepOverride, 'draftedAtMs' | 'draftedByUid'>;
|
|
37
|
+
issues: OutreachValidationIssue[];
|
|
38
|
+
} | {
|
|
39
|
+
refusal: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Every override a request carries, read and validated, and stamped with
|
|
43
|
+
* who confirmed them and when. An empty or absent list reads as no
|
|
44
|
+
* overrides and no issues.
|
|
45
|
+
*/
|
|
46
|
+
export declare function readOutreachStepOverrideRequests(raw: unknown, steps: readonly OutreachSequenceStep[], stamp: {
|
|
47
|
+
uid: string;
|
|
48
|
+
nowMs: number;
|
|
49
|
+
}): OutreachStepOverrideRead;
|
|
50
|
+
/** Whether a read carries an error that keeps its overrides from being stored. */
|
|
51
|
+
export declare function outreachStepOverridesRefused(read: OutreachStepOverrideRead): boolean;
|
|
52
|
+
/** The requests' shape, for a caller that builds one from stored overrides. */
|
|
53
|
+
export declare function outreachStepOverrideRequests(overrides: OutreachStepOverrides | null | undefined): OutreachStepOverrideRequest[];
|