From 9907675fb64e36ecfafa6d12030f15c2cf327abb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 17:31:57 +0000 Subject: [PATCH] fix: handle domain fetch failure without defaulting to onboarding sender - Change fetchVerifiedDomains to return null on error instead of [], distinguishing 'no verified domains' from 'fetch failed' - Add pagination support to fetchVerifiedDomains using recursive page collection via has_more/after cursors - When domain fetch fails in emails send, warn the user and prompt for an explicit sender address without pre-filling onboarding@resend.dev - Apply the same fix to broadcasts create command - Add comprehensive tests for fetchVerifiedDomains covering pagination, error handling, and domain filtering Resolves BU-613 Co-authored-by: Bu Kinoshita --- src/commands/broadcasts/create.ts | 6 +- src/commands/emails/send.ts | 31 +++++-- src/lib/domains.ts | 50 ++++++++--- tests/commands/emails/send.test.ts | 5 +- tests/lib/domains.test.ts | 132 +++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 tests/lib/domains.test.ts diff --git a/src/commands/broadcasts/create.ts b/src/commands/broadcasts/create.ts index 53f73dcb..0d8e4508 100644 --- a/src/commands/broadcasts/create.ts +++ b/src/commands/broadcasts/create.ts @@ -118,7 +118,11 @@ Scheduling: if (!from && isInteractive() && !globalOpts.json) { 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 f91f65be..5ffdc6f8 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -190,21 +190,36 @@ export const sendCommand = new Command('send') : undefined; let fromAddress = opts.from; + let domainFetchFailed = false; if (!fromAddress && !hasTemplate && isInteractive() && !globalOpts.json) { const domains = await fetchVerifiedDomains(resend); - 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 419e8292..f56bd910 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -1242,7 +1242,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: { @@ -1252,8 +1252,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(); + }); +});