@sellable/mcp 0.1.273 → 0.1.275

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/README.md CHANGED
@@ -16,9 +16,10 @@ Each message gets 5+ minutes of Claude attention with deep research - no other t
16
16
 
17
17
  ### Prompt Source Of Truth
18
18
 
19
- There are four public Sellable entrypoints shared across hosts:
19
+ There are five public Sellable entrypoints shared across hosts:
20
20
 
21
21
  - `sellable:create-campaign`
22
+ - `sellable:create-ab-test`
22
23
  - `sellable:foundation`
23
24
  - `sellable:content`
24
25
  - `sellable:create-post`
@@ -29,6 +30,11 @@ the approval-gated workflow from:
29
30
 
30
31
  - `mcp/sellable/skills/create-campaign-v2/SKILL.md`
31
32
 
33
+ The create-ab-test public wrapper prepares clean A/B split lead lists and
34
+ review-copy campaigns from:
35
+
36
+ - `mcp/sellable/skills/create-ab-test/SKILL.md`
37
+
32
38
  The foundation public wrapper loads the core identity/company memory workflow
33
39
  from:
34
40
 
@@ -148,8 +154,9 @@ The installer does the full local setup:
148
154
  `mcp__sellable__*` tools into skill sessions
149
155
 
150
156
  After the installer passes, fully quit and reopen Codex Desktop. Start a new
151
- thread and select `Sellable Create Campaign`, `Sellable Foundation`,
152
- `Sellable Content`, or `Sellable Create Post`; or invoke `$sellable:create-campaign`,
157
+ thread and select `Sellable Create Campaign`, `Sellable Create A/B Test`,
158
+ `Sellable Foundation`, `Sellable Content`, or `Sellable Create Post`; or invoke
159
+ `$sellable:create-campaign`, `$sellable:create-ab-test`,
153
160
  `$sellable:foundation`, `$sellable:content`, or `$sellable:create-post`. If the app still says
154
161
  `mcp__sellable__*` tools are missing after the installer passes, check that
155
162
  `~/.codex/config.toml` contains both `[marketplaces.sellable]` and
@@ -162,19 +169,23 @@ the Sellable MCP tools for Codex Desktop.
162
169
  Use these names consistently:
163
170
 
164
171
  - Claude Code command: `/sellable:create-campaign`
172
+ - Claude Code command: `/sellable:create-ab-test`
165
173
  - Claude Code command: `/sellable:foundation`
166
174
  - Claude Code command: `/sellable:content`
167
175
  - Claude Code command: `/sellable:create-post`
168
176
  - Codex command: `$sellable:create-campaign`
177
+ - Codex command: `$sellable:create-ab-test`
169
178
  - Codex command: `$sellable:foundation`
170
179
  - Codex command: `$sellable:content`
171
180
  - Codex command: `$sellable:create-post`
172
181
  - Codex Desktop plugin: `sellable@sellable`
173
182
  - Codex visible skill: `Sellable Create Campaign`
183
+ - Codex visible skill: `Sellable Create A/B Test`
174
184
  - Codex visible skill: `Sellable Foundation`
175
185
  - Codex visible skill: `Sellable Content`
176
186
  - Codex visible skill: `Sellable Create Post`
177
187
  - Codex skill frontmatter name: `create-campaign`
188
+ - Codex skill frontmatter name: `create-ab-test`
178
189
  - Codex skill frontmatter name: `foundation`
179
190
  - Codex skill frontmatter name: `content`
180
191
  - Codex skill frontmatter name: `create-post`
package/dist/index-dev.js CHANGED
File without changes
package/dist/index.js CHANGED
File without changes
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { handleAddColumn, handleCommitBlueprint, } from "./tools/blueprint-commi
7
7
  import { bootstrapCreateCampaign } from "./tools/bootstrap.js";
8
8
  import { getCampaignTableSchema, queueCampaignCells, reviseMessageTemplateAndRerun, selectCampaignCells, waitForCampaignProcessing, } from "./tools/campaign-processing.js";
