Skip to content

Commit 23599f4

Browse files
mickey-mikeyclaude
andcommitted
feat: add attachment support to gmail.createDraft tool
- Update MimeHelper.createMimeMessageWithAttachments to support inReplyTo and references headers for proper threading with attachments - Add attachments parameter to GmailService.createDraft with file reading and MIME type inference from file extension - Extend gmail.createDraft MCP tool schema to accept attachments array - Add comprehensive tests for attachment handling, MIME type detection, threading with attachments, error handling, and empty attachments - All 522 tests pass Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 9631183 commit 23599f4

7 files changed

Lines changed: 445 additions & 11 deletions

File tree

.claude/settings.local.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(git -C /c/Users/James/dev/workspace diff main HEAD -- workspace-server/src/services/GmailService.ts workspace-server/src/utils/MimeHelper.ts workspace-server/src/index.ts)",
5+
"Bash(git -C /c/Users/James/dev/workspace status)",
6+
"Bash(git -C /c/Users/James/dev/workspace log --oneline -5)",
7+
"Bash(git *)"
8+
]
9+
},
10+
"enableAllProjectMcpServers": true
11+
}

.mcp.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"mcpServers": {
3+
"gemini-workspace": {
4+
"type": "stdio",
5+
"command": "node",
6+
"args": [
7+
"workspace-server/dist/index.js",
8+
"--use-dot-names"
9+
],
10+
"env": {}
11+
}
12+
}
13+
}

workspace-server/src/__tests__/services/GmailService.test.ts

Lines changed: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { google } from 'googleapis';
2020

2121
// Mock the modules
2222
jest.mock('googleapis');
23-
jest.mock('fs/promises');
23+
jest.mock('node:fs/promises');
2424
jest.mock('../../utils/logger');
2525
jest.mock('../../utils/MimeHelper');
2626

@@ -822,6 +822,9 @@ describe('GmailService', () => {
822822
(MimeHelper.createMimeMessage as jest.Mock) = jest
823823
.fn()
824824
.mockReturnValue('base64encodedmessage');
825+
(MimeHelper.createMimeMessageWithAttachments as jest.Mock) = jest
826+
.fn()
827+
.mockReturnValue('base64encodedmessage-with-attachments');
825828
});
826829

