Skip to content

Commit 8ba38bd

Browse files
committed
feat: add custom return path flag and CID/URL attachment syntax
- domains create: new --custom-return-path <subdomain> flag (same as domains claim create) - emails send --attachment: optional ;cid=, ;type=, ;filename= params on paths, plus https:// URLs passed as hosted attachments - emails send --attachments-file: JSON array escape hatch (file or stdin) accepting snake_case and camelCase fields - dry-run now summarizes contentId/contentType/path (content stays redacted as byteLength) - document URL attachment caveats (async fetch failure, no derived filename/type) in help text and agent skill
1 parent b3773da commit 8ba38bd

9 files changed

Lines changed: 625 additions & 24 deletions

File tree

skills/resend-cli/SKILL.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ metadata:
1313
author: resend
1414
# Skill version is independent from the CLI/package.json version —
1515
# bump it on skill content changes, not CLI releases.
16-
version: "2.5.0"
16+
version: "2.6.0"
1717
homepage: https://resend.com/docs/cli-agents
1818
source: https://github.com/resend/resend-cli
1919
openclaw:
@@ -175,6 +175,7 @@ Read the matching reference file for detailed flags and output shapes.
175175
| 7 | **Passing `--events` to `webhooks update` expecting additive behavior** | `--events` replaces the entire subscription list — always pass the complete set |
176176
| 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` |
177177
| 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` |
178+
| 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`) |
178179

179180
## Common Patterns
180181

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

187+
**Send an inline image (CID attachment) — always double-quote `;` params (required on bash, PowerShell, and cmd):**
188+
```bash
189+
resend emails send --from "you@domain.com" --to user@example.com --subject "Hello" --html "<img src=cid:logo>" --attachment "./logo.png;cid=logo"
190+
```
191+
186192
**Send a React Email template (.tsx):**
187193
```bash
188194
resend emails send --from "you@domain.com" --to user@example.com --subject "Welcome" --react-email ./emails/welcome.tsx

skills/resend-cli/references/domains.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Create a new domain and receive DNS records to configure.
2828
| `--region <region>` | string | No | `us-east-1` \| `eu-west-1` \| `sa-east-1` \| `ap-northeast-1` |
2929
| `--tls <mode>` | string | No | `opportunistic` (default) \| `enforced` |
3030
| `--tracking-subdomain <subdomain>` | string | No | Subdomain for click and open tracking (e.g., `track`) |
31+
| `--custom-return-path <subdomain>` | string | No | Subdomain for the Return-Path address (e.g., `bounce`) |
3132
| `--sending` | boolean | No | Enable sending (default: enabled) |
3233
| `--receiving` | boolean | No | Enable receiving (default: disabled) |
3334

skills/resend-cli/references/emails.md

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

33+
**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:
34+
35+
```bash
36+
resend emails send ... --html "<img src=cid:logo>" --attachment "./logo.png;cid=logo"
37+
resend emails send ... --attachment "https://example.com/report.pdf;type=application/pdf"
38+
```
39+
40+
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).
41+
42+
**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:
43+
44+
```bash
45+
resend emails send ... --attachment "https://example.com/report.pdf;filename=report.pdf;type=application/pdf"
46+
```
47+
3248
**Output:** `{"id":"<uuid>"}`
3349

3450
---

