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
8 changes: 7 additions & 1 deletion skills/resend-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ metadata:
author: resend
# Skill version is independent from the CLI/package.json version —
# bump it on skill content changes, not CLI releases.
version: "2.5.0"
version: "2.6.0"
homepage: https://resend.com/docs/cli-agents
source: https://github.com/resend/resend-cli
openclaw:
Expand Down Expand Up @@ -175,6 +175,7 @@ Read the matching reference file for detailed flags and output shapes.
| 7 | **Passing `--events` to `webhooks update` expecting additive behavior** | `--events` replaces the entire subscription list — always pass the complete set |
| 8 | **Expecting `logs list` to include request/response bodies** | List returns summary fields only — use `logs get <id>` for full `request_body` and `response_body` |
| 9 | **CSV import fails with `create_error` ("missing required email column")** | `contacts imports create` matches columns case-sensitively by lowercase names (`email`, `first_name`, `last_name`) — use `--column-map` for headers like `Email`/`First Name` |
| 10 | **URL attachment "succeeds" but the email never arrives** | The API fetches `--attachment "https://..."` URLs after returning the email ID — an unreachable URL fails the email asynchronously. Verify with `emails get <id>` (`last_event: "failed"`), and always pass `;filename=` and `;type=` since neither is derived from the URL (defaults: `attachment-0`, `application/octet-stream`) |

## Common Patterns

Expand All @@ -183,6 +184,11 @@ Read the matching reference file for detailed flags and output shapes.
resend emails send --from "you@domain.com" --to user@example.com --subject "Hello" --text "Body"
```

**Send an inline image (CID attachment) — always double-quote `;` params (required on bash, PowerShell, and cmd):**
```bash
resend emails send --from "you@domain.com" --to user@example.com --subject "Hello" --html "<img src=cid:logo>" --attachment "./logo.png;cid=logo"
```

**Send a React Email template (.tsx):**
```bash
resend emails send --from "you@domain.com" --to user@example.com --subject "Welcome" --react-email ./emails/welcome.tsx
Expand Down
1 change: 1 addition & 0 deletions skills/resend-cli/references/domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Create a new domain and receive DNS records to configure.
| `--region <region>` | string | No | `us-east-1` \| `eu-west-1` \| `sa-east-1` \| `ap-northeast-1` |
| `--tls <mode>` | string | No | `opportunistic` (default) \| `enforced` |
| `--tracking-subdomain <subdomain>` | string | No | Subdomain for click and open tracking (e.g., `track`) |
| `--custom-return-path <subdomain>` | string | No | Subdomain for the Return-Path address (e.g., `bounce`) |
| `--sending` | boolean | No | Enable sending (default: enabled) |
| `--receiving` | boolean | No | Enable receiving (default: disabled) |

Expand Down
18 changes: 17 additions & 1 deletion skills/resend-cli/references/emails.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,27 @@ Send an email via the Resend API.
| `--bcc <addresses...>` | string[] | No | BCC recipients |
| `--reply-to <address>` | string | No | Reply-to address |
| `--scheduled-at <datetime>` | string | No | Schedule for later — ISO 8601 or natural language (e.g. `"in 1 hour"`, `"tomorrow at 9am ET"`) |
| `--attachment <paths...>` | string[] | No | File paths to attach (not compatible with `--template`) |
| `--attachment <specs...>` | string[] | No | File path or `https://` URL to attach, with optional `;cid=`, `;type=`, `;filename=` params (not compatible with `--template`) |
| `--attachments-file <path>` | string | No | Path to a JSON array of attachment objects (`"-"` for stdin; not compatible with `--template`) |
| `--headers <key=value...>` | string[] | No | Custom headers |
| `--tags <name=value...>` | string[] | No | Email tags |
| `--idempotency-key <key>` | string | No | Deduplicate request |

**Attachment syntax:** append `;cid=<id>` (inline content-id referenced as `cid:` in HTML), `;type=<mime>`, and/or `;filename=<name>` to the path or URL. ALWAYS double-quote values containing `;` — single quotes break on Windows cmd, and unquoted `;` breaks on every shell:

```bash
resend emails send ... --html "<img src=cid:logo>" --attachment "./logo.png;cid=logo"
resend emails send ... --attachment "https://example.com/report.pdf;type=application/pdf"
```

