Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { emailsCommand } from './commands/emails/index';
import { openCommand } from './commands/open';
import { segmentsCommand } from './commands/segments/index';
import { teamsDeprecatedCommand } from './commands/teams-deprecated';
import { templatesCommand } from './commands/templates/index';
import { topicsCommand } from './commands/topics/index';
import { webhooksCommand } from './commands/webhooks/index';
import { whoamiCommand } from './commands/whoami';
Expand Down Expand Up @@ -82,6 +83,7 @@ ${pc.gray('Examples:')}
.addCommand(contactsCommand)
.addCommand(contactPropertiesCommand)
.addCommand(segmentsCommand)
.addCommand(templatesCommand)
.addCommand(topicsCommand)
.addCommand(webhooksCommand)
.addCommand(doctorCommand)
Expand Down
142 changes: 142 additions & 0 deletions src/commands/templates/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import * as p from '@clack/prompts';
import { Command } from '@commander-js/extra-typings';
import { runCreate } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { readFile } from '../../lib/files';
import { buildHelpText } from '../../lib/help-text';
import { outputError } from '../../lib/output';
import { cancelAndExit } from '../../lib/prompts';
import { isInteractive } from '../../lib/tty';
import { parseVariables } from './utils';

export const createTemplateCommand = new Command('create')
.description('Create a new template')
.option('--name <name>', 'Template name — required')
.option('--html <html>', 'HTML body')
.option('--html-file <path>', 'Path to an HTML file for the body')
.option('--subject <subject>', 'Email subject')
.option('--text <text>', 'Plain-text body')
.option('--from <address>', 'Sender address')
.option('--reply-to <address>', 'Reply-to address')
.option('--alias <alias>', 'Template alias for lookup by name')
.option(
'--var <var...>',
'Template variable: KEY:type or KEY:type:fallback (repeatable)',
)
.addHelpText(
'after',
buildHelpText({
context: `Creates a new draft template. Use "resend templates publish" to make it available for sending.

--name is required. Body: provide --html or --html-file (mutually exclusive).

--var declares a template variable using the format KEY:type or KEY:type:fallback.
Valid types: string, number.
Variables must match {{{KEY}}} placeholders in the HTML body:
--html "<p>Hi {{{NAME}}}, your total is {{{PRICE}}}</p>"
--var NAME:string --var PRICE:number:0

Non-interactive: --name and a body (--html or --html-file) are required.`,
output: ` {"object":"template","id":"<template-id>"}`,
errorCodes: [
'auth_error',
'missing_name',
'missing_body',
'file_read_error',
'create_error',
],
examples: [
'resend templates create --name "Welcome" --html "<h1>Hello</h1>" --subject "Welcome!"',
'resend templates create --name "Newsletter" --html-file ./template.html --from hello@domain.com',
'resend templates create --name "Onboarding" --html "<p>Hi</p>" --alias onboarding --json',
'resend templates create --name "Order" --html "<p>{{{PRODUCT}}}: {{{PRICE}}}</p>" --var PRODUCT:string:item --var PRICE:number:25',
],
}),
)
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;

let name = opts.name;

if (!name) {
if (!isInteractive()) {
outputError(
{ message: 'Missing --name flag.', code: 'missing_name' },
{ json: globalOpts.json },
);
}
const result = await p.text({
message: 'Template name',
placeholder: 'Welcome Email',
validate: (v) => (!v ? 'Required' : undefined),
});
if (p.isCancel(result)) {
cancelAndExit('Cancelled.');
}
name = result;
}

if (opts.html && opts.htmlFile) {
outputError(
{
message: '--html and --html-file are mutually exclusive.',
code: 'invalid_options',
},
{ json: globalOpts.json },
);
}

let html = opts.html;

if (opts.htmlFile) {
html = readFile(opts.htmlFile, globalOpts);
}

if (!html) {
if (!isInteractive()) {
outputError(
{
message: 'Missing body. Provide --html or --html-file.',
code: 'missing_body',
},
{ json: globalOpts.json },
);
}
const result = await p.text({
message: 'HTML body',
placeholder: '<h1>Hello {{name}}</h1>',
validate: (v) => (!v ? 'Required' : undefined),
});
if (p.isCancel(result)) {
cancelAndExit('Cancelled.');
}
html = result;
}

