@dench.com/cli 3.5.6-staging.344 → 3.6.0-staging.356

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.
@@ -0,0 +1,883 @@
1
+ export type CsvRecipient = {
2
+ email?: string;
3
+ linkedinProfileUrl?: string;
4
+ displayName?: string;
5
+ personalization?: Record<string, string>;
6
+ };
7
+
8
+ export type RecipientTable = {
9
+ header: string[];
10
+ rows: string[][];
11
+ hasHeader: boolean;
12
+ columnLabels: string[];
13
+ columnSamples: string[];
14
+ };
15
+
16
+ export type RecipientField =
17
+ | "firstName"
18
+ | "lastName"
19
+ | "fullName"
20
+ | "email"
21
+ | "linkedin";
22
+
23
+ export type DetectedRecipientColumns = {
24
+ firstName?: number;
25
+ lastName?: number;
26
+ email: number[];
27
+ linkedin: number[];
28
+ };
29
+
30
+ export type RecipientColumnMapping = Partial<
31
+ Record<RecipientField, number | null>
32
+ >;
33
+
34
+ export type ColumnRole =
35
+ | "first name"
36
+ | "last name"
37
+ | "full name"
38
+ | "email"
39
+ | "linkedin"
40
+ | "merge value"
41
+ | "ignored";
42
+
43
+ export type CsvColumn = {
44
+ index: number;
45
+ label: string;
46
+ sample: string;
47
+ role: ColumnRole;
48
+ mergeKey?: string;
49
+ };
50
+
51
+ export type CsvRecipientsResult = {
52
+ recipients: CsvRecipient[];
53
+ truncated: boolean;
54
+ rows: number;
55
+ noChannel: number;
56
+ duplicates: number;
57
+ columns: CsvColumn[];
58
+ hasHeader: boolean;
59
+ error?: string;
60
+ };
61
+
62
+ export const MAX_CSV_RECIPIENTS = 500;
63
+
64
+ export const MAX_CSV_CHARS = 5_000_000;
65
+
66
+ const EMAIL_RE = /[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
67
+ const NAMED_EMAIL_RE = /^(.*?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/;
68
+ const LINKEDIN_URL_RE =
69
+ /(?<![A-Za-z0-9.-])(?:https?:\/\/)?(?:[a-z]{1,4}\.)?linkedin\.com\/in\/[A-Za-z0-9\-_.%]+\/?/i;
70
+
71
+ const FILE_EXTENSION_TLDS = new Set([
72
+ "png",
73
+ "jpg",
74
+ "jpeg",
75
+ "gif",
76
+ "svg",
77
+ "webp",
78
+ "bmp",
79
+ "ico",
80
+ "pdf",
81
+ "csv",
82
+ "xls",
83
+ "xlsx",
84
+ "doc",
85
+ "docx",
86
+ "ppt",
87
+ "pptx",
88
+ "txt",
89
+ "zip",
90
+ "gz",
91
+ "tar",
92
+ "mp3",
93
+ "mp4",
94
+ "mov",
95
+ "json",
96
+ "xml",
97
+ "html",
98
+ "htm",
99
+ "js",
100
+ "jsx",
101
+ "ts",
102
+ "tsx",
103
+ "css",
104
+ "md",
105
+ "exe",
106
+ "dmg",
107
+ "pkg",
108
+ ]);
109
+
110
+ const UNREACHABLE_LOCAL_PARTS = new Set([
111
+ "noreply",
112
+ "no-reply",
113
+ "no_reply",
114
+ "donotreply",
115
+ "do-not-reply",
116
+ "do_not_reply",
117
+ "mailer-daemon",
118
+ "postmaster",
119
+ ]);
120
+
121
+ const RESERVED_PROFILE_SLUGS = new Set([
122
+ "me",
123
+ "edit",
124
+ "search",
125
+ "unavailable",
126
+ "login",
127
+ "signup",
128
+ "in",
129
+ "pub",
130
+ "company",
131
+ "school",
132
+ ]);
133
+
134
+ const NON_PERSON_SLUG_TOKENS = new Set([
135
+ "agency",
136
+ "associates",
137
+ "capital",
138
+ "cfa",
139
+ "cfp",
140
+ "co",
141
+ "coach",
142
+ "coaching",
143
+ "company",
144
+ "consulting",
145
+ "corp",
146
+ "corporation",
147
+ "cpa",
148
+ "dds",
149
+ "enterprises",
150
+ "esq",
151
+ "global",
152
+ "group",
153
+ "hiring",
154
+ "holdings",
155
+ "inc",
156
+ "international",
157
+ "jd",
158
+ "labs",
159
+ "limited",
160
+ "llc",
161
+ "llp",
162
+ "ltd",
163
+ "marketing",
164
+ "mba",
165
+ "md",
166
+ "media",
167
+ "official",
168
+ "partners",
169
+ "phd",
170
+ "pmp",
171
+ "realtor",
172
+ "realty",
173
+ "recruiter",
174
+ "recruiting",
175
+ "rn",
176
+ "sales",
177
+ "services",
178
+ "software",
179
+ "solutions",
180
+ "studio",
181
+ "studios",
182
+ "team",
183
+ "tech",
184
+ "technologies",
185
+ "ventures",
186
+ ]);
187
+
188
+ const HONORIFIC_PREFIX_TOKENS = new Set([
189
+ "capt",
190
+ "col",
191
+ "dame",
192
+ "dr",
193
+ "hon",
194
+ "lt",
195
+ "miss",
196
+ "mr",
197
+ "mrs",
198
+ "ms",
199
+ "prof",
200
+ "professor",
201
+ "rev",
202
+ "sgt",
203
+ "sir",
204
+ ]);
205
+
206
+ const SLUG_DEDUPE_SUFFIX = /^(?=.*\d)[0-9a-f]+$/i;
207
+
208
+ const LOCAL_PART_RE =
209
+ /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
210
+ const DOMAIN_LABEL_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
211
+ const TLD_RE = /^[A-Za-z]{2,24}$/;
212
+
213
+ function isPlausibleEmailAddress(raw: string): boolean {
214
+ const email = raw.trim();
215
+ if (!email || email.length > 254) return false;
216
+ const at = email.lastIndexOf("@");
217
+ if (at <= 0 || at === email.length - 1) return false;
218
+ const local = email.slice(0, at);
219
+ const domain = email.slice(at + 1).replace(/\.$/, "");
220
+ if (local.length > 64 || !LOCAL_PART_RE.test(local)) return false;
221
+ const labels = domain.split(".");
222
+ if (
223
+ labels.length < 2 ||
224
+ !labels.every((label) => DOMAIN_LABEL_RE.test(label))
225
+ ) {
226
+ return false;
227
+ }
228
+ const tld = labels[labels.length - 1].toLowerCase();
229
+ if (!TLD_RE.test(tld) || FILE_EXTENSION_TLDS.has(tld)) return false;
230
+ const bareLocal = local.toLowerCase().split("+")[0];
231
+ return !UNREACHABLE_LOCAL_PARTS.has(bareLocal);
232
+ }
233
+
234
+ type NormalizedLinkedinProfile = {
235
+ profileUrl: string;
236
+ profileKey: string;
237
+ publicIdentifier: string;
238
+ };
239
+
240
+ function normalizeLinkedinProfileUrl(
241
+ raw: string,
242
+ ): NormalizedLinkedinProfile | null {
243
+ const trimmed = raw.trim();
244
+ if (!trimmed) return null;
245
+ const expanded = /^\/?in\/[^/?#\s]+(?:[/?#].*)?$/iu.test(trimmed)
246
+ ? `https://www.linkedin.com/${trimmed.replace(/^\/+/, "")}`
247
+ : trimmed;
248
+ let url: URL;
249
+ try {
250
+ url = new URL(
251
+ /^[a-z][a-z0-9+.-]*:\/\//i.test(expanded)
252
+ ? expanded
253
+ : `https://${expanded}`,
254
+ );
255
+ } catch {
256
+ return null;
257
+ }
258
+ const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
259
+ if (hostname !== "linkedin.com" && !hostname.endsWith(".linkedin.com")) {
260
+ return null;
261
+ }
262
+ const segments = url.pathname
263
+ .split("/")
264
+ .filter(Boolean)
265
+ .map((segment) => {
266
+ try {
267
+ return decodeURIComponent(segment);
268
+ } catch {
269
+ return segment;
270
+ }
271
+ });
272
+ const inIndex = segments.findIndex(
273
+ (segment, index) => segment.toLowerCase() === "in" && index <= 1,
274
+ );
275
+ if (inIndex < 0 || inIndex + 1 >= segments.length) return null;
276
+ const publicIdentifier = segments[inIndex + 1].trim();
277
+ if (
278
+ !publicIdentifier ||
279
+ publicIdentifier.length > 200 ||
280
+ /[/\\?#\s]/u.test(publicIdentifier)
281
+ ) {
282
+ return null;
283
+ }
284
+ const encoded = encodeURIComponent(publicIdentifier).replace(/%2D/gi, "-");
285
+ return {
286
+ profileUrl: `https://www.linkedin.com/in/${encoded}`,
287
+ profileKey: `linkedin:${publicIdentifier.toLocaleLowerCase("en-US")}`,
288
+ publicIdentifier,
289
+ };
290
+ }
291
+
292
+ function isLinkedinProfileUrl(raw: string): boolean {
293
+ const normalized = normalizeLinkedinProfileUrl(raw);
294
+ if (!normalized) return false;
295
+ const slug = normalized.publicIdentifier;
296
+ if (slug.length < 2 || slug.length > 100) return false;
297
+ if (RESERVED_PROFILE_SLUGS.has(slug.toLowerCase())) return false;
298
+ return /^[\p{L}\p{N}\-_%.]+$/u.test(slug) && /[\p{L}\p{N}]/u.test(slug);
299
+ }
300
+
301
+ function displayNameFromLinkedinSlug(
302
+ publicIdentifier: string,
303
+ ): string | undefined {
304
+ const tokens = publicIdentifier
305
+ .trim()
306
+ .split(/[-_.]+/)
307
+ .filter(Boolean);
308
+ while (
309
+ tokens.length > 0 &&
310
+ SLUG_DEDUPE_SUFFIX.test(tokens[tokens.length - 1])
311
+ ) {
312
+ tokens.pop();
313
+ }
314
+ while (
315
+ tokens.length > 0 &&
316
+ HONORIFIC_PREFIX_TOKENS.has(tokens[0].toLowerCase())
317
+ ) {
318
+ tokens.shift();
319
+ }
320
+ if (tokens.length < 2 || tokens.length > 4) return undefined;
321
+ if (!tokens.every((token) => /^[a-zA-Z][a-zA-Z']+$/.test(token))) {
322
+ return undefined;
323
+ }
324
+ if (tokens.some((token) => NON_PERSON_SLUG_TOKENS.has(token.toLowerCase()))) {
325
+ return undefined;
326
+ }
327
+ return tokens
328
+ .map((token) => token[0].toUpperCase() + token.slice(1).toLowerCase())
329
+ .join(" ");
330
+ }
331
+
332
+ export function extractLinkedinUrl(raw: string): string | null {
333
+ const match = raw.match(LINKEDIN_URL_RE);
334
+ if (!match?.[0]) return null;
335
+ const bare = match[0].replace(/\/$/, "");
336
+ const url = /^https?:\/\//i.test(bare) ? bare : `https://${bare}`;
337
+ return isLinkedinProfileUrl(url) ? url : null;
338
+ }
339
+
340
+ function linkedinKey(url: string): string {
341
+ return `li:${url.toLowerCase().replace(/^https?:\/\/(www\.)?/, "")}`;
342
+ }
343
+
344
+ function normalizeEmail(raw: string): string {
345
+ return raw.trim().toLowerCase();
346
+ }
347
+
348
+ export function extractEmail(raw: string): string | null {
349
+ const trimmed = raw.trim().replace(/^["']|["']$/g, "");
350
+ if (!trimmed) return null;
351
+ const named = trimmed.match(NAMED_EMAIL_RE);
352
+ const candidate = named?.[2]?.trim() ?? trimmed.match(EMAIL_RE)?.[0] ?? null;
353
+ return candidate && isPlausibleEmailAddress(candidate) ? candidate : null;
354
+ }
355
+
356
+ function extractNameFromNamedEmail(raw: string): string | undefined {
357
+ const trimmed = raw.trim().replace(/^["']|["']$/g, "");
358
+ const named = trimmed.match(NAMED_EMAIL_RE);
359
+ const name = named?.[1]?.trim().replace(/^["']|["']$/g, "");
360
+ return name || undefined;
361
+ }
362
+
363
+ function derivedNameFromLinkedinUrl(url: string): string | undefined {
364
+ const slug = url.match(/\/in\/([^/?#]+)/i)?.[1];
365
+ if (!slug) return undefined;
366
+ let decoded = slug;
367
+ try {
368
+ decoded = decodeURIComponent(slug);
369
+ } catch {
370
+ decoded = slug;
371
+ }
372
+ return displayNameFromLinkedinSlug(decoded);
373
+ }
374
+
375
+ export type CsvDelimiter = "," | ";" | "\t";
376
+
377
+ export function detectDelimiter(text: string): CsvDelimiter {
378
+ const line = text.replace(/^\uFEFF/, "").split(/\r?\n/, 1)[0] ?? "";
379
+ const counts: Record<CsvDelimiter, number> = { ",": 0, ";": 0, "\t": 0 };
380
+ let quoted = false;
381
+ for (const char of line) {
382
+ if (char === '"') quoted = !quoted;
383
+ else if (!quoted && char in counts) counts[char as CsvDelimiter] += 1;
384
+ }
385
+ const best = (["\t", ";"] as const).find(
386
+ (candidate) =>
387
+ counts[candidate] > counts[","] &&
388
+ counts[candidate] >= Math.max(counts[";"], counts["\t"]),
389
+ );
390
+ return best ?? ",";
391
+ }
392
+
393
+ export function splitCsvRows(
394
+ text: string,
395
+ delimiter: CsvDelimiter = ",",
396
+ ): string[][] {
397
+ const rows: string[][] = [];
398
+ let row: string[] = [];
399
+ let cell = "";
400
+ let inQuotes = false;
401
+ const src = text.replace(/^\uFEFF/, "");
402
+ for (let i = 0; i < src.length; i++) {
403
+ const ch = src[i];
404
+ if (inQuotes) {
405
+ if (ch === '"') {
406
+ if (src[i + 1] === '"') {
407
+ cell += '"';
408
+ i += 1;
409
+ } else {
410
+ inQuotes = false;
411
+ }
412
+ } else {
413
+ cell += ch;
414
+ }
415
+ } else if (ch === '"') {
416
+ inQuotes = true;
417
+ } else if (ch === delimiter) {
418
+ row.push(cell);
419
+ cell = "";
420
+ } else if (ch === "\n" || ch === "\r") {
421
+ if (ch === "\r" && src[i + 1] === "\n") i += 1;
422
+ row.push(cell);
423
+ cell = "";
424
+ if (row.some((value) => value.trim() !== "")) rows.push(row);
425
+ row = [];
426
+ } else {
427
+ cell += ch;
428
+ }
429
+ }
430
+ row.push(cell);
431
+ if (row.some((value) => value.trim() !== "")) rows.push(row);
432
+ return rows;
433
+ }
434
+
435
+ function cellIsExactly(cell: string, value: string): boolean {
436
+ const bare = (text: string) =>
437
+ text
438
+ .trim()
439
+ .replace(/^["']|["']$/g, "")
440
+ .replace(/^https?:\/\//i, "")
441
+ .toLowerCase();
442
+ const trimmed = cell.trim().replace(/^["']|["']$/g, "");
443
+ return bare(trimmed).startsWith(bare(value)) || NAMED_EMAIL_RE.test(trimmed);
444
+ }
445
+
446
+ type ColumnStats = {
447
+ samples: number;
448
+ emailHits: number;
449
+ linkedinHits: number;
450
+ };
451
+
452
+ function columnStats(rows: string[][]): ColumnStats[] {
453
+ const width = Math.max(...rows.map((row) => row.length), 0);
454
+ const stats: ColumnStats[] = Array.from({ length: width }, () => ({
455
+ samples: 0,
456
+ emailHits: 0,
457
+ linkedinHits: 0,
458
+ }));
459
+ for (const row of rows) {
460
+ row.forEach((cell, col) => {
461
+ const value = cell.trim();
462
+ if (!value) return;
463
+ const stat = stats[col];
464
+ stat.samples += 1;
465
+ const email = extractEmail(value);
466
+ if (email && cellIsExactly(value, email)) stat.emailHits += 1;
467
+ const linkedin = extractLinkedinUrl(value);
468
+ if (linkedin && cellIsExactly(value, linkedin)) stat.linkedinHits += 1;
469
+ });
470
+ }
471
+ return stats;
472
+ }
473
+
474
+ function isChannelColumn(
475
+ stat: ColumnStats | undefined,
476
+ hits: (stat: ColumnStats) => number,
477
+ ): boolean {
478
+ return Boolean(stat && stat.samples > 0 && hits(stat) / stat.samples >= 0.5);
479
+ }
480
+
481
+ function pickChannelCell(
482
+ row: string[],
483
+ stats: ColumnStats[],
484
+ extract: (value: string) => string | null,
485
+ hits: (stat: ColumnStats) => number,
486
+ ): { value: string; column: number } | null {
487
+ let best: {
488
+ value: string;
489
+ column: number;
490
+ exact: boolean;
491
+ share: number;
492
+ } | null = null;
493
+ row.forEach((cell, column) => {
494
+ const value = extract(cell);
495
+ if (!value) return;
496
+ const exact = cellIsExactly(cell, value);
497
+ const stat = stats[column];
498
+ const share = stat && stat.samples > 0 ? hits(stat) / stat.samples : 0;
499
+ if (
500
+ !best ||
501
+ (exact && !best.exact) ||
502
+ (exact === best.exact && share > best.share)
503
+ ) {
504
+ best = { value, column, exact, share };
505
+ }
506
+ });
507
+ return best;
508
+ }
509
+
510
+ const emailHitsOf = (stat: ColumnStats) => stat.emailHits;
511
+ const linkedinHitsOf = (stat: ColumnStats) => stat.linkedinHits;
512
+
513
+ function firstRowIsHeader(row: string[]): boolean {
514
+ return !row.some((cell) => extractEmail(cell) || extractLinkedinUrl(cell));
515
+ }
516
+
517
+ function rowPersonalization(
518
+ row: string[],
519
+ header: string[],
520
+ skip: (col: number) => boolean,
521
+ ): Record<string, string> {
522
+ const bag: Record<string, string> = {};
523
+ header.forEach((rawName, col) => {
524
+ const name = rawName.trim();
525
+ if (!name || skip(col)) return;
526
+ const value = row[col]?.trim() ?? "";
527
+ if (!value) return;
528
+ const key = `crm:${name}`;
529
+ if (!(key in bag)) bag[key] = value;
530
+ });
531
+ return bag;
532
+ }
533
+
534
+ function findNameColumns(header: string[]): {
535
+ first?: number;
536
+ last?: number;
537
+ } {
538
+ const indexOf = (pattern: RegExp) =>
539
+ header.findIndex((cell) => pattern.test(cell.trim()));
540
+ const first = indexOf(/^(first|given)([_\s-]*name)?$|^forename$|^fname$/i);
541
+ const last = indexOf(/^(last|family)([_\s-]*name)?$|^surname$|^lname$/i);
542
+ return {
543
+ first: first >= 0 ? first : undefined,
544
+ last: last >= 0 ? last : undefined,
545
+ };
546
+ }
547
+
548
+ const FULL_NAME_HEADER = /^(full[_\s-]*name|name|contact([_\s-]*name)?)$/i;
549
+
550
+ export function detectFullNameColumn(
551
+ table: RecipientTable,
552
+ ): number | undefined {
553
+ if (!table.hasHeader) return undefined;
554
+ const stats = columnStats(table.rows);
555
+ const index = table.header.findIndex(
556
+ (cell, col) =>
557
+ FULL_NAME_HEADER.test(cell.trim()) &&
558
+ !isChannelColumn(stats[col], emailHitsOf) &&
559
+ !isChannelColumn(stats[col], linkedinHitsOf),
560
+ );
561
+ return index >= 0 ? index : undefined;
562
+ }
563
+
564
+ export function withDetectedFullName(
565
+ table: RecipientTable,
566
+ mapping: RecipientColumnMapping,
567
+ ): RecipientColumnMapping {
568
+ if (mapping.fullName !== undefined) return mapping;
569
+ const names = resolveNameColumns(detectRecipientColumns(table), mapping);
570
+ if (names.first !== undefined || names.last !== undefined) return mapping;
571
+ const full = detectFullNameColumn(table);
572
+ return full === undefined ? mapping : { ...mapping, fullName: full };
573
+ }
574
+
575
+ function rowDisplayName(
576
+ row: string[],
577
+ columns: { first?: number; last?: number; full?: number },
578
+ ): string | undefined {
579
+ if (columns.full != null) return row[columns.full]?.trim() || undefined;
580
+ const first = columns.first != null ? row[columns.first]?.trim() : "";
581
+ const last =
582
+ columns.last != null && columns.last !== columns.first
583
+ ? row[columns.last]?.trim()
584
+ : "";
585
+ const combined = [first, last].filter(Boolean).join(" ");
586
+ return combined || undefined;
587
+ }
588
+
589
+ export function columnLetter(col: number): string {
590
+ let n = col;
591
+ let out = "";
592
+ do {
593
+ out = String.fromCharCode(65 + (n % 26)) + out;
594
+ n = Math.floor(n / 26) - 1;
595
+ } while (n >= 0);
596
+ return out;
597
+ }
598
+
599
+ export function parseRecipientTable(
600
+ csvText: string,
601
+ delimiter: CsvDelimiter = ",",
602
+ ): RecipientTable {
603
+ const rows = splitCsvRows(csvText, delimiter);
604
+ const first = rows[0] ?? [];
605
+ const hasHeader = rows.length > 0 && firstRowIsHeader(first);
606
+ const header = hasHeader ? first.map((cell) => cell.trim()) : [];
607
+ const dataRows = hasHeader ? rows.slice(1) : rows;
608
+ const width = Math.max(header.length, ...dataRows.map((row) => row.length));
609
+ const columnLabels = Array.from(
610
+ { length: width },
611
+ (_, col) => header[col] || columnLetter(col),
612
+ );
613
+ const columnSamples = Array.from({ length: width }, (_, col) => {
614
+ const sample =
615
+ dataRows
616
+ .map((row) => row[col]?.trim() ?? "")
617
+ .find((cell) => cell.length > 0) ?? "";
618
+ return sample.length > 40 ? `${sample.slice(0, 39)}…` : sample;
619
+ });
620
+ return { header, rows: dataRows, hasHeader, columnLabels, columnSamples };
621
+ }
622
+
623
+ export function detectRecipientColumns(
624
+ table: RecipientTable,
625
+ ): DetectedRecipientColumns {
626
+ const stats = columnStats(table.rows);
627
+ const rank = (hits: (stat: ColumnStats) => number) =>
628
+ stats
629
+ .map((stat, col) => ({ col, hits: hits(stat) }))
630
+ .filter((entry) => entry.hits > 0)
631
+ .sort((a, b) => b.hits - a.hits || a.col - b.col)
632
+ .map((entry) => entry.col);
633
+ const names = table.hasHeader ? findNameColumns(table.header) : {};
634
+ return {
635
+ firstName: names.first,
636
+ lastName: names.last,
637
+ email: rank(emailHitsOf),
638
+ linkedin: rank(linkedinHitsOf),
639
+ };
640
+ }
641
+
642
+ function resolveNameColumns(
643
+ detected: DetectedRecipientColumns,
644
+ mapping: RecipientColumnMapping,
645
+ ): { first?: number; last?: number; full?: number } {
646
+ const pick = (field: "firstName" | "lastName") =>
647
+ mapping[field] === null ? undefined : (mapping[field] ?? detected[field]);
648
+ if (
649
+ typeof mapping.fullName === "number" &&
650
+ typeof mapping.firstName !== "number" &&
651
+ typeof mapping.lastName !== "number"
652
+ )
653
+ return { full: mapping.fullName };
654
+ return { first: pick("firstName"), last: pick("lastName") };
655
+ }
656
+
657
+ export function recipientsFromTable(
658
+ table: RecipientTable,
659
+ mapping: RecipientColumnMapping = {},
660
+ ): CsvRecipientsResult {
661
+ const empty = (error: string): CsvRecipientsResult => ({
662
+ recipients: [],
663
+ truncated: false,
664
+ rows: table.rows.length,
665
+ noChannel: 0,
666
+ duplicates: 0,
667
+ columns: [],
668
+ hasHeader: table.hasHeader,
669
+ error,
670
+ });
671
+ if (table.rows.length === 0 && !table.hasHeader) {
672
+ return empty("This file is empty.");
673
+ }
674
+ const stats = columnStats(table.rows);
675
+ const detected = detectRecipientColumns(table);
676
+ const nameColumns = resolveNameColumns(detected, mapping);
677
+ const preferred = (field: "email" | "linkedin") =>
678
+ typeof mapping[field] === "number" ? (mapping[field] as number) : undefined;
679
+ const pickChannel = (
680
+ row: string[],
681
+ field: "email" | "linkedin",
682
+ extract: (value: string) => string | null,
683
+ hits: (stat: ColumnStats) => number,
684
+ ) => {
685
+ if (mapping[field] === null) return null;
686
+ const chosen = preferred(field);
687
+ if (chosen !== undefined) {
688
+ const value = extract(row[chosen] ?? "");
689
+ if (value) return { value, column: chosen };
690
+ }
691
+ return pickChannelCell(row, stats, extract, hits);
692
+ };
693
+ const consumed = (col: number) =>
694
+ isChannelColumn(stats[col], emailHitsOf) ||
695
+ isChannelColumn(stats[col], linkedinHitsOf) ||
696
+ preferred("email") === col ||
697
+ preferred("linkedin") === col ||
698
+ nameColumns.first === col ||
699
+ nameColumns.last === col;
700
+ const recipients: CsvRecipient[] = [];
701
+ const seen = new Set<string>();
702
+ let noChannel = 0;
703
+ let duplicates = 0;
704
+ for (const row of table.rows) {
705
+ const email = pickChannel(row, "email", extractEmail, emailHitsOf);
706
+ const linkedin = pickChannel(
707
+ row,
708
+ "linkedin",
709
+ extractLinkedinUrl,
710
+ linkedinHitsOf,
711
+ );
712
+ const emailRaw = email ? (row[email.column] ?? "") : "";
713
+ const address = extractEmail(emailRaw);
714
+ const linkedinUrl = linkedin?.value ?? null;
715
+ if (!address && !linkedinUrl) {
716
+ noChannel += 1;
717
+ continue;
718
+ }
719
+ const keys = [
720
+ ...(address ? [normalizeEmail(address)] : []),
721
+ ...(linkedinUrl ? [linkedinKey(linkedinUrl)] : []),
722
+ ];
723
+ if (keys.some((key) => seen.has(key))) {
724
+ duplicates += 1;
725
+ continue;
726
+ }
727
+ for (const key of keys) seen.add(key);
728
+ const personalization = table.hasHeader
729
+ ? rowPersonalization(row, table.header, consumed)
730
+ : undefined;
731
+ const name =
732
+ rowDisplayName(row, nameColumns)?.trim() ||
733
+ extractNameFromNamedEmail(emailRaw) ||
734
+ (linkedinUrl ? derivedNameFromLinkedinUrl(linkedinUrl) : undefined);
735
+ recipients.push({
736
+ ...(address ? { email: address } : {}),
737
+ ...(linkedinUrl ? { linkedinProfileUrl: linkedinUrl } : {}),
738
+ ...(name ? { displayName: name } : {}),
739
+ ...(personalization && Object.keys(personalization).length > 0
740
+ ? { personalization }
741
+ : {}),
742
+ });
743
+ }
744
+ const columns: CsvColumn[] = table.columnLabels.map((label, index) => {
745
+ const role: ColumnRole =
746
+ nameColumns.first === index
747
+ ? "first name"
748
+ : nameColumns.last === index
749
+ ? "last name"
750
+ : nameColumns.full === index
751
+ ? "full name"
752
+ : preferred("email") === index ||
753
+ isChannelColumn(stats[index], emailHitsOf)
754
+ ? "email"
755
+ : preferred("linkedin") === index ||
756
+ isChannelColumn(stats[index], linkedinHitsOf)
757
+ ? "linkedin"
758
+ : table.hasHeader && table.header[index]?.trim()
759
+ ? "merge value"
760
+ : "ignored";
761
+ return {
762
+ index,
763
+ label,
764
+ sample: table.columnSamples[index] ?? "",
765
+ role,
766
+ ...(role === "merge value" || role === "full name"
767
+ ? { mergeKey: `crm:${table.header[index].trim()}` }
768
+ : {}),
769
+ };
770
+ });
771
+ if (recipients.length === 0) {
772
+ return {
773
+ ...empty(
774
+ "No email addresses or LinkedIn profile links in this file. Each person needs at least one.",
775
+ ),
776
+ noChannel,
777
+ duplicates,
778
+ columns,
779
+ };
780
+ }
781
+ return {
782
+ recipients: recipients.slice(0, MAX_CSV_RECIPIENTS),
783
+ truncated: recipients.length > MAX_CSV_RECIPIENTS,
784
+ rows: table.rows.length,
785
+ noChannel,
786
+ duplicates,
787
+ columns,
788
+ hasHeader: table.hasHeader,
789
+ };
790
+ }
791
+
792
+ const MAP_FIELDS: Record<string, RecipientField> = {
793
+ email: "email",
794
+ emailaddress: "email",
795
+ workemail: "email",
796
+ linkedin: "linkedin",
797
+ linkedinurl: "linkedin",
798
+ linkedinprofile: "linkedin",
799
+ linkedinprofileurl: "linkedin",
800
+ profileurl: "linkedin",
801
+ first: "firstName",
802
+ firstname: "firstName",
803
+ given: "firstName",
804
+ givenname: "firstName",
805
+ last: "lastName",
806
+ lastname: "lastName",
807
+ surname: "lastName",
808
+ family: "lastName",
809
+ familyname: "lastName",
810
+ name: "fullName",
811
+ fullname: "fullName",
812
+ full: "fullName",
813
+ contact: "fullName",
814
+ contactname: "fullName",
815
+ displayname: "fullName",
816
+ };
817
+
818
+ export const COLUMN_MAP_USAGE =
819
+ 'e.g. --map "email=Work Email" --map "linkedin=Profile URL" --map name="Full Name" (or --map first="First Name" --map last=Surname). Fields: email, linkedin, name (the whole name in one column), first, last. A column is its header or its spreadsheet letter (A, B, ...); none switches a field off.';
820
+
821
+ function letterIndex(letters: string): number {
822
+ let index = 0;
823
+ for (const char of letters.toUpperCase())
824
+ index = index * 26 + (char.charCodeAt(0) - 64);
825
+ return index - 1;
826
+ }
827
+
828
+ function resolveColumn(
829
+ table: RecipientTable,
830
+ reference: string,
831
+ spec: string,
832
+ ): number | null {
833
+ if (/^(none|off|skip)$/i.test(reference)) return null;
834
+ const lowered = reference.toLowerCase();
835
+ const byLabel = table.columnLabels.findIndex(
836
+ (label) => label.trim().toLowerCase() === lowered,
837
+ );
838
+ if (byLabel >= 0) return byLabel;
839
+ const width = table.columnLabels.length;
840
+ if (/^[A-Za-z]{1,3}$/.test(reference)) {
841
+ const index = letterIndex(reference);
842
+ if (index < width) return index;
843
+ }
844
+ if (/^\d+$/.test(reference)) {
845
+ const index = Number(reference) - 1;
846
+ if (index >= 0 && index < width) return index;
847
+ }
848
+ throw new Error(
849
+ `--map "${spec}": no column called "${reference}". Columns: ${table.columnLabels.join(", ")}.`,
850
+ );
851
+ }
852
+
853
+ export function parseColumnMap(
854
+ specs: readonly string[],
855
+ table: RecipientTable,
856
+ ): RecipientColumnMapping {
857
+ const mapping: RecipientColumnMapping = {};
858
+ for (const spec of specs) {
859
+ const at = spec.indexOf("=");
860
+ const name = at === -1 ? "" : spec.slice(0, at).trim();
861
+ const reference = at === -1 ? "" : spec.slice(at + 1).trim();
862
+ const field = MAP_FIELDS[name.toLowerCase().replace(/[^a-z0-9]/g, "")];
863
+ if (!field || !reference) {
864
+ throw new Error(
865
+ `--map "${spec}" needs a field and a column, ${COLUMN_MAP_USAGE}`,
866
+ );
867
+ }
868
+ if (field in mapping) {
869
+ throw new Error(`--map sets ${name} twice; keep one column per field.`);
870
+ }
871
+ mapping[field] = resolveColumn(table, reference, spec);
872
+ }
873
+ if (
874
+ typeof mapping.fullName === "number" &&
875
+ (typeof mapping.firstName === "number" ||
876
+ typeof mapping.lastName === "number")
877
+ ) {
878
+ throw new Error(
879
+ "--map name is the whole name in one column; use it or --map first/last, not both.",
880
+ );
881
+ }
882
+ return mapping;
883
+ }