diff --git a/skills/resend-cli/SKILL.md b/skills/resend-cli/SKILL.md index 143027ef..9aaa35ef 100644 --- a/skills/resend-cli/SKILL.md +++ b/skills/resend-cli/SKILL.md @@ -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: @@ -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 ` 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 ` (`last_event: "failed"`), and always pass `;filename=` and `;type=` since neither is derived from the URL (defaults: `attachment-0`, `application/octet-stream`) | ## Common Patterns @@ -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 "" --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 diff --git a/skills/resend-cli/references/domains.md b/skills/resend-cli/references/domains.md index 5191a37c..8f1524e7 100644 --- a/skills/resend-cli/references/domains.md +++ b/skills/resend-cli/references/domains.md @@ -28,6 +28,7 @@ Create a new domain and receive DNS records to configure. | `--region ` | string | No | `us-east-1` \| `eu-west-1` \| `sa-east-1` \| `ap-northeast-1` | | `--tls ` | string | No | `opportunistic` (default) \| `enforced` | | `--tracking-subdomain ` | string | No | Subdomain for click and open tracking (e.g., `track`) | +| `--custom-return-path ` | 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) | diff --git a/skills/resend-cli/references/emails.md b/skills/resend-cli/references/emails.md index fb96efea..afe5f0ce 100644 --- a/skills/resend-cli/references/emails.md +++ b/skills/resend-cli/references/emails.md @@ -24,11 +24,27 @@ Send an email via the Resend API. | `--bcc ` | string[] | No | BCC recipients | | `--reply-to
` | string | No | Reply-to address | | `--scheduled-at ` | string | No | Schedule for later — ISO 8601 or natural language (e.g. `"in 1 hour"`, `"tomorrow at 9am ET"`) | -| `--attachment ` | string[] | No | File paths to attach (not compatible with `--template`) | +| `--attachment ` | string[] | No | File path or `https://` URL to attach, with optional `;cid=`, `;type=`, `;filename=` params (not compatible with `--template`) | +| `--attachments-file ` | string | No | Path to a JSON array of attachment objects (`"-"` for stdin; not compatible with `--template`) | | `--headers ` | string[] | No | Custom headers | | `--tags ` | string[] | No | Email tags | | `--idempotency-key ` | string | No | Deduplicate request | +**Attachment syntax:** append `;cid=` (inline content-id referenced as `cid:` in HTML), `;type=`, and/or `;filename=` 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 "" --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 `). 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":""}` --- diff --git a/src/commands/domains/create.ts b/src/commands/domains/create.ts index 9492a972..148be09c 100644 --- a/src/commands/domains/create.ts +++ b/src/commands/domains/create.ts @@ -26,6 +26,10 @@ export const createDomainCommand = new Command('create') '--tracking-subdomain ', 'Subdomain for click and open tracking (e.g. track)', ) + .option( + '--custom-return-path ', + 'Subdomain for the Return-Path address (e.g. bounce)', + ) .option('--sending', 'Enable sending capability (default: enabled)') .option('--receiving', 'Enable receiving capability (default: disabled)') .addHelpText( @@ -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', ], @@ -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 }), diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index 7b875f62..096f4e59 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -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'; @@ -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'), + }), })), }; } @@ -61,7 +71,14 @@ export const sendCommand = new Command('send') '--scheduled-at ', 'Schedule email for later — ISO 8601 or natural language e.g. "in 1 hour", "tomorrow at 9am ET"', ) - .option('--attachment ', 'File path(s) to attach') + .option( + '--attachment ', + 'File path or URL to attach, with optional ;cid= ;type= ;filename= params (quote the value)', + ) + .option( + '--attachments-file ', + 'Path to a JSON array of attachment objects (use "-" for stdin)', + ) .option( '--headers ', 'Custom headers as key=value pairs (e.g. X-Entity-Ref-ID=123)', @@ -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 `), 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":""}', errorCodes: [ 'auth_error', @@ -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', @@ -108,6 +126,9 @@ 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 "Hi"', '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 "" --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', ], }), @@ -115,11 +136,12 @@ export const sendCommand = new Command('send') .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 }, @@ -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 }, @@ -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); + } 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 diff --git a/src/lib/attachments.ts b/src/lib/attachments.ts new file mode 100644 index 00000000..80f391fe --- /dev/null +++ b/src/lib/attachments.ts @@ -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 = { + 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 = {}; + 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; + }); +} diff --git a/tests/commands/domains/create.test.ts b/tests/commands/domains/create.test.ts index 32c47d1b..dc7b98d0 100644 --- a/tests/commands/domains/create.test.ts +++ b/tests/commands/domains/create.test.ts @@ -115,6 +115,50 @@ describe('domains create command', () => { expect(args.trackingSubdomain).toBe('track'); }); + it('passes customReturnPath when --custom-return-path is set', async () => { + spies = setupOutputSpies(); + + const { createDomainCommand } = await import( + '../../../src/commands/domains/create' + ); + await createDomainCommand.parseAsync( + ['--name', 'example.com', '--custom-return-path', 'bounce'], + { from: 'user' }, + ); + + const args = mockCreate.mock.calls[0][0] as Record; + expect(args.customReturnPath).toBe('bounce'); + }); + + it('forwards an explicit empty --custom-return-path to the API', async () => { + spies = setupOutputSpies(); + + const { createDomainCommand } = await import( + '../../../src/commands/domains/create' + ); + await createDomainCommand.parseAsync( + ['--name', 'example.com', '--custom-return-path', ''], + { from: 'user' }, + ); + + const args = mockCreate.mock.calls[0][0] as Record; + expect(args.customReturnPath).toBe(''); + }); + + it('omits customReturnPath when flag is absent', async () => { + spies = setupOutputSpies(); + + const { createDomainCommand } = await import( + '../../../src/commands/domains/create' + ); + await createDomainCommand.parseAsync(['--name', 'example.com'], { + from: 'user', + }); + + const args = mockCreate.mock.calls[0][0] as Record; + expect('customReturnPath' in args).toBe(false); + }); + it('passes receiving capability when --receiving flag is set', async () => { spies = setupOutputSpies(); diff --git a/tests/commands/emails/send.test.ts b/tests/commands/emails/send.test.ts index 61286587..f488b3a4 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -453,6 +453,229 @@ describe('send command', () => { expect(output).toContain('file_read_error'); }); + it('parses ;cid= and ;type= attachment params into SDK fields', async () => { + spies = setupOutputSpies(); + + const tmpFile = join( + dirname(fileURLToPath(import.meta.url)), + '__test_inline.png', + ); + writeFileSync(tmpFile, 'png bytes'); + + try { + const { sendCommand } = await import('../../../src/commands/emails/send'); + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--html', + '', + '--attachment', + `${tmpFile};cid=logo;type=image/png;filename=logo.png`, + ], + { from: 'user' }, + ); + + const callArgs = mockSend.mock.calls[0][0] as Record; + const attachments = callArgs.attachments as Array< + Record + >; + expect(attachments).toHaveLength(1); + expect(attachments[0].filename).toBe('logo.png'); + expect(attachments[0].contentId).toBe('logo'); + expect(attachments[0].contentType).toBe('image/png'); + expect(Buffer.isBuffer(attachments[0].content)).toBe(true); + } finally { + unlinkSync(tmpFile); + } + }); + + it('passes URL attachments as path without reading a file', async () => { + spies = setupOutputSpies(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--text', + 'Hi', + '--attachment', + 'https://example.com/report.pdf;cid=doc', + ], + { from: 'user' }, + ); + + const callArgs = mockSend.mock.calls[0][0] as Record; + const attachments = callArgs.attachments as Array>; + expect(attachments).toEqual([ + { path: 'https://example.com/report.pdf', contentId: 'doc' }, + ]); + }); + + it('errors with invalid_attachment for unrecognized ;key= params', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--text', + 'Hi', + '--attachment', + './a.png;content-id=logo', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_attachment'); + }); + + it('merges --attachments-file entries after --attachment ones', async () => { + spies = setupOutputSpies(); + + const testDir = dirname(fileURLToPath(import.meta.url)); + const attachmentFile = join(testDir, '__test_merge.txt'); + const jsonFile = join(testDir, '__test_attachments.json'); + writeFileSync(attachmentFile, 'hi'); + writeFileSync( + jsonFile, + '[{"path":"https://example.com/a.pdf","content_id":"doc"}]', + ); + + try { + const { sendCommand } = await import('../../../src/commands/emails/send'); + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--text', + 'Hi', + '--attachment', + attachmentFile, + '--attachments-file', + jsonFile, + ], + { from: 'user' }, + ); + + const callArgs = mockSend.mock.calls[0][0] as Record; + const attachments = callArgs.attachments as Array< + Record + >; + expect(attachments).toHaveLength(2); + expect(attachments[0].filename).toBe('__test_merge.txt'); + expect(attachments[1]).toEqual({ + path: 'https://example.com/a.pdf', + contentId: 'doc', + }); + } finally { + unlinkSync(attachmentFile); + unlinkSync(jsonFile); + } + }); + + it('errors with invalid_attachment for a malformed attachments file', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const jsonFile = join( + dirname(fileURLToPath(import.meta.url)), + '__test_bad_attachments.json', + ); + writeFileSync(jsonFile, '{"path":"x"}'); + + try { + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--text', + 'Hi', + '--attachments-file', + jsonFile, + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_attachment'); + } finally { + unlinkSync(jsonFile); + } + }); + + it('dry-run summarizes attachment metadata without raw content', async () => { + spies = setupOutputSpies(); + + const tmpFile = join( + dirname(fileURLToPath(import.meta.url)), + '__test_dryrun.png', + ); + writeFileSync(tmpFile, 'png bytes'); + + try { + const { sendCommand } = await import('../../../src/commands/emails/send'); + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--text', + 'Hi', + '--attachment', + `${tmpFile};cid=logo`, + '--attachment', + 'https://example.com/report.pdf', + '--dry-run', + ], + { from: 'user' }, + ); + + expect(mockSend).not.toHaveBeenCalled(); + const out = JSON.parse(spies.logSpy.mock.calls[0][0] as string); + expect(out.request.attachments).toEqual([ + { filename: '__test_dryrun.png', contentId: 'logo', byteLength: 9 }, + { path: 'https://example.com/report.pdf' }, + ]); + } finally { + unlinkSync(tmpFile); + } + }); + it('parses key=value headers correctly', async () => { spies = setupOutputSpies(); @@ -1194,6 +1417,58 @@ describe('send command', () => { expect(output).toContain('template_attachment_conflict'); }); + it('errors with template_attachment_conflict when --template and --attachments-file used together', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--template', + 'tmpl_123', + '--to', + 'b@test.com', + '--attachments-file', + './attachments.json', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('template_attachment_conflict'); + }); + + it('does not silently ignore an empty --attachments-file value', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--text', + 'Hi', + '--attachments-file', + '', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('file_read_error'); + }); + it('sends email with --react-email flag', async () => { spies = setupOutputSpies(); diff --git a/tests/lib/attachments.test.ts b/tests/lib/attachments.test.ts new file mode 100644 index 00000000..6d0ebcf8 --- /dev/null +++ b/tests/lib/attachments.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { + parseAttachmentSpec, + parseAttachmentsJson, +} from '../../src/lib/attachments'; + +describe('parseAttachmentSpec', () => { + it('parses a bare path with no params', () => { + expect(parseAttachmentSpec('./report.pdf')).toEqual({ + source: './report.pdf', + isUrl: false, + }); + }); + + it('parses cid, type, and filename params', () => { + expect( + parseAttachmentSpec('./logo.png;cid=logo;type=image/png;filename=l.png'), + ).toEqual({ + source: './logo.png', + isUrl: false, + contentId: 'logo', + contentType: 'image/png', + filename: 'l.png', + }); + }); + + it('detects http and https URLs', () => { + expect(parseAttachmentSpec('https://example.com/a.pdf;cid=doc')).toEqual({ + source: 'https://example.com/a.pdf', + isUrl: true, + contentId: 'doc', + }); + expect(parseAttachmentSpec('HTTP://example.com/a.pdf').isUrl).toBe(true); + }); + + it('keeps MIME parameters inside the type value', () => { + expect( + parseAttachmentSpec('./a.txt;type=text/plain;charset=utf-8').contentType, + ).toBe('text/plain;charset=utf-8'); + }); + + it('accepts Windows paths and paths with plain semicolons or equals', () => { + expect(parseAttachmentSpec('C:\\Users\\me\\logo.png;cid=logo').source).toBe( + 'C:\\Users\\me\\logo.png', + ); + expect(parseAttachmentSpec('./odd;name.txt').source).toBe('./odd;name.txt'); + expect(parseAttachmentSpec('./key=value.txt').source).toBe( + './key=value.txt', + ); + }); + + it('rejects unrecognized ;key= params in the source', () => { + expect(() => parseAttachmentSpec('./a.png;content-id=logo')).toThrow( + /Unrecognized attachment parameter/, + ); + }); + + it('rejects unrecognized ;key= params inside cid and filename values', () => { + expect(() => parseAttachmentSpec('./a.png;cid=logo;foo=bar')).toThrow( + /Unrecognized attachment parameter/, + ); + expect(() => parseAttachmentSpec('./a.png;filename=a;foo=bar')).toThrow( + /Unrecognized attachment parameter/, + ); + }); + + it('rejects duplicate params', () => { + expect(() => parseAttachmentSpec('./a.png;cid=a;cid=b')).toThrow( + /Duplicate ";cid="/, + ); + }); + + it('rejects empty param values and empty source', () => { + expect(() => parseAttachmentSpec('./a.png;cid=')).toThrow(/Empty ";cid="/); + expect(() => parseAttachmentSpec(';cid=logo')).toThrow( + /Missing file path or URL/, + ); + }); +}); + +describe('parseAttachmentsJson', () => { + it('parses an array of attachment objects', () => { + expect( + parseAttachmentsJson( + '[{"path":"https://example.com/a.pdf","contentId":"doc"}]', + ), + ).toEqual([{ path: 'https://example.com/a.pdf', contentId: 'doc' }]); + }); + + it('aliases snake_case fields to camelCase', () => { + expect( + parseAttachmentsJson( + '[{"content":"aGk=","filename":"a.png","content_type":"image/png","content_id":"logo"}]', + ), + ).toEqual([ + { + content: 'aGk=', + filename: 'a.png', + contentType: 'image/png', + contentId: 'logo', + }, + ]); + }); + + it('rejects invalid JSON, non-arrays, and non-object items', () => { + expect(() => parseAttachmentsJson('not json')).toThrow(/not valid JSON/); + expect(() => parseAttachmentsJson('{"path":"x"}')).toThrow( + /must contain a JSON array/, + ); + expect(() => parseAttachmentsJson('["x"]')).toThrow( + /index 0 must be a JSON object/, + ); + }); + + it('rejects unsupported fields and non-string values', () => { + expect(() => parseAttachmentsJson('[{"path":"x","size":1}]')).toThrow( + /unsupported field "size"/, + ); + expect(() => parseAttachmentsJson('[{"path":1}]')).toThrow( + /"path" must be a string/, + ); + }); + + it('rejects setting a field via both snake_case and camelCase', () => { + expect(() => + parseAttachmentsJson('[{"path":"x","content_id":"a","contentId":"b"}]'), + ).toThrow(/more than once/); + }); + + it('rejects attachments without content or path', () => { + expect(() => parseAttachmentsJson('[{"filename":"a.png"}]')).toThrow( + /must include "content"/, + ); + }); +});