From 64fae91097e904952c8b2db50845cdd95e1d0a87 Mon Sep 17 00:00:00 2001 From: AOJDevStudio Date: Tue, 3 Mar 2026 19:42:25 -0600 Subject: [PATCH] feat(gmail): add reply, forward, attachment, and message management operations Implements GDRIVE-12: Complete human-parity email operations including: - replyToMessage with proper MIME threading headers - replyAllToMessage with recipient deduplication - forwardMessage with quoted original content - listAttachments, downloadAttachment, sendWithAttachments - trashMessage, untrashMessage, deleteMessage (with safety guard) - markAsRead, markAsUnread, archiveMessage Co-Authored-By: Claude Opus 4.6 --- .../sdk/runtime-rate-limiter-scope.test.ts | 7 +- .../gmail/__tests__/attachments.test.ts | 563 ++++++++++++++ src/modules/gmail/__tests__/forward.test.ts | 394 ++++++++++ src/modules/gmail/__tests__/manage.test.ts | 335 ++++++++ src/modules/gmail/__tests__/reply.test.ts | 712 ++++++++++++++++++ src/modules/gmail/attachments.ts | 264 +++++++ src/modules/gmail/forward.ts | 187 +++++ src/modules/gmail/index.ts | 44 +- src/modules/gmail/manage.ts | 283 +++++++ src/modules/gmail/reply.ts | 303 ++++++++ src/modules/gmail/types.ts | 303 ++++++++ src/modules/gmail/utils.ts | 117 +++ src/sdk/runtime.ts | 48 ++ src/sdk/spec.ts | 132 ++++ src/sdk/types.ts | 12 + 15 files changed, 3701 insertions(+), 3 deletions(-) create mode 100644 src/modules/gmail/__tests__/attachments.test.ts create mode 100644 src/modules/gmail/__tests__/forward.test.ts create mode 100644 src/modules/gmail/__tests__/manage.test.ts create mode 100644 src/modules/gmail/__tests__/reply.test.ts create mode 100644 src/modules/gmail/attachments.ts create mode 100644 src/modules/gmail/forward.ts create mode 100644 src/modules/gmail/manage.ts create mode 100644 src/modules/gmail/reply.ts diff --git a/src/__tests__/sdk/runtime-rate-limiter-scope.test.ts b/src/__tests__/sdk/runtime-rate-limiter-scope.test.ts index 061b15e..ee7742e 100644 --- a/src/__tests__/sdk/runtime-rate-limiter-scope.test.ts +++ b/src/__tests__/sdk/runtime-rate-limiter-scope.test.ts @@ -12,7 +12,10 @@ describe('createSDKRuntime rate limiter injection', () => { createSDKRuntime(context, limiter); createSDKRuntime(context, limiter); - // 47 wrapped SDK operations per runtime creation. - expect(wrap).toHaveBeenCalledTimes(94); + // 59 wrapped SDK operations per runtime creation (47 original + 12 new Gmail operations). + // Actual: replyToMessage, replyAllToMessage, forwardMessage, listAttachments, + // downloadAttachment, sendWithAttachments, trashMessage, untrashMessage, + // deleteMessage, markAsRead, markAsUnread, archiveMessage = 12 new. + expect(wrap).toHaveBeenCalledTimes(118); }); }); diff --git a/src/modules/gmail/__tests__/attachments.test.ts b/src/modules/gmail/__tests__/attachments.test.ts new file mode 100644 index 0000000..a5c5cbd --- /dev/null +++ b/src/modules/gmail/__tests__/attachments.test.ts @@ -0,0 +1,563 @@ +/** + * Tests for Gmail attachment operations - listAttachments, downloadAttachment, sendWithAttachments + */ + +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { listAttachments, downloadAttachment, sendWithAttachments } from '../attachments.js'; + +describe('listAttachments', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + messages: { + get: jest.fn(), + }, + }, + }; + + mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('returns attachment metadata for messages with attachments', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + mimeType: 'multipart/mixed', + headers: [ + { name: 'Subject', value: 'Email with attachment' }, + ], + parts: [ + { + mimeType: 'text/plain', + body: { data: Buffer.from('Body text').toString('base64url') }, + }, + { + mimeType: 'application/pdf', + filename: 'document.pdf', + body: { + attachmentId: 'att123', + size: 12345, + }, + }, + ], + }, + }, + }); + + const result = await listAttachments({ messageId: 'msg123' }, mockContext); + + expect(result.attachments).toHaveLength(1); + const att = result.attachments[0]!; + expect(att.filename).toBe('document.pdf'); + expect(att.mimeType).toBe('application/pdf'); + expect(att.attachmentId).toBe('att123'); + expect(att.size).toBe(12345); + }); + + test('returns empty array for messages with no attachments', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + mimeType: 'text/plain', + headers: [ + { name: 'Subject', value: 'Simple email' }, + ], + body: { data: Buffer.from('Just text').toString('base64url') }, + }, + }, + }); + + const result = await listAttachments({ messageId: 'msg123' }, mockContext); + + expect(result.attachments).toHaveLength(0); + }); + + test('includes filename, mimeType, size, and attachmentId per attachment', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + mimeType: 'multipart/mixed', + headers: [], + parts: [ + { + mimeType: 'image/png', + filename: 'photo.png', + body: { + attachmentId: 'attXYZ', + size: 98765, + }, + }, + ], + }, + }, + }); + + const result = await listAttachments({ messageId: 'msg123' }, mockContext); + + expect(result.attachments[0]!).toMatchObject({ + filename: 'photo.png', + mimeType: 'image/png', + attachmentId: 'attXYZ', + size: 98765, + }); + }); + + test('handles multiple attachments', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + mimeType: 'multipart/mixed', + headers: [], + parts: [ + { + mimeType: 'text/plain', + body: { data: Buffer.from('text').toString('base64url') }, + }, + { + mimeType: 'application/pdf', + filename: 'file1.pdf', + body: { attachmentId: 'att1', size: 1000 }, + }, + { + mimeType: 'image/jpeg', + filename: 'photo.jpg', + body: { attachmentId: 'att2', size: 2000 }, + }, + ], + }, + }, + }); + + const result = await listAttachments({ messageId: 'msg123' }, mockContext); + + expect(result.attachments).toHaveLength(2); + }); +}); + +describe('downloadAttachment', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + messages: { + attachments: { + get: jest.fn(), + }, + get: jest.fn(), + }, + }, + }; + + mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('calls users.messages.attachments.get with correct params', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + payload: { + mimeType: 'multipart/mixed', + headers: [], + parts: [ + { + mimeType: 'application/pdf', + filename: 'doc.pdf', + body: { attachmentId: 'att123', size: 500 }, + }, + ], + }, + }, + }); + + mockGmailApi.users.messages.attachments.get.mockResolvedValue({ + data: { + size: 500, + data: Buffer.from('PDF content').toString('base64url'), + }, + }); + + await downloadAttachment( + { messageId: 'msg123', attachmentId: 'att123' }, + mockContext + ); + + expect(mockGmailApi.users.messages.attachments.get).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + messageId: 'msg123', + id: 'att123', + }) + ); + }); + + test('returns base64-encoded attachment data', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + payload: { + mimeType: 'multipart/mixed', + headers: [], + parts: [ + { + mimeType: 'text/plain', + filename: 'file.txt', + body: { attachmentId: 'att123', size: 100 }, + }, + ], + }, + }, + }); + + const attachmentData = Buffer.from('File content here').toString('base64url'); + mockGmailApi.users.messages.attachments.get.mockResolvedValue({ + data: { + size: 100, + data: attachmentData, + }, + }); + + const result = await downloadAttachment( + { messageId: 'msg123', attachmentId: 'att123' }, + mockContext + ); + + expect(result.data).toBe(attachmentData); + }); + + test('returns filename and mimeType in result', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + payload: { + mimeType: 'multipart/mixed', + headers: [], + parts: [ + { + mimeType: 'image/png', + filename: 'image.png', + body: { attachmentId: 'att999', size: 300 }, + }, + ], + }, + }, + }); + + mockGmailApi.users.messages.attachments.get.mockResolvedValue({ + data: { + size: 300, + data: Buffer.from('PNG data').toString('base64url'), + }, + }); + + const result = await downloadAttachment( + { messageId: 'msg123', attachmentId: 'att999' }, + mockContext + ); + + expect(result.filename).toBe('image.png'); + expect(result.mimeType).toBe('image/png'); + expect(result.size).toBe(300); + }); +}); + +describe('sendWithAttachments', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + messages: { + send: jest.fn(), + }, + }, + }; + + mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('builds multipart/mixed MIME message', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'sent123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await sendWithAttachments( + { + to: ['recipient@example.com'], + subject: 'Email with file', + body: 'Please see attached.', + attachments: [ + { + filename: 'test.txt', + mimeType: 'text/plain', + data: Buffer.from('file contents').toString('base64'), + }, + ], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('Content-Type: multipart/mixed'); + }); + + test('includes text body as first part of multipart message', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'sent123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await sendWithAttachments( + { + to: ['recipient@example.com'], + subject: 'Test', + body: 'Hello there, see attached.', + attachments: [ + { + filename: 'file.txt', + mimeType: 'text/plain', + data: Buffer.from('data').toString('base64'), + }, + ], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('Hello there, see attached.'); + expect(rawDecoded).toContain('Content-Type: text/plain'); + }); + + test('encodes each attachment as base64 in MIME part', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'sent123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + const attachmentContent = Buffer.from('attachment data here').toString('base64'); + + await sendWithAttachments( + { + to: ['recipient@example.com'], + subject: 'Test', + body: 'Body', + attachments: [ + { + filename: 'data.bin', + mimeType: 'application/octet-stream', + data: attachmentContent, + }, + ], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('Content-Transfer-Encoding: base64'); + }); + + test('includes Content-Disposition: attachment header per part', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'sent123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await sendWithAttachments( + { + to: ['recipient@example.com'], + subject: 'Test', + body: 'Body', + attachments: [ + { + filename: 'report.pdf', + mimeType: 'application/pdf', + data: Buffer.from('pdf data').toString('base64'), + }, + ], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('Content-Disposition: attachment; filename="report.pdf"'); + }); + + test('uses unique boundary string in Content-Type header', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'sent123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await sendWithAttachments( + { + to: ['recipient@example.com'], + subject: 'Test', + body: 'Body', + attachments: [ + { + filename: 'file.txt', + mimeType: 'text/plain', + data: Buffer.from('content').toString('base64'), + }, + ], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toMatch(/Content-Type: multipart\/mixed; boundary="[^"]+"/); + }); + + test('validates all recipient email addresses', async () => { + await expect( + sendWithAttachments( + { + to: ['invalid-email'], + subject: 'Test', + body: 'Body', + attachments: [], + }, + mockContext + ) + ).rejects.toThrow(); + }); + + test('invalidates gmail:list and gmail:search caches', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'sent123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await sendWithAttachments( + { + to: ['recipient@example.com'], + subject: 'Test', + body: 'Body', + attachments: [ + { + filename: 'file.txt', + mimeType: 'text/plain', + data: Buffer.from('content').toString('base64'), + }, + ], + }, + mockContext + ); + + expect(mockContext.cacheManager.invalidate).toHaveBeenCalledWith('gmail:list'); + expect(mockContext.cacheManager.invalidate).toHaveBeenCalledWith('gmail:search'); + }); + + test('returns messageId, threadId, labelIds, and message', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'sent999', + threadId: 'thread999', + labelIds: ['SENT', 'INBOX'], + }, + }); + + const result = await sendWithAttachments( + { + to: ['recipient@example.com'], + subject: 'Test', + body: 'Body', + attachments: [], + }, + mockContext + ); + + expect(result.messageId).toBe('sent999'); + expect(result.threadId).toBe('thread999'); + expect(result.labelIds).toEqual(['SENT', 'INBOX']); + expect(result.message).toBe('Message sent successfully'); + }); +}); diff --git a/src/modules/gmail/__tests__/forward.test.ts b/src/modules/gmail/__tests__/forward.test.ts new file mode 100644 index 0000000..45cba81 --- /dev/null +++ b/src/modules/gmail/__tests__/forward.test.ts @@ -0,0 +1,394 @@ +/** + * Tests for Gmail forward operations - forwardMessage + */ + +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { forwardMessage } from '../forward.js'; + +describe('forwardMessage', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + messages: { + get: jest.fn(), + send: jest.fn(), + }, + }, + }; + + mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('fetches original message for content and headers', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Original Subject' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Original body').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread123', + labelIds: ['SENT'], + }, + }); + + await forwardMessage( + { + messageId: 'msg123', + to: ['recipient@example.com'], + }, + mockContext + ); + + expect(mockGmailApi.users.messages.get).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + id: 'msg123', + format: 'full', + }) + ); + }); + + test('prefixes subject with Fwd: when not already present', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Meeting Notes' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Notes content').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread123', + labelIds: ['SENT'], + }, + }); + + await forwardMessage( + { + messageId: 'msg123', + to: ['recipient@example.com'], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('Subject: Fwd: Meeting Notes'); + }); + + test('does not double-prefix subject already starting with Fwd:', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Fwd: Already forwarded' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Content').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread123', + labelIds: ['SENT'], + }, + }); + + await forwardMessage( + { + messageId: 'msg123', + to: ['recipient@example.com'], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('Subject: Fwd: Already forwarded'); + expect(rawDecoded).not.toContain('Subject: Fwd: Fwd:'); + }); + + test('includes quoted original email in body', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Original' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('This is the original body').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread123', + labelIds: ['SENT'], + }, + }); + + await forwardMessage( + { + messageId: 'msg123', + to: ['recipient@example.com'], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('This is the original body'); + }); + + test('prepends custom body before quoted original when body provided', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Original' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Original body content').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread123', + labelIds: ['SENT'], + }, + }); + + await forwardMessage( + { + messageId: 'msg123', + to: ['recipient@example.com'], + body: 'FYI, see below.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + const bodyIndex = rawDecoded.indexOf('\r\n\r\n'); + const bodyContent = rawDecoded.substring(bodyIndex + 4); + // Custom body should appear before the quoted original + expect(bodyContent.indexOf('FYI, see below.')).toBeLessThan(bodyContent.indexOf('Original body content')); + }); + + test('sends to specified to recipients', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Body').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread123', + labelIds: ['SENT'], + }, + }); + + await forwardMessage( + { + messageId: 'msg123', + to: ['target@example.com', 'target2@example.com'], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('target@example.com'); + expect(rawDecoded).toContain('target2@example.com'); + }); + + test('does not set threading headers (new thread)', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + { name: 'Message-ID', value: '' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Body').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread123', + labelIds: ['SENT'], + }, + }); + + await forwardMessage( + { + messageId: 'msg123', + to: ['target@example.com'], + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).not.toContain('In-Reply-To:'); + // threadId should not be set on request (forward creates new thread) + expect(sendCall.requestBody.threadId).toBeUndefined(); + }); + + test('invalidates gmail:list cache after sending', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Body').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread123', + labelIds: ['SENT'], + }, + }); + + await forwardMessage( + { + messageId: 'msg123', + to: ['target@example.com'], + }, + mockContext + ); + + expect(mockContext.cacheManager.invalidate).toHaveBeenCalledWith('gmail:list'); + }); + + test('returns messageId, threadId, and success message', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Body').toString('base64url') }, + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'fwd123', + threadId: 'newthread456', + labelIds: ['SENT'], + }, + }); + + const result = await forwardMessage( + { + messageId: 'msg123', + to: ['target@example.com'], + }, + mockContext + ); + + expect(result.messageId).toBe('fwd123'); + expect(result.threadId).toBe('newthread456'); + expect(result.message).toBe('Message forwarded successfully'); + }); +}); diff --git a/src/modules/gmail/__tests__/manage.test.ts b/src/modules/gmail/__tests__/manage.test.ts new file mode 100644 index 0000000..bf7492f --- /dev/null +++ b/src/modules/gmail/__tests__/manage.test.ts @@ -0,0 +1,335 @@ +/** + * Tests for Gmail message management operations + * trashMessage, untrashMessage, deleteMessage, markAsRead, markAsUnread, archiveMessage + */ + +import { describe, test, expect, jest } from '@jest/globals'; +import { + trashMessage, + untrashMessage, + deleteMessage, + markAsRead, + markAsUnread, + archiveMessage, +} from '../manage.js'; + +function makeContext(): { mockGmailApi: any; mockContext: any } { + const mockGmailApi = { + users: { + messages: { + trash: jest.fn(), + untrash: jest.fn(), + delete: jest.fn(), + modify: jest.fn(), + }, + }, + }; + + const mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + + return { mockGmailApi, mockContext }; +} + +describe('trashMessage', () => { + test('calls users.messages.trash with correct userId and id', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.trash.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['TRASH'], + }, + }); + + await trashMessage({ id: 'msg123' }, mockContext); + + expect(mockGmailApi.users.messages.trash).toHaveBeenCalledWith({ + userId: 'me', + id: 'msg123', + }); + }); + + test('returns trashed message id and labelIds', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.trash.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['TRASH'], + }, + }); + + const result = await trashMessage({ id: 'msg123' }, mockContext); + + expect(result.id).toBe('msg123'); + expect(result.labelIds).toEqual(['TRASH']); + expect(result.message).toBe('Message moved to trash'); + }); + + test('invalidates gmail:getMessage cache for that id', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.trash.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['TRASH'], + }, + }); + + await trashMessage({ id: 'msg123' }, mockContext); + + expect(mockContext.cacheManager.invalidate).toHaveBeenCalledWith('gmail:getMessage:msg123'); + }); + + test('invalidates gmail:list cache', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.trash.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['TRASH'], + }, + }); + + await trashMessage({ id: 'msg123' }, mockContext); + + expect(mockContext.cacheManager.invalidate).toHaveBeenCalledWith('gmail:list'); + }); +}); + +describe('untrashMessage', () => { + test('calls users.messages.untrash with correct userId and id', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.untrash.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['INBOX'], + }, + }); + + await untrashMessage({ id: 'msg123' }, mockContext); + + expect(mockGmailApi.users.messages.untrash).toHaveBeenCalledWith({ + userId: 'me', + id: 'msg123', + }); + }); + + test('returns untrashed message id and labelIds', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.untrash.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['INBOX'], + }, + }); + + const result = await untrashMessage({ id: 'msg123' }, mockContext); + + expect(result.id).toBe('msg123'); + expect(result.labelIds).toEqual(['INBOX']); + expect(result.message).toBe('Message restored from trash'); + }); + + test('invalidates gmail:getMessage cache for that id', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.untrash.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['INBOX'], + }, + }); + + await untrashMessage({ id: 'msg123' }, mockContext); + + expect(mockContext.cacheManager.invalidate).toHaveBeenCalledWith('gmail:getMessage:msg123'); + }); +}); + +describe('deleteMessage', () => { + test('throws error when safetyAcknowledged is not provided', async () => { + const { mockContext } = makeContext(); + + await expect( + deleteMessage({ id: 'msg123' } as any, mockContext) + ).rejects.toThrow(); + }); + + test('throws error when safetyAcknowledged is false', async () => { + const { mockContext } = makeContext(); + + await expect( + deleteMessage({ id: 'msg123', safetyAcknowledged: false }, mockContext) + ).rejects.toThrow(); + }); + + test('calls users.messages.delete when safetyAcknowledged is true', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.delete.mockResolvedValue({ data: {} }); + + await deleteMessage({ id: 'msg123', safetyAcknowledged: true }, mockContext); + + expect(mockGmailApi.users.messages.delete).toHaveBeenCalledWith({ + userId: 'me', + id: 'msg123', + }); + }); + + test('returns confirmation message on success', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.delete.mockResolvedValue({ data: {} }); + + const result = await deleteMessage({ id: 'msg123', safetyAcknowledged: true }, mockContext); + + expect(result.id).toBe('msg123'); + expect(result.message).toContain('permanently deleted'); + }); +}); + +describe('markAsRead', () => { + test('calls modifyLabels removing UNREAD label', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.modify.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['INBOX'], + }, + }); + + await markAsRead({ id: 'msg123' }, mockContext); + + expect(mockGmailApi.users.messages.modify).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + id: 'msg123', + requestBody: expect.objectContaining({ + removeLabelIds: ['UNREAD'], + }), + }) + ); + }); + + test('returns result with updated labelIds', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.modify.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['INBOX'], + }, + }); + + const result = await markAsRead({ id: 'msg123' }, mockContext); + + expect(result.id).toBe('msg123'); + expect(result.labelIds).toEqual(['INBOX']); + expect(result.message).toBe('Message marked as read'); + }); +}); + +describe('markAsUnread', () => { + test('calls modifyLabels adding UNREAD label', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.modify.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['INBOX', 'UNREAD'], + }, + }); + + await markAsUnread({ id: 'msg123' }, mockContext); + + expect(mockGmailApi.users.messages.modify).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + id: 'msg123', + requestBody: expect.objectContaining({ + addLabelIds: ['UNREAD'], + }), + }) + ); + }); + + test('returns result with updated labelIds', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.modify.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: ['INBOX', 'UNREAD'], + }, + }); + + const result = await markAsUnread({ id: 'msg123' }, mockContext); + + expect(result.id).toBe('msg123'); + expect(result.labelIds).toEqual(['INBOX', 'UNREAD']); + expect(result.message).toBe('Message marked as unread'); + }); +}); + +describe('archiveMessage', () => { + test('calls modifyLabels removing INBOX label', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.modify.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: [], + }, + }); + + await archiveMessage({ id: 'msg123' }, mockContext); + + expect(mockGmailApi.users.messages.modify).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + id: 'msg123', + requestBody: expect.objectContaining({ + removeLabelIds: ['INBOX'], + }), + }) + ); + }); + + test('returns result with updated labelIds', async () => { + const { mockGmailApi, mockContext } = makeContext(); + + mockGmailApi.users.messages.modify.mockResolvedValue({ + data: { + id: 'msg123', + labelIds: [], + }, + }); + + const result = await archiveMessage({ id: 'msg123' }, mockContext); + + expect(result.id).toBe('msg123'); + expect(result.labelIds).toEqual([]); + expect(result.message).toBe('Message archived'); + }); +}); diff --git a/src/modules/gmail/__tests__/reply.test.ts b/src/modules/gmail/__tests__/reply.test.ts new file mode 100644 index 0000000..837fd21 --- /dev/null +++ b/src/modules/gmail/__tests__/reply.test.ts @@ -0,0 +1,712 @@ +/** + * Tests for Gmail reply operations - replyToMessage and replyAllToMessage + */ + +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { replyToMessage, replyAllToMessage } from '../reply.js'; + +describe('replyToMessage', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + messages: { + get: jest.fn(), + send: jest.fn(), + }, + }, + }; + + mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('fetches original message to get MIME Message-ID header', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Original Subject' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyToMessage( + { + messageId: 'msg123', + body: 'Thanks for your message.', + }, + mockContext + ); + + expect(mockGmailApi.users.messages.get).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + id: 'msg123', + format: 'metadata', + }) + ); + }); + + test('sets In-Reply-To header to original MIME Message-ID', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyToMessage( + { + messageId: 'msg123', + body: 'Reply body.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('In-Reply-To: '); + }); + + test('sets References header combining original References and Message-ID', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyToMessage( + { + messageId: 'msg123', + body: 'Reply body.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('References: '); + }); + + test('sets threadId on the outgoing message request', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread456', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread456', + labelIds: ['SENT'], + }, + }); + + await replyToMessage( + { + messageId: 'msg123', + body: 'Reply.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + expect(sendCall.requestBody.threadId).toBe('thread456'); + }); + + test('prefixes subject with Re: when subject lacks Re: prefix', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Original Subject' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyToMessage( + { + messageId: 'msg123', + body: 'Reply.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('Subject: Re: Original Subject'); + }); + + test('does not double-prefix subject already starting with Re:', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Re: Already replied' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyToMessage( + { + messageId: 'msg123', + body: 'Reply.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('Subject: Re: Already replied'); + expect(rawDecoded).not.toContain('Subject: Re: Re:'); + }); + + test('handles missing Message-ID header gracefully', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'No ID message' }, + // No Message-ID header + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + // Should not throw + const result = await replyToMessage( + { + messageId: 'msg123', + body: 'Reply without threading.', + }, + mockContext + ); + + expect(result.messageId).toBe('reply123'); + }); + + test('sends to original From address by default', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'original@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyToMessage( + { + messageId: 'msg123', + body: 'Reply.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('To: original@example.com'); + }); + + test('invalidates gmail:list cache after sending', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyToMessage( + { + messageId: 'msg123', + body: 'Reply.', + }, + mockContext + ); + + expect(mockContext.cacheManager.invalidate).toHaveBeenCalledWith('gmail:list'); + }); + + test('returns messageId, threadId, and labelIds', async () => { + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + const result = await replyToMessage( + { + messageId: 'msg123', + body: 'Reply.', + }, + mockContext + ); + + expect(result.messageId).toBe('reply123'); + expect(result.threadId).toBe('thread123'); + expect(result.labelIds).toEqual(['SENT']); + expect(result.message).toBe('Reply sent successfully'); + }); +}); + +describe('replyAllToMessage', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + getProfile: jest.fn(), + messages: { + get: jest.fn(), + send: jest.fn(), + }, + }, + }; + + mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('calls users.getProfile to fetch own email address', async () => { + mockGmailApi.users.getProfile.mockResolvedValue({ + data: { emailAddress: 'me@example.com' }, + }); + + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'me@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyAllToMessage( + { + messageId: 'msg123', + body: 'Reply all.', + }, + mockContext + ); + + expect(mockGmailApi.users.getProfile).toHaveBeenCalledWith({ userId: 'me' }); + }); + + test('includes original To recipients in reply recipients', async () => { + mockGmailApi.users.getProfile.mockResolvedValue({ + data: { emailAddress: 'me@example.com' }, + }); + + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'me@example.com, other@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyAllToMessage( + { + messageId: 'msg123', + body: 'Reply all.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('other@example.com'); + }); + + test('includes original Cc recipients in reply cc', async () => { + mockGmailApi.users.getProfile.mockResolvedValue({ + data: { emailAddress: 'me@example.com' }, + }); + + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'me@example.com' }, + { name: 'Cc', value: 'cc1@example.com, cc2@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyAllToMessage( + { + messageId: 'msg123', + body: 'Reply all.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('cc1@example.com'); + expect(rawDecoded).toContain('cc2@example.com'); + }); + + test('excludes own email from all recipient lists', async () => { + mockGmailApi.users.getProfile.mockResolvedValue({ + data: { emailAddress: 'me@example.com' }, + }); + + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'me@example.com, other@example.com' }, + { name: 'Cc', value: 'me@example.com, cc@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyAllToMessage( + { + messageId: 'msg123', + body: 'Reply all.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + // me@example.com should appear at most in From line, not in To or Cc recipients + const lines = rawDecoded.split('\r\n'); + const toLine = lines.find((l: string) => l.startsWith('To:')) || ''; + const ccLine = lines.find((l: string) => l.startsWith('Cc:')) || ''; + expect(toLine).not.toContain('me@example.com'); + expect(ccLine).not.toContain('me@example.com'); + }); + + test('deduplicates final recipient list', async () => { + mockGmailApi.users.getProfile.mockResolvedValue({ + data: { emailAddress: 'me@example.com' }, + }); + + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'sender@example.com, other@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyAllToMessage( + { + messageId: 'msg123', + body: 'Reply all.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + // sender@example.com should appear only once in To field + const toLineMatch = rawDecoded.match(/^To: (.+)$/m); + if (toLineMatch) { + const toLine = toLineMatch[1] ?? ''; + const occurrences = (toLine.match(/sender@example\.com/g) || []).length; + expect(occurrences).toBe(1); + } + }); + + test('always replies to original From address', async () => { + mockGmailApi.users.getProfile.mockResolvedValue({ + data: { emailAddress: 'me@example.com' }, + }); + + mockGmailApi.users.messages.get.mockResolvedValue({ + data: { + id: 'msg123', + threadId: 'thread123', + payload: { + headers: [ + { name: 'From', value: 'original-sender@example.com' }, + { name: 'To', value: 'me@example.com' }, + { name: 'Subject', value: 'Hello' }, + { name: 'Message-ID', value: '' }, + { name: 'References', value: '' }, + ], + }, + }, + }); + + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'reply123', + threadId: 'thread123', + labelIds: ['SENT'], + }, + }); + + await replyAllToMessage( + { + messageId: 'msg123', + body: 'Reply all.', + }, + mockContext + ); + + const sendCall = mockGmailApi.users.messages.send.mock.calls[0][0]; + const rawDecoded = Buffer.from(sendCall.requestBody.raw, 'base64url').toString(); + expect(rawDecoded).toContain('original-sender@example.com'); + }); +}); diff --git a/src/modules/gmail/attachments.ts b/src/modules/gmail/attachments.ts new file mode 100644 index 0000000..c335030 --- /dev/null +++ b/src/modules/gmail/attachments.ts @@ -0,0 +1,264 @@ +/** + * Gmail attachment operations - listAttachments, downloadAttachment, sendWithAttachments + */ + +import type { gmail_v1 } from 'googleapis'; +import type { GmailContext } from '../types.js'; +import type { + ListAttachmentsOptions, + ListAttachmentsResult, + AttachmentInfo, + DownloadAttachmentOptions, + DownloadAttachmentResult, + SendWithAttachmentsOptions, + SendWithAttachmentsResult, +} from './types.js'; +import { buildMultipartMessage, encodeToBase64Url, validateAndSanitizeRecipients } from './utils.js'; + +/** + * Recursively extract attachment metadata from a message part tree + */ +function extractAttachments( + parts: gmail_v1.Schema$MessagePart[] | undefined, + result: AttachmentInfo[] +): void { + if (!parts) {return;} + + for (const part of parts) { + // A part is an attachment if it has an attachmentId and a filename + if (part.body?.attachmentId && part.filename && part.filename.length > 0) { + result.push({ + attachmentId: part.body.attachmentId, + filename: part.filename, + mimeType: part.mimeType || 'application/octet-stream', + size: part.body.size || 0, + }); + } + + // Recurse into nested parts + if (part.parts) { + extractAttachments(part.parts, result); + } + } +} + +/** + * List all attachments for a given message. + * + * Returns attachment metadata (filename, mimeType, size, attachmentId). + * Use downloadAttachment() to retrieve the actual file content. + * + * @param options Message ID to list attachments for + * @param context Gmail API context + * @returns List of attachment metadata + * + * @example + * ```typescript + * const result = await listAttachments({ messageId: '18c123abc' }, context); + * + * result.attachments.forEach(att => { + * console.log(`${att.filename} (${att.size} bytes)`); + * }); + * ``` + */ +export async function listAttachments( + options: ListAttachmentsOptions, + context: GmailContext +): Promise { + const { messageId } = options; + + const response = await context.gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'full', + }); + + const payload = response.data.payload; + const attachments: AttachmentInfo[] = []; + + if (payload) { + // Check top-level body (for simple non-multipart messages) + if (payload.body?.attachmentId && payload.filename && payload.filename.length > 0) { + attachments.push({ + attachmentId: payload.body.attachmentId, + filename: payload.filename, + mimeType: payload.mimeType || 'application/octet-stream', + size: payload.body.size || 0, + }); + } + + // Recursively check parts + extractAttachments(payload.parts, attachments); + } + + context.performanceMonitor.track('gmail:listAttachments', Date.now() - context.startTime); + context.logger.info('Listed attachments', { + messageId, + count: attachments.length, + }); + + return { + messageId, + attachments, + }; +} + +/** + * Download a specific attachment from a message. + * + * Returns the attachment content as base64-encoded data along with metadata. + * + * @param options Message ID and attachment ID to download + * @param context Gmail API context + * @returns Attachment data and metadata + * + * @example + * ```typescript + * const att = await downloadAttachment({ + * messageId: '18c123abc', + * attachmentId: 'ANGjdJ...', + * }, context); + * + * // Write to file: Buffer.from(att.data, 'base64').toString() + * console.log(`Downloaded: ${att.filename} (${att.size} bytes)`); + * ``` + */ +export async function downloadAttachment( + options: DownloadAttachmentOptions, + context: GmailContext +): Promise { + const { messageId, attachmentId } = options; + + // First get the message to find the attachment metadata (filename, mimeType) + const messageResponse = await context.gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'full', + }); + + // Find the attachment part to get its metadata + const allAttachments: AttachmentInfo[] = []; + if (messageResponse.data.payload) { + const payload = messageResponse.data.payload; + if (payload.body?.attachmentId && payload.filename) { + allAttachments.push({ + attachmentId: payload.body.attachmentId, + filename: payload.filename, + mimeType: payload.mimeType || 'application/octet-stream', + size: payload.body.size || 0, + }); + } + extractAttachments(payload.parts, allAttachments); + } + + const attachmentMeta = allAttachments.find(a => a.attachmentId === attachmentId); + + // Download the attachment content + const response = await context.gmail.users.messages.attachments.get({ + userId: 'me', + messageId, + id: attachmentId, + }); + + const data = response.data.data || ''; + const size = response.data.size || 0; + + context.performanceMonitor.track('gmail:downloadAttachment', Date.now() - context.startTime); + context.logger.info('Downloaded attachment', { + messageId, + attachmentId, + size, + }); + + return { + messageId, + attachmentId, + filename: attachmentMeta?.filename || '', + mimeType: attachmentMeta?.mimeType || 'application/octet-stream', + size, + data, + }; +} + +/** + * Send a message with file attachments using multipart/mixed MIME encoding. + * + * @param options Message content, recipients, and attachments + * @param context Gmail API context + * @returns Sent message info + * + * @example + * ```typescript + * const result = await sendWithAttachments({ + * to: ['recipient@example.com'], + * subject: 'Here is the report', + * body: 'Please find the report attached.', + * attachments: [{ + * filename: 'report.pdf', + * mimeType: 'application/pdf', + * data: pdfBase64String, + * }], + * }, context); + * ``` + */ +export async function sendWithAttachments( + options: SendWithAttachmentsOptions, + context: GmailContext +): Promise { + const { to, cc, bcc, subject, body, isHtml, from, attachments } = options; + + // Validate recipients (throws if any invalid) + validateAndSanitizeRecipients(to, 'to'); + if (cc && cc.length > 0) {validateAndSanitizeRecipients(cc, 'cc');} + if (bcc && bcc.length > 0) {validateAndSanitizeRecipients(bcc, 'bcc');} + + // Build params without passing undefined to optional fields (exactOptionalPropertyTypes) + const messageParams: Parameters[0] = { + to, + subject, + body, + attachments: attachments || [], + }; + if (cc && cc.length > 0) {messageParams.cc = cc;} + if (bcc && bcc.length > 0) {messageParams.bcc = bcc;} + if (isHtml !== undefined) {messageParams.isHtml = isHtml;} + if (from) {messageParams.from = from;} + + // Build the multipart MIME message + const emailMessage = buildMultipartMessage(messageParams); + + const encodedMessage = encodeToBase64Url(emailMessage); + + const response = await context.gmail.users.messages.send({ + userId: 'me', + requestBody: { + raw: encodedMessage, + }, + }); + + const messageId = response.data.id; + const threadId = response.data.threadId; + const labelIds = response.data.labelIds || []; + + if (!messageId) { + throw new Error('Failed to send message with attachments - no message ID returned'); + } + + await context.cacheManager.invalidate('gmail:list'); + await context.cacheManager.invalidate('gmail:search'); + + context.performanceMonitor.track('gmail:sendWithAttachments', Date.now() - context.startTime); + context.logger.info('Sent message with attachments', { + messageId, + to, + subject, + attachmentCount: attachments?.length || 0, + }); + + return { + messageId, + threadId: threadId || '', + labelIds, + message: 'Message sent successfully', + }; +} diff --git a/src/modules/gmail/forward.ts b/src/modules/gmail/forward.ts new file mode 100644 index 0000000..e75d8be --- /dev/null +++ b/src/modules/gmail/forward.ts @@ -0,0 +1,187 @@ +/** + * Gmail forward operations - forwardMessage + */ + +import type { gmail_v1 } from 'googleapis'; +import type { GmailContext } from '../types.js'; +import type { + ForwardMessageOptions, + ForwardMessageResult, +} from './types.js'; +import { buildEmailMessage, encodeToBase64Url } from './utils.js'; + +/** + * Find a header value (case-insensitive) from headers array + */ +function findHeader(headers: gmail_v1.Schema$MessagePartHeader[], name: string): string { + const lower = name.toLowerCase(); + const header = headers.find(h => h.name?.toLowerCase() === lower); + return header?.value || ''; +} + +/** + * Extract plain text body from a Gmail message payload + */ +function extractPlainBody(payload: gmail_v1.Schema$MessagePart | undefined): string { + if (!payload) {return '';} + + const decode = (data: string | undefined | null): string => { + if (!data) {return '';} + return Buffer.from(data, 'base64url').toString('utf-8'); + }; + + // Simple single-part message + if (payload.body?.data) { + const mimeType = payload.mimeType || ''; + if (mimeType === 'text/plain' || mimeType === 'text/html') { + return decode(payload.body.data); + } + return decode(payload.body.data); + } + + // Multipart — prefer text/plain + if (payload.parts) { + for (const part of payload.parts) { + if (part.mimeType === 'text/plain' && part.body?.data) { + return decode(part.body.data); + } + } + // Fallback to first part with data + for (const part of payload.parts) { + if (part.body?.data) { + return decode(part.body.data); + } + // Recurse into nested multipart + if (part.mimeType?.startsWith('multipart/') && part.parts) { + const nested = extractPlainBody(part); + if (nested) {return nested;} + } + } + } + + return ''; +} + +/** + * Get subject prefix for forwards — adds Fwd: if not already present + */ +function forwardSubject(subject: string): string { + if (/^fwd:/i.test(subject.trim())) { + return subject; + } + return `Fwd: ${subject}`; +} + +/** + * Forward a message to new recipients with the original content quoted. + * + * Does not set threading headers (forwarding creates a new thread). + * Optionally prepends a custom body before the quoted original. + * + * @param options Forward content, recipients, and message to forward + * @param context Gmail API context + * @returns Sent message info + * + * @example + * ```typescript + * const result = await forwardMessage({ + * messageId: '18c123abc', + * to: ['colleague@example.com'], + * body: 'FYI, see below.', + * }, context); + * + * console.log(`Forwarded as: ${result.messageId}`); + * ``` + */ +export async function forwardMessage( + options: ForwardMessageOptions, + context: GmailContext +): Promise { + const { messageId, to, cc, bcc, body, isHtml, from } = options; + + // Fetch original message for content and headers + const originalResponse = await context.gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'full', + }); + + const originalData = originalResponse.data; + const headers = originalData.payload?.headers || []; + + const originalFrom = findHeader(headers, 'From'); + const originalSubject = findHeader(headers, 'Subject'); + const originalDate = findHeader(headers, 'Date'); + const originalTo = findHeader(headers, 'To'); + + // Build forward subject + const subject = forwardSubject(originalSubject); + + // Extract original body text + const originalBody = extractPlainBody(originalData.payload || undefined); + + // Build quoted forward body + const quotedHeader = [ + `---------- Forwarded message ---------`, + `From: ${originalFrom}`, + `Date: ${originalDate}`, + `Subject: ${originalSubject}`, + `To: ${originalTo}`, + ``, + ].join('\r\n'); + + const forwardedBody = body + ? `${body}\r\n\r\n${quotedHeader}${originalBody}` + : `${quotedHeader}${originalBody}`; + + // Build params object without undefined optional fields (exactOptionalPropertyTypes) + const messageParams: Parameters[0] = { + to, + subject, + body: forwardedBody, + }; + if (cc && cc.length > 0) {messageParams.cc = cc;} + if (bcc && bcc.length > 0) {messageParams.bcc = bcc;} + if (isHtml !== undefined) {messageParams.isHtml = isHtml;} + if (from) {messageParams.from = from;} + // No inReplyTo or references — forward creates a new thread + + const emailMessage = buildEmailMessage(messageParams); + + const encodedMessage = encodeToBase64Url(emailMessage); + + // Forward does NOT include threadId (new thread) + const params: gmail_v1.Params$Resource$Users$Messages$Send = { + userId: 'me', + requestBody: { + raw: encodedMessage, + }, + }; + + const response = await context.gmail.users.messages.send(params); + + const sentMessageId = response.data.id; + const sentThreadId = response.data.threadId; + const labelIds = response.data.labelIds || []; + + if (!sentMessageId) { + throw new Error('Failed to forward message - no message ID returned'); + } + + await context.cacheManager.invalidate('gmail:list'); + await context.cacheManager.invalidate('gmail:search'); + + context.performanceMonitor.track('gmail:forwardMessage', Date.now() - context.startTime); + context.logger.info('Forwarded message', { + originalMessageId: messageId, + messageId: sentMessageId, + to, + }); + + return { + messageId: sentMessageId, + threadId: sentThreadId || '', + labelIds, + message: 'Message forwarded successfully', + }; +} diff --git a/src/modules/gmail/index.ts b/src/modules/gmail/index.ts index 7f7d077..65a6e69 100644 --- a/src/modules/gmail/index.ts +++ b/src/modules/gmail/index.ts @@ -2,7 +2,7 @@ * Gmail module - Email operations for the gdrive MCP server * * @module gmail - * @version 3.2.0 + * @version 3.3.0 */ // Types @@ -36,6 +36,36 @@ export type { LabelInfo, ModifyLabelsOptions, ModifyLabelsResult, + // Reply types + ReplyToMessageOptions, + ReplyToMessageResult, + ReplyAllToMessageOptions, + ReplyAllToMessageResult, + // Forward types + ForwardMessageOptions, + ForwardMessageResult, + // Attachment types + AttachmentInfo, + ListAttachmentsOptions, + ListAttachmentsResult, + DownloadAttachmentOptions, + DownloadAttachmentResult, + OutboundAttachment, + SendWithAttachmentsOptions, + SendWithAttachmentsResult, + // Management types + TrashMessageOptions, + TrashMessageResult, + UntrashMessageOptions, + UntrashMessageResult, + DeleteMessageOptions, + DeleteMessageResult, + MarkAsReadOptions, + MarkAsReadResult, + MarkAsUnreadOptions, + MarkAsUnreadResult, + ArchiveMessageOptions, + ArchiveMessageResult, } from './types.js'; // List operations @@ -55,3 +85,15 @@ export { sendMessage, sendDraft } from './send.js'; // Label operations export { listLabels, modifyLabels } from './labels.js'; + +// Reply operations +export { replyToMessage, replyAllToMessage } from './reply.js'; + +// Forward operations +export { forwardMessage } from './forward.js'; + +// Attachment operations +export { listAttachments, downloadAttachment, sendWithAttachments } from './attachments.js'; + +// Message management operations +export { trashMessage, untrashMessage, deleteMessage, markAsRead, markAsUnread, archiveMessage } from './manage.js'; diff --git a/src/modules/gmail/manage.ts b/src/modules/gmail/manage.ts new file mode 100644 index 0000000..5057fe4 --- /dev/null +++ b/src/modules/gmail/manage.ts @@ -0,0 +1,283 @@ +/** + * Gmail message management operations + * trashMessage, untrashMessage, deleteMessage, markAsRead, markAsUnread, archiveMessage + */ + +import type { gmail_v1 } from 'googleapis'; +import type { GmailContext } from '../types.js'; +import type { + TrashMessageOptions, + TrashMessageResult, + UntrashMessageOptions, + UntrashMessageResult, + DeleteMessageOptions, + DeleteMessageResult, + MarkAsReadOptions, + MarkAsReadResult, + MarkAsUnreadOptions, + MarkAsUnreadResult, + ArchiveMessageOptions, + ArchiveMessageResult, +} from './types.js'; + +/** + * Move a message to the trash. + * + * The message can be recovered with untrashMessage(). + * Use deleteMessage() for permanent deletion. + * + * @param options Message ID to trash + * @param context Gmail API context + * @returns Updated message info with TRASH label + * + * @example + * ```typescript + * const result = await trashMessage({ id: '18c123abc' }, context); + * console.log(result.message); // 'Message moved to trash' + * ``` + */ +export async function trashMessage( + options: TrashMessageOptions, + context: GmailContext +): Promise { + const { id } = options; + + const response = await context.gmail.users.messages.trash({ + userId: 'me', + id, + }); + + const labelIds = response.data.labelIds || []; + + await context.cacheManager.invalidate(`gmail:getMessage:${id}`); + await context.cacheManager.invalidate('gmail:list'); + + context.performanceMonitor.track('gmail:trashMessage', Date.now() - context.startTime); + context.logger.info('Trashed message', { id }); + + return { + id, + labelIds, + message: 'Message moved to trash', + }; +} + +/** + * Restore a message from the trash. + * + * @param options Message ID to restore + * @param context Gmail API context + * @returns Updated message info + * + * @example + * ```typescript + * const result = await untrashMessage({ id: '18c123abc' }, context); + * console.log(result.message); // 'Message restored from trash' + * ``` + */ +export async function untrashMessage( + options: UntrashMessageOptions, + context: GmailContext +): Promise { + const { id } = options; + + const response = await context.gmail.users.messages.untrash({ + userId: 'me', + id, + }); + + const labelIds = response.data.labelIds || []; + + await context.cacheManager.invalidate(`gmail:getMessage:${id}`); + await context.cacheManager.invalidate('gmail:list'); + + context.performanceMonitor.track('gmail:untrashMessage', Date.now() - context.startTime); + context.logger.info('Untrashed message', { id }); + + return { + id, + labelIds, + message: 'Message restored from trash', + }; +} + +/** + * Permanently and irrecoverably delete a message. + * + * This operation CANNOT be undone. The safetyAcknowledged parameter must be + * set to true to confirm the caller understands the message cannot be recovered. + * Use trashMessage() instead if recovery might be needed. + * + * @param options Message ID and required safety acknowledgment + * @param context Gmail API context + * @returns Deletion confirmation + * + * @example + * ```typescript + * const result = await deleteMessage({ + * id: '18c123abc', + * safetyAcknowledged: true, + * }, context); + * ``` + */ +export async function deleteMessage( + options: DeleteMessageOptions, + context: GmailContext +): Promise { + const { id, safetyAcknowledged } = options; + + if (!safetyAcknowledged) { + throw new Error( + 'deleteMessage requires safetyAcknowledged: true. ' + + 'This operation permanently deletes the message and cannot be undone. ' + + 'Use trashMessage() if you want a recoverable deletion.' + ); + } + + await context.gmail.users.messages.delete({ + userId: 'me', + id, + }); + + await context.cacheManager.invalidate(`gmail:getMessage:${id}`); + await context.cacheManager.invalidate('gmail:list'); + + context.performanceMonitor.track('gmail:deleteMessage', Date.now() - context.startTime); + context.logger.info('Permanently deleted message', { id }); + + return { + id, + message: `Message ${id} permanently deleted`, + }; +} + +/** + * Mark a message as read by removing the UNREAD label. + * + * @param options Message ID to mark as read + * @param context Gmail API context + * @returns Updated message info + * + * @example + * ```typescript + * await markAsRead({ id: '18c123abc' }, context); + * ``` + */ +export async function markAsRead( + options: MarkAsReadOptions, + context: GmailContext +): Promise { + const { id } = options; + + const requestBody: gmail_v1.Schema$ModifyMessageRequest = { + removeLabelIds: ['UNREAD'], + }; + + const response = await context.gmail.users.messages.modify({ + userId: 'me', + id, + requestBody, + }); + + const labelIds = response.data.labelIds || []; + + await context.cacheManager.invalidate(`gmail:getMessage:${id}`); + await context.cacheManager.invalidate('gmail:list'); + + context.performanceMonitor.track('gmail:markAsRead', Date.now() - context.startTime); + context.logger.info('Marked message as read', { id }); + + return { + id, + labelIds, + message: 'Message marked as read', + }; +} + +/** + * Mark a message as unread by adding the UNREAD label. + * + * @param options Message ID to mark as unread + * @param context Gmail API context + * @returns Updated message info + * + * @example + * ```typescript + * await markAsUnread({ id: '18c123abc' }, context); + * ``` + */ +export async function markAsUnread( + options: MarkAsUnreadOptions, + context: GmailContext +): Promise { + const { id } = options; + + const requestBody: gmail_v1.Schema$ModifyMessageRequest = { + addLabelIds: ['UNREAD'], + }; + + const response = await context.gmail.users.messages.modify({ + userId: 'me', + id, + requestBody, + }); + + const labelIds = response.data.labelIds || []; + + await context.cacheManager.invalidate(`gmail:getMessage:${id}`); + await context.cacheManager.invalidate('gmail:list'); + + context.performanceMonitor.track('gmail:markAsUnread', Date.now() - context.startTime); + context.logger.info('Marked message as unread', { id }); + + return { + id, + labelIds, + message: 'Message marked as unread', + }; +} + +/** + * Archive a message by removing the INBOX label. + * + * The message remains searchable but is removed from the inbox view. + * + * @param options Message ID to archive + * @param context Gmail API context + * @returns Updated message info + * + * @example + * ```typescript + * await archiveMessage({ id: '18c123abc' }, context); + * ``` + */ +export async function archiveMessage( + options: ArchiveMessageOptions, + context: GmailContext +): Promise { + const { id } = options; + + const requestBody: gmail_v1.Schema$ModifyMessageRequest = { + removeLabelIds: ['INBOX'], + }; + + const response = await context.gmail.users.messages.modify({ + userId: 'me', + id, + requestBody, + }); + + const labelIds = response.data.labelIds || []; + + await context.cacheManager.invalidate(`gmail:getMessage:${id}`); + await context.cacheManager.invalidate('gmail:list'); + + context.performanceMonitor.track('gmail:archiveMessage', Date.now() - context.startTime); + context.logger.info('Archived message', { id }); + + return { + id, + labelIds, + message: 'Message archived', + }; +} diff --git a/src/modules/gmail/reply.ts b/src/modules/gmail/reply.ts new file mode 100644 index 0000000..02219ba --- /dev/null +++ b/src/modules/gmail/reply.ts @@ -0,0 +1,303 @@ +/** + * Gmail reply operations - replyToMessage and replyAllToMessage + */ + +import type { gmail_v1 } from 'googleapis'; +import type { GmailContext } from '../types.js'; +import type { + ReplyToMessageOptions, + ReplyToMessageResult, + ReplyAllToMessageOptions, + ReplyAllToMessageResult, +} from './types.js'; +import { buildEmailMessage, encodeToBase64Url } from './utils.js'; + +/** + * Extract email address from a "Name " format or plain email string + */ +function extractEmailAddress(value: string): string { + const match = value.match(/<([^>]+)>/); + return match ? (match[1] ?? '').trim() : value.trim(); +} + +/** + * Parse a comma-separated list of email addresses + */ +function parseEmailList(value: string | undefined): string[] { + if (!value || value.trim() === '') {return [];} + return value.split(',').map(e => e.trim()).filter(e => e.length > 0); +} + +/** + * Parse headers from raw gmail message headers array + */ +function findHeader(headers: gmail_v1.Schema$MessagePartHeader[], name: string): string { + const lower = name.toLowerCase(); + const header = headers.find(h => h.name?.toLowerCase() === lower); + return header?.value || ''; +} + +/** + * Get subject prefix for replies — adds Re: if not already present + */ +function replySubject(subject: string): string { + if (/^re:/i.test(subject.trim())) { + return subject; + } + return `Re: ${subject}`; +} + +/** + * Reply to a specific message, maintaining proper MIME threading headers. + * + * Fetches the original message to extract its MIME Message-ID header (not the + * Gmail UI ID) and sets In-Reply-To and References headers accordingly. + * + * @param options Reply content and message to reply to + * @param context Gmail API context + * @returns Sent reply info + * + * @example + * ```typescript + * const result = await replyToMessage({ + * messageId: '18c123abc', + * body: 'Thanks for reaching out!', + * }, context); + * + * console.log(`Reply sent: ${result.messageId}`); + * ``` + */ +export async function replyToMessage( + options: ReplyToMessageOptions, + context: GmailContext +): Promise { + const { messageId, body, isHtml, cc, bcc, from } = options; + + // Fetch original message to get threading headers + const originalResponse = await context.gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'metadata', + metadataHeaders: ['From', 'Subject', 'Message-ID', 'References', 'To'], + }); + + const originalData = originalResponse.data; + const headers = originalData.payload?.headers || []; + + const originalFrom = findHeader(headers, 'From'); + const originalSubject = findHeader(headers, 'Subject'); + const originalMessageId = findHeader(headers, 'Message-ID'); + const originalReferences = findHeader(headers, 'References'); + const threadId = originalData.threadId || ''; + + // Build threading headers from the original message's MIME Message-ID + let inReplyTo: string | undefined; + let references: string | undefined; + + if (originalMessageId) { + inReplyTo = originalMessageId; + // References = original References + original Message-ID + references = originalReferences + ? `${originalReferences} ${originalMessageId}` + : originalMessageId; + } + + // Determine subject + const subject = replySubject(originalSubject); + + // Build params without passing undefined to optional fields (exactOptionalPropertyTypes) + const messageParams: Parameters[0] = { + to: [originalFrom], + subject, + body, + }; + if (cc && cc.length > 0) {messageParams.cc = cc;} + if (bcc && bcc.length > 0) {messageParams.bcc = bcc;} + if (isHtml !== undefined) {messageParams.isHtml = isHtml;} + if (from) {messageParams.from = from;} + if (inReplyTo) {messageParams.inReplyTo = inReplyTo;} + if (references) {messageParams.references = references;} + + // Build the email message + const emailMessage = buildEmailMessage(messageParams); + + const encodedMessage = encodeToBase64Url(emailMessage); + + // Build params with threadId to keep reply in the same thread + const params: gmail_v1.Params$Resource$Users$Messages$Send = { + userId: 'me', + requestBody: { + raw: encodedMessage, + threadId, + }, + }; + + const response = await context.gmail.users.messages.send(params); + + const sentMessageId = response.data.id; + const sentThreadId = response.data.threadId; + const labelIds = response.data.labelIds || []; + + if (!sentMessageId) { + throw new Error('Failed to send reply - no message ID returned'); + } + + // Invalidate cached message/thread lists + await context.cacheManager.invalidate('gmail:list'); + await context.cacheManager.invalidate('gmail:search'); + + context.performanceMonitor.track('gmail:replyToMessage', Date.now() - context.startTime); + context.logger.info('Sent reply', { + originalMessageId: messageId, + messageId: sentMessageId, + }); + + return { + messageId: sentMessageId, + threadId: sentThreadId || '', + labelIds, + message: 'Reply sent successfully', + }; +} + +/** + * Reply-all to a message, including all original recipients except yourself. + * + * Fetches the user's own email via users.getProfile to exclude from recipients. + * Deduplicates the final recipient list. + * + * @param options Reply-all content and message to reply to + * @param context Gmail API context + * @returns Sent reply info + * + * @example + * ```typescript + * const result = await replyAllToMessage({ + * messageId: '18c123abc', + * body: 'Thanks all!', + * }, context); + * ``` + */ +export async function replyAllToMessage( + options: ReplyAllToMessageOptions, + context: GmailContext +): Promise { + const { messageId, body, isHtml, bcc, from } = options; + + // Fetch own email address to exclude from recipients + const profileResponse = await context.gmail.users.getProfile({ userId: 'me' }); + const ownEmail = profileResponse.data.emailAddress || ''; + const ownEmailLower = ownEmail.toLowerCase(); + + // Fetch original message threading and recipient headers + const originalResponse = await context.gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'metadata', + metadataHeaders: ['From', 'To', 'Cc', 'Subject', 'Message-ID', 'References'], + }); + + const originalData = originalResponse.data; + const headers = originalData.payload?.headers || []; + + const originalFrom = findHeader(headers, 'From'); + const originalTo = findHeader(headers, 'To'); + const originalCc = findHeader(headers, 'Cc'); + const originalSubject = findHeader(headers, 'Subject'); + const originalMessageId = findHeader(headers, 'Message-ID'); + const originalReferences = findHeader(headers, 'References'); + const threadId = originalData.threadId || ''; + + // Build threading headers + let inReplyTo: string | undefined; + let references: string | undefined; + + if (originalMessageId) { + inReplyTo = originalMessageId; + references = originalReferences + ? `${originalReferences} ${originalMessageId}` + : originalMessageId; + } + + // Collect all To recipients: original From + original To recipients + const toAddresses: string[] = []; + toAddresses.push(originalFrom); + toAddresses.push(...parseEmailList(originalTo)); + + // Deduplicate To recipients and exclude self + const seenEmails = new Set(); + const filteredTo: string[] = []; + for (const addr of toAddresses) { + const bare = extractEmailAddress(addr).toLowerCase(); + if (bare !== ownEmailLower && !seenEmails.has(bare)) { + seenEmails.add(bare); + filteredTo.push(addr); + } + } + + // Collect Cc recipients from original Cc, excluding self and already-in-To + const ccAddresses = parseEmailList(originalCc); + const filteredCc: string[] = []; + for (const addr of ccAddresses) { + const bare = extractEmailAddress(addr).toLowerCase(); + if (bare !== ownEmailLower && !seenEmails.has(bare)) { + seenEmails.add(bare); + filteredCc.push(addr); + } + } + + const subject = replySubject(originalSubject); + + // Build params without passing undefined to optional fields (exactOptionalPropertyTypes) + const messageParams: Parameters[0] = { + to: filteredTo, + subject, + body, + }; + if (filteredCc.length > 0) {messageParams.cc = filteredCc;} + if (bcc && bcc.length > 0) {messageParams.bcc = bcc;} + if (isHtml !== undefined) {messageParams.isHtml = isHtml;} + if (from) {messageParams.from = from;} + if (inReplyTo) {messageParams.inReplyTo = inReplyTo;} + if (references) {messageParams.references = references;} + + const emailMessage = buildEmailMessage(messageParams); + + const encodedMessage = encodeToBase64Url(emailMessage); + + const params: gmail_v1.Params$Resource$Users$Messages$Send = { + userId: 'me', + requestBody: { + raw: encodedMessage, + threadId, + }, + }; + + const response = await context.gmail.users.messages.send(params); + + const sentMessageId = response.data.id; + const sentThreadId = response.data.threadId; + const labelIds = response.data.labelIds || []; + + if (!sentMessageId) { + throw new Error('Failed to send reply-all - no message ID returned'); + } + + await context.cacheManager.invalidate('gmail:list'); + await context.cacheManager.invalidate('gmail:search'); + + context.performanceMonitor.track('gmail:replyAllToMessage', Date.now() - context.startTime); + context.logger.info('Sent reply-all', { + originalMessageId: messageId, + messageId: sentMessageId, + toCount: filteredTo.length, + ccCount: filteredCc.length, + }); + + return { + messageId: sentMessageId, + threadId: sentThreadId || '', + labelIds, + message: 'Reply sent successfully', + }; +} diff --git a/src/modules/gmail/types.ts b/src/modules/gmail/types.ts index 0e596ab..3fe4c32 100644 --- a/src/modules/gmail/types.ts +++ b/src/modules/gmail/types.ts @@ -313,3 +313,306 @@ export interface ModifyLabelsResult { labelIds: string[]; message: string; } + +// ============================================================================ +// Reply Operations +// ============================================================================ + +/** + * Options for replying to a message + */ +export interface ReplyToMessageOptions { + /** The Gmail message ID to reply to */ + messageId: string; + /** Reply body text */ + body: string; + /** Whether body is HTML (default: false) */ + isHtml?: boolean; + /** CC recipients */ + cc?: string[]; + /** BCC recipients */ + bcc?: string[]; + /** Send from a different email address (send-as alias) */ + from?: string; +} + +/** + * Result of sending a reply + */ +export interface ReplyToMessageResult { + messageId: string; + threadId: string; + labelIds: string[]; + message: string; +} + +/** + * Options for reply-all to a message + */ +export interface ReplyAllToMessageOptions { + /** The Gmail message ID to reply-all to */ + messageId: string; + /** Reply body text */ + body: string; + /** Whether body is HTML (default: false) */ + isHtml?: boolean; + /** BCC recipients (additional, beyond auto-detected) */ + bcc?: string[]; + /** Send from a different email address (send-as alias) */ + from?: string; +} + +/** + * Result of sending a reply-all + */ +export interface ReplyAllToMessageResult { + messageId: string; + threadId: string; + labelIds: string[]; + message: string; +} + +// ============================================================================ +// Forward Operations +// ============================================================================ + +/** + * Options for forwarding a message + */ +export interface ForwardMessageOptions { + /** The Gmail message ID to forward */ + messageId: string; + /** Recipients to forward to */ + to: string[]; + /** CC recipients */ + cc?: string[]; + /** BCC recipients */ + bcc?: string[]; + /** Optional custom message to prepend before the forwarded content */ + body?: string; + /** Whether body is HTML (default: false) */ + isHtml?: boolean; + /** Send from a different email address (send-as alias) */ + from?: string; +} + +/** + * Result of forwarding a message + */ +export interface ForwardMessageResult { + messageId: string; + threadId: string; + labelIds: string[]; + message: string; +} + +// ============================================================================ +// Attachment Operations +// ============================================================================ + +/** + * Metadata about a single attachment + */ +export interface AttachmentInfo { + /** Gmail attachment ID (use with downloadAttachment) */ + attachmentId: string; + /** Filename of the attachment */ + filename: string; + /** MIME type of the attachment */ + mimeType: string; + /** Size in bytes */ + size: number; +} + +/** + * Options for listing attachments on a message + */ +export interface ListAttachmentsOptions { + /** The Gmail message ID */ + messageId: string; +} + +/** + * Result of listing attachments + */ +export interface ListAttachmentsResult { + messageId: string; + attachments: AttachmentInfo[]; +} + +/** + * Options for downloading a specific attachment + */ +export interface DownloadAttachmentOptions { + /** The Gmail message ID */ + messageId: string; + /** The attachment ID from listAttachments */ + attachmentId: string; +} + +/** + * Result of downloading an attachment + */ +export interface DownloadAttachmentResult { + messageId: string; + attachmentId: string; + filename: string; + mimeType: string; + size: number; + /** Base64url-encoded attachment data */ + data: string; +} + +/** + * An attachment to include when sending a message + */ +export interface OutboundAttachment { + /** Filename to use for the attachment */ + filename: string; + /** MIME type of the attachment */ + mimeType: string; + /** Base64-encoded file content */ + data: string; +} + +/** + * Options for sending a message with attachments + */ +export interface SendWithAttachmentsOptions { + /** Recipient email addresses */ + to: string[]; + /** CC recipients */ + cc?: string[]; + /** BCC recipients */ + bcc?: string[]; + /** Email subject */ + subject: string; + /** Email body */ + body: string; + /** Whether body is HTML (default: false) */ + isHtml?: boolean; + /** Send from a different email address (send-as alias) */ + from?: string; + /** File attachments to include */ + attachments: OutboundAttachment[]; +} + +/** + * Result of sending a message with attachments + */ +export interface SendWithAttachmentsResult { + messageId: string; + threadId: string; + labelIds: string[]; + message: string; +} + +// ============================================================================ +// Message Management Operations +// ============================================================================ + +/** + * Options for trashing a message + */ +export interface TrashMessageOptions { + /** The message ID */ + id: string; +} + +/** + * Result of trashing a message + */ +export interface TrashMessageResult { + id: string; + labelIds: string[]; + message: string; +} + +/** + * Options for untrashing a message + */ +export interface UntrashMessageOptions { + /** The message ID */ + id: string; +} + +/** + * Result of untrashing a message + */ +export interface UntrashMessageResult { + id: string; + labelIds: string[]; + message: string; +} + +/** + * Options for permanently deleting a message + */ +export interface DeleteMessageOptions { + /** The message ID */ + id: string; + /** + * Must be true to confirm permanent deletion. + * This operation cannot be undone — use trashMessage() for recoverable deletion. + */ + safetyAcknowledged: boolean; +} + +/** + * Result of deleting a message + */ +export interface DeleteMessageResult { + id: string; + message: string; +} + +/** + * Options for marking a message as read + */ +export interface MarkAsReadOptions { + /** The message ID */ + id: string; +} + +/** + * Result of marking as read + */ +export interface MarkAsReadResult { + id: string; + labelIds: string[]; + message: string; +} + +/** + * Options for marking a message as unread + */ +export interface MarkAsUnreadOptions { + /** The message ID */ + id: string; +} + +/** + * Result of marking as unread + */ +export interface MarkAsUnreadResult { + id: string; + labelIds: string[]; + message: string; +} + +/** + * Options for archiving a message + */ +export interface ArchiveMessageOptions { + /** The message ID */ + id: string; +} + +/** + * Result of archiving a message + */ +export interface ArchiveMessageResult { + id: string; + labelIds: string[]; + message: string; +} diff --git a/src/modules/gmail/utils.ts b/src/modules/gmail/utils.ts index a16935b..224b344 100644 --- a/src/modules/gmail/utils.ts +++ b/src/modules/gmail/utils.ts @@ -157,3 +157,120 @@ export function encodeToBase64Url(content: string): string { .replace(/\//g, '_') .replace(/=+$/, ''); } + +/** + * Attachment data for building multipart MIME messages + */ +export interface AttachmentData { + filename: string; + mimeType: string; + /** Base64-encoded file content */ + data: string; +} + +/** + * Build a multipart/mixed RFC 2822 email message with attachments. + * + * Structure: + * Content-Type: multipart/mixed; boundary="" + * [headers] + * + * -- + * Content-Type: text/plain (or text/html) + * [body text] + * + * -- + * Content-Type: + * Content-Disposition: attachment; filename="" + * Content-Transfer-Encoding: base64 + * [base64 attachment data] + * ---- + * + * @param options Message content, recipients, and attachments + * @returns RFC 2822 formatted multipart email string + */ +export function buildMultipartMessage(options: { + to: string[]; + cc?: string[]; + bcc?: string[]; + subject: string; + body: string; + isHtml?: boolean; + from?: string; + inReplyTo?: string; + references?: string; + attachments: AttachmentData[]; +}): string { + const { to, cc, bcc, subject, body, isHtml = false, from, inReplyTo, references, attachments } = options; + + // Generate a unique boundary + const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + const lines: string[] = []; + + // Add envelope headers + if (from) { + const sanitizedFrom = sanitizeHeaderValue(from); + if (!isValidEmailAddress(sanitizedFrom)) { + throw new Error(`Invalid from email address: ${sanitizedFrom}`); + } + lines.push(`From: ${sanitizedFrom}`); + } + + const sanitizedTo = validateAndSanitizeRecipients(to, 'to'); + lines.push(`To: ${sanitizedTo.join(', ')}`); + + if (cc && cc.length > 0) { + const sanitizedCc = validateAndSanitizeRecipients(cc, 'cc'); + lines.push(`Cc: ${sanitizedCc.join(', ')}`); + } + + if (bcc && bcc.length > 0) { + const sanitizedBcc = validateAndSanitizeRecipients(bcc, 'bcc'); + lines.push(`Bcc: ${sanitizedBcc.join(', ')}`); + } + + lines.push(`Subject: ${encodeSubject(subject)}`); + + if (inReplyTo) { + lines.push(`In-Reply-To: ${sanitizeHeaderValue(inReplyTo)}`); + } + if (references) { + lines.push(`References: ${sanitizeHeaderValue(references)}`); + } + + lines.push('MIME-Version: 1.0'); + lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); + lines.push(''); // Blank line between headers and body + + // Body part + lines.push(`--${boundary}`); + lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset="UTF-8"`); + lines.push('Content-Transfer-Encoding: quoted-printable'); + lines.push(''); + lines.push(body); + lines.push(''); + + // Attachment parts + for (const attachment of attachments) { + const safeName = sanitizeHeaderValue(attachment.filename); + lines.push(`--${boundary}`); + lines.push(`Content-Type: ${sanitizeHeaderValue(attachment.mimeType)}; name="${safeName}"`); + lines.push(`Content-Disposition: attachment; filename="${safeName}"`); + lines.push('Content-Transfer-Encoding: base64'); + lines.push(''); + // Break base64 data into 76-char lines per RFC 2045 + const b64 = attachment.data.replace(/[^A-Za-z0-9+/=]/g, ''); + const chunks: string[] = []; + for (let i = 0; i < b64.length; i += 76) { + chunks.push(b64.slice(i, i + 76)); + } + lines.push(chunks.join('\r\n')); + lines.push(''); + } + + // Final boundary + lines.push(`--${boundary}--`); + + return lines.join('\r\n'); +} diff --git a/src/sdk/runtime.ts b/src/sdk/runtime.ts index 71b473a..139427b 100644 --- a/src/sdk/runtime.ts +++ b/src/sdk/runtime.ts @@ -180,6 +180,54 @@ export function createSDKRuntime( const { modifyLabels } = await import('../modules/gmail/index.js'); return modifyLabels(opts as Parameters[0], context); }), + replyToMessage: limiter.wrap('gmail', async (opts: unknown) => { + const { replyToMessage } = await import('../modules/gmail/index.js'); + return replyToMessage(opts as Parameters[0], context); + }), + replyAllToMessage: limiter.wrap('gmail', async (opts: unknown) => { + const { replyAllToMessage } = await import('../modules/gmail/index.js'); + return replyAllToMessage(opts as Parameters[0], context); + }), + forwardMessage: limiter.wrap('gmail', async (opts: unknown) => { + const { forwardMessage } = await import('../modules/gmail/index.js'); + return forwardMessage(opts as Parameters[0], context); + }), + listAttachments: limiter.wrap('gmail', async (opts: unknown) => { + const { listAttachments } = await import('../modules/gmail/index.js'); + return listAttachments(opts as Parameters[0], context); + }), + downloadAttachment: limiter.wrap('gmail', async (opts: unknown) => { + const { downloadAttachment } = await import('../modules/gmail/index.js'); + return downloadAttachment(opts as Parameters[0], context); + }), + sendWithAttachments: limiter.wrap('gmail', async (opts: unknown) => { + const { sendWithAttachments } = await import('../modules/gmail/index.js'); + return sendWithAttachments(opts as Parameters[0], context); + }), + trashMessage: limiter.wrap('gmail', async (opts: unknown) => { + const { trashMessage } = await import('../modules/gmail/index.js'); + return trashMessage(opts as Parameters[0], context); + }), + untrashMessage: limiter.wrap('gmail', async (opts: unknown) => { + const { untrashMessage } = await import('../modules/gmail/index.js'); + return untrashMessage(opts as Parameters[0], context); + }), + deleteMessage: limiter.wrap('gmail', async (opts: unknown) => { + const { deleteMessage } = await import('../modules/gmail/index.js'); + return deleteMessage(opts as Parameters[0], context); + }), + markAsRead: limiter.wrap('gmail', async (opts: unknown) => { + const { markAsRead } = await import('../modules/gmail/index.js'); + return markAsRead(opts as Parameters[0], context); + }), + markAsUnread: limiter.wrap('gmail', async (opts: unknown) => { + const { markAsUnread } = await import('../modules/gmail/index.js'); + return markAsUnread(opts as Parameters[0], context); + }), + archiveMessage: limiter.wrap('gmail', async (opts: unknown) => { + const { archiveMessage } = await import('../modules/gmail/index.js'); + return archiveMessage(opts as Parameters[0], context); + }), }, calendar: { diff --git a/src/sdk/spec.ts b/src/sdk/spec.ts index 2040373..57a5e97 100644 --- a/src/sdk/spec.ts +++ b/src/sdk/spec.ts @@ -470,6 +470,138 @@ export const SDK_SPEC: SDKSpec = { }, returns: "{ messageId, labelIds: string[] } — updated list of all label IDs on message", }, + replyToMessage: { + signature: "replyToMessage(options: { messageId: string, body: string, isHtml?: boolean, cc?: string[], bcc?: string[], from?: string }): Promise<{ messageId, threadId, labelIds, message }>", + description: "Reply to a specific message with proper MIME threading (In-Reply-To and References headers). Fetches the original to extract its MIME Message-ID. Reply is placed in the same thread.", + example: "const result = await sdk.gmail.replyToMessage({\n messageId: '18c123abc',\n body: 'Thanks for the update, I will follow up shortly.',\n});\nreturn result.messageId;", + params: { + messageId: "string (required) — Gmail message ID to reply to", + body: "string (required) — reply body text or HTML", + isHtml: "boolean (optional, default false) — whether body is HTML", + cc: "string[] (optional) — additional CC recipients", + bcc: "string[] (optional) — BCC recipients", + from: "string (optional) — send from a specific send-as alias", + }, + returns: "{ messageId, threadId, labelIds: string[], message: string }", + }, + replyAllToMessage: { + signature: "replyAllToMessage(options: { messageId: string, body: string, isHtml?: boolean, bcc?: string[], from?: string }): Promise<{ messageId, threadId, labelIds, message }>", + description: "Reply-all to a message. Automatically includes all original To/Cc recipients and excludes your own email. Deduplicates recipients.", + example: "const result = await sdk.gmail.replyAllToMessage({\n messageId: '18c123abc',\n body: 'Thanks everyone, see you all at the meeting.',\n});\nreturn result.messageId;", + params: { + messageId: "string (required) — Gmail message ID to reply-all to", + body: "string (required) — reply body text or HTML", + isHtml: "boolean (optional, default false) — whether body is HTML", + bcc: "string[] (optional) — additional BCC recipients", + from: "string (optional) — send from a specific send-as alias", + }, + returns: "{ messageId, threadId, labelIds: string[], message: string }", + }, + forwardMessage: { + signature: "forwardMessage(options: { messageId: string, to: string[], cc?: string[], bcc?: string[], body?: string, isHtml?: boolean, from?: string }): Promise<{ messageId, threadId, labelIds, message }>", + description: "Forward a message to new recipients. Quotes the original message content. Optionally prepends a custom message. Creates a new thread (no threading headers).", + example: "const result = await sdk.gmail.forwardMessage({\n messageId: '18c123abc',\n to: ['colleague@example.com'],\n body: 'FYI — thought this might be relevant to you.',\n});\nreturn result.messageId;", + params: { + messageId: "string (required) — Gmail message ID to forward", + to: "string[] (required) — recipients to forward to", + cc: "string[] (optional) — CC recipients", + bcc: "string[] (optional) — BCC recipients", + body: "string (optional) — custom message to prepend before the forwarded content", + isHtml: "boolean (optional, default false) — whether body is HTML", + from: "string (optional) — send from a specific send-as alias", + }, + returns: "{ messageId, threadId, labelIds: string[], message: string }", + }, + listAttachments: { + signature: "listAttachments(options: { messageId: string }): Promise<{ messageId, attachments: AttachmentInfo[] }>", + description: "List all attachments for a message. Returns metadata (filename, mimeType, size, attachmentId). Use downloadAttachment() to get file content.", + example: "const result = await sdk.gmail.listAttachments({ messageId: '18c123abc' });\nresult.attachments.forEach(att => {\n console.log(`${att.filename} (${att.size} bytes)`);\n});", + params: { + messageId: "string (required) — Gmail message ID", + }, + returns: "{ messageId, attachments: AttachmentInfo[] } — AttachmentInfo = { attachmentId, filename, mimeType, size }", + }, + downloadAttachment: { + signature: "downloadAttachment(options: { messageId: string, attachmentId: string }): Promise<{ messageId, attachmentId, filename, mimeType, size, data }>", + description: "Download a specific attachment from a message. Returns base64url-encoded file content.", + example: "const att = await sdk.gmail.downloadAttachment({\n messageId: '18c123abc',\n attachmentId: 'ANGjdJ...',\n});\n// Decode: Buffer.from(att.data, 'base64url')\nconsole.log(`Downloaded: ${att.filename}`);", + params: { + messageId: "string (required) — Gmail message ID", + attachmentId: "string (required) — attachment ID from listAttachments()", + }, + returns: "{ messageId, attachmentId, filename, mimeType, size, data: string } — data is base64url-encoded", + }, + sendWithAttachments: { + signature: "sendWithAttachments(options: { to: string[], subject: string, body: string, attachments: OutboundAttachment[], cc?: string[], bcc?: string[], isHtml?: boolean, from?: string }): Promise<{ messageId, threadId, labelIds, message }>", + description: "Send an email with file attachments using multipart/mixed MIME encoding.", + example: "const result = await sdk.gmail.sendWithAttachments({\n to: ['recipient@example.com'],\n subject: 'Here is the report',\n body: 'Please find the quarterly report attached.',\n attachments: [{\n filename: 'report.pdf',\n mimeType: 'application/pdf',\n data: pdfBase64String,\n }],\n});\nreturn result.messageId;", + params: { + to: "string[] (required) — recipient email addresses", + subject: "string (required) — email subject", + body: "string (required) — email body text or HTML", + "attachments": "OutboundAttachment[] (required) — [{ filename, mimeType, data: base64 }]", + cc: "string[] (optional) — CC recipients", + bcc: "string[] (optional) — BCC recipients", + isHtml: "boolean (optional, default false) — whether body is HTML", + from: "string (optional) — send from a specific send-as alias", + }, + returns: "{ messageId, threadId, labelIds: string[], message: string }", + }, + trashMessage: { + signature: "trashMessage(options: { id: string }): Promise<{ id, labelIds, message }>", + description: "Move a message to the trash. Recoverable with untrashMessage(). Use deleteMessage() for permanent deletion.", + example: "const result = await sdk.gmail.trashMessage({ id: '18c123abc' });\nconsole.log(result.message); // 'Message moved to trash'", + params: { + id: "string (required) — message ID to trash", + }, + returns: "{ id, labelIds: string[], message: string }", + }, + untrashMessage: { + signature: "untrashMessage(options: { id: string }): Promise<{ id, labelIds, message }>", + description: "Restore a message from the trash.", + example: "const result = await sdk.gmail.untrashMessage({ id: '18c123abc' });\nconsole.log(result.message); // 'Message restored from trash'", + params: { + id: "string (required) — message ID to restore", + }, + returns: "{ id, labelIds: string[], message: string }", + }, + deleteMessage: { + signature: "deleteMessage(options: { id: string, safetyAcknowledged: true }): Promise<{ id, message }>", + description: "Permanently and irrecoverably delete a message. Cannot be undone. Requires safetyAcknowledged: true. Use trashMessage() for recoverable deletion.", + example: "// PERMANENT — cannot be undone!\nconst result = await sdk.gmail.deleteMessage({\n id: '18c123abc',\n safetyAcknowledged: true,\n});\nconsole.log(result.message);", + params: { + id: "string (required) — message ID to permanently delete", + safetyAcknowledged: "true (required) — must be true to confirm permanent deletion", + }, + returns: "{ id, message: string }", + }, + markAsRead: { + signature: "markAsRead(options: { id: string }): Promise<{ id, labelIds, message }>", + description: "Mark a message as read by removing the UNREAD label.", + example: "await sdk.gmail.markAsRead({ id: '18c123abc' });", + params: { + id: "string (required) — message ID", + }, + returns: "{ id, labelIds: string[], message: string }", + }, + markAsUnread: { + signature: "markAsUnread(options: { id: string }): Promise<{ id, labelIds, message }>", + description: "Mark a message as unread by adding the UNREAD label.", + example: "await sdk.gmail.markAsUnread({ id: '18c123abc' });", + params: { + id: "string (required) — message ID", + }, + returns: "{ id, labelIds: string[], message: string }", + }, + archiveMessage: { + signature: "archiveMessage(options: { id: string }): Promise<{ id, labelIds, message }>", + description: "Archive a message by removing the INBOX label. Message remains searchable.", + example: "await sdk.gmail.archiveMessage({ id: '18c123abc' });", + params: { + id: "string (required) — message ID to archive", + }, + returns: "{ id, labelIds: string[], message: string }", + }, }, // ───────────────────────────────────────── diff --git a/src/sdk/types.ts b/src/sdk/types.ts index 0d51e85..99790f5 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -77,6 +77,18 @@ export interface SDKRuntime { sendDraft(options: unknown): Promise; listLabels(options: unknown): Promise; modifyLabels(options: unknown): Promise; + replyToMessage(options: unknown): Promise; + replyAllToMessage(options: unknown): Promise; + forwardMessage(options: unknown): Promise; + listAttachments(options: unknown): Promise; + downloadAttachment(options: unknown): Promise; + sendWithAttachments(options: unknown): Promise; + trashMessage(options: unknown): Promise; + untrashMessage(options: unknown): Promise; + deleteMessage(options: unknown): Promise; + markAsRead(options: unknown): Promise; + markAsUnread(options: unknown): Promise; + archiveMessage(options: unknown): Promise; }; calendar: { listCalendars(options: unknown): Promise;