await runCreate(
{
spinner: {
loading: 'Creating template...',
success: 'Template created',
fail: 'Failed to create template',
},
sdkCall: (resend) =>
Promise.resolve(
resend.templates.create({
name,
html,
...(opts.subject && { subject: opts.subject }),
...(opts.text && { text: opts.text }),
...(opts.from && { from: opts.from }),
...(opts.replyTo && { replyTo: opts.replyTo }),
...(opts.alias && { alias: opts.alias }),
...(opts.var && { variables: parseVariables(opts.var) }),
}),
),
onInteractive: (d) => {
console.log(`\nTemplate created: ${d.id}`);
},
},
globalOpts,
);
});
48 changes: 48 additions & 0 deletions src/commands/templates/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Command } from '@commander-js/extra-typings';
import { runDelete } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';

export const deleteTemplateCommand = new Command('delete')
.alias('rm')
.description('Delete a template')
.argument('<id>', 'Template ID or alias')
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Deleting by alias produces incorrect --json output because the helper echoes the input string into the id field instead of the API response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/templates/delete.ts, line 9:

<comment>Deleting by alias produces incorrect `--json` output because the helper echoes the input string into the `id` field instead of the API response.</comment>

<file context>
@@ -0,0 +1,48 @@
+export const deleteTemplateCommand = new Command('delete')
+  .alias('rm')
+  .description('Delete a template')
+  .argument('<id>', 'Template ID or alias')
+  .option(
+    '--yes',
</file context>
Fix with Cubic

.option(
'--yes',
'Skip the confirmation prompt (required in non-interactive mode)',
)
.addHelpText(
'after',
buildHelpText({
context: `Warning: Deleting a template is permanent and cannot be undone.

Non-interactive: --yes is required to confirm deletion when stdin/stdout is not a TTY.`,
output: ` {"object":"template","id":"<uuid>","deleted":true}`,
errorCodes: ['auth_error', 'confirmation_required', 'delete_error'],
examples: [
'resend templates delete 78261eea-8f8b-4381-83c6-79fa7120f1cf',
'resend templates delete 78261eea-8f8b-4381-83c6-79fa7120f1cf --yes',
'resend templates rm my-template-alias --yes',
'resend templates delete 78261eea-8f8b-4381-83c6-79fa7120f1cf --yes --json',
],
}),
)
.action(async (id, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
await runDelete(
id,
!!opts.yes,
{
confirmMessage: `Delete template ${id}?\nThis action is permanent and cannot be undone.`,
spinner: {
loading: 'Deleting template...',
success: 'Template deleted',
fail: 'Failed to delete template',
},
object: 'template',
successMsg: 'Template deleted.',
sdkCall: (resend) => resend.templates.remove(id),
},
globalOpts,
);
});
39 changes: 39 additions & 0 deletions src/commands/templates/duplicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Command } from '@commander-js/extra-typings';
import { runCreate } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';

export const duplicateTemplateCommand = new Command('duplicate')
.description('Duplicate a template')
.argument('<id>', 'Template ID or alias to duplicate')
.addHelpText(
'after',
buildHelpText({
context: `Creates a copy of an existing template and returns the new template ID.
The duplicate is created as a draft with " (Copy)" appended to the original name.
All fields (HTML, subject, variables, etc.) are copied to the new template.`,
output: ` {"object":"template","id":"<new-template-id>"}`,
errorCodes: ['auth_error', 'create_error'],
examples: [
'resend templates duplicate 78261eea-8f8b-4381-83c6-79fa7120f1cf',
'resend templates duplicate my-template-alias --json',
],
}),
)
.action(async (id, _opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
await runCreate(
{
spinner: {
loading: 'Duplicating template...',
success: 'Template duplicated',
fail: 'Failed to duplicate template',
},
sdkCall: (resend) => Promise.resolve(resend.templates.duplicate(id)),
onInteractive: (d) => {
console.log(`\nTemplate duplicated: ${d.id}`);
},
},
globalOpts,
);
});
69 changes: 69 additions & 0 deletions src/commands/templates/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Command } from '@commander-js/extra-typings';
import { runGet } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';

