Skip to content

Commit e381ba4

Browse files
plossonclaude
andcommitted
feat(gmail): add label CRUD and apply/remove commands
Adds `gmail labels {list,create,delete,rename}` for managing labels and `gmail label <id...>` with repeatable --apply/--remove and a --thread switch for operating on messages or threads. System labels are blocked from delete/rename but can be applied/removed (so --remove INBOX archives, --apply STARRED stars). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b29c1c7 commit e381ba4

6 files changed

Lines changed: 302 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ agentio gmail send --to <email> --subject <subject> [--body <body>] [--attachmen
137137
agentio gmail draft --to <email> --subject <subject> [--body <body>] [--attachment <path>] [--reply-to <thread-id>]
138138
agentio gmail archive <message-id...>
139139
agentio gmail mark <message-id...> --read|--unread
140+
agentio gmail labels list
141+
agentio gmail labels create <name> # "/" nests in the Gmail UI
142+
agentio gmail labels delete <name-or-id> # user labels only
143+
agentio gmail labels rename <old> <new>
144+
agentio gmail label <id...> [--apply <name>]... [--remove <name>]... [--thread]
140145
agentio gmail attachment <message-id> [--output <dir>]
141146
agentio gmail export <message-id> [--output <path>] # Export as PDF
142147
agentio gmail profile add|list|remove

claude/skills/agentio-gmail/SKILL.md

Lines changed: 21 additions & 1 deletion
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), draft, reply (via --reply-to), 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, manage labels (list/create/delete/rename, apply/remove on messages or threads), download attachments, or export to PDF. Requires agentio CLI with a configured Gmail profile.
44
---
55

66
# Gmail Operations with agentio
@@ -111,6 +111,26 @@ agentio gmail mark <message-id> --read
111111
agentio gmail mark <message-id> --unread
112112
```
113113

114+
## Manage Labels
115+
116+
```bash
117+
agentio gmail labels list
118+
agentio gmail labels create <name> # Use "/" for nesting, e.g. "auto/receipts"
119+
agentio gmail labels delete <name-or-id> # User labels only (system labels refused)
120+
agentio gmail labels rename <old> <new>
121+
```
122+
123+
## Apply / Remove Labels
124+
125+
```bash
126+
agentio gmail label <id...> [--apply <name>]... [--remove <name>]... [--thread]
127+
```
128+
129+
- `--apply` and `--remove` are repeatable and can be combined in one call.
130+
- IDs are message IDs by default; pass `--thread` to operate on threads instead.
131+
- Labels can be referenced by name or ID. System labels (`INBOX`, `STARRED`, `UNREAD`, `IMPORTANT`, ...) work too — e.g. `--remove INBOX` archives, `--apply STARRED` stars.
132+
- Idempotent: applying a label that's already present (or removing one that isn't) is a successful no-op.
133+
114134
## Download Attachments
115135

116136
```bash

src/commands/gmail.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ 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, printDraftResult, printArchived, printMarked, printAttachmentList, printAttachmentDownloaded, raw } from '../utils/output';
10+
import { printMessageList, printMessage, printSendResult, printDraftResult, printArchived, printMarked, printAttachmentList, printAttachmentDownloaded, printLabelList, printLabelCreated, printLabelDeleted, printLabelRenamed, printLabelModified, raw } from '../utils/output';
1111
import { CliError, handleError } from '../utils/errors';
1212
import { readStdin } from '../utils/stdin';
1313
import { enforceWriteAccess } from '../utils/read-only';
@@ -330,6 +330,107 @@ Query Syntax Examples:
330330
}
331331
});
332332

