Skip to content

Commit 7085769

Browse files
plossonclaude
andcommitted
feat: add attachment support to gmail send command
- Add --attachment <path> option (repeatable) to send command - Add GmailAttachment interface with filename, path, and mimeType - Implement multipart MIME message building for attachments - Add MIME type detection for common file extensions - Use Bun.file() for reading attachment contents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e6436a7 commit 7085769

3 files changed

Lines changed: 145 additions & 13 deletions

File tree

src/commands/gmail.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Command } from 'commander';
2+
import { basename } from 'path';
23
import { getValidTokens, createGoogleAuth } from '../auth/token-manager';
34
import { GmailClient } from '../services/gmail/client';
45
import { success } from '../utils/output';
56
import { CliError, handleError } from '../utils/errors';
67
import { readStdin } from '../utils/stdin';
8+
import type { GmailAttachment } from '../types/gmail';
79

810
async function getGmailClient(profileName?: string): Promise<{ client: GmailClient; profile: string }> {
911
const { tokens, profile } = await getValidTokens('gmail', profileName);
@@ -78,6 +80,7 @@ export function registerGmailCommands(program: Command): void {
7880
.requiredOption('--subject <subject>', 'Email subject')
7981
.option('--body <body>', 'Email body (or pipe via stdin)')
8082
.option('--html', 'Treat body as HTML')
83+
.option('--attachment <path>', 'File to attach (repeatable)', (val, acc: string[]) => [...acc, val], [])
8184
.action(async (options) => {
8285
try {
8386
let body = options.body;
@@ -91,6 +94,14 @@ export function registerGmailCommands(program: Command): void {
9194
throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
9295
}
9396

97+
// Process attachments
98+
const attachments: GmailAttachment[] | undefined = options.attachment.length
99+
? options.attachment.map((path: string) => ({
100+
path,
101+
filename: basename(path),
102+
}))
103+
: undefined;
104+
94105
const { client, profile } = await getGmailClient(options.profile);
95106
const result = await client.send({
96107
to: options.to,
@@ -99,6 +110,7 @@ export function registerGmailCommands(program: Command): void {
99110
subject: options.subject,
100111
body,
101112
isHtml: options.html,
113+
attachments,
102114
});
103115
success('gmail', 'send', profile, result);
104116
} catch (error) {

src/services/gmail/client.ts

Lines changed: 126 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,43 @@
11
import { google, gmail_v1 } from 'googleapis';
22
import type { OAuth2Client } from 'google-auth-library';
3-
import type { GmailMessage, GmailListOptions, GmailSendOptions, GmailReplyOptions } from '../../types/gmail';
3+
import { basename } from 'path';
4+
import type { GmailMessage, GmailListOptions, GmailSendOptions, GmailReplyOptions, GmailAttachment } from '../../types/gmail';
45
import { CliError } from '../../utils/errors';
56

7+
// Common MIME types by extension
8+
const MIME_TYPES: Record<string, string> = {
9+
'.txt': 'text/plain',
10+
'.html': 'text/html',
11+
'.css': 'text/css',
12+
'.js': 'application/javascript',
13+
'.json': 'application/json',
14+
'.xml': 'application/xml',
15+
'.pdf': 'application/pdf',
16+
'.zip': 'application/zip',
17+
'.gz': 'application/gzip',
18+
'.tar': 'application/x-tar',
19+
'.png': 'image/png',
20+
'.jpg': 'image/jpeg',
21+
'.jpeg': 'image/jpeg',
22+
'.gif': 'image/gif',
23+
'.svg': 'image/svg+xml',
24+
'.webp': 'image/webp',
25+
'.mp3': 'audio/mpeg',
26+
'.mp4': 'video/mp4',
27+
'.wav': 'audio/wav',
28+
'.doc': 'application/msword',
29+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
30+
'.xls': 'application/vnd.ms-excel',
31+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
32+
'.ppt': 'application/vnd.ms-powerpoint',
33+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
34+
};
35+
36+
function getMimeType(filename: string): string {
37+
const ext = filename.toLowerCase().match(/\.[^.]+$/)?.[0] || '';
38+
return MIME_TYPES[ext] || 'application/octet-stream';
39+
}
40+
641
export class GmailClient {
742
private gmail: gmail_v1.Gmail;
843
private userEmail: string | null = null;
@@ -139,21 +174,38 @@ export class GmailClient {
139174
}
140175

141176
async send(options: GmailSendOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
142-
const { to, cc, bcc, subject, body, isHtml } = options;
177+
const { to, cc, bcc, subject, body, isHtml, attachments } = options;
143178
const userEmail = await this.getUserEmail();
144179

145-
const headers = [
146-
`From: ${userEmail}`,
147-
`To: ${to.join(', ')}`,
148-
cc?.length ? `Cc: ${cc.join(', ')}` : '',
149-
bcc?.length ? `Bcc: ${bcc.join(', ')}` : '',
150-
`Subject: ${subject}`,
151-
`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
152-
'',
153-
body,
154-
].filter(Boolean).join('\r\n');
180+
let rawMessage: string;
181+
182+
if (attachments && attachments.length > 0) {
183+
// Build multipart MIME message with attachments
184+
rawMessage = await this.buildMultipartMessage({
185+
from: userEmail,
186+
to,
187+
cc,
188+
bcc,
189+
subject,
190+
body,
191+
isHtml,
192+
attachments,
193+
});
194+
} else {
195+
// Simple message without attachments
196+
rawMessage = [
197+
`From: ${userEmail}`,
198+
`To: ${to.join(', ')}`,
199+
cc?.length ? `Cc: ${cc.join(', ')}` : '',
200+
bcc?.length ? `Bcc: ${bcc.join(', ')}` : '',
201+
`Subject: ${subject}`,
202+
`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
203+
'',
204+
body,
205+
].filter(Boolean).join('\r\n');
206+
}
155207

156-
const encodedMessage = Buffer.from(headers).toString('base64url');
208+
const encodedMessage = Buffer.from(rawMessage).toString('base64url');
157209

158210
try {
159211
const response = await this.gmail.users.messages.send({
@@ -171,6 +223,67 @@ export class GmailClient {
171223
}
172224
}
173225

226+
private async buildMultipartMessage(options: {
227+
from: string;
228+
to: string[];
229+
cc?: string[];
230+
bcc?: string[];
231+
subject: string;
232+
body: string;
233+
isHtml?: boolean;
234+
attachments: GmailAttachment[];
235+
}): Promise<string> {
236+
const { from, to, cc, bcc, subject, body, isHtml, attachments } = options;
237+
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
238+
239+
const headers = [
240+
`From: ${from}`,
241+
`To: ${to.join(', ')}`,
242+
cc?.length ? `Cc: ${cc.join(', ')}` : '',
243+
bcc?.length ? `Bcc: ${bcc.join(', ')}` : '',
244+
`Subject: ${subject}`,
245+
'MIME-Version: 1.0',
246+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
247+
'',
248+
`--${boundary}`,
249+
`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
250+
'',
251+
body,
252+
].filter(Boolean);
253+
254+
// Add attachments
255+
for (const attachment of attachments) {
256+
try {
257+
const file = Bun.file(attachment.path);
258+
const exists = await file.exists();
259+
if (!exists) {
260+
throw new CliError('NOT_FOUND', `Attachment not found: ${attachment.path}`);
261+
}
262+
263+
const content = await file.arrayBuffer();
264+
const base64Content = Buffer.from(content).toString('base64');
265+
const filename = attachment.filename || basename(attachment.path);
266+
const mimeType = attachment.mimeType || getMimeType(filename);
267+
268+
headers.push(
269+
`--${boundary}`,
270+
`Content-Type: ${mimeType}; name="${filename}"`,
271+
'Content-Transfer-Encoding: base64',
272+
`Content-Disposition: attachment; filename="${filename}"`,
273+
'',
274+
base64Content
275+
);
276+
} catch (error: any) {
277+
if (error instanceof CliError) throw error;
278+
throw new CliError('API_ERROR', `Failed to read attachment ${attachment.path}: ${error.message}`);
279+
}
280+
}
281+
282+
headers.push(`--${boundary}--`);
283+
284+
return headers.join('\r\n');
285+
}
286+
174287
async reply(options: GmailReplyOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
175288
const { threadId, body, isHtml } = options;
176289

src/types/gmail.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,20 @@ export interface GmailListOptions {
1717
labels?: string[];
1818
}
1919

20+
export interface GmailAttachment {
21+
filename: string;
22+
path: string;
23+
mimeType?: string;
24+
}
25+
2026
export interface GmailSendOptions {
2127
to: string[];
2228
cc?: string[];
2329
bcc?: string[];
2430
subject: string;
2531
body: string;
2632
isHtml?: boolean;
33+
attachments?: GmailAttachment[];
2734
}
2835

2936
export interface GmailReplyOptions {

0 commit comments

Comments
 (0)