@sonordev/contracts 2.7.1 → 2.8.0
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/CHANGELOG.md +15 -0
- package/README.md +123 -0
- package/dist/business-claims.d.ts +456 -0
- package/dist/business-claims.js +1378 -0
- package/dist/business-claims.mjs +1340 -0
- package/dist/outreach.d.ts +267 -0
- package/dist/outreach.js +351 -0
- package/dist/outreach.mjs +328 -0
- package/package.json +12 -2
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** Bumped when a stored shape here (SegmentGrant, the segment key) changes incompatibly. */
|
|
2
|
+
export declare const OUTREACH_CONTRACT_VERSION = 1;
|
|
3
|
+
/** The badge Signal's outreach writer files and acts under. */
|
|
4
|
+
export declare const OUTREACH_WRITER_BADGE = "signal.outreach.writer";
|
|
5
|
+
/** The ledger kind segments belong to, and its key under settings.autonomy.segments. */
|
|
6
|
+
export declare const OUTREACH_EMAIL_KIND = "outreach_email";
|
|
7
|
+
/**
|
|
8
|
+
* Signal's one graduation bar: a track record earns sending without a click
|
|
9
|
+
* per item once at least minMeasured are measured, at least minWinRate of
|
|
10
|
+
* them won, and none degraded without being undone. Single source of truth
|
|
11
|
+
* for both APIs: Signal measures a kind or a segment against it, and an
|
|
12
|
+
* earned segment grant needs minMeasured receipts wherever
|
|
13
|
+
* grantEvidenceRefusal checks one.
|
|
14
|
+
*/
|
|
15
|
+
export declare const GRADUATION_BAR: {
|
|
16
|
+
readonly minMeasured: 5;
|
|
17
|
+
readonly minWinRate: 0.7;
|
|
18
|
+
readonly maxUnrevertedDegradations: 0;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A segment's standing, one vocabulary everywhere:
|
|
22
|
+
* - ungranted: nobody turned it on (its next email is a first email);
|
|
23
|
+
* - granted: a person turned it on and it's sending without a per-email click;
|
|
24
|
+
* - paused: it stopped itself (a quality loss or a list signal) until a person resumes it;
|
|
25
|
+
* - lapsed: the pitch changed since it was turned on, so its next email is a first email again;
|
|
26
|
+
* - revoked: a person stopped it.
|
|
27
|
+
* "Awaiting first approval" is a drip check's answer, never a stored state.
|
|
28
|
+
*/
|
|
29
|
+
export declare const SEGMENT_STATES: readonly ['ungranted', 'granted', 'paused', 'lapsed', 'revoked'];
|
|
30
|
+
export type SegmentState = (typeof SEGMENT_STATES)[number];
|
|
31
|
+
export type OutreachSegmentSource = 'drip' | 'mcp';
|
|
32
|
+
/** The vertical of a lead with no industry. Never grantable. */
|
|
33
|
+
export declare const UNSPECIFIED_INDUSTRY = "unspecified";
|
|
34
|
+
/**
|
|
35
|
+
* A prospect's industry as written, in the one order every caller uses: the
|
|
36
|
+
* target company's vertical, then its industry, then the lead's
|
|
37
|
+
* company_industry. Null when none is set.
|
|
38
|
+
*/
|
|
39
|
+
export declare function prospectIndustry(prospect: {
|
|
40
|
+
vertical?: unknown;
|
|
41
|
+
industry?: unknown;
|
|
42
|
+
company_industry?: unknown;
|
|
43
|
+
} | null | undefined): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* An industry as it appears in a segment key: NFKC, accents folded, lowercase,
|
|
46
|
+
* "&" read as "and", every run of anything that isn't a-z or 0-9 a single
|
|
47
|
+
* "-", trimmed, at most 48 characters. Empty is 'unspecified'. There's no
|
|
48
|
+
* synonym table: "Roofing" and "Roofers" are two segments.
|
|
49
|
+
*/
|
|
50
|
+
export declare function normalizeIndustry(value: unknown): string;
|
|
51
|
+
/** The fields of an outreach_narratives row that are its pitch. Its name, its switch and its forbidden phrases aren't. */
|
|
52
|
+
export declare const NARRATIVE_PITCH_FIELDS: readonly ['positioning_statement', 'target_icp_description', 'tone_profile', 'hook_types', 'example_angles', 'value_prop_bullets'];
|
|
53
|
+
/** A narrative row, or any object carrying its pitch fields; every other column (name, enabled, forbidden_phrases, ...) is read past. */
|
|
54
|
+
export type NarrativePitch = {
|
|
55
|
+
[K in (typeof NARRATIVE_PITCH_FIELDS)[number]]?: unknown;
|
|
56
|
+
} & Record<string, unknown>;
|
|
57
|
+
/**
|
|
58
|
+
* The narrative's pitch, fingerprinted: FNV-1a of canonical JSON of
|
|
59
|
+
* NARRATIVE_PITCH_FIELDS only. Rewriting the pitch changes it, and that
|
|
60
|
+
* lapses a segment's grant. Renaming the narrative, switching it on or off,
|
|
61
|
+
* or tightening its forbidden phrases or avoid_tone doesn't: those take
|
|
62
|
+
* effect at once through the checker and the prompt.
|
|
63
|
+
*/
|
|
64
|
+
export declare function narrativeFingerprint(narrative: NarrativePitch | null | undefined): string;
|
|
65
|
+
/** target_ref.segment on an outreach_email row: stamped once, at filing. */
|
|
66
|
+
export interface OutreachSegment {
|
|
67
|
+
key: string;
|
|
68
|
+
source: OutreachSegmentSource;
|
|
69
|
+
narrative_id: string | null;
|
|
70
|
+
mailbox_id: string | null;
|
|
71
|
+
vertical: string;
|
|
72
|
+
label: string;
|
|
73
|
+
angle_fp: string | null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A segment: which pitch, from which mailbox, to which kind of business.
|
|
77
|
+
* - drip: `drip:<narrative uuid>@<mailbox uuid>|<vertical>`. Null when either id isn't a UUID.
|
|
78
|
+
* - mcp: `mcp:-|<vertical>`. For metrics only; never granted.
|
|
79
|
+
*/
|
|
80
|
+
export declare function outreachSegment(input: {
|
|
81
|
+
source: OutreachSegmentSource;
|
|
82
|
+
narrativeId?: string | null;
|
|
83
|
+
mailboxId?: string | null;
|
|
84
|
+
industry?: unknown;
|
|
85
|
+
label?: string | null;
|
|
86
|
+
angleFp?: string | null;
|
|
87
|
+
}): OutreachSegment | null;
|
|
88
|
+
/** A segment's label: "<narrative name> to <industry as written>, from <mailbox address>". */
|
|
89
|
+
export declare function outreachSegmentLabel(input: {
|
|
90
|
+
narrativeName?: unknown;
|
|
91
|
+
industry?: unknown;
|
|
92
|
+
mailboxAddress?: unknown;
|
|
93
|
+
}): string;
|
|
94
|
+
export type ParsedOutreachSegmentKey = {
|
|
95
|
+
source: 'drip';
|
|
96
|
+
narrative_id: string;
|
|
97
|
+
mailbox_id: string;
|
|
98
|
+
vertical: string;
|
|
99
|
+
} | {
|
|
100
|
+
source: 'mcp';
|
|
101
|
+
vertical: string;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* A segment key, parsed strictly, or null. Nothing else is ever used as a key
|
|
105
|
+
* in projects.settings, so a caller-chosen key ("__proto__", a long string)
|
|
106
|
+
* is refused before any read or write.
|
|
107
|
+
*/
|
|
108
|
+
export declare function parseOutreachSegmentKey(key: unknown): ParsedOutreachSegmentKey | null;
|
|
109
|
+
/**
|
|
110
|
+
* Whether a segment can ever be turned on: a drip segment with a known
|
|
111
|
+
* industry. An mcp segment, or a lead with no industry, always waits for a
|
|
112
|
+
* person. (A row whose recipient Sonor already knows waits too; that's the
|
|
113
|
+
* row's relationship, checked separately.)
|
|
114
|
+
*/
|
|
115
|
+
export declare function isGrantableSegmentKey(key: unknown): boolean;
|
|
116
|
+
/**
|
|
117
|
+
* A person's standing approval of one segment, at
|
|
118
|
+
* projects.settings.autonomy.segments.outreach_email[key]. Written only by
|
|
119
|
+
* Signal's AutonomyPolicyService.setSegmentGrant, refused on every generic
|
|
120
|
+
* settings writer, and only ever trusted after grantEvidenceRefusal passes.
|
|
121
|
+
*/
|
|
122
|
+
export interface SegmentGrant {
|
|
123
|
+
state: 'granted' | 'revoked';
|
|
124
|
+
/** first_email: a person approved the segment's first email unedited with "Send the rest like this". earned: they accepted Signal's offer once the bar was met. */
|
|
125
|
+
source: 'first_email' | 'earned';
|
|
126
|
+
/** contacts.id of the person who turned it on. */
|
|
127
|
+
by: string;
|
|
128
|
+
/** Where they did it. Never MCP. */
|
|
129
|
+
via: 'dashboard' | 'drafts';
|
|
130
|
+
at: string;
|
|
131
|
+
/** first_email: the ledger id of that email. earned: the offer's dedup key. */
|
|
132
|
+
ref: string;
|
|
133
|
+
/** earned: the ledger ids of the wins that earned it. */
|
|
134
|
+
receipts?: string[];
|
|
135
|
+
/** The mailbox it sends from. A grant never covers another mailbox. */
|
|
136
|
+
mailbox_id: string;
|
|
137
|
+
label: string;
|
|
138
|
+
/** narrativeFingerprint when it was turned on. A rewritten pitch lapses it. */
|
|
139
|
+
angle_fp: string;
|
|
140
|
+
/** Stopped itself. Cleared only by a person's resume. */
|
|
141
|
+
paused?: {
|
|
142
|
+
at: string;
|
|
143
|
+
reason: string;
|
|
144
|
+
loss_ref: string | null;
|
|
145
|
+
};
|
|
146
|
+
resumed?: {
|
|
147
|
+
by: string;
|
|
148
|
+
at: string;
|
|
149
|
+
};
|
|
150
|
+
revoked?: {
|
|
151
|
+
by: string;
|
|
152
|
+
at: string;
|
|
153
|
+
reason: string;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Every well-formed grant, by segment key. Pass projects.settings or its
|
|
158
|
+
* autonomy document. A key that doesn't parse strictly, or a grant that isn't
|
|
159
|
+
* the SegmentGrant shape, is left out: an unreadable grant counts as no grant.
|
|
160
|
+
*/
|
|
161
|
+
export declare function readSegmentGrants(source: unknown): Record<string, SegmentGrant>;
|
|
162
|
+
/** The grant for a segment when it's live (granted, not paused, on a grantable key), else null. Evidence is checked separately. */
|
|
163
|
+
export declare function activeSegmentGrant(source: unknown, key: string): SegmentGrant | null;
|
|
164
|
+
export type SegmentGrantCoverage = {
|
|
165
|
+
covers: true;
|
|
166
|
+
} | {
|
|
167
|
+
covers: false;
|
|
168
|
+
code: 'not_granted' | 'revoked' | 'paused' | 'other_person' | 'granted_after' | 'angle_changed' | 'other_mailbox';
|
|
169
|
+
reason: string;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Whether a live grant covers one row: refused when there's no grant, it was
|
|
173
|
+
* revoked or paused, the row names another approver, the row was filed
|
|
174
|
+
* before the grant, the pitch changed since (a different fingerprint), or the
|
|
175
|
+
* row sends from another mailbox.
|
|
176
|
+
*/
|
|
177
|
+
export declare function segmentGrantCovers(grant: SegmentGrant | null | undefined, row: {
|
|
178
|
+
by?: string | null;
|
|
179
|
+
createdAt: string | null | undefined;
|
|
180
|
+
angleFp: string | null | undefined;
|
|
181
|
+
mailboxId: string | null | undefined;
|
|
182
|
+
}): SegmentGrantCoverage;
|
|
183
|
+
/** The columns of a signal_proposals row grantEvidenceRefusal reads. */
|
|
184
|
+
export interface GrantEvidenceRow {
|
|
185
|
+
id: string;
|
|
186
|
+
kind: string;
|
|
187
|
+
status: string;
|
|
188
|
+
project_id?: string | null;
|
|
189
|
+
created_by?: string | null;
|
|
190
|
+
reviewed_by?: string | null;
|
|
191
|
+
reviewed_at?: string | null;
|
|
192
|
+
target_ref?: unknown;
|
|
193
|
+
payload?: unknown;
|
|
194
|
+
}
|
|
195
|
+
export interface GrantEvidenceScope {
|
|
196
|
+
/** The segment key the grant is stored under. */
|
|
197
|
+
key: string;
|
|
198
|
+
/** GRADUATION_BAR.minMeasured: how many receipts an earned grant needs. */
|
|
199
|
+
minMeasured: number;
|
|
200
|
+
/** The project the grant is stored on. Rows from another project never count. */
|
|
201
|
+
projectId?: string | null;
|
|
202
|
+
/** contacts.id of reviewers known to be people (not agents). When given, every reviewer must be in it. */
|
|
203
|
+
people?: ReadonlySet<string> | ReadonlyArray<string> | null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Why a grant can't be trusted, or null when the ledger rows it cites stand
|
|
207
|
+
* behind it. A grant that fails counts as no grant, so a grant forged into
|
|
208
|
+
* settings never sends anything.
|
|
209
|
+
*
|
|
210
|
+
* - first_email: grant.ref is an applied outreach_email the writer filed in
|
|
211
|
+
* this project and segment, from the grant's mailbox, approved by grant.by
|
|
212
|
+
* at or before grant.at, in Sonor (via dashboard or drafts, never MCP),
|
|
213
|
+
* unedited, and read back verified.
|
|
214
|
+
* - earned: at least scope.minMeasured distinct receipts, each an applied
|
|
215
|
+
* writer row in this segment and mailbox, for the same pitch, approved by a
|
|
216
|
+
* person in Sonor at or before grant.at, unedited and read back verified.
|
|
217
|
+
*/
|
|
218
|
+
export declare function grantEvidenceRefusal(grant: SegmentGrant | null | undefined, refRows: ReadonlyArray<GrantEvidenceRow> | null | undefined, scope: GrantEvidenceScope): string | null;
|
|
219
|
+
/** The live roles of the person asking, read by the caller. */
|
|
220
|
+
export interface SegmentGranterRoles {
|
|
221
|
+
contact_type?: string | null;
|
|
222
|
+
is_platform_admin?: boolean | null;
|
|
223
|
+
/** Their role in the project's org. */
|
|
224
|
+
org_role?: string | null;
|
|
225
|
+
/** Their role on the project itself. */
|
|
226
|
+
project_role?: string | null;
|
|
227
|
+
/** Their role in the agency that manages the project's org. */
|
|
228
|
+
managing_org_role?: string | null;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Whether this person may turn a segment on (or resume it): never an agent
|
|
232
|
+
* contact; otherwise a platform admin, or an owner or admin of the project,
|
|
233
|
+
* its org, or the agency that manages that org.
|
|
234
|
+
*/
|
|
235
|
+
export declare function segmentGranterEligible(roles: SegmentGranterRoles | null | undefined): boolean;
|
|
236
|
+
/**
|
|
237
|
+
* Domains anyone can sign up for. A matching address says nothing about the
|
|
238
|
+
* company someone works for, so a relationship check never matches on one.
|
|
239
|
+
*/
|
|
240
|
+
export declare const PUBLIC_MAILBOX_DOMAINS: ReadonlyArray<string>;
|
|
241
|
+
/** Whether an address or a domain is a public mailbox provider's. Case and a leading "mailto:" or "@" don't matter. */
|
|
242
|
+
export declare function isPublicMailboxDomain(value: unknown): boolean;
|
|
243
|
+
/**
|
|
244
|
+
* - live: still waiting on a person or a held send. Leave the lead reserved.
|
|
245
|
+
* - sent: the email went out. The lead moves on.
|
|
246
|
+
* - retire: a person said it shouldn't go to this person. Drop the lead.
|
|
247
|
+
* - declined: a person turned this email down for another reason. The lead goes back in line.
|
|
248
|
+
* - refile: nothing was decided about the lead (wrong timing, expired,
|
|
249
|
+
* superseded, the kind switched off, a send that never went out). It may be
|
|
250
|
+
* filed again after its cooldown.
|
|
251
|
+
*/
|
|
252
|
+
export type DripRowDisposition = 'live' | 'sent' | 'retire' | 'declined' | 'refile';
|
|
253
|
+
/** The columns of a signal_proposals row dripRowDisposition reads. */
|
|
254
|
+
export interface DripLedgerRow {
|
|
255
|
+
status: string;
|
|
256
|
+
review_reason?: string | null;
|
|
257
|
+
reviewed_by?: string | null;
|
|
258
|
+
autonomy_decision?: unknown;
|
|
259
|
+
payload?: unknown;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* What a drip's ledger row means for its lead. Signal's filing dedup and the
|
|
263
|
+
* Sonor API's reservation release both read it, so "decided" means one thing.
|
|
264
|
+
* A status it doesn't know reads as live: a lead stays reserved rather than
|
|
265
|
+
* being written to twice.
|
|
266
|
+
*/
|
|
267
|
+
export declare function dripRowDisposition(row: DripLedgerRow | null | undefined): DripRowDisposition;
|
package/dist/outreach.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/business-claims.ts
|
|
4
|
+
function fnv1a(text) {
|
|
5
|
+
let hash = 2166136261;
|
|
6
|
+
for (let i = 0; i < text.length; i++) {
|
|
7
|
+
hash ^= text.charCodeAt(i);
|
|
8
|
+
hash = Math.imul(hash, 16777619);
|
|
9
|
+
}
|
|
10
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/contracts/outreach.ts
|
|
14
|
+
var OUTREACH_CONTRACT_VERSION = 1;
|
|
15
|
+
var OUTREACH_WRITER_BADGE = "signal.outreach.writer";
|
|
16
|
+
var OUTREACH_EMAIL_KIND = "outreach_email";
|
|
17
|
+
var GRADUATION_BAR = { minMeasured: 5, minWinRate: 0.7, maxUnrevertedDegradations: 0 };
|
|
18
|
+
var SEGMENT_STATES = ["ungranted", "granted", "paused", "lapsed", "revoked"];
|
|
19
|
+
var UNSPECIFIED_INDUSTRY = "unspecified";
|
|
20
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
var isUuid = (value) => typeof value === "string" && UUID.test(value);
|
|
22
|
+
var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
var nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
|
|
24
|
+
function prospectIndustry(prospect) {
|
|
25
|
+
for (const value of [prospect?.vertical, prospect?.industry, prospect?.company_industry]) {
|
|
26
|
+
if (nonEmpty(value)) return value.trim();
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function normalizeIndustry(value) {
|
|
31
|
+
if (typeof value !== "string") return UNSPECIFIED_INDUSTRY;
|
|
32
|
+
const slug = value.normalize("NFKC").normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
|
|
33
|
+
return slug || UNSPECIFIED_INDUSTRY;
|
|
34
|
+
}
|
|
35
|
+
var NARRATIVE_PITCH_FIELDS = [
|
|
36
|
+
"positioning_statement",
|
|
37
|
+
"target_icp_description",
|
|
38
|
+
"tone_profile",
|
|
39
|
+
"hook_types",
|
|
40
|
+
"example_angles",
|
|
41
|
+
"value_prop_bullets"
|
|
42
|
+
];
|
|
43
|
+
function canonical(value) {
|
|
44
|
+
if (value === null || value === void 0) return "null";
|
|
45
|
+
if (typeof value === "string") return JSON.stringify(value.normalize("NFKC").replace(/\s+/g, " ").trim());
|
|
46
|
+
if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
|
|
47
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
48
|
+
if (isRecord(value)) {
|
|
49
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
50
|
+
}
|
|
51
|
+
return "null";
|
|
52
|
+
}
|
|
53
|
+
function narrativeFingerprint(narrative) {
|
|
54
|
+
const source = isRecord(narrative) ? narrative : {};
|
|
55
|
+
const pitch = {};
|
|
56
|
+
for (const field of NARRATIVE_PITCH_FIELDS) pitch[field] = source[field] ?? null;
|
|
57
|
+
return fnv1a(canonical(pitch));
|
|
58
|
+
}
|
|
59
|
+
function outreachSegment(input) {
|
|
60
|
+
const vertical = normalizeIndustry(input?.industry);
|
|
61
|
+
const label = typeof input?.label === "string" ? input.label : "";
|
|
62
|
+
const angle_fp = typeof input?.angleFp === "string" && input.angleFp ? input.angleFp : null;
|
|
63
|
+
if (input?.source === "drip") {
|
|
64
|
+
if (!isUuid(input.narrativeId) || !isUuid(input.mailboxId)) return null;
|
|
65
|
+
const narrative_id = input.narrativeId.toLowerCase();
|
|
66
|
+
const mailbox_id = input.mailboxId.toLowerCase();
|
|
67
|
+
return { key: `drip:${narrative_id}@${mailbox_id}|${vertical}`, source: "drip", narrative_id, mailbox_id, vertical, label, angle_fp };
|
|
68
|
+
}
|
|
69
|
+
if (input?.source === "mcp") {
|
|
70
|
+
const mailbox_id = isUuid(input.mailboxId) ? input.mailboxId.toLowerCase() : null;
|
|
71
|
+
return { key: `mcp:-|${vertical}`, source: "mcp", narrative_id: null, mailbox_id, vertical, label, angle_fp };
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function outreachSegmentLabel(input) {
|
|
76
|
+
const name = nonEmpty(input?.narrativeName) ? input.narrativeName.trim() : "Outreach";
|
|
77
|
+
const industry = nonEmpty(input?.industry) ? input.industry.trim() : "leads with no industry";
|
|
78
|
+
const from = nonEmpty(input?.mailboxAddress) ? `, from ${input.mailboxAddress.trim()}` : "";
|
|
79
|
+
return `${name} to ${industry}${from}`;
|
|
80
|
+
}
|
|
81
|
+
var DRIP_KEY = /^drip:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})@([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\|([a-z0-9-]{1,48})$/;
|
|
82
|
+
var MCP_KEY = /^mcp:-\|([a-z0-9-]{1,48})$/;
|
|
83
|
+
function parseOutreachSegmentKey(key) {
|
|
84
|
+
if (typeof key !== "string" || key.length > 140) return null;
|
|
85
|
+
const drip = DRIP_KEY.exec(key);
|
|
86
|
+
if (drip) return { source: "drip", narrative_id: drip[1], mailbox_id: drip[2], vertical: drip[3] };
|
|
87
|
+
const mcp = MCP_KEY.exec(key);
|
|
88
|
+
if (mcp) return { source: "mcp", vertical: mcp[1] };
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function isGrantableSegmentKey(key) {
|
|
92
|
+
const parsed = parseOutreachSegmentKey(key);
|
|
93
|
+
return parsed?.source === "drip" && parsed.vertical !== UNSPECIFIED_INDUSTRY;
|
|
94
|
+
}
|
|
95
|
+
var isDate = (value) => typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
96
|
+
function readGrant(value) {
|
|
97
|
+
if (!isRecord(value)) return null;
|
|
98
|
+
const { state, source, by, via, at, ref, receipts, mailbox_id, label, angle_fp, paused, resumed, revoked } = value;
|
|
99
|
+
if (state !== "granted" && state !== "revoked") return null;
|
|
100
|
+
if (source !== "first_email" && source !== "earned") return null;
|
|
101
|
+
if (via !== "dashboard" && via !== "drafts") return null;
|
|
102
|
+
if (!nonEmpty(by) || !isDate(at) || !nonEmpty(ref) || !nonEmpty(mailbox_id) || !nonEmpty(angle_fp) || typeof label !== "string") return null;
|
|
103
|
+
const grant = { state, source, by, via, at, ref, mailbox_id, label, angle_fp };
|
|
104
|
+
if (receipts !== void 0) {
|
|
105
|
+
if (!Array.isArray(receipts) || !receipts.every(nonEmpty)) return null;
|
|
106
|
+
grant.receipts = [...receipts];
|
|
107
|
+
}
|
|
108
|
+
if (paused !== void 0 && paused !== null) {
|
|
109
|
+
if (!isRecord(paused) || !isDate(paused.at) || typeof paused.reason !== "string") return null;
|
|
110
|
+
grant.paused = { at: paused.at, reason: paused.reason, loss_ref: nonEmpty(paused.loss_ref) ? paused.loss_ref : null };
|
|
111
|
+
}
|
|
112
|
+
if (resumed !== void 0 && resumed !== null) {
|
|
113
|
+
if (!isRecord(resumed) || !nonEmpty(resumed.by) || !isDate(resumed.at)) return null;
|
|
114
|
+
grant.resumed = { by: resumed.by, at: resumed.at };
|
|
115
|
+
}
|
|
116
|
+
if (revoked !== void 0 && revoked !== null) {
|
|
117
|
+
if (!isRecord(revoked) || !nonEmpty(revoked.by) || !isDate(revoked.at) || typeof revoked.reason !== "string") return null;
|
|
118
|
+
grant.revoked = { by: revoked.by, at: revoked.at, reason: revoked.reason };
|
|
119
|
+
}
|
|
120
|
+
return grant;
|
|
121
|
+
}
|
|
122
|
+
function readSegmentGrants(source) {
|
|
123
|
+
const autonomy = isRecord(source) && isRecord(source.autonomy) ? source.autonomy : source;
|
|
124
|
+
const segments = isRecord(autonomy) && isRecord(autonomy.segments) ? autonomy.segments[OUTREACH_EMAIL_KIND] : null;
|
|
125
|
+
const out = {};
|
|
126
|
+
if (!isRecord(segments)) return out;
|
|
127
|
+
for (const key of Object.keys(segments)) {
|
|
128
|
+
if (!parseOutreachSegmentKey(key)) continue;
|
|
129
|
+
const grant = readGrant(segments[key]);
|
|
130
|
+
if (grant) out[key] = grant;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function activeSegmentGrant(source, key) {
|
|
135
|
+
if (!isGrantableSegmentKey(key)) return null;
|
|
136
|
+
const grant = readSegmentGrants(source)[key];
|
|
137
|
+
return grant && grant.state === "granted" && !grant.paused ? grant : null;
|
|
138
|
+
}
|
|
139
|
+
function segmentGrantCovers(grant, row) {
|
|
140
|
+
if (!grant) return { covers: false, code: "not_granted", reason: "Nobody's turned this segment on." };
|
|
141
|
+
if (grant.state === "revoked") return { covers: false, code: "revoked", reason: "Someone stopped this segment." };
|
|
142
|
+
if (grant.paused) return { covers: false, code: "paused", reason: `This segment stopped itself: ${grant.paused.reason}` };
|
|
143
|
+
if (grant.state !== "granted") return { covers: false, code: "not_granted", reason: "Nobody's turned this segment on." };
|
|
144
|
+
if (row.by !== void 0 && row.by !== grant.by) return { covers: false, code: "other_person", reason: "This row names someone other than the person who turned the segment on." };
|
|
145
|
+
const created = Date.parse(row.createdAt ?? "");
|
|
146
|
+
const granted = Date.parse(grant.at);
|
|
147
|
+
if (!Number.isFinite(created) || !Number.isFinite(granted) || created < granted) {
|
|
148
|
+
return { covers: false, code: "granted_after", reason: "This email was written before the segment was turned on." };
|
|
149
|
+
}
|
|
150
|
+
if (!row.angleFp || row.angleFp !== grant.angle_fp) {
|
|
151
|
+
return { covers: false, code: "angle_changed", reason: "The pitch changed since this segment was turned on, so its next email needs a look." };
|
|
152
|
+
}
|
|
153
|
+
if (!row.mailboxId || row.mailboxId.toLowerCase() !== grant.mailbox_id.toLowerCase()) {
|
|
154
|
+
return { covers: false, code: "other_mailbox", reason: "This email sends from a different mailbox than the one the segment was turned on for." };
|
|
155
|
+
}
|
|
156
|
+
return { covers: true };
|
|
157
|
+
}
|
|
158
|
+
function segmentOf(row) {
|
|
159
|
+
return isRecord(row.target_ref) && isRecord(row.target_ref.segment) ? row.target_ref.segment : null;
|
|
160
|
+
}
|
|
161
|
+
function payloadOf(row) {
|
|
162
|
+
return isRecord(row.payload) ? row.payload : {};
|
|
163
|
+
}
|
|
164
|
+
function viaOf(row) {
|
|
165
|
+
const approval = payloadOf(row)._approval;
|
|
166
|
+
return isRecord(approval) ? approval.via : void 0;
|
|
167
|
+
}
|
|
168
|
+
function wasEdited(row) {
|
|
169
|
+
const edited = payloadOf(row).edited;
|
|
170
|
+
return isRecord(edited) && typeof edited.count === "number" && edited.count > 0;
|
|
171
|
+
}
|
|
172
|
+
function readBackStatus(row) {
|
|
173
|
+
const execution = payloadOf(row)._execution;
|
|
174
|
+
return isRecord(execution) && isRecord(execution.read_back) ? execution.read_back.status : void 0;
|
|
175
|
+
}
|
|
176
|
+
function evidenceRowRefusal(row, grant, scope, people) {
|
|
177
|
+
if (!row) return "isn't on record";
|
|
178
|
+
if (row.kind !== OUTREACH_EMAIL_KIND || row.created_by !== OUTREACH_WRITER_BADGE) return "isn't an email Signal's outreach writer wrote";
|
|
179
|
+
if (scope.projectId && row.project_id && row.project_id !== scope.projectId) return "belongs to another project";
|
|
180
|
+
if (row.status !== "applied") return "wasn't approved and sent";
|
|
181
|
+
const segment = segmentOf(row);
|
|
182
|
+
if (!segment || segment.key !== scope.key) return "is in a different segment";
|
|
183
|
+
if (typeof segment.mailbox_id !== "string" || segment.mailbox_id.toLowerCase() !== grant.mailbox_id.toLowerCase()) return "went out from a different mailbox";
|
|
184
|
+
if (typeof segment.angle_fp === "string" && segment.angle_fp !== grant.angle_fp) return "was written for an earlier version of the pitch";
|
|
185
|
+
if (!nonEmpty(row.reviewed_by) || row.reviewed_by.startsWith("agent:")) return "wasn't approved by a person";
|
|
186
|
+
if (people && !people.has(row.reviewed_by)) return "wasn't approved by a person";
|
|
187
|
+
const reviewed = Date.parse(row.reviewed_at ?? "");
|
|
188
|
+
if (!Number.isFinite(reviewed) || reviewed > Date.parse(grant.at)) return "was approved after the segment was turned on";
|
|
189
|
+
const via = viaOf(row);
|
|
190
|
+
if (via !== "dashboard" && via !== "drafts") return via === "mcp" ? "was approved through MCP, not in Sonor" : "doesn't say where it was approved";
|
|
191
|
+
if (wasEdited(row)) return "was edited before it was approved";
|
|
192
|
+
if (readBackStatus(row) !== "verified") return "wasn't confirmed to have arrived intact";
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
function grantEvidenceRefusal(grant, refRows, scope) {
|
|
196
|
+
if (!grant) return "There's no grant.";
|
|
197
|
+
if (!isGrantableSegmentKey(scope?.key)) return "This segment can't be turned on.";
|
|
198
|
+
const rows = /* @__PURE__ */ new Map();
|
|
199
|
+
for (const row of refRows ?? []) if (row && typeof row.id === "string") rows.set(row.id, row);
|
|
200
|
+
const people = scope.people ? new Set(scope.people) : null;
|
|
201
|
+
if (grant.source === "first_email") {
|
|
202
|
+
const row = rows.get(grant.ref);
|
|
203
|
+
const refusal = evidenceRowRefusal(row, grant, scope, people);
|
|
204
|
+
if (refusal) return `The email this segment was turned on from ${refusal}.`;
|
|
205
|
+
if (row.reviewed_by !== grant.by) return "The email this segment was turned on from was approved by someone else.";
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const receipts = Array.from(new Set(grant.receipts ?? []));
|
|
209
|
+
const needed = Math.max(1, Math.ceil(Number(scope.minMeasured) || 0));
|
|
210
|
+
if (receipts.length < needed) {
|
|
211
|
+
return `Only ${receipts.length} of the ${needed} approvals this segment needs to earn its own sending are on record.`;
|
|
212
|
+
}
|
|
213
|
+
for (const id of receipts) {
|
|
214
|
+
const refusal = evidenceRowRefusal(rows.get(id), grant, scope, people);
|
|
215
|
+
if (refusal) return `One of the approvals this segment earned its sending with ${refusal}.`;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
var GRANTER_ROLES = /* @__PURE__ */ new Set(["owner", "admin"]);
|
|
220
|
+
function segmentGranterEligible(roles) {
|
|
221
|
+
if (!roles) return false;
|
|
222
|
+
if (typeof roles.contact_type === "string" && roles.contact_type.trim().toLowerCase() === "agent") return false;
|
|
223
|
+
if (roles.is_platform_admin === true) return true;
|
|
224
|
+
return [roles.org_role, roles.project_role, roles.managing_org_role].some(
|
|
225
|
+
(role) => typeof role === "string" && GRANTER_ROLES.has(role.trim().toLowerCase())
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
var PUBLIC_MAILBOX_DOMAINS = [
|
|
229
|
+
"gmail.com",
|
|
230
|
+
"googlemail.com",
|
|
231
|
+
"yahoo.com",
|
|
232
|
+
"ymail.com",
|
|
233
|
+
"rocketmail.com",
|
|
234
|
+
"hotmail.com",
|
|
235
|
+
"outlook.com",
|
|
236
|
+
"live.com",
|
|
237
|
+
"msn.com",
|
|
238
|
+
"aol.com",
|
|
239
|
+
"aim.com",
|
|
240
|
+
"icloud.com",
|
|
241
|
+
"me.com",
|
|
242
|
+
"mac.com",
|
|
243
|
+
"protonmail.com",
|
|
244
|
+
"proton.me",
|
|
245
|
+
"pm.me",
|
|
246
|
+
"gmx.com",
|
|
247
|
+
"gmx.net",
|
|
248
|
+
"mail.com",
|
|
249
|
+
"zoho.com",
|
|
250
|
+
"zohomail.com",
|
|
251
|
+
"yandex.com",
|
|
252
|
+
"fastmail.com",
|
|
253
|
+
"hey.com",
|
|
254
|
+
"tutanota.com",
|
|
255
|
+
"tuta.io",
|
|
256
|
+
"att.net",
|
|
257
|
+
"sbcglobal.net",
|
|
258
|
+
"bellsouth.net",
|
|
259
|
+
"comcast.net",
|
|
260
|
+
"verizon.net",
|
|
261
|
+
"cox.net",
|
|
262
|
+
"charter.net",
|
|
263
|
+
"earthlink.net",
|
|
264
|
+
"frontier.com",
|
|
265
|
+
"windstream.net",
|
|
266
|
+
"centurylink.net",
|
|
267
|
+
"optonline.net",
|
|
268
|
+
"juno.com",
|
|
269
|
+
"netzero.net",
|
|
270
|
+
"roadrunner.com",
|
|
271
|
+
"rr.com",
|
|
272
|
+
"qq.com",
|
|
273
|
+
"163.com",
|
|
274
|
+
"126.com",
|
|
275
|
+
"mail.ru",
|
|
276
|
+
"web.de",
|
|
277
|
+
"t-online.de",
|
|
278
|
+
"orange.fr",
|
|
279
|
+
"free.fr",
|
|
280
|
+
"btinternet.com",
|
|
281
|
+
"sky.com",
|
|
282
|
+
"rogers.com",
|
|
283
|
+
"shaw.ca",
|
|
284
|
+
"sympatico.ca",
|
|
285
|
+
"bigpond.com"
|
|
286
|
+
];
|
|
287
|
+
var PUBLIC_DOMAIN_SET = new Set(PUBLIC_MAILBOX_DOMAINS);
|
|
288
|
+
var PUBLIC_DOMAIN_FAMILY = /^(?:yahoo|ymail|hotmail|live|outlook|msn|aol|gmx|yandex)\.(?:[a-z]{2,3}|co\.[a-z]{2}|com\.[a-z]{2})$/;
|
|
289
|
+
var PUBLIC_DOMAIN_SUFFIX = [".rr.com"];
|
|
290
|
+
function isPublicMailboxDomain(value) {
|
|
291
|
+
if (typeof value !== "string") return false;
|
|
292
|
+
let domain = value.trim().toLowerCase().replace(/^mailto:/, "");
|
|
293
|
+
const at = domain.lastIndexOf("@");
|
|
294
|
+
if (at >= 0) domain = domain.slice(at + 1);
|
|
295
|
+
domain = domain.replace(/\.+$/, "").replace(/^www\./, "");
|
|
296
|
+
if (!domain) return false;
|
|
297
|
+
return PUBLIC_DOMAIN_SET.has(domain) || PUBLIC_DOMAIN_FAMILY.test(domain) || PUBLIC_DOMAIN_SUFFIX.some((suffix) => domain.endsWith(suffix));
|
|
298
|
+
}
|
|
299
|
+
function dripRowDisposition(row) {
|
|
300
|
+
const payload = isRecord(row?.payload) ? row.payload : {};
|
|
301
|
+
const execution = isRecord(payload._execution) ? payload._execution : {};
|
|
302
|
+
switch (row?.status) {
|
|
303
|
+
case "proposed":
|
|
304
|
+
case "approved":
|
|
305
|
+
return "live";
|
|
306
|
+
case "applied":
|
|
307
|
+
case "auto_applied":
|
|
308
|
+
case "executed":
|
|
309
|
+
case "reverted":
|
|
310
|
+
return "sent";
|
|
311
|
+
case "failed":
|
|
312
|
+
return execution.sent === true ? "sent" : "refile";
|
|
313
|
+
case "rejected": {
|
|
314
|
+
const decision = isRecord(row?.autonomy_decision) ? row.autonomy_decision : {};
|
|
315
|
+
if (decision.allowed === "off" && !row?.reviewed_by) return "refile";
|
|
316
|
+
const reason = row?.review_reason;
|
|
317
|
+
if (reason === "wrong_recipient") return "retire";
|
|
318
|
+
if (reason === "wrong_timing" || reason === "withdrawn") return "refile";
|
|
319
|
+
return "declined";
|
|
320
|
+
}
|
|
321
|
+
case "expired":
|
|
322
|
+
case "superseded":
|
|
323
|
+
case "discarded":
|
|
324
|
+
return "refile";
|
|
325
|
+
default:
|
|
326
|
+
return "live";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
exports.GRADUATION_BAR = GRADUATION_BAR;
|
|
331
|
+
exports.NARRATIVE_PITCH_FIELDS = NARRATIVE_PITCH_FIELDS;
|
|
332
|
+
exports.OUTREACH_CONTRACT_VERSION = OUTREACH_CONTRACT_VERSION;
|
|
333
|
+
exports.OUTREACH_EMAIL_KIND = OUTREACH_EMAIL_KIND;
|
|
334
|
+
exports.OUTREACH_WRITER_BADGE = OUTREACH_WRITER_BADGE;
|
|
335
|
+
exports.PUBLIC_MAILBOX_DOMAINS = PUBLIC_MAILBOX_DOMAINS;
|
|
336
|
+
exports.SEGMENT_STATES = SEGMENT_STATES;
|
|
337
|
+
exports.UNSPECIFIED_INDUSTRY = UNSPECIFIED_INDUSTRY;
|
|
338
|
+
exports.activeSegmentGrant = activeSegmentGrant;
|
|
339
|
+
exports.dripRowDisposition = dripRowDisposition;
|
|
340
|
+
exports.grantEvidenceRefusal = grantEvidenceRefusal;
|
|
341
|
+
exports.isGrantableSegmentKey = isGrantableSegmentKey;
|
|
342
|
+
exports.isPublicMailboxDomain = isPublicMailboxDomain;
|
|
343
|
+
exports.narrativeFingerprint = narrativeFingerprint;
|
|
344
|
+
exports.normalizeIndustry = normalizeIndustry;
|
|
345
|
+
exports.outreachSegment = outreachSegment;
|
|
346
|
+
exports.outreachSegmentLabel = outreachSegmentLabel;
|
|
347
|
+
exports.parseOutreachSegmentKey = parseOutreachSegmentKey;
|
|
348
|
+
exports.prospectIndustry = prospectIndustry;
|
|
349
|
+
exports.readSegmentGrants = readSegmentGrants;
|
|
350
|
+
exports.segmentGrantCovers = segmentGrantCovers;
|
|
351
|
+
exports.segmentGranterEligible = segmentGranterEligible;
|