diff --git a/src/commands/api-keys/create.ts b/src/commands/api-keys/create.ts index 4fd5aeb5..5607a1b6 100644 --- a/src/commands/api-keys/create.ts +++ b/src/commands/api-keys/create.ts @@ -58,7 +58,7 @@ Permissions: const nameResult = await p.text({ message: 'Key name', - placeholder: 'My API Key', + placeholder: 'e.g. My API Key', validate: (v) => { if (!v) { return 'Name is required'; diff --git a/src/commands/broadcasts/create.ts b/src/commands/broadcasts/create.ts index 166b1809..36ac2aa1 100644 --- a/src/commands/broadcasts/create.ts +++ b/src/commands/broadcasts/create.ts @@ -3,6 +3,8 @@ import { Command } from '@commander-js/extra-typings'; import type { CreateBroadcastOptions } from 'resend'; import { runCreate } from '../../lib/actions'; import type { GlobalOpts } from '../../lib/client'; +import { requireClient } from '../../lib/client'; +import { fetchVerifiedDomains, promptForFromAddress } from '../../lib/domains'; import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; import { outputError } from '../../lib/output'; @@ -91,10 +93,19 @@ Scheduling: ); } + const resend = await requireClient(globalOpts); + let from = opts.from; let subject = opts.subject; let segmentId = opts.segmentId; + if (!from && isInteractive() && !globalOpts.json) { + const domains = await fetchVerifiedDomains(resend); + if (domains.length > 0) { + from = await promptForFromAddress(domains); + } + } + if (!from) { if (!isInteractive() || globalOpts.json) { outputError( @@ -104,7 +115,7 @@ Scheduling: } const result = await p.text({ message: 'From address', - placeholder: 'hello@domain.com', + placeholder: 'e.g. hello@domain.com', validate: (v) => (!v ? 'Required' : undefined), }); if (p.isCancel(result)) { @@ -122,7 +133,7 @@ Scheduling: } const result = await p.text({ message: 'Subject', - placeholder: 'Weekly Newsletter', + placeholder: 'e.g. Weekly Newsletter', validate: (v) => (!v ? 'Required' : undefined), }); if (p.isCancel(result)) { diff --git a/src/commands/contact-properties/create.ts b/src/commands/contact-properties/create.ts index 99b8154d..c083a2fa 100644 --- a/src/commands/contact-properties/create.ts +++ b/src/commands/contact-properties/create.ts @@ -57,7 +57,7 @@ built-in contact fields and may cause unexpected behavior in broadcasts.`, const key = await requireText( opts.key, - { message: 'Property key', placeholder: 'company_name' }, + { message: 'Property key', placeholder: 'e.g. company_name' }, { message: 'Missing --key flag.', code: 'missing_key' }, globalOpts, ); diff --git a/src/commands/contacts/create.ts b/src/commands/contacts/create.ts index 19520cfe..788c5e8e 100644 --- a/src/commands/contacts/create.ts +++ b/src/commands/contacts/create.ts @@ -69,7 +69,6 @@ Unsubscribed: setting --unsubscribed is a team-wide opt-out from all broadcasts, if (isInteractive() && !globalOpts.json && !opts.firstName) { const result = await p.text({ message: 'First name (optional)', - placeholder: 'Jane', }); if (p.isCancel(result)) { cancelAndExit('Cancelled.'); @@ -82,7 +81,6 @@ Unsubscribed: setting --unsubscribed is a team-wide opt-out from all broadcasts, if (isInteractive() && !globalOpts.json && !opts.lastName) { const result = await p.text({ message: 'Last name (optional)', - placeholder: 'Smith', }); if (p.isCancel(result)) { cancelAndExit('Cancelled.'); diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index bccf335d..efd78e69 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -1,85 +1,17 @@ import { readFileSync } from 'node:fs'; import { basename } from 'node:path'; -import * as p from '@clack/prompts'; import { Command } from '@commander-js/extra-typings'; -import type { CreateEmailOptions, Resend } from 'resend'; +import type { CreateEmailOptions } from 'resend'; import type { GlobalOpts } from '../../lib/client'; import { requireClient } from '../../lib/client'; +import { fetchVerifiedDomains, promptForFromAddress } from '../../lib/domains'; import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; import { outputError, outputResult } from '../../lib/output'; -import { - cancelAndExit, - promptForMissing, - requireText, -} from '../../lib/prompts'; +import { promptForMissing, requireText } from '../../lib/prompts'; import { withSpinner } from '../../lib/spinner'; import { isInteractive } from '../../lib/tty'; -export async function fetchVerifiedDomains(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); - } catch { - return []; - } -} - -const FROM_PREFIXES = ['noreply', 'hello', 'hi', 'info', 'support', 'team']; - -async function promptForFromAddress(domains: string[]): Promise { - let domain: string; - if (domains.length === 1) { - domain = domains[0]; - } else { - const result = await p.select({ - message: 'Select a verified domain', - options: domains.map((d) => ({ value: d, label: d })), - }); - if (p.isCancel(result)) { - cancelAndExit('Send cancelled.'); - } - domain = result; - } - - const options: Array<{ value: string | null; label: string }> = - FROM_PREFIXES.map((prefix) => ({ - value: `${prefix}@${domain}`, - label: `${prefix}@${domain}`, - })); - options.push({ value: null, label: 'Custom address...' }); - - const result = await p.select({ - message: `From address (@${domain})`, - options, - }); - if (p.isCancel(result)) { - cancelAndExit('Send cancelled.'); - } - - if (result === null) { - const custom = await p.text({ - message: 'From address', - placeholder: `you@${domain}`, - validate: (v) => - !v || !v.includes('@') ? 'Enter a valid email address' : undefined, - }); - if (p.isCancel(custom)) { - cancelAndExit('Send cancelled.'); - } - return custom; - } - - return result; -} - export const sendCommand = new Command('send') .description('Send an email') .option('--from
', 'Sender address (required)') @@ -249,18 +181,21 @@ export const sendCommand = new Command('send') { flag: 'from', message: 'From address', - placeholder: 'you@example.com', + placeholder: 'onboarding@resend.dev', + defaultValue: 'onboarding@resend.dev', required: !hasTemplate, }, { flag: 'to', message: 'To address', - placeholder: 'recipient@example.com', + placeholder: 'delivered@resend.dev', + defaultValue: 'delivered@resend.dev', }, { flag: 'subject', message: 'Subject', placeholder: 'Hello!', + defaultValue: 'Hello!', required: !hasTemplate, }, ]; @@ -298,7 +233,8 @@ export const sendCommand = new Command('send') undefined, { message: 'Email body (plain text)', - placeholder: 'Type your message...', + placeholder: 'Hello, World!', + defaultValue: 'Hello, World!', }, { message: diff --git a/src/commands/segments/create.ts b/src/commands/segments/create.ts index 2459232c..172119e1 100644 --- a/src/commands/segments/create.ts +++ b/src/commands/segments/create.ts @@ -27,7 +27,7 @@ Non-interactive: --name is required.`, const name = await requireText( opts.name, - { message: 'Segment name', placeholder: 'Newsletter Subscribers' }, + { message: 'Segment name', placeholder: 'e.g. Newsletter Subscribers' }, { message: 'Missing --name flag.', code: 'missing_name' }, globalOpts, ); diff --git a/src/commands/templates/create.ts b/src/commands/templates/create.ts index b6617549..8f5324c0 100644 --- a/src/commands/templates/create.ts +++ b/src/commands/templates/create.ts @@ -76,7 +76,7 @@ Non-interactive: --name and a body (--html or --html-file) are required. --text- } const result = await p.text({ message: 'Template name', - placeholder: 'Welcome Email', + placeholder: 'e.g. Welcome Email', validate: (v) => (!v ? 'Required' : undefined), }); if (p.isCancel(result)) { diff --git a/src/commands/topics/create.ts b/src/commands/topics/create.ts index 5565c02d..26232f96 100644 --- a/src/commands/topics/create.ts +++ b/src/commands/topics/create.ts @@ -46,7 +46,7 @@ Non-interactive: --name is required.`, const name = await requireText( opts.name, - { message: 'Topic name', placeholder: 'Product Updates' }, + { message: 'Topic name', placeholder: 'e.g. Product Updates' }, { message: 'Missing --name flag.', code: 'missing_name' }, globalOpts, ); diff --git a/src/lib/domains.ts b/src/lib/domains.ts new file mode 100644 index 00000000..e8f545d0 --- /dev/null +++ b/src/lib/domains.ts @@ -0,0 +1,67 @@ +import * as p from '@clack/prompts'; +import type { Resend } from 'resend'; +import { cancelAndExit } from './prompts'; + +export async function fetchVerifiedDomains(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); + } catch { + return []; + } +} + +const FROM_PREFIXES = ['noreply', 'hello']; + +export async function promptForFromAddress(domains: string[]): Promise { + let domain: string; + if (domains.length === 1) { + domain = domains[0]; + } else { + const result = await p.select({ + message: 'Select a verified domain', + options: domains.map((d) => ({ value: d, label: d })), + }); + if (p.isCancel(result)) { + cancelAndExit('Send cancelled.'); + } + domain = result; + } + + const options: Array<{ value: string | null; label: string }> = + FROM_PREFIXES.map((prefix) => ({ + value: `${prefix}@${domain}`, + label: `${prefix}@${domain}`, + })); + options.push({ value: null, label: 'Custom address...' }); + + const result = await p.select({ + message: `From address (@${domain})`, + options, + }); + if (p.isCancel(result)) { + cancelAndExit('Send cancelled.'); + } + + if (result === null) { + const custom = await p.text({ + message: 'From address', + placeholder: `e.g. you@${domain}`, + validate: (v) => + !v || !v.includes('@') ? 'Enter a valid email address' : undefined, + }); + if (p.isCancel(custom)) { + cancelAndExit('Send cancelled.'); + } + return custom; + } + + return result; +} diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 9359b3e4..3cf9db88 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -9,6 +9,7 @@ export interface FieldSpec { flag: string; message: string; placeholder?: string; + defaultValue?: string; required?: boolean; validate?: (value: string | undefined) => string | undefined; } @@ -104,6 +105,7 @@ export async function requireText( prompt: { message: string; placeholder?: string; + defaultValue?: string; validate?: (value: string | undefined) => string | Error | undefined; }, error: { message: string; code: string }, @@ -120,10 +122,13 @@ export async function requireText( const result = await p.text({ message: prompt.message, placeholder: prompt.placeholder, + defaultValue: prompt.defaultValue, validate: prompt.validate ?? ((v) => - !v || v.length === 0 ? `${prompt.message} is required` : undefined), + !prompt.defaultValue && (!v || v.length === 0) + ? `${prompt.message} is required` + : undefined), }); if (p.isCancel(result)) { cancelAndExit('Cancelled.'); @@ -203,10 +208,11 @@ export async function promptForMissing< p.text({ message: field.message, placeholder: field.placeholder, + defaultValue: field.defaultValue, validate: field.validate ?? ((v) => - !v || v.length === 0 + !field.defaultValue && (!v || v.length === 0) ? `${field.message} is required` : undefined), }), diff --git a/tests/commands/emails/send.test.ts b/tests/commands/emails/send.test.ts index 4244db3e..47d65644 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -1074,9 +1074,7 @@ describe('send command', () => { }); test('degrades gracefully when domain fetch fails', async () => { - const { fetchVerifiedDomains } = await import( - '../../../src/commands/emails/send' - ); + const { fetchVerifiedDomains } = await import('../../../src/lib/domains'); const failingResend = { domains: { list: vi.fn(async () => {