Skip to content

Commit a3e36e7

Browse files
plossonclaude
andcommitted
feat: add gmail draft command and unify reply into send/draft via --reply-to
Replace the standalone `gmail reply` command with a `--reply-to <thread-id>` option on both `send` and `draft` commands. This enables draft replies and reduces code duplication by sharing compose options, body/attachment parsing, and MIME building across send and draft flows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8433154 commit a3e36e7

File tree

6 files changed

+205
-191
lines changed

6 files changed

+205
-191
lines changed

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ src/
117117
agentio gmail list [--limit N] [--query Q] [--label L]
118118
agentio gmail get <message-id> [--format text|html|raw] [--body-only]
119119
agentio gmail search --query <query> [--limit N]
120-
agentio gmail send --to <email> --subject <subject> [--body <body>] [--attachment <path>]
121-
agentio gmail reply --thread-id <id> [--body <body>]
120+
agentio gmail send --to <email> --subject <subject> [--body <body>] [--attachment <path>] [--reply-to <thread-id>]
121+
agentio gmail draft --to <email> --subject <subject> [--body <body>] [--attachment <path>] [--reply-to <thread-id>]
122122
agentio gmail archive <message-id...>
123123
agentio gmail mark <message-id...> --read|--unread
124124
agentio gmail attachment <message-id> [--output <dir>]

claude/skills/agentio-gmail/SKILL.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: agentio-gmail
3-
description: Use when interacting with Gmail - list, read, search, send (with attachments/inline images), reply, archive, mark, download attachments, or export to PDF. Requires agentio CLI with a configured Gmail profile.
3+
description: Use when interacting with Gmail - list, read, search, send (with attachments/inline images), draft, reply (via --reply-to), archive, mark, download attachments, or export to PDF. Requires agentio CLI with a configured Gmail profile.
44
---
55

