Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions src/commands/contacts/import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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<string, string>[] | 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);
if (!headers.includes('email')) {
return null;
}

const rows: Record<string, string>[] = [];

for (let r = 1; r < table.length; r++) {
const cells = table[r];
if (!cells || cells.every((c) => c.trim() === '')) {
continue;
}

const row: Record<string, string> = {};
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) {
return null;
}
return rows;
}

export const importContactsCommand = new Command('import')
.description('Create contacts from a CSV file')
.option(
'--file <path>',
'Path to CSV (header row required; "-" for stdin in non-interactive mode)',
)
.option(
'--segment-id <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<string, string | number | null> | 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,
});
}
});
3 changes: 3 additions & 0 deletions src/commands/contacts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -47,6 +49,7 @@ Segments:
}),
)
.addCommand(createContactCommand)
.addCommand(importContactsCommand)
.addCommand(getContactCommand)
.addCommand(listContactsCommand, { isDefault: true })
.addCommand(updateContactCommand)
Expand Down
24 changes: 21 additions & 3 deletions src/commands/contacts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,37 @@ export function parseTopicsJson(
return parsed as Array<{ id: string; subscription: 'opt_in' | 'opt_out' }>;
}

export function tryParsePropertiesJsonObject(
raw: string,
): Record<string, string | number | null> | undefined {
try {
const parsed = JSON.parse(raw) as unknown;
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
return undefined;
}
return parsed as Record<string, string | number | null>;
} catch {
return undefined;
}
}

export function parsePropertiesJson(
raw: string | undefined,
globalOpts: GlobalOpts,
): Record<string, string | number | null> | undefined {
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw) as Record<string, string | number | null>;
} catch {
const value = tryParsePropertiesJsonObject(raw);
if (value === undefined) {
outputError(
{ message: 'Invalid --properties JSON.', code: 'invalid_properties' },
{ json: globalOpts.json },
);
}
return value;
}
78 changes: 78 additions & 0 deletions src/lib/parse-csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* 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;
let hasContent = false;

const pushField = () => {
row.push(field);
field = '';
};
const pushRow = () => {
rows.push(row);
row = [];
hasContent = false;
};

while (i < content.length) {
const c = content[i];

if (inQuotes) {
if (c === '"') {
if (content[i + 1] === '"') {
field += '"';
i += 2;
} else {
inQuotes = false;
i++;
}
} else {
field += c;
i++;
}
continue;
}

switch (c) {
case '"':
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unquoted " characters start quoted parsing mid-field, which can silently shift CSV columns and corrupt imported data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/parse-csv.ts, line 44:

<comment>Unquoted `"` characters start quoted parsing mid-field, which can silently shift CSV columns and corrupt imported data.</comment>

<file context>
@@ -0,0 +1,72 @@
+    }
+
+    switch (c) {
+      case '"':
+        inQuotes = true;
+        i++;
</file context>
Fix with Cubic

inQuotes = true;
hasContent = true;
i++;
break;
case ',':
pushField();
hasContent = true;
i++;
break;
case '\r':
i++;
break;
case '\n':
pushField();
if (hasContent || row.length > 1) {
pushRow();
} else if (row.length === 1) {
row = [];
}
i++;
break;
default:
field += c;
hasContent = true;
i++;
}
}

pushField();
if (hasContent || row.length > 1 || (row.length === 1 && row[0] !== '')) {
pushRow();
}

return rows;
}