827830
it('should create a draft email', async () => {
@@ -994,6 +997,228 @@ describe('GmailService', () => {
994997
const response = JSON.parse(result.content[0].text);
995998
expect(response.status).toBe('draft_created');
996999
});
1000+
1001+
it('should create a draft with attachments using createMimeMessageWithAttachments', async () => {
1002+
const mockDraft = {
1003+
id: 'draft-attach-1',
1004+
message: { id: 'msg-attach-1', threadId: null, labelIds: ['DRAFT'] },
1005+
};
1006+
mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft });
1007+
1008+
const mockFileBuffer = Buffer.from('PDF content');
1009+
(fs.readFile as any).mockResolvedValue(mockFileBuffer);
1010+
1011+
const result = await gmailService.createDraft({
1012+
to: 'recipient@example.com',
1013+
subject: 'Draft with Attachment',
1014+
body: 'See attached.',
1015+
attachments: [{ filePath: '/tmp/report.pdf', mimeType: 'application/pdf' }],
1016+
});
1017+
1018+
expect((fs.readFile as any).mock.calls[0][0]).toBe('/tmp/report.pdf');
1019+
expect(
1020+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1021+
).toHaveBeenCalledWith(
1022+
expect.objectContaining({
1023+
attachments: [
1024+
{
1025+
filename: 'report.pdf',
1026+
content: mockFileBuffer,
1027+
contentType: 'application/pdf',
1028+
},
1029+
],
1030+
inReplyTo: undefined,
1031+
references: undefined,
1032+
}),
1033+
);
1034+
expect(MimeHelper.createMimeMessage).not.toHaveBeenCalled();
1035+
1036+
expect(mockGmailAPI.users.drafts.create).toHaveBeenCalledWith({
1037+
userId: 'me',
1038+
requestBody: { message: { raw: 'base64encodedmessage-with-attachments' } },
1039+
});
1040+
1041+
const response = JSON.parse(result.content[0].text);
1042+
expect(response.status).toBe('draft_created');
1043+
expect(response.id).toBe('draft-attach-1');
1044+
});
1045+
1046+
it('should use filename override when provided', async () => {
1047+
mockGmailAPI.users.drafts.create.mockResolvedValue({
1048+
data: { id: 'draft2', message: { id: 'msg2', threadId: null, labelIds: [] } },
1049+
});
1050+
(fs.readFile as any).mockResolvedValue(Buffer.from('data'));
1051+
1052+
await gmailService.createDraft({
1053+
to: 'a@example.com',
1054+
subject: 'S',
1055+
body: 'B',
1056+
attachments: [{ filePath: '/tmp/123abc.tmp', filename: 'custom-name.pdf' }],
1057+
});
1058+
1059+
expect(
1060+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1061+
).toHaveBeenCalledWith(
1062+
expect.objectContaining({
1063+
attachments: expect.arrayContaining([
1064+
expect.objectContaining({ filename: 'custom-name.pdf' }),
1065+
]),
1066+
}),
1067+
);
1068+
});
1069+
1070+
it('should infer MIME type from extension when mimeType not provided', async () => {
1071+
mockGmailAPI.users.drafts.create.mockResolvedValue({
1072+
data: { id: 'd3', message: { id: 'm3', threadId: null, labelIds: [] } },
1073+
});
1074+
(fs.readFile as any).mockResolvedValue(Buffer.from('data'));
1075+
1076+
await gmailService.createDraft({
1077+
to: 'a@example.com',
1078+
subject: 'S',
1079+
body: 'B',
1080+
attachments: [{ filePath: '/tmp/report.xlsx' }],
1081+
});
1082+
1083+
expect(
1084+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1085+
).toHaveBeenCalledWith(
1086+
expect.objectContaining({
1087+
attachments: expect.arrayContaining([
1088+
expect.objectContaining({
1089+
contentType:
1090+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1091+
}),
1092+
]),
1093+
}),
1094+
);
1095+
});
1096+
1097+
it('should fall back to application/octet-stream for unknown extension', async () => {
1098+
mockGmailAPI.users.drafts.create.mockResolvedValue({
1099+
data: { id: 'd4', message: { id: 'm4', threadId: null, labelIds: [] } },
1100+
});
1101+
(fs.readFile as any).mockResolvedValue(Buffer.from('data'));
1102+
1103+
await gmailService.createDraft({
1104+
to: 'a@example.com',
1105+
subject: 'S',
1106+
body: 'B',
1107+
attachments: [{ filePath: '/tmp/mystery.xyz' }],
1108+
});
1109+
1110+
expect(
1111+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1112+
).toHaveBeenCalledWith(
1113+
expect.objectContaining({
1114+
attachments: expect.arrayContaining([
1115+
expect.objectContaining({ contentType: 'application/octet-stream' }),
1116+
]),
1117+
}),
1118+
);
1119+
});
1120+
1121+
it('should pass inReplyTo and references to createMimeMessageWithAttachments for threaded draft with attachments', async () => {
1122+
const mockDraft = {
1123+
id: 'draft-thread-attach',
1124+
message: { id: 'msg-ta', threadId: 'thread1', labelIds: ['DRAFT'] },
1125+
};
1126+
mockGmailAPI.users.threads.get.mockResolvedValue({
1127+
data: {
1128+
messages: [
1129+
{
1130+
payload: {
1131+
headers: [
1132+
{ name: 'Message-ID', value: '<orig@mail.example.com>' },
1133+
{ name: 'References', value: '<prev@mail.example.com>' },
1134+
],
1135+
},
1136+
},
1137+
],
1138+
},
1139+
});
1140+
mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft });
1141+
(fs.readFile as any).mockResolvedValue(Buffer.from('data'));
1142+
1143+
const result = await gmailService.createDraft({
1144+
to: 'b@example.com',
1145+
subject: 'Re: Attached Reply',
1146+
body: 'See file.',
1147+
threadId: 'thread1',
1148+
attachments: [{ filePath: '/tmp/file.pdf' }],
1149+
});
1150+
1151+
expect(
1152+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1153+
).toHaveBeenCalledWith(
1154+
expect.objectContaining({
1155+
inReplyTo: '<orig@mail.example.com>',
1156+
references: '<prev@mail.example.com> <orig@mail.example.com>',
1157+
}),
1158+
);
1159+
expect(mockGmailAPI.users.drafts.create).toHaveBeenCalledWith({
1160+
userId: 'me',
1161+
requestBody: {
1162+
message: {
1163+
raw: 'base64encodedmessage-with-attachments',
1164+
threadId: 'thread1',
1165+
},
1166+
},
1167+
});
1168+
1169+
const response = JSON.parse(result.content[0].text);
1170+
expect(response.status).toBe('draft_created');
1171+
});
1172+
1173+
it('should reject a relative filePath and return error without calling Gmail API', async () => {
1174+
const result = await gmailService.createDraft({
1175+
to: 'a@example.com',
1176+
subject: 'S',
1177+
body: 'B',
1178+
attachments: [{ filePath: 'relative/path/file.pdf' }],
1179+
});
1180+
1181+
const response = JSON.parse(result.content[0].text);
1182+
expect(response.error).toContain('must be an absolute path');
1183+
expect(mockGmailAPI.users.drafts.create).not.toHaveBeenCalled();
1184+
expect((fs.readFile as any).mock.calls).toHaveLength(0);
1185+
});
1186+
1187+
it('should handle readFile failure (file not found) gracefully', async () => {
1188+
(fs.readFile as any).mockRejectedValue(
1189+
new Error('ENOENT: no such file'),
1190+
);
1191+
1192+
const result = await gmailService.createDraft({
1193+
to: 'a@example.com',
1194+
subject: 'S',
1195+
body: 'B',
1196+
attachments: [{ filePath: '/tmp/missing.pdf' }],
1197+
});
1198+
1199+
const response = JSON.parse(result.content[0].text);
1200+
expect(response.error).toContain('ENOENT');
1201+
expect(mockGmailAPI.users.drafts.create).not.toHaveBeenCalled();
1202+
});
1203+
1204+
it('should use createMimeMessage (not WithAttachments) when attachments array is empty', async () => {
1205+
mockGmailAPI.users.drafts.create.mockResolvedValue({
1206+
data: { id: 'd5', message: { id: 'm5', threadId: null, labelIds: [] } },
1207+
});
1208+
1209+
await gmailService.createDraft({
1210+
to: 'a@example.com',
1211+
subject: 'S',
1212+
body: 'B',
1213+
attachments: [],
1214+
});
1215+
1216+
expect(MimeHelper.createMimeMessage).toHaveBeenCalled();
1217+
expect(
1218+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1219+
).not.toHaveBeenCalled();
1220+
expect((fs.readFile as any).mock.calls).toHaveLength(0);
1221+
});
9971222
});
9981223