66
# Gmail Operations with agentio
@@ -53,15 +53,24 @@ agentio gmail send --to <email> --subject <subject> [--body <body>] [options]
5353
```
5454

5555
Options:
56-
- `--to <email>`: Recipient (repeatable)
56+
- `--to <email>`: Recipient (repeatable, required unless --reply-to)
5757
- `--cc <email>`: CC recipient (repeatable)
5858
- `--bcc <email>`: BCC recipient (repeatable)
59-
- `--subject <subject>`: Email subject
59+
- `--subject <subject>`: Email subject (required unless --reply-to)
6060
- `--body <body>`: Email body (or pipe via stdin)
6161
- `--html`: Treat body as HTML
62+
- `--reply-to <thread-id>`: Thread ID to reply to (derives to/subject from thread)
6263
- `--attachment <path>`: File to attach (repeatable)
6364
- `--inline <cid:path>`: Inline image (repeatable). Supports PNG, JPG, GIF only (not SVG)
6465

66+
### Replying to a Thread
67+
68+
```bash
69+
agentio gmail send --reply-to <thread-id> --body "Thanks!"
70+
```
71+
72+
When using `--reply-to`, the recipient and subject are derived from the thread. You can override them with explicit `--to`/`--subject`.
73+
6574
### Inline Images
6675

6776
To embed images directly in HTML emails, use `--inline` with a content ID and file path:
@@ -77,10 +86,16 @@ agentio gmail send \
7786

7887
Reference inline images in HTML using `src="cid:<contentId>"`.
7988

80-
## Reply to a Thread
89+
## Create a Draft
90+
91+
```bash
92+
agentio gmail draft --to <email> --subject <subject> [--body <body>] [options]
93+
```
94+
95+
Takes the same options as `send`. Supports `--reply-to` for draft replies:
8196

8297
```bash
83-
agentio gmail reply --thread-id <id> [--body <body>] [--html]
98+
agentio gmail draft --reply-to <thread-id> --body "Draft reply"
8499
```
85100

86101
## Archive a Message

src/commands/gmail.ts

Lines changed: 93 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,94 @@ import { setProfile, getProfile } from '../config/config-manager';
77
import { createProfileCommands } from '../utils/profile-commands';
88
import { performOAuthFlow } from '../auth/oauth';
99
import { GmailClient } from '../services/gmail/client';
10-
import { printMessageList, printMessage, printSendResult, printArchived, printMarked, printAttachmentList, printAttachmentDownloaded, raw } from '../utils/output';
10+
import { printMessageList, printMessage, printSendResult, printDraftResult, printArchived, printMarked, printAttachmentList, printAttachmentDownloaded, raw } from '../utils/output';
1111
import { CliError, handleError } from '../utils/errors';
1212
import { readStdin } from '../utils/stdin';
1313
import { enforceWriteAccess } from '../utils/read-only';
14-
import type { GmailAttachment } from '../types/gmail';
14+
import type { GmailAttachment, GmailSendOptions } from '../types/gmail';
15+
16+
function addComposeOptions(cmd: Command): Command {
17+
return cmd
18+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
19+
.option('--to <email>', 'Recipient (repeatable, required unless --reply-to)', (val: string, acc: string[]) => [...acc, val], [])
20+
.option('--cc <email>', 'CC recipient (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
21+
.option('--bcc <email>', 'BCC recipient (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
22+
.option('--subject <subject>', 'Email subject (required unless --reply-to)')
23+
.option('--body <body>', 'Email body (or pipe via stdin)')
24+
.option('--html', 'Treat body as HTML')
25+
.option('--reply-to <thread-id>', 'Thread ID to reply to (derives to/subject from thread)')
26+
.option('--attachment <path>', 'File to attach (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
27+
.option('--inline <cid:path>', 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)', (val: string, acc: string[]) => [...acc, val], []);
28+
}
29+
30+
async function parseBody(body: string | undefined): Promise<string> {
31+
if (!body) {
32+
body = await readStdin() ?? undefined;
33+
}
34+
if (!body) {
35+
throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
36+
}
37+
return body;
38+
}
39+
40+
function parseAttachments(paths: string[]): GmailAttachment[] {
41+
return paths.map((path: string) => ({
42+
path,
43+
filename: basename(path),
44+
}));
45+
}
46+
47+
function parseInlineAttachments(specs: string[]): GmailAttachment[] {
48+
return specs.map((spec: string) => {
49+
const colonIndex = spec.indexOf(':');
50+
if (colonIndex === -1) {
51+
throw new CliError('INVALID_PARAMS', `Invalid inline format: ${spec}`, 'Use format: contentId:filepath (e.g., logo:./logo.png)');
52+
}
53+
const contentId = spec.substring(0, colonIndex);
54+
const path = spec.substring(colonIndex + 1);
55+
return {
56+
path,
57+
filename: basename(path),
58+
contentId,
59+
};
60+
});
61+
}
62+
63+
async function parseSendOptions(options: Record<string, unknown>): Promise<GmailSendOptions> {
64+
const replyTo = options.replyTo as string | undefined;
65+
const to = options.to as string[];
66+
const subject = options.subject as string | undefined;
67+
68+
if (!replyTo) {
69+
if (!to.length) {
70+
throw new CliError('INVALID_PARAMS', '--to is required (unless using --reply-to)');
71+
}
72+
if (!subject) {
73+
throw new CliError('INVALID_PARAMS', '--subject is required (unless using --reply-to)');
74+
}
75+
}
76+
77+
const body = await parseBody(options.body as string | undefined);
78+
79+
const regularAttachments = parseAttachments(options.attachment as string[]);
80+
const inlineAttachments = parseInlineAttachments(options.inline as string[]);
81+
82+
const attachments: GmailAttachment[] | undefined =
83+
regularAttachments.length || inlineAttachments.length
84+
? [...regularAttachments, ...inlineAttachments]
85+
: undefined;
86+
87+
return {
88+
to,
89+
cc: (options.cc as string[]).length ? options.cc as string[] : undefined,
90+
bcc: (options.bcc as string[]).length ? options.bcc as string[] : undefined,
91+
subject: subject || '',
92+
body,
93+
isHtml: options.html as boolean | undefined,
94+
attachments,
95+
replyTo,
96+
};
97+
}
1598

1699
function escapeHtml(text: string): string {
17100
return text
@@ -176,112 +259,27 @@ Query Syntax Examples:
176259
}
177260
});
178261

179-
gmail
180-
.command('send')
181-
.description('Send an email')
182-
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
183-
.requiredOption('--to <email>', 'Recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
184-
.option('--cc <email>', 'CC recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
185-
.option('--bcc <email>', 'BCC recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
186-
.requiredOption('--subject <subject>', 'Email subject')
187-
.option('--body <body>', 'Email body (or pipe via stdin)')
188-
.option('--html', 'Treat body as HTML')
189-
.option('--attachment <path>', 'File to attach (repeatable)', (val, acc: string[]) => [...acc, val], [])
190-
.option('--inline <cid:path>', 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)', (val, acc: string[]) => [...acc, val], [])
262+
addComposeOptions(gmail.command('send').description('Send an email'))
191263
.action(async (options) => {
192264
try {
193-
let body = options.body;
194-
195-
// Check for stdin if no body provided
196-
if (!body) {
197-
body = await readStdin();
198-
}
199-
200-
if (!body) {
201-
throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
202-
}
203-
204-
// Process regular attachments
205-
const regularAttachments: GmailAttachment[] = options.attachment.map((path: string) => ({
206-
path,
207-
filename: basename(path),
208-
}));
209-
210-
// Process inline images (format: contentId:filepath)
211-
const inlineAttachments: GmailAttachment[] = options.inline.map((spec: string) => {
212-
const colonIndex = spec.indexOf(':');
213-
if (colonIndex === -1) {
214-
throw new CliError('INVALID_PARAMS', `Invalid inline format: ${spec}`, 'Use format: contentId:filepath (e.g., logo:./logo.png)');
215-
}
216-
const contentId = spec.substring(0, colonIndex);
217-
const path = spec.substring(colonIndex + 1);
218-
return {
219-
path,
220-
filename: basename(path),
221-
contentId,
222-
};
223-
});
224-
225-
// Combine attachments
226-
const attachments: GmailAttachment[] | undefined =
227-
regularAttachments.length || inlineAttachments.length
228-
? [...regularAttachments, ...inlineAttachments]
229-
: undefined;
230-
265+
const sendOptions = await parseSendOptions(options);
231266
const { client, profile } = await getGmailClient(options.profile);
232267
await enforceWriteAccess('gmail', profile, 'send email');
233-
const result = await client.send({
234-
to: options.to,
235-
cc: options.cc.length ? options.cc : undefined,
236-
bcc: options.bcc.length ? options.bcc : undefined,
237-
subject: options.subject,
238-
body,
239-
isHtml: options.html,
240-
attachments,
241-
});
268+
const result = await client.send(sendOptions);
242269
printSendResult(result);
243270
} catch (error) {
244271
handleError(error);
245272
}
246273
});
247274

248-
gmail
249-
.command('reply')
250-
.description('Reply to a thread')
251-
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
252-
.requiredOption('--thread-id <id>', 'Thread ID')
253-
.option('--body <body>', 'Reply body (or pipe via stdin)')
254-
.option('--html', 'Treat body as HTML')
255-
.option('--attachment <path>', 'File to attach (repeatable)', (val, acc: string[]) => [...acc, val], [])
275+
addComposeOptions(gmail.command('draft').description('Create an email draft'))
256276
.action(async (options) => {
257277
try {
258-
let body = options.body;
259-
260-
if (!body) {
261-
body = await readStdin();
262-
}
263-
264-
if (!body) {
265-
throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
266-
}
267-
268-
const attachments: GmailAttachment[] | undefined =
269-
options.attachment.length
270-
? options.attachment.map((path: string) => ({
271-
path,
272-
filename: basename(path),
273-
}))
274-
: undefined;
275-
278+
const sendOptions = await parseSendOptions(options);
276279
const { client, profile } = await getGmailClient(options.profile);
277-
await enforceWriteAccess('gmail', profile, 'reply to email');
278-
const result = await client.reply({
279-
threadId: options.threadId,
280-
body,
281-
isHtml: options.html,
282-
attachments,
283-
});
284-
printSendResult(result);
280+
await enforceWriteAccess('gmail', profile, 'create draft');
281+
const result = await client.draft(sendOptions);
282+
printDraftResult(result);
285283
} catch (error) {
286284
handleError(error);
287285
}

0 commit comments

Comments
 (0)