export const getTemplateCommand = new Command('get')
.description('Retrieve a template by ID or alias')
.argument('<id>', 'Template ID or alias')
.addHelpText(
'after',
buildHelpText({
context: `Returns the full template including HTML body, variables, and publication status.`,
output: ` {"object":"template","id":"...","name":"...","subject":"...","status":"draft|published","html":"...","alias":"...","from":"...","reply_to":["..."],"variables":[...],"created_at":"...","updated_at":"..."}`,
errorCodes: ['auth_error', 'fetch_error'],
examples: [
'resend templates get 78261eea-8f8b-4381-83c6-79fa7120f1cf',
'resend templates get my-template-alias',
'resend templates get 78261eea-8f8b-4381-83c6-79fa7120f1cf --json',
],
}),
)
.action(async (id, _opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
await runGet(
{
spinner: {
loading: 'Fetching template...',
success: 'Template fetched',
fail: 'Failed to fetch template',
},
sdkCall: (resend) => resend.templates.get(id),
onInteractive: (data) => {
console.log(`\n${data.name}`);
console.log(`ID: ${data.id}`);
console.log(`Status: ${data.status}`);
if (data.alias) {
console.log(`Alias: ${data.alias}`);
}
if (data.subject) {
console.log(`Subject: ${data.subject}`);
}
if (data.from) {
console.log(`From: ${data.from}`);
}
if (data.reply_to?.length) {
console.log(`Reply-To: ${data.reply_to.join(', ')}`);
}
if (data.html) {
const snippet =
data.html.length > 200
? `${data.html.slice(0, 197)}...`
: data.html;
console.log(`HTML: ${snippet}`);
}
if (data.variables?.length) {
console.log(
`Variables: ${data.variables.map((v) => v.key).join(', ')}`,
);
}
if (data.published_at) {
console.log(`Published: ${data.published_at}`);
}
console.log(`Created: ${data.created_at}`);
console.log(`Updated: ${data.updated_at}`);
},
},
globalOpts,
);
});
48 changes: 48 additions & 0 deletions src/commands/templates/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Command } from '@commander-js/extra-typings';
import { buildHelpText } from '../../lib/help-text';
import { createTemplateCommand } from './create';
import { deleteTemplateCommand } from './delete';
import { duplicateTemplateCommand } from './duplicate';
import { getTemplateCommand } from './get';
import { listTemplatesCommand } from './list';
import { publishTemplateCommand } from './publish';
import { updateTemplateCommand } from './update';

export const templatesCommand = new Command('templates')
.description('Manage templates — reusable email templates with variables')
.addHelpText(
'after',
buildHelpText({
context: `Lifecycle:
Templates follow a draft → published workflow:
1. create — creates a draft template
2. update — edits name, subject, HTML, variables, etc.
3. publish — promotes the draft to published status
4. duplicate — copies an existing template as a new draft
Published templates can be used in emails via template_id.
After updating a published template, re-publish to make changes live.

Template variables:
Variables use triple-brace syntax in HTML: {{{VAR_NAME}}}
Each variable must be declared with --var when creating or updating:
--var KEY:type e.g. --var NAME:string
--var KEY:type:fallback e.g. --var PRICE:number:25
Valid types: string, number.`,
examples: [
'resend templates list',
'resend templates create --name "Welcome" --html "<h1>Hello {{{NAME}}}</h1>" --subject "Welcome!" --var NAME:string',
'resend templates get 78261eea-8f8b-4381-83c6-79fa7120f1cf',
'resend templates update 78261eea-8f8b-4381-83c6-79fa7120f1cf --subject "Updated Subject"',
'resend templates publish 78261eea-8f8b-4381-83c6-79fa7120f1cf',
'resend templates duplicate 78261eea-8f8b-4381-83c6-79fa7120f1cf',
'resend templates delete 78261eea-8f8b-4381-83c6-79fa7120f1cf --yes',
],
}),
)
.addCommand(createTemplateCommand)
.addCommand(getTemplateCommand)
.addCommand(listTemplatesCommand, { isDefault: true })
.addCommand(updateTemplateCommand)
.addCommand(deleteTemplateCommand)
.addCommand(publishTemplateCommand)
.addCommand(duplicateTemplateCommand);
Loading