Skip to content
Draft
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
6 changes: 5 additions & 1 deletion src/commands/broadcasts/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ Scheduling:
if (!from && isInteractive() && !globalOpts.json && !opts.dryRun) {
const resend = await requireClient(globalOpts);
const domains = await fetchVerifiedDomains(resend);
if (domains.length > 0) {
if (domains === null) {
process.stderr.write(
'Warning: Could not fetch verified domains. Please provide a sender address explicitly.\n',
);
} else if (domains.length > 0) {
from = await promptForFromAddress(domains);
}
}
Expand Down
31 changes: 23 additions & 8 deletions src/commands/emails/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export const sendCommand = new Command('send')
: undefined;

let fromAddress = opts.from;
let domainFetchFailed = false;
if (
!opts.dryRun &&
!fromAddress &&
Expand All @@ -216,19 +217,33 @@ export const sendCommand = new Command('send')
permission: 'sending_access',
});
const domains = await fetchVerifiedDomains(clientForDomains);
if (domains.length > 0) {
if (domains === null) {
domainFetchFailed = true;
process.stderr.write(
'Warning: Could not fetch verified domains. Please provide a sender address explicitly.\n',
);
} else if (domains.length > 0) {
fromAddress = await promptForFromAddress(domains);
}
}

const fromPromptSpec = domainFetchFailed
? {
flag: 'from',
message: 'From address',
placeholder: 'you@yourdomain.com',
required: !hasTemplate,
}
: {
flag: 'from',
message: 'From address',
placeholder: 'onboarding@resend.dev',
defaultValue: 'onboarding@resend.dev',
required: !hasTemplate,
};

const promptFields = [
{
flag: 'from',
message: 'From address',
placeholder: 'onboarding@resend.dev',
defaultValue: 'onboarding@resend.dev',
required: !hasTemplate,
},
fromPromptSpec,
{
flag: 'to',
message: 'To address',
Expand Down
50 changes: 38 additions & 12 deletions src/lib/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,47 @@ import * as p from '@clack/prompts';
import type { Resend } from 'resend';
import { cancelAndExit } from './prompts';

export async function fetchVerifiedDomains(resend: Resend): Promise<string[]> {
const isVerifiedSendingDomain = (d: {
status: string;
capabilities: { sending: string };
}) => d.status === 'verified' && d.capabilities.sending === 'enabled';

const collectVerifiedDomains = async (
resend: Resend,
accumulated: readonly string[],
after?: string,
): Promise<string[] | null> => {
const { data, error } = await resend.domains.list(
after ? { after } : undefined,
);

if (error || !data) {
return null;
}

const names = data.data.filter(isVerifiedSendingDomain).map((d) => d.name);
const all = [...accumulated, ...names];

if (!(data.has_more ?? false) || data.data.length === 0) {
return all;
}

return collectVerifiedDomains(
resend,
all,
data.data[data.data.length - 1].id,
);
};

export const fetchVerifiedDomains = async (
resend: Resend,
): Promise<string[] | null> => {
try {
const { data, error } = await resend.domains.list();
if (error || !data) {
return [];
}
return data.data
.filter(
(d) => d.status === 'verified' && d.capabilities.sending === 'enabled',
)
.map((d) => d.name);
return await collectVerifiedDomains(resend, []);
} catch {
return [];
return null;
}
}
};

const FROM_PREFIXES = ['noreply', 'hello'];

Expand Down
5 changes: 2 additions & 3 deletions tests/commands/emails/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1353,7 +1353,7 @@ describe('send command', () => {
);
});

test('degrades gracefully when domain fetch fails', async () => {
test('returns null when domain fetch fails', async () => {
const { fetchVerifiedDomains } = await import('../../../src/lib/domains');
const failingResend = {
domains: {
Expand All @@ -1363,8 +1363,7 @@ describe('send command', () => {
},
} as Record<string, unknown>;

// Should return [] without throwing, so the caller falls through to promptForMissing
const result = await fetchVerifiedDomains(failingResend);
expect(result).toEqual([]);
expect(result).toBeNull();
});
});
132 changes: 132 additions & 0 deletions tests/lib/domains.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchVerifiedDomains } from '../../src/lib/domains';

const makeDomain = (
id: string,
name: string,
status = 'verified',
sending = 'enabled',
) => ({
id,
name,
status,
capabilities: { sending },
});

const makeResend = (
pages: Array<{
data: ReturnType<typeof makeDomain>[];
has_more: boolean;
}>,
) => {
const listFn = vi.fn();
pages.forEach((page) => {
listFn.mockResolvedValueOnce({
data: { data: page.data, has_more: page.has_more },
error: null,
});
});
return { domains: { list: listFn } } as Record<string, unknown>;
};

describe('fetchVerifiedDomains', () => {
it('returns verified sending-enabled domains from a single page', async () => {
const resend = makeResend([
{
data: [
makeDomain('d1', 'example.com'),
makeDomain('d2', 'pending.com', 'pending'),
makeDomain('d3', 'nosend.com', 'verified', 'disabled'),
],
has_more: false,
},
]);

const result = await fetchVerifiedDomains(resend);
expect(result).toEqual(['example.com']);
});

it('paginates through all pages', async () => {
const resend = makeResend([
{
data: [makeDomain('d1', 'page1.com')],
has_more: true,
},
{
data: [makeDomain('d2', 'page2.com')],
has_more: true,
},
{
data: [makeDomain('d3', 'page3.com')],
has_more: false,
},
]);

const result = await fetchVerifiedDomains(resend);
expect(result).toEqual(['page1.com', 'page2.com', 'page3.com']);

const listFn = resend.domains.list as ReturnType<typeof vi.fn>;
expect(listFn).toHaveBeenCalledTimes(3);
expect(listFn).toHaveBeenNthCalledWith(1, undefined);
expect(listFn).toHaveBeenNthCalledWith(2, { after: 'd1' });
expect(listFn).toHaveBeenNthCalledWith(3, { after: 'd2' });
});

it('returns null when domains.list returns an error', async () => {
const resend = {
domains: {
list: vi.fn().mockResolvedValue({
data: null,
error: { message: 'restricted_api_key', name: 'restricted_api_key' },
}),
},
} as Record<string, unknown>;

const result = await fetchVerifiedDomains(resend);
expect(result).toBeNull();
});

it('returns null when domains.list throws', async () => {
const resend = {
domains: {
list: vi.fn().mockRejectedValue(new Error('Network error')),
},
} as Record<string, unknown>;

const result = await fetchVerifiedDomains(resend);
expect(result).toBeNull();
});

it('returns empty array when no domains match the filter', async () => {
const resend = makeResend([
{
data: [
makeDomain('d1', 'pending.com', 'pending'),
makeDomain('d2', 'nosend.com', 'verified', 'disabled'),
],
has_more: false,
},
]);

const result = await fetchVerifiedDomains(resend);
expect(result).toEqual([]);
});

it('returns null when a subsequent page errors', async () => {
const listFn = vi
.fn()
.mockResolvedValueOnce({
data: { data: [makeDomain('d1', 'page1.com')], has_more: true },
error: null,
})
.mockResolvedValueOnce({
data: null,
error: { message: 'rate_limited', name: 'rate_limited' },
});

const resend = { domains: { list: listFn } } as Record<string, unknown>;

const result = await fetchVerifiedDomains(resend);
expect(result).toBeNull();
});
});