9
9
  import { createCampaign, duplicateCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
10
+ import { prepareCampaignAbTest } from "./tools/campaign-ab-test.js";
10
11
  import { queueCells, updateCell } from "./tools/cells.js";
11
12
  import { handleStartCliLogin, handleWaitForCliLogin, } from "./tools/cli-login.js";
12
13
  import { calculateLinkedInHookPreviewTool, capturePostIdeaTool, getPostDraftTool, getPostIdeaTool, getPublishedPostTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, renderLinkedInPostPreviewTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
@@ -18,6 +19,7 @@ import { copySenderConfigTool, getEngageMemoryTool, migrateFlatConfigsTool, reco
18
19
  import { getEngageStateTool, setEngageStateTool, } from "./tools/engage-state.js";
19
20
  import { bulkEnrichWithProspeo, enrichWithProspeo, getProspeoCredits, } from "./tools/enrichment.js";
20
21
  import { getCampaignFramework } from "./tools/framework.js";
22
+ import { confirmHarvestJobCompanies, searchHarvestJobs, } from "./tools/harvest-jobs.js";
21
23
  import { cancelLeadImport, confirmLeadList, confirmProspeoCompanyAccounts, getProviderPrompt, importLeads, listDncEntriesTool, loadCsvDncEntriesTool, loadCsvDomains, loadCsvLinkedinLeads, lookupSalesNavFilter, saveDomainFilters, searchApollo, searchProspeo, searchProspeoCompanies, searchSalesNav, searchSignals, selectPromisingPosts, setHeadlineICPCriteria, } from "./tools/leads.js";
22
24
  import { fetchCompany, fetchCompanyPosts, fetchLinkedInPosts, fetchLinkedInProfile, fetchPostEngagers, getLinkedInProfile, getUserPosts, } from "./tools/linkedin.js";
23
25
  import { getCampaignNavigationState } from "./tools/navigation.js";
@@ -214,6 +216,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
214
216
  markCampaignContextDirty(result.campaignOfferId, "duplicate_campaign");
215
217
  }
216
218
  break;
219
+ case "prepare_campaign_ab_test":
220
+ result = await prepareCampaignAbTest(args);
221
+ if (result?.variants?.A?.campaignId) {
222
+ markCampaignContextDirty(result.variants.A.campaignId, "prepare_campaign_ab_test");
223
+ }
224
+ if (result?.variants?.B?.campaignId) {
225
+ markCampaignContextDirty(result.variants.B.campaignId, "prepare_campaign_ab_test");
226
+ }
227
+ break;
217
228
  case "update_campaign_brief":
218
229
  result = await updateCampaignBrief(args?.campaignId, args?.campaignBrief);
219
230
  if (args?.campaignId) {
@@ -372,6 +383,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
372
383
  case "confirm_prospeo_company_accounts":
373
384
  result = await confirmProspeoCompanyAccounts(args);
374
385
  break;
386
+ case "search_harvest_jobs":
387
+ result = await searchHarvestJobs(args);
388
+ break;
389
+ case "confirm_harvest_job_companies":
390
+ result = await confirmHarvestJobCompanies(args);
391
+ break;
375
392
  case "load_csv_domains":
376
393
  result = await loadCsvDomains(args);
377
394
  break;
@@ -0,0 +1,72 @@
1
+ export interface PrepareCampaignAbTestInput {
2
+ sourceCampaignId: string;
3
+ variantName: string;
4
+ variantBriefDelta: string;
5
+ variantALabel?: string;
6
+ variantABriefDelta?: string;
7
+ splitStrategy?: "alternating_stable_sort";
8
+ targetCounts?: {
9
+ a?: number;
10
+ b?: number;
11
+ };
12
+ dryRun?: boolean;
13
+ idempotencyKey?: string;
14
+ }
15
+ export declare const campaignAbTestToolDefinitions: {
16
+ name: string;
17
+ description: string;
18
+ inputSchema: {
19
+ type: string;
20
+ properties: {
21
+ sourceCampaignId: {
22
+ type: string;
23
+ description: string;
24
+ };
25
+ variantName: {
26
+ type: string;
27
+ description: string;
28
+ };
29
+ variantBriefDelta: {
30
+ type: string;
31
+ description: string;
32
+ };
33
+ variantALabel: {
34
+ type: string;
35
+ description: string;
36
+ };
37
+ variantABriefDelta: {
38
+ type: string;
39
+ description: string;
40
+ };
41
+ splitStrategy: {
42
+ type: string;
43
+ enum: string[];
44
+ description: string;
45
+ };
46
+ targetCounts: {
47
+ type: string;
48
+ properties: {
49
+ a: {
50
+ type: string;
51
+ };
52
+ b: {
53
+ type: string;
54
+ };
55
+ };
56
+ additionalProperties: boolean;
57
+ description: string;
58
+ };
59
+ dryRun: {
60
+ type: string;
61
+ description: string;
62
+ };
63
+ idempotencyKey: {
64
+ type: string;
65
+ description: string;
66
+ };
67
+ };
68
+ required: string[];
69
+ additionalProperties: boolean;
70
+ };
71
+ }[];
72
+ export declare function prepareCampaignAbTest(input: PrepareCampaignAbTestInput): Promise<unknown>;
@@ -0,0 +1,60 @@
1
+ import { getApi } from "../api.js";
2
+ export const campaignAbTestToolDefinitions = [
3
+ {
4
+ name: "prepare_campaign_ab_test",
5
+ description: "Prepare a campaign A/B test from an existing campaign using its clean source lead list. Creates deterministic A/B split lead lists and review-copy campaigns, updates the variant brief, and never launches either campaign.",
6
+ inputSchema: {
7
+ type: "object",
8
+ properties: {
9
+ sourceCampaignId: {
10
+ type: "string",
11
+ description: "Existing source campaign ID.",
12
+ },
13
+ variantName: {
14
+ type: "string",
15
+ description: "Short label for variant B, e.g. New CTA.",
16
+ },
17
+ variantBriefDelta: {
18
+ type: "string",
19
+ description: "The specific campaign brief/copy change for variant B.",
20
+ },
21
+ variantALabel: {
22
+ type: "string",
23
+ description: "Optional label for the control variant.",
24
+ },
25
+ variantABriefDelta: {
26
+ type: "string",
27
+ description: "Optional explicit brief change for variant A. Omit for unchanged control.",
28
+ },
29
+ splitStrategy: {
30
+ type: "string",
31
+ enum: ["alternating_stable_sort"],
32
+ description: "Deterministic split strategy. Defaults to alternating_stable_sort.",
33
+ },
34
+ targetCounts: {
35
+ type: "object",
36
+ properties: {
37
+ a: { type: "number" },
38
+ b: { type: "number" },
39
+ },
40
+ additionalProperties: false,
41
+ description: "Optional exact split counts. Omit for an even alternating split.",
42
+ },
43
+ dryRun: {
44
+ type: "boolean",
45
+ description: "When true, returns split plan and review names without creating campaigns/lists.",
46
+ },
47
+ idempotencyKey: {
48
+ type: "string",
49
+ description: "Optional stable key for retrying the same A/B preparation safely.",
50
+ },
51
+ },
52
+ required: ["sourceCampaignId", "variantName", "variantBriefDelta"],
53
+ additionalProperties: false,
54
+ },
55
+ },
56
+ ];
57
+ export async function prepareCampaignAbTest(input) {
58
+ const api = getApi();
59
+ return api.post("/api/v3/mcp/campaign-ab-test", input);
60
+ }
@@ -9,6 +9,18 @@ export type CsvLinkedinLimits = {
9
9
  maxRows: number;
10
10
  maxCarryColumns: number;
11
11
  };
12
+ export type CsvLinkedinIgnoredReservedColumn = {
13
+ originalHeader: string;
14
+ canonicalName: string;
15
+ reason: string;
16
+ };
17
+ export type CsvLinkedinContentFingerprint = {
18
+ fileSha256: string;
19
+ sanitizedHeaderSha256: string;
20
+ selectedColumnsSha256: string;
21
+ ignoredReservedColumnsSha256: string;
22
+ rowCount: number;
23
+ };
12
24
  export type CsvLinkedinPreview = {
13
25
  resolvedFilePath: string;
14
26
  suggestedLeadListName: string;
@@ -26,6 +38,8 @@ export type CsvLinkedinPreview = {
26
38
  duplicateLinkedInCount: number;
27
39
  emptyLinkedInRowCount: number;
28
40
  duplicatePolicy: "first_wins";
41
+ ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
42
+ reservedColumnWarning: string | null;
29
43
  limits: CsvLinkedinLimits;
30
44
  warnings: string[];
31
45
  blockingErrors: string[];
@@ -38,6 +52,8 @@ export type CsvLinkedinConfirmationPayload = {
38
52
  fileMtimeMs: number;
39
53
  linkedInColumn: string;
40
54
  selectedColumns: string[];
55
+ ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
56
+ contentFingerprint: CsvLinkedinContentFingerprint;
41
57
  };
42
58
  export type PreparedLinkedinLeadRow = {
43
59
  row: Record<string, string>;
@@ -76,6 +92,9 @@ type ParsedCsvLinkedinFile = {
76
92
  headers: string[];
77
93
  rows: Array<Record<string, string>>;
78
94
  totalRows: number;
95
+ ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
96
+ reservedColumnWarning: string | null;
97
+ fileSha256: string;
79
98
  blockingErrors: string[];
80
99
  warnings: string[];
81
100
  };
@@ -86,6 +105,10 @@ export type LinkedInValidationResult = {
86
105
  valid: false;
87
106
  reason: string;
88
107
  };
108
+ export declare function getReservedWorkflowColumnMatch(header: string): {
109
+ canonicalName: string;
110
+ reason: string;
111
+ } | null;
89
112
  export declare function validateAndNormalizeLinkedInUrl(value: string): LinkedInValidationResult;
90
113
  export declare function makeLinkedinConfirmationToken(payload: CsvLinkedinConfirmationPayload): string;
91
114
  export declare function parseLinkedinConfirmationToken(token: string): CsvLinkedinConfirmationPayload;
@@ -1,5 +1,5 @@
1
1
  import { parse } from "csv-parse/sync";
2
- import { createHmac, randomBytes } from "node:crypto";
2
+ import { createHash, createHmac, randomBytes } from "node:crypto";
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
  import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
5
5
  import { resolveWorkspaceRoot } from "../utils/workspace-root.js";
@@ -49,6 +49,86 @@ const CSV_LINKEDIN_LIMITS = {
49
49
  maxRows: MAX_CSV_LINKEDIN_UPLOAD_ROWS,
50
50
  maxCarryColumns: MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS,
51
51
  };
52
+ const RESERVED_WORKFLOW_COLUMNS = new Map([
53
+ "DNC Check",
54
+ "Enable ICP Filtering",
55
+ "ICP Score",
56
+ "ICP Alignment Notes",
57
+ "Passes Rubric",
58
+ "Generate Message",
59
+ "Message",
60
+ "Subject",
61
+ "Approved",
62
+ "Enable Scheduling",
63
+ "Scheduled",
64
+ "Scheduled At",
65
+ "Scheduled For",
66
+ "Sent",
67
+ "Sent At",
68
+ "Send Status",
69
+ "Delivery Status",
70
+ "Reply Status",
71
+ "Connection Status",
72
+ "Invite Status",
73
+ "InMail Status",
74
+ "DM Status",
75
+ "Status",
76
+ "Result",
77
+ "Error",
78
+ "Last Error",
79
+ "Generated Message",
80
+ "Message Draft",
81
+ "Message Status",
82
+ "Approval Status",
83
+ "Queued At",
84
+ "Processing Status",
85
+ "Workflow Status",
86
+ "Cell Status",
87
+ ].map((name) => [normalizeReservedWorkflowHeaderKey(name), name]));
88
+ function normalizeReservedWorkflowHeaderKey(value) {
89
+ return value
90
+ .trim()
91
+ .replace(/\s*(?:\(\d+\)|[._-]\d+|\s+\d+)$/u, "")
92
+ .toLowerCase()
93
+ .replace(/[\s_.-]+/g, " ")
94
+ .replace(/[^a-z0-9 ]+/g, "")
95
+ .trim();
96
+ }
97
+ export function getReservedWorkflowColumnMatch(header) {
98
+ const key = normalizeReservedWorkflowHeaderKey(header);
99
+ if (!key) {
100
+ return null;
101
+ }
102
+ const canonicalName = RESERVED_WORKFLOW_COLUMNS.get(key);
103
+ if (!canonicalName) {
104
+ return null;
105
+ }
106
+ return {
107
+ canonicalName,
108
+ reason: "Sellable workflow/output columns are recreated by each campaign table and cannot be imported as lead source fields.",
109
+ };
110
+ }
111
+ function buildReservedColumnWarning(ignored) {
112
+ if (ignored.length === 0) {
113
+ return null;
114
+ }
115
+ const names = Array.from(new Set(ignored.map((column) => column.originalHeader)));
116
+ return `Detected campaign workflow columns and stripped them because Sellable recreates those columns in each campaign table: ${names.join(", ")}.`;
117
+ }
118
+ function hashJson(value) {
119
+ return createHash("sha256")
120
+ .update(JSON.stringify(value))
121
+ .digest("hex");
122
+ }
123
+ function buildContentFingerprint(params) {
124
+ return {
125
+ fileSha256: params.fileSha256,
126
+ sanitizedHeaderSha256: hashJson(params.headers),
127
+ selectedColumnsSha256: hashJson(params.selectedColumns),
128
+ ignoredReservedColumnsSha256: hashJson(params.ignoredReservedColumns),
129
+ rowCount: params.rowCount,
130
+ };
131
+ }
52
132
  function normalizeHeaderKey(value) {
53
133
  return value
54
134
  .trim()
@@ -61,6 +141,17 @@ function compareStringArrays(left, right) {
61
141
  }
62
142
  return left.every((value, index) => value === right[index]);
63
143
  }
144
+ function compareIgnoredReservedColumns(left, right) {
145
+ if (left.length !== right.length) {
146
+ return false;
147
+ }
148
+ return left.every((column, index) => {
149
+ const other = right[index];
150
+ return (column.originalHeader === other.originalHeader &&
151
+ column.canonicalName === other.canonicalName &&
152
+ column.reason === other.reason);
153
+ });
154
+ }
64
155
  function getLimits() {
65
156
  return { ...CSV_LINKEDIN_LIMITS };
66
157
  }
@@ -100,6 +191,30 @@ function buildRowObjects(headers, dataRows) {
100
191
  return out;
101
192
  });
102
193
  }
194
+ function stripReservedWorkflowColumns(headers, dataRows) {
195
+ const keepIndexes = [];
196
+ const ignoredReservedColumns = [];
197
+ headers.forEach((header, index) => {
198
+ const match = getReservedWorkflowColumnMatch(header);
199
+ if (!match) {
200
+ keepIndexes.push(index);
201
+ return;
202
+ }
203
+ ignoredReservedColumns.push({
204
+ originalHeader: header,
205
+ canonicalName: match.canonicalName,
206
+ reason: match.reason,
207
+ });
208
+ });
209
+ if (ignoredReservedColumns.length === 0) {
210
+ return { headers, dataRows, ignoredReservedColumns };
211
+ }
212
+ return {
213
+ headers: keepIndexes.map((index) => headers[index]),
214
+ dataRows: dataRows.map((row) => keepIndexes.map((index) => String(row[index] ?? ""))),
215
+ ignoredReservedColumns,
216
+ };
217
+ }
103
218
  function resolveHeaderSelection(headers, requested, kind) {
104
219
  const trimmed = requested.trim();
105
220
  if (!trimmed) {
@@ -516,7 +631,17 @@ export function matchesLinkedinConfirmationToken(token, payload) {
516
631
  parsed.fileSizeBytes === payload.fileSizeBytes &&
517
632
  parsed.fileMtimeMs === payload.fileMtimeMs &&
518
633
  parsed.linkedInColumn === payload.linkedInColumn &&
519
- compareStringArrays(parsed.selectedColumns, payload.selectedColumns));
634
+ compareStringArrays(parsed.selectedColumns, payload.selectedColumns) &&
635
+ compareIgnoredReservedColumns(parsed.ignoredReservedColumns, payload.ignoredReservedColumns) &&
636
+ parsed.contentFingerprint.fileSha256 ===
637
+ payload.contentFingerprint.fileSha256 &&
638
+ parsed.contentFingerprint.sanitizedHeaderSha256 ===
639
+ payload.contentFingerprint.sanitizedHeaderSha256 &&
640
+ parsed.contentFingerprint.selectedColumnsSha256 ===
641
+ payload.contentFingerprint.selectedColumnsSha256 &&
642
+ parsed.contentFingerprint.ignoredReservedColumnsSha256 ===
643
+ payload.contentFingerprint.ignoredReservedColumnsSha256 &&
644
+ parsed.contentFingerprint.rowCount === payload.contentFingerprint.rowCount);
520
645
  }
521
646
  export function parseCsvLinkedinFile(input) {
522
647
  const resolvedFilePath = resolveCsvLinkedinPath(input.filePath, input.workspaceRoot);
@@ -536,8 +661,9 @@ export function parseCsvLinkedinFile(input) {
536
661
  blockingErrors.push(`CSV file exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_BYTES} byte limit.`);
537
662
  }
538
663
  let rawRows;
664
+ let content;
539
665
  try {
540
- const content = readFileSync(resolvedFilePath, "utf8");
666
+ content = readFileSync(resolvedFilePath, "utf8");
541
667
  rawRows = parse(content, {
542
668
  bom: true,
543
669
  columns: false,
@@ -559,6 +685,9 @@ export function parseCsvLinkedinFile(input) {
559
685
  headers: [],
560
686
  rows: [],
561
687
  totalRows: 0,
688
+ ignoredReservedColumns: [],
689
+ reservedColumnWarning: null,
690
+ fileSha256: "",
562
691
  blockingErrors,
563
692
  warnings,
564
693
  };
@@ -567,6 +696,14 @@ export function parseCsvLinkedinFile(input) {
567
696
  const dataRows = rawRows
568
697
  .slice(1)
569
698
  .map((row) => row.map((value) => String(value ?? "")));
699
+ const fileSha256 = createHash("sha256").update(content).digest("hex");
700
+ const stripped = stripReservedWorkflowColumns(headers, dataRows);
701
+ const sanitizedHeaders = stripped.headers;
702
+ const sanitizedDataRows = stripped.dataRows;
703
+ const reservedColumnWarning = buildReservedColumnWarning(stripped.ignoredReservedColumns);
704
+ if (reservedColumnWarning) {
705
+ warnings.push(reservedColumnWarning);
706
+ }
570
707
  if (headers.length === 0) {
571
708
  blockingErrors.push("CSV file must include at least one header column.");
572
709
  }
@@ -574,7 +711,10 @@ export function parseCsvLinkedinFile(input) {
574
711
  if (blankHeaderCount > 0) {
575
712
  blockingErrors.push("CSV header row contains blank column names.");
576
713
  }
577
- const duplicateHeaders = findDuplicateHeaders(headers);
714
+ if (sanitizedHeaders.length === 0 && headers.length > 0) {
715
+ blockingErrors.push("CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; all usable columns were Sellable workflow/output columns.");
716
+ }
717
+ const duplicateHeaders = findDuplicateHeaders(sanitizedHeaders);
578
718
  if (duplicateHeaders.length > 0) {
579
719
  blockingErrors.push(`CSV header row contains duplicate columns: ${duplicateHeaders.join(", ")}`);
580
720
  }
@@ -585,7 +725,9 @@ export function parseCsvLinkedinFile(input) {
585
725
  if (dataRows.length > MAX_CSV_LINKEDIN_UPLOAD_ROWS) {
586
726
  blockingErrors.push(`CSV contains ${dataRows.length} data rows, which exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_ROWS} row limit.`);
587
727
  }
588
- const rows = blockingErrors.length > 0 ? [] : buildRowObjects(headers, dataRows);
728
+ const rows = blockingErrors.length > 0
729
+ ? []
730
+ : buildRowObjects(sanitizedHeaders, sanitizedDataRows);
589
731
  if (rows.length === 0 && dataRows.length === 0) {
590
732
  warnings.push("CSV contains headers but no data rows.");
591
733
  }
@@ -594,9 +736,12 @@ export function parseCsvLinkedinFile(input) {
594
736
  suggestedLeadListName: buildLeadListNameSuggestion(resolvedFilePath),
595
737
  fileSizeBytes: stats.size,
596
738
  fileMtimeMs: stats.mtimeMs,
597
- headers,
739
+ headers: sanitizedHeaders,
598
740
  rows,
599
741
  totalRows: dataRows.length,
742
+ ignoredReservedColumns: stripped.ignoredReservedColumns,
743
+ reservedColumnWarning,
744
+ fileSha256,
600
745
  blockingErrors,
601
746
  warnings,
602
747
  };
@@ -752,8 +897,13 @@ export function buildCsvLinkedinPreview(input) {
752
897
  duplicateCount: 0,
753
898
  emptyCount: 0,
754
899
  };
900
+ if (!resolvedLinkedInColumn && parsed.ignoredReservedColumns.length > 0) {
901
+ blockingErrors.push("CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; after stripping Sellable workflow/output columns, no LinkedIn profile URL column was available.");
902
+ }
755
903
  if (resolvedLinkedInColumn && prepared.rows.length === 0) {
756
- blockingErrors.push("CSV did not produce any valid LinkedIn profile URLs after validation.");
904
+ blockingErrors.push(parsed.ignoredReservedColumns.length > 0
905
+ ? "CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; no valid LinkedIn profile URLs remained after stripping Sellable workflow/output columns."
906
+ : "CSV did not produce any valid LinkedIn profile URLs after validation.");
757
907
  }
758
908
  if (prepared.invalidCount > 0) {
759
909
  warnings.push(`Skipped ${prepared.invalidCount} invalid LinkedIn URL row(s).`);
@@ -772,6 +922,14 @@ export function buildCsvLinkedinPreview(input) {
772
922
  fileMtimeMs: parsed.fileMtimeMs,
773
923
  linkedInColumn: resolvedLinkedInColumn,
774
924
  selectedColumns,
925
+ ignoredReservedColumns: parsed.ignoredReservedColumns,
926
+ contentFingerprint: buildContentFingerprint({
927
+ fileSha256: parsed.fileSha256,
928
+ headers,
929
+ selectedColumns,
930
+ ignoredReservedColumns: parsed.ignoredReservedColumns,
931
+ rowCount: parsed.totalRows,
932
+ }),
775
933
  }
776
934
  : null;
777
935
  return {
@@ -792,6 +950,8 @@ export function buildCsvLinkedinPreview(input) {
792
950
  duplicateLinkedInCount: prepared.duplicateCount,
793
951
  emptyLinkedInRowCount: prepared.emptyCount,
794
952
  duplicatePolicy: "first_wins",
953
+ ignoredReservedColumns: parsed.ignoredReservedColumns,
954
+ reservedColumnWarning: parsed.reservedColumnWarning,
795
955
  limits: getLimits(),
796
956
  warnings,
797
957
  blockingErrors,