Skip to content

Commit c28ac74

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 c28ac74

6 files changed

Lines changed: 553 additions & 11 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ commit_message.txt
3737
# Release directory
3838
release/
3939

40+
# Configuration and Local Settings
41+
.mcp.json
42+
.claude/
43+
4044
# VitePress
4145
docs/.vitepress/dist
4246
docs/.vitepress/cache

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

Lines changed: 315 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

@@ -760,6 +760,25 @@ describe('GmailService', () => {
760760
expect(response.labelIds).toEqual(['SENT']);
761761
});
762762

763+
it('should support replyTo in email', async () => {
764+
mockGmailAPI.users.messages.send.mockResolvedValue({
765+
data: { id: 'sent-msg-reply' },
766+
});
767+
768+
await gmailService.send({
769+
to: 'recipient@example.com',
770+
subject: 'Test Subject',
771+
body: 'Test Body',
772+
replyTo: 'support@example.com',
773+
});
774+
775+
expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith(
776+
expect.objectContaining({
777+
replyTo: 'support@example.com',
778+
}),
779+
);
780+
});
781+
763782
it('should send email with multiple recipients', async () => {
764783
mockGmailAPI.users.messages.send.mockResolvedValue({
765784
data: { id: 'sent-msg-2' },
@@ -822,6 +841,13 @@ describe('GmailService', () => {
822841
(MimeHelper.createMimeMessage as jest.Mock) = jest
823842
.fn()
824843
.mockReturnValue('base64encodedmessage');
844+
(MimeHelper.createMimeMessageWithAttachments as jest.Mock) = jest
845+
.fn()
846+
.mockReturnValue('base64encodedmessage-with-attachments');
847+
(fs.stat as any).mockResolvedValue({
848+
isFile: () => true,
849+
size: 1024,
850+
});
825851
});
826852

827853
it('should create a draft email', async () => {
@@ -843,6 +869,18 @@ describe('GmailService', () => {
843869
body: 'Draft Body',
844870
});
845871

872+
expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith({
873+
to: 'recipient@example.com',
874+
subject: 'Draft Subject',
875+
body: 'Draft Body',
876+
cc: undefined,
877+
bcc: undefined,
878+
replyTo: undefined,
879+
isHtml: false,
880+
inReplyTo: undefined,
881+
references: undefined,
882+
});
883+
846884
expect(mockGmailAPI.users.drafts.create).toHaveBeenCalledWith({
847885
userId: 'me',
848886
requestBody: {
@@ -859,6 +897,60 @@ describe('GmailService', () => {
859897
expect(response.message.threadId).toBe('thread1');
860898
});
861899

900+
it('should support replyTo in draft email', async () => {
901+
mockGmailAPI.users.drafts.create.mockResolvedValue({
902+
data: { id: 'd-reply', message: { id: 'm-reply' } },
903+
});
904+
905+
await gmailService.createDraft({
906+
to: 'recipient@example.com',
907+
subject: 'Draft Subject',
908+
body: 'Draft Body',
909+
replyTo: 'support@example.com',
910+
});
911+
912+
expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith(
913+
expect.objectContaining({
914+
replyTo: 'support@example.com',
915+
}),
916+
);
917+
});
918+
919+
it('should enforce maximum total attachment size', async () => {
920+
(fs.stat as any).mockResolvedValue({
921+
isFile: () => true,
922+
size: 30 * 1024 * 1024, // 30MB
923+
});
924+
925+
const result = await gmailService.createDraft({
926+
to: 'recipient@example.com',
927+
subject: 'Too Large',
928+
body: 'Body',
929+
attachments: [{ filePath: '/tmp/huge.zip' }],
930+
});
931+
932+
const response = JSON.parse(result.content[0].text);
933+
expect(response.error).toContain('exceeds the maximum allowed limit');
934+
expect(fs.readFile).not.toHaveBeenCalled();
935+
});
936+
937+
it('should validate attachment path is a file', async () => {
938+
(fs.stat as any).mockResolvedValue({
939+
isFile: () => false,
940+
size: 0,
941+
});
942+
943+
const result = await gmailService.createDraft({
944+
to: 'recipient@example.com',
945+
subject: 'Not a file',
946+
body: 'Body',
947+
attachments: [{ filePath: '/tmp/directory' }],
948+
});
949+
950+
const response = JSON.parse(result.content[0].text);
951+
expect(response.error).toContain('path is not a file');
952+
});
953+
862954
it('should handle draft creation errors', async () => {
863955
const apiError = new Error('Failed to create draft');
864956
mockGmailAPI.users.drafts.create.mockRejectedValue(apiError);
@@ -994,6 +1086,228 @@ describe('GmailService', () => {
9941086
const response = JSON.parse(result.content[0].text);
9951087
expect(response.status).toBe('draft_created');
9961088
});
1089+
1090+
it('should create a draft with attachments using createMimeMessageWithAttachments', async () => {
1091+
const mockDraft = {
1092+
id: 'draft-attach-1',
1093+
message: { id: 'msg-attach-1', threadId: null, labelIds: ['DRAFT'] },
1094+
};
1095+
mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft });
1096+
1097+
const mockFileBuffer = Buffer.from('PDF content');
1098+
(fs.readFile as any).mockResolvedValue(mockFileBuffer);
1099+
1100+
const result = await gmailService.createDraft({
1101+
to: 'recipient@example.com',
1102+
subject: 'Draft with Attachment',
1103+
body: 'See attached.',
1104+
attachments: [{ filePath: '/tmp/report.pdf', mimeType: 'application/pdf' }],
1105+
});
1106+
1107+
expect((fs.readFile as any).mock.calls[0][0]).toBe('/tmp/report.pdf');
1108+
expect(
1109+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1110+
).toHaveBeenCalledWith(
1111+
expect.objectContaining({
1112+
attachments: [
1113+
{
1114+
filename: 'report.pdf',
1115+
content: mockFileBuffer,
1116+
contentType: 'application/pdf',
1117+
},
1118+
],
1119+
inReplyTo: undefined,
1120+
references: undefined,
1121+
}),
1122+
);
1123+
expect(MimeHelper.createMimeMessage).not.toHaveBeenCalled();
1124+
1125+
expect(mockGmailAPI.users.drafts.create).toHaveBeenCalledWith({
1126+
userId: 'me',
1127+
requestBody: { message: { raw: 'base64encodedmessage-with-attachments' } },
1128+
});
1129+
1130+
const response = JSON.parse(result.content[0].text);
1131+
expect(response.status).toBe('draft_created');
1132+
expect(response.id).toBe('draft-attach-1');
1133+
});
1134+
1135+
it('should use filename override when provided', async () => {
1136+
mockGmailAPI.users.drafts.create.mockResolvedValue({
1137+
data: { id: 'draft2', message: { id: 'msg2', threadId: null, labelIds: [] } },
1138+
});
1139+
(fs.readFile as any).mockResolvedValue(Buffer.from('data'));
1140+
1141+
await gmailService.createDraft({
1142+
to: 'a@example.com',
1143+
subject: 'S',
1144+
body: 'B',
1145+
attachments: [{ filePath: '/tmp/123abc.tmp', filename: 'custom-name.pdf' }],
1146+
});
1147+
1148+
expect(
1149+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1150+
).toHaveBeenCalledWith(
1151+
expect.objectContaining({
1152+
attachments: expect.arrayContaining([
1153+
expect.objectContaining({ filename: 'custom-name.pdf' }),
1154+
]),
1155+
}),
1156+
);
1157+
});
1158+
1159+
it('should infer MIME type from extension when mimeType not provided', async () => {
1160+
mockGmailAPI.users.drafts.create.mockResolvedValue({
1161+
data: { id: 'd3', message: { id: 'm3', threadId: null, labelIds: [] } },
1162+
});
1163+
(fs.readFile as any).mockResolvedValue(Buffer.from('data'));
1164+
1165+
await gmailService.createDraft({
1166+
to: 'a@example.com',
1167+
subject: 'S',
1168+
body: 'B',
1169+
attachments: [{ filePath: '/tmp/report.xlsx' }],
1170+
});
1171+
1172+
expect(
1173+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1174+
).toHaveBeenCalledWith(
1175+
expect.objectContaining({
1176+
attachments: expect.arrayContaining([
1177+
expect.objectContaining({
1178+
contentType:
1179+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1180+
}),
1181+
]),
1182+
}),
1183+
);
1184+
});
1185+
1186+
it('should fall back to application/octet-stream for unknown extension', async () => {
1187+
mockGmailAPI.users.drafts.create.mockResolvedValue({
1188+
data: { id: 'd4', message: { id: 'm4', threadId: null, labelIds: [] } },
1189+
});
1190+
(fs.readFile as any).mockResolvedValue(Buffer.from('data'));
1191+
1192+
await gmailService.createDraft({
1193+
to: 'a@example.com',
1194+
subject: 'S',
1195+
body: 'B',
1196+
attachments: [{ filePath: '/tmp/mystery.xyz' }],
1197+
});
1198+
1199+
expect(
1200+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1201+
).toHaveBeenCalledWith(
1202+
expect.objectContaining({
1203+
attachments: expect.arrayContaining([
1204+
expect.objectContaining({ contentType: 'application/octet-stream' }),
1205+
]),
1206+
}),
1207+
);
1208+
});
1209+
1210+
it('should pass inReplyTo and references to createMimeMessageWithAttachments for threaded draft with attachments', async () => {
1211+
const mockDraft = {
1212+
id: 'draft-thread-attach',
1213+
message: { id: 'msg-ta', threadId: 'thread1', labelIds: ['DRAFT'] },
1214+
};
1215+
mockGmailAPI.users.threads.get.mockResolvedValue({
1216+
data: {
1217+
messages: [
1218+
{
1219+
payload: {
1220+
headers: [
1221+
{ name: 'Message-ID', value: '<orig@mail.example.com>' },
1222+
{ name: 'References', value: '<prev@mail.example.com>' },
1223+
],
1224+
},
1225+
},
1226+
],
1227+
},
1228+
});
1229+
mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft });
1230+
(fs.readFile as any).mockResolvedValue(Buffer.from('data'));
1231+
1232+
const result = await gmailService.createDraft({
1233+
to: 'b@example.com',
1234+
subject: 'Re: Attached Reply',
1235+
body: 'See file.',
1236+
threadId: 'thread1',
1237+
attachments: [{ filePath: '/tmp/file.pdf' }],
1238+
});
1239+
1240+
expect(
1241+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1242+
).toHaveBeenCalledWith(
1243+
expect.objectContaining({
1244+
inReplyTo: '<orig@mail.example.com>',
1245+
references: '<prev@mail.example.com> <orig@mail.example.com>',
1246+
}),
1247+
);
1248+
expect(mockGmailAPI.users.drafts.create).toHaveBeenCalledWith({
1249+
userId: 'me',
1250+
requestBody: {
1251+
message: {
1252+
raw: 'base64encodedmessage-with-attachments',
1253+
threadId: 'thread1',
1254+
},
1255+
},
1256+
});
1257+
1258+
const response = JSON.parse(result.content[0].text);
1259+
expect(response.status).toBe('draft_created');
1260+
});
1261+
1262+
it('should reject a relative filePath and return error without calling Gmail API', async () => {
1263+
const result = await gmailService.createDraft({
1264+
to: 'a@example.com',
1265+
subject: 'S',
1266+
body: 'B',
1267+
attachments: [{ filePath: 'relative/path/file.pdf' }],
1268+
});
1269+
1270+
const response = JSON.parse(result.content[0].text);
1271+
expect(response.error).toContain('must be an absolute path');
1272+
expect(mockGmailAPI.users.drafts.create).not.toHaveBeenCalled();
1273+
expect((fs.readFile as any).mock.calls).toHaveLength(0);
1274+
});
1275+
1276+
it('should handle readFile failure (file not found) gracefully', async () => {
1277+
(fs.readFile as any).mockRejectedValue(
1278+
new Error('ENOENT: no such file'),
1279+
);
1280+
1281+
const result = await gmailService.createDraft({
1282+
to: 'a@example.com',
1283+
subject: 'S',
1284+
body: 'B',
1285+
attachments: [{ filePath: '/tmp/missing.pdf' }],
1286+
});
1287+
1288+
const response = JSON.parse(result.content[0].text);
1289+
expect(response.error).toContain('ENOENT');
1290+
expect(mockGmailAPI.users.drafts.create).not.toHaveBeenCalled();
1291+
});
1292+
1293+
it('should use createMimeMessage (not WithAttachments) when attachments array is empty', async () => {
1294+
mockGmailAPI.users.drafts.create.mockResolvedValue({
1295+
data: { id: 'd5', message: { id: 'm5', threadId: null, labelIds: [] } },
1296+
});
1297+
1298+
await gmailService.createDraft({
1299+
to: 'a@example.com',
1300+
subject: 'S',
1301+
body: 'B',
1302+
attachments: [],
1303+
});
1304+
1305+
expect(MimeHelper.createMimeMessage).toHaveBeenCalled();
1306+
expect(
1307+
MimeHelper.createMimeMessageWithAttachments as jest.Mock,
1308+
).not.toHaveBeenCalled();
1309+
expect((fs.readFile as any).mock.calls).toHaveLength(0);
1310+
});
9971311
});
9981312

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

0 commit comments

Comments
 (0)