333+
const labels = gmail
334+
.command('labels')
335+
.description('Manage Gmail labels');
336+
337+
labels
338+
.command('list')
339+
.description('List all labels')
340+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
341+
.action(async (options) => {
342+
try {
343+
const { client } = await getGmailClient(options.profile);
344+
const result = await client.listLabels();
345+
printLabelList(result);
346+
} catch (error) {
347+
handleError(error);
348+
}
349+
});
350+
351+
labels
352+
.command('create')
353+
.argument('<name>', 'Label name (use "/" for nesting, e.g. "auto/receipts")')
354+
.description('Create a new label')
355+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
356+
.action(async (name: string, options) => {
357+
try {
358+
const { client, profile } = await getGmailClient(options.profile);
359+
await enforceWriteAccess('gmail', profile, 'create label');
360+
const result = await client.createLabel(name);
361+
printLabelCreated(result);
362+
} catch (error) {
363+
handleError(error);
364+
}
365+
});
366+
367+
labels
368+
.command('delete')
369+
.argument('<name-or-id>', 'Label name or ID')
370+
.description('Delete a user label')
371+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
372+
.action(async (nameOrId: string, options) => {
373+
try {
374+
const { client, profile } = await getGmailClient(options.profile);
375+
await enforceWriteAccess('gmail', profile, 'delete label');
376+
const result = await client.deleteLabel(nameOrId);
377+
printLabelDeleted(result.name, result.id);
378+
} catch (error) {
379+
handleError(error);
380+
}
381+
});
382+
383+
labels
384+
.command('rename')
385+
.argument('<old>', 'Existing label name or ID')
386+
.argument('<new>', 'New label name')
387+
.description('Rename a user label')
388+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
389+
.action(async (oldName: string, newName: string, options) => {
390+
try {
391+
const { client, profile } = await getGmailClient(options.profile);
392+
await enforceWriteAccess('gmail', profile, 'rename label');
393+
const result = await client.renameLabel(oldName, newName);
394+
printLabelRenamed(oldName, result);
395+
} catch (error) {
396+
handleError(error);
397+
}
398+
});
399+
400+
gmail
401+
.command('label')
402+
.argument('<id...>', 'Message ID(s) (or thread ID(s) with --thread)')
403+
.description('Apply and/or remove labels on messages or threads')
404+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
405+
.option('--apply <name>', 'Label to apply (name or ID, repeatable)', (val: string, acc: string[]) => [...acc, val], [])
406+
.option('--remove <name>', 'Label to remove (name or ID, repeatable)', (val: string, acc: string[]) => [...acc, val], [])
407+
.option('--thread', 'Treat IDs as thread IDs')
408+
.action(async (ids: string[], options) => {
409+
try {
410+
const apply = options.apply as string[];
411+
const remove = options.remove as string[];
412+
if (!apply.length && !remove.length) {
413+
throw new CliError('INVALID_PARAMS', 'Specify at least one --apply or --remove');
414+
}
415+
416+
const { client, profile } = await getGmailClient(options.profile);
417+
await enforceWriteAccess('gmail', profile, 'modify labels');
418+
419+
const [addLabelIds, removeLabelIds] = await Promise.all([
420+
client.resolveLabelIds(apply),
421+
client.resolveLabelIds(remove),
422+
]);
423+
424+
const isThread = options.thread === true;
425+
for (const id of ids) {
426+
await client.modifyLabels(id, addLabelIds, removeLabelIds, isThread);
427+
printLabelModified(id, isThread, apply, remove);
428+
}
429+
} catch (error) {
430+
handleError(error);
431+
}
432+
});
433+
333434
gmail
334435
.command('attachment')
335436
.argument('<message-id>', 'Message ID')

src/services/gmail/client.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { gmail, type gmail_v1 } from '@googleapis/gmail';
22
import type { OAuth2Client } from 'google-auth-library';
33
import { basename } from 'path';
4-
import type { GmailMessage, GmailListOptions, GmailSendOptions, GmailAttachment, GmailAttachmentInfo } from '../../types/gmail';
4+
import type { GmailMessage, GmailListOptions, GmailSendOptions, GmailAttachment, GmailAttachmentInfo, GmailLabel } from '../../types/gmail';
55
import type { ServiceClient, ValidationResult } from '../../types/service';
66
import { CliError } from '../../utils/errors';
77

@@ -588,6 +588,128 @@ export class GmailClient implements ServiceClient {
588588
}
589589
}
590590