For paths containing a literal `;key=` or for scripted use, pass `--attachments-file` with a JSON array of objects with `content` (base64) or `path` (URL), plus optional `filename`, `content_type`, `content_id` (camelCase also accepted).

**URL attachment caveats:** the API fetches the URL *after* the send request returns an email ID — an unreachable URL fails the email asynchronously (`last_event: "failed"` on `emails get <id>`). Filename and MIME type are NOT derived from the URL (stored as `attachment-0` / `application/octet-stream`), so pass `;filename=` and `;type=` with every URL attachment:

```bash
resend emails send ... --attachment "https://example.com/report.pdf;filename=report.pdf;type=application/pdf"
```

**Output:** `{"id":"<uuid>"}`

---
Expand Down
8 changes: 8 additions & 0 deletions src/commands/domains/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export const createDomainCommand = new Command('create')
'--tracking-subdomain <subdomain>',
'Subdomain for click and open tracking (e.g. track)',
)
.option(
'--custom-return-path <subdomain>',
'Subdomain for the Return-Path address (e.g. bounce)',
)
.option('--sending', 'Enable sending capability (default: enabled)')
.option('--receiving', 'Enable receiving capability (default: disabled)')
.addHelpText(
Expand All @@ -40,6 +44,7 @@ export const createDomainCommand = new Command('create')
'resend domains create --name example.com',
'resend domains create --name example.com --region eu-west-1 --tls enforced',
'resend domains create --name example.com --tracking-subdomain track',
'resend domains create --name example.com --custom-return-path bounce',
'resend domains create --name example.com --receiving --json',
'resend domains create --name example.com --sending --receiving --json',
],
Expand All @@ -66,6 +71,9 @@ export const createDomainCommand = new Command('create')
...(opts.trackingSubdomain && {
trackingSubdomain: opts.trackingSubdomain,
}),
...(opts.customReturnPath !== undefined && {
customReturnPath: opts.customReturnPath,
}),
...((opts.sending || opts.receiving) && {
capabilities: {
...(opts.sending && { sending: 'enabled' as const }),
Expand Down
107 changes: 85 additions & 22 deletions src/commands/emails/send.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import { Command } from '@commander-js/extra-typings';
import type { CreateEmailOptions } from 'resend';
import type { Attachment, CreateEmailOptions } from 'resend';
import {
type AttachmentSpec,
parseAttachmentSpec,
parseAttachmentsJson,
} from '../../lib/attachments';
import type { GlobalOpts } from '../../lib/client';
import { requireClient } from '../../lib/client';
import { fetchVerifiedDomains, promptForFromAddress } from '../../lib/domains';
Expand All @@ -21,10 +26,15 @@ function serializeEmailPayloadForDryRun(payload: CreateEmailOptions): unknown {
return {
...rest,
attachments: attachments.map((a) => ({
filename: a.filename,
byteLength: Buffer.isBuffer(a.content)
? a.content.byteLength
: Buffer.byteLength(String(a.content), 'utf8'),
...(a.filename !== undefined && { filename: a.filename }),
...(a.path && { path: a.path }),
...(a.contentType && { contentType: a.contentType }),
...(a.contentId && { contentId: a.contentId }),
...(a.content !== undefined && {
byteLength: Buffer.isBuffer(a.content)
? a.content.byteLength
: Buffer.byteLength(String(a.content), 'utf8'),
}),
})),
};
}
Expand Down Expand Up @@ -61,7 +71,14 @@ export const sendCommand = new Command('send')
'--scheduled-at <datetime>',
'Schedule email for later — ISO 8601 or natural language e.g. "in 1 hour", "tomorrow at 9am ET"',
)
.option('--attachment <paths...>', 'File path(s) to attach')
.option(
'--attachment <specs...>',
'File path or URL to attach, with optional ;cid= ;type= ;filename= params (quote the value)',
)
.option(
'--attachments-file <path>',
'Path to a JSON array of attachment objects (use "-" for stdin)',
)
.option(
'--headers <key=value...>',
'Custom headers as key=value pairs (e.g. X-Entity-Ref-ID=123)',
Expand All @@ -87,7 +104,7 @@ export const sendCommand = new Command('send')
'after',
buildHelpText({
context:
'Required: --to and either --template, --react-email, or (--from, --subject, and one of --text | --text-file | --html | --html-file).\nUse --dry-run to print the request JSON without sending (attachments show filename and byteLength only).',
'Required: --to and either --template, --react-email, or (--from, --subject, and one of --text | --text-file | --html | --html-file).\nAttachments: --attachment takes a local path or https:// URL plus optional ;cid= (inline content-id), ;type= (MIME type), ;filename= params. Always double-quote values containing ";" — required on every shell (bash, PowerShell, cmd). For paths containing ";key=" or scripted use, pass a JSON array via --attachments-file.\nURL attachments are fetched by the API after send: an unreachable URL fails the email (check `emails get <id>`), and filename/MIME type are not derived from the URL — pass ;filename= and ;type=.\nUse --dry-run to print the request JSON without sending (attachment content shows byteLength only).',
output: ' {"id":"<email-id>"}',
errorCodes: [
'auth_error',
Expand All @@ -98,6 +115,7 @@ export const sendCommand = new Command('send')
'invalid_header',
'invalid_tag',
'invalid_var',
'invalid_attachment',
'template_body_conflict',
'template_attachment_conflict',
'react_email_build_error',
Expand All @@ -108,18 +126,22 @@ export const sendCommand = new Command('send')
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --text "Hi"',
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --html "<b>Hi</b>"',
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --text "Hi" --attachment ./report.pdf',
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --html "<img src=cid:logo>" --attachment "./logo.png;cid=logo"',
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --text "Hi" --attachment "https://example.com/report.pdf;filename=report.pdf;type=application/pdf"',
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --text "Hi" --attachments-file ./attachments.json',
'resend emails send --template tmpl_123 --to delivered@resend.dev',
],
}),
)
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;

if (opts.htmlFile === '-' && opts.textFile === '-') {
const stdinReaders = [opts.htmlFile, opts.textFile, opts.attachmentsFile];
if (stdinReaders.filter((f) => f === '-').length > 1) {
outputError(
{
message:
'Cannot read both --html-file and --text-file from stdin. Pipe to one and pass the other as a file path.',
'Only one of --html-file, --text-file, or --attachments-file can read from stdin ("-"). Pass the others as file paths.',
code: 'invalid_options',
},
{ json: globalOpts.json },
Expand Down Expand Up @@ -173,10 +195,14 @@ export const sendCommand = new Command('send')
);
}

if (hasTemplate && opts.attachment) {
if (
hasTemplate &&
(opts.attachment || opts.attachmentsFile !== undefined)
) {
outputError(
{
message: 'Cannot use --attachment with --template',
message:
'Cannot use --attachment or --attachments-file with --template',
code: 'template_attachment_conflict',
},
{ json: globalOpts.json },
Expand Down Expand Up @@ -295,21 +321,58 @@ export const sendCommand = new Command('send')

const toAddresses = opts.to ?? [filled.to];

// Parse attachments from file paths
const attachments = opts.attachment?.map((filePath) => {
let attachments: Attachment[] | undefined = opts.attachment?.map(
(value) => {
let spec: AttachmentSpec;
try {
spec = parseAttachmentSpec(value);

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Unknown suffixes following a valid suffix are accepted as part of the preceding value, so --attachment "a.png;cid=logo;foo=bar" does not return invalid_attachment. Validate parameter-like tokens in every suffix value (while preserving MIME parameters in ;type=) before constructing metadata.

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

<comment>Unknown suffixes following a valid suffix are accepted as part of the preceding value, so `--attachment "a.png;cid=logo;foo=bar"` does not return `invalid_attachment`. Validate parameter-like tokens in every suffix value (while preserving MIME parameters in `;type=`) before constructing metadata.</comment>

<file context>
@@ -295,21 +318,58 @@ export const sendCommand = new Command('send')
+      (value) => {
+        let spec: AttachmentSpec;
+        try {
+          spec = parseAttachmentSpec(value);
+        } catch (err) {
+          return outputError(
</file context>
Fix with cubic

} catch (err) {
return outputError(
{ message: (err as Error).message, code: 'invalid_attachment' },
{ json: globalOpts.json },
);
}
const metadata = {
...(spec.contentType && { contentType: spec.contentType }),
...(spec.contentId && { contentId: spec.contentId }),
};
if (spec.isUrl) {
return {
path: spec.source,
...(spec.filename && { filename: spec.filename }),
...metadata,
};
}
try {
const content = readFileSync(spec.source);
return {
filename: spec.filename ?? basename(spec.source),
content,
...metadata,
};
} catch {
return outputError(
{
message: `Failed to read file: ${spec.source}`,
code: 'file_read_error',
},
{ json: globalOpts.json },
);
}
},
);

if (opts.attachmentsFile !== undefined) {
const raw = readFile(opts.attachmentsFile, globalOpts);
try {
const content = readFileSync(filePath);
return { filename: basename(filePath), content };
} catch {
return outputError(
{
message: `Failed to read file: ${filePath}`,
code: 'file_read_error',
},
attachments = [...(attachments ?? []), ...parseAttachmentsJson(raw)];
} catch (err) {
outputError(
{ message: (err as Error).message, code: 'invalid_attachment' },
{ json: globalOpts.json },
);
}
});
}

// Parse key=value headers
const headers = opts.headers
Expand Down
115 changes: 115 additions & 0 deletions src/lib/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Attachment } from 'resend';

export interface AttachmentSpec {
source: string;
isUrl: boolean;
filename?: string;
contentType?: string;
contentId?: string;
}

// Split only on recognized ";key=" tokens so paths and MIME parameters
// (e.g. "type=text/plain;charset=utf-8") containing ";" or "=" still parse.
const PARAM_SPLIT = /;(cid|type|filename)=/;
const PARAM_LIKE = /;[\w-]+=/;

const SPEC_FIELDS = {
cid: 'contentId',
type: 'contentType',
filename: 'filename',
} as const;

export function parseAttachmentSpec(value: string): AttachmentSpec {
const segments = value.split(PARAM_SPLIT);
const source = segments[0];
if (!source) {
throw new Error(`Missing file path or URL in attachment "${value}".`);
}
if (PARAM_LIKE.test(source)) {
throw new Error(
`Unrecognized attachment parameter in "${value}". Supported: ;cid=, ;type=, ;filename= (use --attachments-file for paths containing ";key=").`,
);
}
const spec: AttachmentSpec = {
source,
isUrl: /^https?:\/\//i.test(source),
};
for (let i = 1; i < segments.length; i += 2) {
const key = segments[i] as keyof typeof SPEC_FIELDS;
const paramValue = segments[i + 1];
const field = SPEC_FIELDS[key];
if (spec[field] !== undefined) {
throw new Error(`Duplicate ";${key}=" in attachment "${value}".`);
}
if (!paramValue) {
throw new Error(`Empty ";${key}=" in attachment "${value}".`);
}
// MIME parameters like "type=text/plain;charset=utf-8" are legitimate,
// but a ";key=" inside a cid or filename value is a typo.
if (field !== 'contentType' && PARAM_LIKE.test(paramValue)) {
throw new Error(
`Unrecognized attachment parameter in "${value}". Supported: ;cid=, ;type=, ;filename= (use --attachments-file for paths containing ";key=").`,
);
}
spec[field] = paramValue;
}
return spec;
}

const FIELD_ALIASES: Record<string, string> = {
content_type: 'contentType',
content_id: 'contentId',
};

const ALLOWED_FIELDS = new Set([
'content',
'filename',
'path',
'contentType',
'contentId',
]);

export function parseAttachmentsJson(raw: string): Attachment[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('Attachments file is not valid JSON.');
}
if (!Array.isArray(parsed)) {
throw new Error(
'Attachments file must contain a JSON array of attachment objects.',
);
}
return parsed.map((item, i) => {
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
throw new Error(`Attachment at index ${i} must be a JSON object.`);
}
const attachment: Record<string, string> = {};
for (const [rawKey, fieldValue] of Object.entries(item)) {
const key = FIELD_ALIASES[rawKey] ?? rawKey;
if (!ALLOWED_FIELDS.has(key)) {
throw new Error(
`Attachment at index ${i} has unsupported field "${rawKey}". Supported: content, filename, path, content_type/contentType, content_id/contentId.`,
);
}
if (key in attachment) {
throw new Error(
`Attachment at index ${i} sets "${key}" more than once (snake_case and camelCase are aliases).`,
);
}
if (typeof fieldValue !== 'string') {
throw new Error(
`Attachment at index ${i}: "${rawKey}" must be a string.`,
);
}
attachment[key] = fieldValue;
}
if (!attachment.content && !attachment.path) {
throw new Error(
`Attachment at index ${i} must include "content" (base64) or "path" (hosted URL).`,
);
}
return attachment as Attachment;
});
}
Loading
Loading