diff --git a/src/commands/broadcasts/create.ts b/src/commands/broadcasts/create.ts index 89aa2186..1ba6b316 100644 --- a/src/commands/broadcasts/create.ts +++ b/src/commands/broadcasts/create.ts @@ -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); } } diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index b94eca9e..9bc7b25b 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -205,6 +205,7 @@ export const sendCommand = new Command('send') : undefined; let fromAddress = opts.from; + let domainFetchFailed = false; if ( !opts.dryRun && !fromAddress && @@ -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', diff --git a/src/lib/domains.ts b/src/lib/domains.ts index e8f545d0..55ad17a6 100644 --- a/src/lib/domains.ts +++ b/src/lib/domains.ts @@ -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 { +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 => { + 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 => { 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']; diff --git a/tests/commands/emails/send.test.ts b/tests/commands/emails/send.test.ts index aae2c9a3..d0005569 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -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: { @@ -1363,8 +1363,7 @@ describe('send command', () => { }, } as Record; - // Should return [] without throwing, so the caller falls through to promptForMissing const result = await fetchVerifiedDomains(failingResend); - expect(result).toEqual([]); + expect(result).toBeNull(); }); }); diff --git a/tests/lib/domains.test.ts b/tests/lib/domains.test.ts new file mode 100644 index 00000000..abb7ff6f --- /dev/null +++ b/tests/lib/domains.test.ts @@ -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[]; + 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; +}; + +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; + 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; + + 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; + + 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; + + const result = await fetchVerifiedDomains(resend); + expect(result).toBeNull(); + }); +});