591+
private mapLabel(label: gmail_v1.Schema$Label): GmailLabel {
592+
return {
593+
id: label.id!,
594+
name: label.name!,
595+
type: label.type === 'system' ? 'system' : 'user',
596+
...(label.messageListVisibility ? { messageListVisibility: label.messageListVisibility } : {}),
597+
...(label.labelListVisibility ? { labelListVisibility: label.labelListVisibility } : {}),
598+
};
599+
}
600+
601+
async listLabels(): Promise<GmailLabel[]> {
602+
try {
603+
const response = await this.gmail.users.labels.list({ userId: 'me' });
604+
const labels = (response.data.labels || []).map((l) => this.mapLabel(l));
605+
labels.sort((a, b) => {
606+
if (a.type !== b.type) return a.type === 'system' ? -1 : 1;
607+
return a.name.localeCompare(b.name);
608+
});
609+
return labels;
610+
} catch (error) {
611+
const message = error instanceof Error ? error.message : String(error);
612+
throw new CliError('API_ERROR', `Gmail API error: ${message}`);
613+
}
614+
}
615+
616+
async createLabel(name: string): Promise<GmailLabel> {
617+
try {
618+
const response = await this.gmail.users.labels.create({
619+
userId: 'me',
620+
requestBody: {
621+
name,
622+
messageListVisibility: 'show',
623+
labelListVisibility: 'labelShow',
624+
},
625+
});
626+
return this.mapLabel(response.data);
627+
} catch (error) {
628+
const message = error instanceof Error ? error.message : String(error);
629+
throw new CliError('API_ERROR', `Failed to create label: ${message}`);
630+
}
631+
}
632+
633+
async deleteLabel(nameOrId: string): Promise<{ id: string; name: string }> {
634+
const label = await this.resolveLabel(nameOrId);
635+
if (label.type === 'system') {
636+
throw new CliError('INVALID_PARAMS', `Cannot delete system label: ${label.name}`);
637+
}
638+
try {
639+
await this.gmail.users.labels.delete({ userId: 'me', id: label.id });
640+
return { id: label.id, name: label.name };
641+
} catch (error) {
642+
const message = error instanceof Error ? error.message : String(error);
643+
throw new CliError('API_ERROR', `Failed to delete label: ${message}`);
644+
}
645+
}
646+
647+
async renameLabel(oldNameOrId: string, newName: string): Promise<GmailLabel> {
648+
const label = await this.resolveLabel(oldNameOrId);
649+
if (label.type === 'system') {
650+
throw new CliError('INVALID_PARAMS', `Cannot rename system label: ${label.name}`);
651+
}
652+
try {
653+
const response = await this.gmail.users.labels.patch({
654+
userId: 'me',
655+
id: label.id,
656+
requestBody: { name: newName },
657+
});
658+
return this.mapLabel(response.data);
659+
} catch (error) {
660+
const message = error instanceof Error ? error.message : String(error);
661+
throw new CliError('API_ERROR', `Failed to rename label: ${message}`);
662+
}
663+
}
664+
665+
async resolveLabelIds(namesOrIds: string[]): Promise<string[]> {
666+
if (namesOrIds.length === 0) return [];
667+
const labels = await this.listLabels();
668+
const byId = new Map(labels.map((l) => [l.id, l]));
669+
const byName = new Map(labels.map((l) => [l.name.toLowerCase(), l]));
670+
return namesOrIds.map((input) => {
671+
const direct = byId.get(input);
672+
if (direct) return direct.id;
673+
const named = byName.get(input.toLowerCase());
674+
if (named) return named.id;
675+
throw new CliError('NOT_FOUND', `Label not found: ${input}`);
676+
});
677+
}
678+
679+
private async resolveLabel(nameOrId: string): Promise<GmailLabel> {
680+
const labels = await this.listLabels();
681+
const direct = labels.find((l) => l.id === nameOrId);
682+
if (direct) return direct;
683+
const named = labels.find((l) => l.name.toLowerCase() === nameOrId.toLowerCase());
684+
if (named) return named;
685+
throw new CliError('NOT_FOUND', `Label not found: ${nameOrId}`);
686+
}
687+
688+
async modifyLabels(
689+
id: string,
690+
addLabelIds: string[],
691+
removeLabelIds: string[],
692+
isThread: boolean,
693+
): Promise<void> {
694+
const requestBody = {
695+
...(addLabelIds.length ? { addLabelIds } : {}),
696+
...(removeLabelIds.length ? { removeLabelIds } : {}),
697+
};
698+
try {
699+
if (isThread) {
700+
await this.gmail.users.threads.modify({ userId: 'me', id, requestBody });
701+
} else {
702+
await this.gmail.users.messages.modify({ userId: 'me', id, requestBody });
703+
}
704+
} catch (error) {
705+
if (this.isNotFoundError(error)) {
706+
throw new CliError('NOT_FOUND', `${isThread ? 'Thread' : 'Message'} not found: ${id}`);
707+
}
708+
const message = error instanceof Error ? error.message : String(error);
709+
throw new CliError('API_ERROR', `Failed to modify labels: ${message}`);
710+
}
711+
}
712+
591713
private isNotFoundError(error: unknown): boolean {
592714
if (error && typeof error === 'object' && 'code' in error) {
593715
return (error as { code: unknown }).code === 404;

src/types/gmail.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ export interface GmailAttachmentInfo {
3232
size: number;
3333
}
3434

35+
export interface GmailLabel {
36+
id: string;
37+
name: string;
38+
type: 'system' | 'user';
39+
messageListVisibility?: string;
40+
labelListVisibility?: string;
41+
}
42+
3543
export interface GmailSendOptions {
3644
to: string[];
3745
cc?: string[];

src/utils/output.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { homedir } from 'os';
2-
import type { GmailMessage, GmailAttachmentInfo } from '../types/gmail';
2+
import type { GmailMessage, GmailAttachmentInfo, GmailLabel } from '../types/gmail';
33
import type { GChatMessage, GChatSpace, GChatMember, GChatUser } from '../types/gchat';
44
import type { GDocsDocument, GDocsCreateResult } from '../types/gdocs';
55
import type { GDriveFile, GDriveDownloadResult, GDriveUploadResult } from '../types/gdrive';
@@ -116,6 +116,48 @@ export function raw(text: string): void {
116116
console.log(text);
117117
}
118118

119+
// Format Gmail label list
120+
export function printLabelList(labels: GmailLabel[]): void {
121+
if (labels.length === 0) {
122+
console.log('No labels found');
123+
return;
124+
}
125+
const nameWidth = Math.max(4, ...labels.map((l) => l.name.length));
126+
const typeWidth = 6;
127+
console.log(`${'NAME'.padEnd(nameWidth)} ${'TYPE'.padEnd(typeWidth)} ID`);
128+
for (const label of labels) {
129+
console.log(`${label.name.padEnd(nameWidth)} ${label.type.padEnd(typeWidth)} ${label.id}`);
130+
}
131+
console.log(`\n${labels.length} label(s)`);
132+
}
133+
134+
export function printLabelCreated(label: GmailLabel): void {
135+
console.log(`Created label: ${label.name}`);
136+
console.log(`ID: ${label.id}`);
137+
}
138+
139+
export function printLabelDeleted(name: string, id: string): void {
140+
console.log(`Deleted label: ${name} (${id})`);
141+
}
142+
143+
export function printLabelRenamed(oldName: string, label: GmailLabel): void {
144+
console.log(`Renamed label: ${oldName} -> ${label.name}`);
145+
console.log(`ID: ${label.id}`);
146+
}
147+
148+
export function printLabelModified(
149+
id: string,
150+
isThread: boolean,
151+
applied: string[],
152+
removed: string[],
153+
): void {
154+
const target = isThread ? 'thread' : 'message';
155+
const parts: string[] = [];
156+
if (applied.length) parts.push(`applied [${applied.join(', ')}]`);
157+
if (removed.length) parts.push(`removed [${removed.join(', ')}]`);
158+
console.log(`${target} ${id}: ${parts.join('; ')}`);
159+
}
160+
119161
// Google Chat specific formatters
120162
export function printGChatSendResult(result: { messageId: string; spaceId?: string; isJsonPayload?: boolean }): void {
121163
console.log('Message sent');

0 commit comments

Comments
 (0)