src/commands/domains/create.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export const createDomainCommand = new Command('create')
2626
'--tracking-subdomain <subdomain>',
2727
'Subdomain for click and open tracking (e.g. track)',
2828
)
29+
.option(
30+
'--custom-return-path <subdomain>',
31+
'Subdomain for the Return-Path address (e.g. bounce)',
32+
)
2933
.option('--sending', 'Enable sending capability (default: enabled)')
3034
.option('--receiving', 'Enable receiving capability (default: disabled)')
3135
.addHelpText(
@@ -40,6 +44,7 @@ export const createDomainCommand = new Command('create')
4044
'resend domains create --name example.com',
4145
'resend domains create --name example.com --region eu-west-1 --tls enforced',
4246
'resend domains create --name example.com --tracking-subdomain track',
47+
'resend domains create --name example.com --custom-return-path bounce',
4348
'resend domains create --name example.com --receiving --json',
4449
'resend domains create --name example.com --sending --receiving --json',
4550
],
@@ -66,6 +71,9 @@ export const createDomainCommand = new Command('create')
6671
...(opts.trackingSubdomain && {
6772
trackingSubdomain: opts.trackingSubdomain,
6873
}),
74+
...(opts.customReturnPath && {
75+
customReturnPath: opts.customReturnPath,
76+
}),
6977
...((opts.sending || opts.receiving) && {
7078
capabilities: {
7179
...(opts.sending && { sending: 'enabled' as const }),

src/commands/emails/send.ts

Lines changed: 82 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { readFileSync } from 'node:fs';
22
import { basename } from 'node:path';
33
import { Command } from '@commander-js/extra-typings';
4-
import type { CreateEmailOptions } from 'resend';
4+
import type { Attachment, CreateEmailOptions } from 'resend';
5+
import {
6+
type AttachmentSpec,
7+
parseAttachmentSpec,
8+
parseAttachmentsJson,
9+
} from '../../lib/attachments';
510
import type { GlobalOpts } from '../../lib/client';
611
import { requireClient } from '../../lib/client';
712
import { fetchVerifiedDomains, promptForFromAddress } from '../../lib/domains';
@@ -21,10 +26,15 @@ function serializeEmailPayloadForDryRun(payload: CreateEmailOptions): unknown {
2126
return {
2227
...rest,
2328
attachments: attachments.map((a) => ({
24-
filename: a.filename,
25-
byteLength: Buffer.isBuffer(a.content)
26-
? a.content.byteLength
27-
: Buffer.byteLength(String(a.content), 'utf8'),
29+
...(a.filename !== undefined && { filename: a.filename }),
30+
...(a.path && { path: a.path }),
31+
...(a.contentType && { contentType: a.contentType }),
32+
...(a.contentId && { contentId: a.contentId }),
33+
...(a.content !== undefined && {
34+
byteLength: Buffer.isBuffer(a.content)
35+
? a.content.byteLength
36+
: Buffer.byteLength(String(a.content), 'utf8'),
37+
}),
2838
})),
2939
};
3040
}
@@ -61,7 +71,14 @@ export const sendCommand = new Command('send')
6171
'--scheduled-at <datetime>',
6272
'Schedule email for later — ISO 8601 or natural language e.g. "in 1 hour", "tomorrow at 9am ET"',
6373
)
64-
.option('--attachment <paths...>', 'File path(s) to attach')
74+
.option(
75+
'--attachment <specs...>',
76+
'File path or URL to attach, with optional ;cid= ;type= ;filename= params (quote the value)',
77+
)
78+
.option(
79+
'--attachments-file <path>',
80+
'Path to a JSON array of attachment objects (use "-" for stdin)',
81+
)
6582
.option(
6683
'--headers <key=value...>',
6784
'Custom headers as key=value pairs (e.g. X-Entity-Ref-ID=123)',
@@ -87,7 +104,7 @@ export const sendCommand = new Command('send')
87104
'after',
88105
buildHelpText({
89106
context:
90-
'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).',
107+
'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).',
91108
output: ' {"id":"<email-id>"}',
92109
errorCodes: [
93110
'auth_error',
@@ -98,6 +115,7 @@ export const sendCommand = new Command('send')
98115
'invalid_header',
99116
'invalid_tag',
100117
'invalid_var',
118+
'invalid_attachment',
101119
'template_body_conflict',
102120
'template_attachment_conflict',
103121
'react_email_build_error',
@@ -108,18 +126,22 @@ export const sendCommand = new Command('send')
108126
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --text "Hi"',
109127
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --html "<b>Hi</b>"',
110128
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --text "Hi" --attachment ./report.pdf',
129+
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --html "<img src=cid:logo>" --attachment "./logo.png;cid=logo"',
130+
'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"',
131+
'resend emails send --from onboarding@resend.dev --to delivered@resend.dev --subject "Hello" --text "Hi" --attachments-file ./attachments.json',
111132
'resend emails send --template tmpl_123 --to delivered@resend.dev',
112133
],
113134
}),
114135
)
115136
.action(async (opts, cmd) => {
116137
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
117138

118-
if (opts.htmlFile === '-' && opts.textFile === '-') {
139+
const stdinReaders = [opts.htmlFile, opts.textFile, opts.attachmentsFile];
140+
if (stdinReaders.filter((f) => f === '-').length > 1) {
119141
outputError(
120142
{
121143
message:
122-
'Cannot read both --html-file and --text-file from stdin. Pipe to one and pass the other as a file path.',
144+
'Only one of --html-file, --text-file, or --attachments-file can read from stdin ("-"). Pass the others as file paths.',
123145
code: 'invalid_options',
124146
},
125147
{ json: globalOpts.json },
@@ -173,10 +195,11 @@ export const sendCommand = new Command('send')
173195
);
174196
}
175197

176-
if (hasTemplate && opts.attachment) {
198+
if (hasTemplate && (opts.attachment || opts.attachmentsFile)) {
177199
outputError(
178200
{
179-
message: 'Cannot use --attachment with --template',
201+
message:
202+
'Cannot use --attachment or --attachments-file with --template',
180203
code: 'template_attachment_conflict',
181204
},
182205
{ json: globalOpts.json },
@@ -295,21 +318,58 @@ export const sendCommand = new Command('send')
295318

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

298-
// Parse attachments from file paths
299-
const attachments = opts.attachment?.map((filePath) => {
321+
let attachments: Attachment[] | undefined = opts.attachment?.map(
322+
(value) => {
323+
let spec: AttachmentSpec;
324+
try {
325+
spec = parseAttachmentSpec(value);
326+
} catch (err) {
327+
return outputError(
328+
{ message: (err as Error).message, code: 'invalid_attachment' },
329+
{ json: globalOpts.json },
330+
);
331+
}
332+
const metadata = {
333+
...(spec.contentType && { contentType: spec.contentType }),
334+
...(spec.contentId && { contentId: spec.contentId }),
335+
};
336+
if (spec.isUrl) {
337+
return {
338+
path: spec.source,
339+
...(spec.filename && { filename: spec.filename }),
340+
...metadata,
341+
};
342+
}
343+
try {
344+
const content = readFileSync(spec.source);
345+
return {
346+
filename: spec.filename ?? basename(spec.source),
347+
content,
348+
...metadata,
349+
};
350+
} catch {
351+
return outputError(
352+
{
353+
message: `Failed to read file: ${spec.source}`,
354+
code: 'file_read_error',
355+
},
356+
{ json: globalOpts.json },
357+
);
358+
}
359+
},
360+
);
361+
362+
if (opts.attachmentsFile) {
363+
const raw = readFile(opts.attachmentsFile, globalOpts);
300364
try {
301-
const content = readFileSync(filePath);
302-
return { filename: basename(filePath), content };
303-
} catch {
304-
return outputError(
305-
{
306-
message: `Failed to read file: ${filePath}`,
307-
code: 'file_read_error',
308-
},
365+
attachments = [...(attachments ?? []), ...parseAttachmentsJson(raw)];
366+
} catch (err) {
367+
outputError(
368+
{ message: (err as Error).message, code: 'invalid_attachment' },
309369
{ json: globalOpts.json },
310370
);
311371
}
312-
});
372+
}
313373

314374
// Parse key=value headers
315375
const headers = opts.headers

src/lib/attachments.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Attachment } from 'resend';
2+
3+
export interface AttachmentSpec {
4+
source: string;
5+
isUrl: boolean;
6+
filename?: string;
7+
contentType?: string;
8+
contentId?: string;
9+
}
10+
11+
// Split only on recognized ";key=" tokens so paths and MIME parameters
12+
// (e.g. "type=text/plain;charset=utf-8") containing ";" or "=" still parse.
13+
const PARAM_SPLIT = /;(cid|type|filename)=/;
14+
const PARAM_LIKE = /;[\w-]+=/;
15+
16+
const SPEC_FIELDS = {
17+
cid: 'contentId',
18+
type: 'contentType',
19+
filename: 'filename',
20+
} as const;
21+
22+
export function parseAttachmentSpec(value: string): AttachmentSpec {
23+
const segments = value.split(PARAM_SPLIT);
24+
const source = segments[0];
25+
if (!source) {
26+
throw new Error(`Missing file path or URL in attachment "${value}".`);
27+
}
28+
if (PARAM_LIKE.test(source)) {
29+
throw new Error(
30+
`Unrecognized attachment parameter in "${value}". Supported: ;cid=, ;type=, ;filename= (use --attachments-file for paths containing ";key=").`,
31+
);
32+
}
33+
const spec: AttachmentSpec = {
34+
source,
35+
isUrl: /^https?:\/\//i.test(source),
36+
};
37+
for (let i = 1; i < segments.length; i += 2) {
38+
const key = segments[i] as keyof typeof SPEC_FIELDS;
39+
const paramValue = segments[i + 1];
40+
const field = SPEC_FIELDS[key];
41+
if (spec[field] !== undefined) {
42+
throw new Error(`Duplicate ";${key}=" in attachment "${value}".`);
43+
}
44+
if (!paramValue) {
45+
throw new Error(`Empty ";${key}=" in attachment "${value}".`);
46+
}
47+
spec[field] = paramValue;
48+
}
49+
return spec;
50+
}
51+
52+
const FIELD_ALIASES: Record<string, string> = {
53+
content_type: 'contentType',
54+
content_id: 'contentId',
55+
};
56+
57+
const ALLOWED_FIELDS = new Set([
58+
'content',
59+
'filename',
60+
'path',
61+
'contentType',
62+
'contentId',
63+
]);
64+
65+
export function parseAttachmentsJson(raw: string): Attachment[] {
66+
let parsed: unknown;
67+
try {
68+
parsed = JSON.parse(raw);
69+
} catch {
70+
throw new Error('Attachments file is not valid JSON.');
71+
}
72+
if (!Array.isArray(parsed)) {
73+
throw new Error(
74+
'Attachments file must contain a JSON array of attachment objects.',
75+
);
76+
}
77+
return parsed.map((item, i) => {
78+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
79+
throw new Error(`Attachment at index ${i} must be a JSON object.`);
80+
}
81+
const attachment: Record<string, string> = {};
82+
for (const [rawKey, fieldValue] of Object.entries(item)) {
83+
const key = FIELD_ALIASES[rawKey] ?? rawKey;
84+
if (!ALLOWED_FIELDS.has(key)) {
85+
throw new Error(
86+
`Attachment at index ${i} has unsupported field "${rawKey}". Supported: content, filename, path, content_type, content_id.`,
87+
);
88+
}
89+
if (key in attachment) {
90+
throw new Error(
91+
`Attachment at index ${i} sets "${key}" more than once (snake_case and camelCase are aliases).`,
92+
);
93+
}
94+
if (typeof fieldValue !== 'string') {
95+
throw new Error(
96+
`Attachment at index ${i}: "${rawKey}" must be a string.`,
97+
);
98+
}
99+
attachment[key] = fieldValue;
100+
}
101+
if (!attachment.content && !attachment.path) {
102+
throw new Error(
103+
`Attachment at index ${i} must include "content" (base64) or "path" (hosted URL).`,
104+
);
105+
}
106+
return attachment as Attachment;
107+
});
108+
}

0 commit comments

Comments
 (0)