Skip to content
Merged
2 changes: 1 addition & 1 deletion src/commands/api-keys/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
15 changes: 13 additions & 2 deletions src/commands/broadcasts/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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)) {
Expand All @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/contact-properties/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
2 changes: 0 additions & 2 deletions src/commands/contacts/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -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.');
Expand Down
84 changes: 10 additions & 74 deletions src/commands/emails/send.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string> {
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 <address>', 'Sender address (required)')
Expand Down Expand Up @@ -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,
},
];
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/commands/segments/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/templates/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/topics/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
67 changes: 67 additions & 0 deletions src/lib/domains.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string> {
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;
}
10 changes: 8 additions & 2 deletions src/lib/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface FieldSpec {
flag: string;
message: string;
placeholder?: string;
defaultValue?: string;
required?: boolean;
validate?: (value: string | undefined) => string | undefined;
}
Expand Down Expand Up @@ -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 },
Expand All @@ -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.');
Expand Down Expand Up @@ -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),
}),
Expand Down
4 changes: 1 addition & 3 deletions tests/commands/emails/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down