9991224
describe('sendDraft', () => {

workspace-server/src/__tests__/utils/MimeHelper.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,74 @@ describe('MimeHelper', () => {
365365
expect(boundary2Match).toBeTruthy();
366366
expect(boundary1Match![1]).not.toBe(boundary2Match![1]);
367367
});
368+
369+
it('should include In-Reply-To and References headers when provided with attachments', () => {
370+
const messageId = '<original@mail.example.com>';
371+
const refs = '<earlier@mail.example.com> <original@mail.example.com>';
372+
const attachments = [
373+
{
374+
filename: 'test.txt',
375+
content: Buffer.from('hello'),
376+
contentType: 'text/plain',
377+
},
378+
];
379+
380+
const encoded = MimeHelper.createMimeMessageWithAttachments({
381+
to: 'recipient@example.com',
382+
subject: 'Re: Test',
383+
body: 'Reply with attachment',
384+
inReplyTo: messageId,
385+
references: refs,
386+
attachments,
387+
});
388+
389+
const decoded = MimeHelper.decodeBase64Url(encoded);
390+
391+
expect(decoded).toContain(`In-Reply-To: ${messageId}`);
392+
expect(decoded).toContain(`References: ${refs}`);
393+
// Still multipart
394+
expect(decoded).toContain('Content-Type: multipart/mixed; boundary=');
395+
});
396+
397+
it('should include In-Reply-To and References headers when no attachments (fallback path)', () => {
398+
const messageId = '<original@mail.example.com>';
399+
400+
const encoded = MimeHelper.createMimeMessageWithAttachments({
401+
to: 'recipient@example.com',
402+
subject: 'Re: Test',
403+
body: 'Reply without attachment',
404+
inReplyTo: messageId,
405+
references: messageId,
406+
// no attachments — exercises the createMimeMessage fallback
407+
});
408+
409+
const decoded = MimeHelper.decodeBase64Url(encoded);
410+
411+
expect(decoded).toContain(`In-Reply-To: ${messageId}`);
412+
expect(decoded).toContain(`References: ${messageId}`);
413+
});
414+
415+
it('should not include In-Reply-To or References in multipart message when not provided', () => {
416+
const attachments = [
417+
{
418+
filename: 'file.pdf',
419+
content: Buffer.from('data'),
420+
contentType: 'application/pdf',
421+
},
422+
];
423+
424+
const encoded = MimeHelper.createMimeMessageWithAttachments({
425+
to: 'recipient@example.com',
426+
subject: 'New Draft',
427+
body: 'Body',
428+
attachments,
429+
});
430+
431+
const decoded = MimeHelper.decodeBase64Url(encoded);
432+
433+
expect(decoded).not.toContain('In-Reply-To:');
434+
expect(decoded).not.toContain('References:');
435+
});
368436
});
369437

370438
describe('decodeBase64Url', () => {

workspace-server/src/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,6 +1299,32 @@ System labels that can be modified:
12991299
.describe(
13001300
'The thread ID to create the draft as a reply to. When provided, the draft will be linked to the existing thread with appropriate reply headers.',
13011301
),
1302+
attachments: z
1303+
.array(
1304+
z.object({
1305+
filePath: z
1306+
.string()
1307+
.describe(
1308+
'Absolute local filesystem path to the file to attach (e.g., "/Users/name/downloads/report.pdf"). Use gmail.downloadAttachment first to save an email attachment locally before referencing it here.',
1309+
),
1310+
filename: z
1311+
.string()
1312+
.optional()
1313+
.describe(
1314+
'Display name for the attachment in the email. Defaults to the filename portion of filePath.',
1315+
),
1316+
mimeType: z
1317+
.string()
1318+
.optional()
1319+
.describe(
1320+
'MIME type of the attachment (e.g., "application/pdf"). Inferred from the file extension when omitted; falls back to "application/octet-stream".',
1321+
),
1322+
}),
1323+
)
1324+
.optional()
1325+
.describe(
1326+
'Files to attach to the draft. Each entry must reference an absolute local path. Download attachments first with gmail.downloadAttachment if needed.',
1327+
),
13021328
},
13031329
},
13041330
gmailService.createDraft,

0 commit comments

Comments
 (0)