From 932a7c0476e0f3da89f195cc54508f51435708f7 Mon Sep 17 00:00:00 2001 From: Styles Date: Sat, 11 Apr 2026 16:03:59 +0530 Subject: [PATCH 1/3] feat: add import contacts command and enhance JSON parsing utility - Introduced `importContactsCommand` to the contacts command module. - Updated command examples to include usage of the new import feature. - Added `tryParsePropertiesJsonObject` utility function for improved JSON parsing, enhancing error handling in `parsePropertiesJson` function. --- src/commands/contacts/import.ts | 221 ++++++++++++++++++++++++++++++++ src/commands/contacts/index.ts | 3 + src/commands/contacts/utils.ts | 24 +++- src/lib/parse-csv.ts | 72 +++++++++++ 4 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 src/commands/contacts/import.ts create mode 100644 src/lib/parse-csv.ts diff --git a/src/commands/contacts/import.ts b/src/commands/contacts/import.ts new file mode 100644 index 00000000..9f918d0d --- /dev/null +++ b/src/commands/contacts/import.ts @@ -0,0 +1,221 @@ +import { Command } from '@commander-js/extra-typings'; +import type { GlobalOpts } from '../../lib/client'; +import { requireClient } from '../../lib/client'; +import { readFile } from '../../lib/files'; +import { buildHelpText } from '../../lib/help-text'; +import { outputError, outputResult } from '../../lib/output'; +import { parseCsvTable } from '../../lib/parse-csv'; +import { requireText } from '../../lib/prompts'; +import { createSpinner } from '../../lib/spinner'; +import { isInteractive } from '../../lib/tty'; +import { tryParsePropertiesJsonObject } from './utils'; + +const CONCURRENCY = 5; + +const CSV_ERROR_MESSAGES: Record< + 'empty' | 'no_data' | 'no_email_column', + string +> = { + empty: 'CSV file is empty.', + no_data: 'No data rows after the header.', + no_email_column: + 'CSV must include an "email" column (or "e-mail") in the header row.', +}; + +function parseContactsImportCsv(raw: string): Record[] | null { + const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; + if (!text.trim()) { + return null; + } + + const table = parseCsvTable(text); + const headerRow = table[0]; + if (!headerRow) { + return null; + } + + const normalize = (h: string): string => { + const n = h.trim().toLowerCase().replace(/\s+/g, '_'); + if (n === 'e-mail' || n === 'e_mail') { + return 'email'; + } + if (n === 'firstname') { + return 'first_name'; + } + if (n === 'lastname') { + return 'last_name'; + } + return n; + }; + + const headers = headerRow.map(normalize); + const rows: Record[] = []; + + for (let r = 1; r < table.length; r++) { + const cells = table[r]; + if (!cells || cells.every((c) => c.trim() === '')) { + continue; + } + + const row: Record = {}; + for (let c = 0; c < headers.length; c++) { + const key = headers[c]; + if (key) { + row[key] = cells[c]?.trim() ?? ''; + } + } + rows.push(row); + } + + if (rows.length === 0 || !rows[0]?.email) { + return null; + } + return rows; +} + +export const importContactsCommand = new Command('import') + .description('Create contacts from a CSV file') + .option( + '--file ', + 'Path to CSV (header row required; "-" for stdin in non-interactive mode)', + ) + .option( + '--segment-id ', + 'Segment ID to add each imported contact to on creation (repeatable)', + ) + .addHelpText( + 'after', + buildHelpText({ + context: `The first row must be column headers. Required column: email (or e-mail). + +Optional columns: first_name, last_name, properties (JSON object per cell). + +Processing: up to ${CONCURRENCY} rows are imported concurrently. Failed rows are collected and reported at the end. Exit code 1 if any rows fail.`, + errorCodes: [ + 'auth_error', + 'missing_file', + 'file_read_error', + 'stdin_read_error', + 'invalid_csv', + 'import_error', + ], + examples: [ + 'resend contacts import --file ./users.csv --segment-id 7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d', + 'resend contacts import --file ./users.csv', + ], + }), + ) + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + + const filePath = await requireText( + opts.file, + { message: 'Path to CSV file', placeholder: './contacts.csv' }, + { message: 'Missing --file flag.', code: 'missing_file' }, + globalOpts, + ); + + const rows = parseContactsImportCsv(readFile(filePath, globalOpts)); + if (!rows) { + outputError( + { message: CSV_ERROR_MESSAGES.no_email_column, code: 'invalid_csv' }, + { json: globalOpts.json }, + ); + } + + const resend = await requireClient(globalOpts); + const segments = opts.segmentId ?? []; + const spinner = createSpinner( + `Importing ${rows.length} contacts...`, + globalOpts.quiet, + ); + + const imported: { row: number; id: string; email: string }[] = []; + const errors: { row: number; email?: string; message: string }[] = []; + + // Process in chunks for concurrency + for (let i = 0; i < rows.length; i += CONCURRENCY) { + const chunk = rows.slice(i, i + CONCURRENCY); + await Promise.all( + chunk.map(async (row, idx) => { + const rowNum = i + idx + 2; + const email = row.email?.trim(); + if (!email) { + errors.push({ row: rowNum, message: 'Missing email' }); + return; + } + + let properties: Record | undefined; + if (row.properties) { + const parsed = tryParsePropertiesJsonObject(row.properties); + if (!parsed) { + errors.push({ + row: rowNum, + email, + message: 'Invalid properties JSON', + }); + return; + } + properties = parsed; + } + + const { data, error } = await resend.contacts.create({ + email, + ...(row.first_name && { firstName: row.first_name }), + ...(row.last_name && { lastName: row.last_name }), + ...(properties && { properties }), + ...(segments.length > 0 && { + segments: segments.map((id) => ({ id })), + }), + }); + + if (error || !data) { + errors.push({ + row: rowNum, + email, + message: error?.message ?? 'Failed', + }); + } else { + imported.push({ row: rowNum, id: data.id, email }); + } + }), + ); + } + + spinner.stop(); + + if (imported.length === 0) { + outputError( + { + message: `No contacts imported (${errors.length} failed).`, + code: 'import_error', + }, + { json: globalOpts.json }, + ); + } + + if (errors.length > 0) { + process.exitCode = 1; + } + + if (!globalOpts.json && isInteractive()) { + console.log( + `Imported ${imported.length} contact${imported.length === 1 ? '' : 's'}`, + ); + for (const c of imported) { + console.log(` ${c.email} ${c.id}`); + } + if (errors.length > 0) { + console.warn(`\n${errors.length} failed:`); + for (const e of errors) { + console.warn( + ` [row ${e.row}${e.email ? ` ${e.email}` : ''}] ${e.message}`, + ); + } + } + } else { + outputResult(errors.length > 0 ? { data: imported, errors } : imported, { + json: globalOpts.json, + }); + } + }); diff --git a/src/commands/contacts/index.ts b/src/commands/contacts/index.ts index 23b65d42..2211a368 100644 --- a/src/commands/contacts/index.ts +++ b/src/commands/contacts/index.ts @@ -4,6 +4,7 @@ import { addContactSegmentCommand } from './add-segment'; import { createContactCommand } from './create'; import { deleteContactCommand } from './delete'; import { getContactCommand } from './get'; +import { importContactsCommand } from './import'; import { listContactsCommand } from './list'; import { removeContactSegmentCommand } from './remove-segment'; import { listContactSegmentsCommand } from './segments'; @@ -33,6 +34,7 @@ Segments: Manage membership with "resend contacts add-segment" and "resend contacts remove-segment".`, examples: [ 'resend contacts list', + 'resend contacts import --file ./users.csv --segment-id 78261eea-8f8b-4381-83c6-79fa7120f1cf', 'resend contacts create --email steve.wozniak@gmail.com --first-name Steve', 'resend contacts get e169aa45-1ecf-4183-9955-b1499d5701d3', 'resend contacts get steve.wozniak@gmail.com', @@ -47,6 +49,7 @@ Segments: }), ) .addCommand(createContactCommand) + .addCommand(importContactsCommand) .addCommand(getContactCommand) .addCommand(listContactsCommand, { isDefault: true }) .addCommand(updateContactCommand) diff --git a/src/commands/contacts/utils.ts b/src/commands/contacts/utils.ts index 3fbcdbf1..54e73d20 100644 --- a/src/commands/contacts/utils.ts +++ b/src/commands/contacts/utils.ts @@ -113,6 +113,24 @@ export function parseTopicsJson( return parsed as Array<{ id: string; subscription: 'opt_in' | 'opt_out' }>; } +export function tryParsePropertiesJsonObject( + raw: string, +): Record | undefined { + try { + const parsed = JSON.parse(raw) as unknown; + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + return undefined; + } + return parsed as Record; + } catch { + return undefined; + } +} + export function parsePropertiesJson( raw: string | undefined, globalOpts: GlobalOpts, @@ -120,12 +138,12 @@ export function parsePropertiesJson( if (!raw) { return undefined; } - try { - return JSON.parse(raw) as Record; - } catch { + const value = tryParsePropertiesJsonObject(raw); + if (value === undefined) { outputError( { message: 'Invalid --properties JSON.', code: 'invalid_properties' }, { json: globalOpts.json }, ); } + return value; } diff --git a/src/lib/parse-csv.ts b/src/lib/parse-csv.ts new file mode 100644 index 00000000..2d61e513 --- /dev/null +++ b/src/lib/parse-csv.ts @@ -0,0 +1,72 @@ +/** + * Parse CSV into rows of string fields (comma delimiter, RFC 4180 quoting: + * fields may be wrapped in `"`, and `"` inside a field is escaped as `""`). + */ +export function parseCsvTable(content: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let i = 0; + let inQuotes = false; + + const pushField = () => { + row.push(field); + field = ''; + }; + const pushRow = () => { + rows.push(row); + row = []; + }; + + while (i < content.length) { + const c = content[i]; + if (c === undefined) { + break; + } + + if (inQuotes) { + if (c === '"') { + if (content[i + 1] === '"') { + field += '"'; + i += 2; + } else { + inQuotes = false; + i++; + } + } else { + field += c; + i++; + } + continue; + } + + switch (c) { + case '"': + inQuotes = true; + i++; + break; + case ',': + pushField(); + i++; + break; + case '\r': + i++; + break; + case '\n': + pushField(); + pushRow(); + i++; + break; + default: + field += c; + i++; + } + } + + pushField(); + if (row.length > 0 && row.some((cell) => cell.trim() !== '')) { + pushRow(); + } + + return rows; +} From 2b792ea1d2e0fdaef396eea85f5abc271ba55240 Mon Sep 17 00:00:00 2001 From: Styles Date: Sat, 11 Apr 2026 16:09:59 +0530 Subject: [PATCH 2/3] fix: improved CSV parsing to handle empty rows correctly --- src/lib/parse-csv.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib/parse-csv.ts b/src/lib/parse-csv.ts index 2d61e513..07287f30 100644 --- a/src/lib/parse-csv.ts +++ b/src/lib/parse-csv.ts @@ -8,6 +8,7 @@ export function parseCsvTable(content: string): string[][] { let field = ''; let i = 0; let inQuotes = false; + let hasContent = false; const pushField = () => { row.push(field); @@ -16,13 +17,11 @@ export function parseCsvTable(content: string): string[][] { const pushRow = () => { rows.push(row); row = []; + hasContent = false; }; while (i < content.length) { const c = content[i]; - if (c === undefined) { - break; - } if (inQuotes) { if (c === '"') { @@ -43,10 +42,12 @@ export function parseCsvTable(content: string): string[][] { switch (c) { case '"': inQuotes = true; + hasContent = true; i++; break; case ',': pushField(); + hasContent = true; i++; break; case '\r': @@ -54,17 +55,22 @@ export function parseCsvTable(content: string): string[][] { break; case '\n': pushField(); - pushRow(); + if (hasContent || row.length > 1) { + pushRow(); + } else if (row.length === 1) { + row = []; + } i++; break; default: field += c; + hasContent = true; i++; } } pushField(); - if (row.length > 0 && row.some((cell) => cell.trim() !== '')) { + if (hasContent || row.length > 1 || (row.length === 1 && row[0] !== '')) { pushRow(); } From fb6b1d0da8ae73ec58f6fba253f8aeaf20e1dc87 Mon Sep 17 00:00:00 2001 From: Styles Date: Sat, 11 Apr 2026 16:12:29 +0530 Subject: [PATCH 3/3] fix: ensure email header is present in CSV import and simplify row validation --- src/commands/contacts/import.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/contacts/import.ts b/src/commands/contacts/import.ts index 9f918d0d..9ea70526 100644 --- a/src/commands/contacts/import.ts +++ b/src/commands/contacts/import.ts @@ -49,6 +49,10 @@ function parseContactsImportCsv(raw: string): Record[] | null { }; const headers = headerRow.map(normalize); + if (!headers.includes('email')) { + return null; + } + const rows: Record[] = []; for (let r = 1; r < table.length; r++) { @@ -67,7 +71,7 @@ function parseContactsImportCsv(raw: string): Record[] | null { rows.push(row); } - if (rows.length === 0 || !rows[0]?.email) { + if (rows.length === 0) { return null; } return rows;