diff --git a/workspace-server/src/__tests__/services/GmailService.test.ts b/workspace-server/src/__tests__/services/GmailService.test.ts index 6c77760..d85d436 100644 --- a/workspace-server/src/__tests__/services/GmailService.test.ts +++ b/workspace-server/src/__tests__/services/GmailService.test.ts @@ -822,6 +822,9 @@ describe('GmailService', () => { (MimeHelper.createMimeMessage as jest.Mock) = jest .fn() .mockReturnValue('base64encodedmessage'); + (MimeHelper.buildQuotedBlock as jest.Mock) = jest + .fn() + .mockReturnValue('\r\n\r\nQUOTED_BLOCK'); }); it('should create a draft email', async () => { @@ -994,6 +997,308 @@ describe('GmailService', () => { const response = JSON.parse(result.content[0].text); expect(response.status).toBe('draft_created'); }); + + it('should fetch quoted message and append quote block to body', async () => { + const mockDraft = { id: 'draft1', message: { id: 'msg1', threadId: 'thread1' } }; + mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft }); + + mockGmailAPI.users.threads.get.mockResolvedValue({ + data: { + messages: [ + { + id: 'msg1', + payload: { + headers: [ + { name: 'Message-ID', value: '' }, + { name: 'From', value: 'original@example.com' }, + { name: 'Date', value: 'Mon, 5 May 2026 10:00:00 +0000' }, + { name: 'References', value: '' }, + ], + parts: [ + { + mimeType: 'text/plain', + body: { + data: Buffer.from('Original message body').toString('base64'), + }, + }, + ], + }, + }, + ], + }, + }); + + const result = await gmailService.createDraft({ + to: 'recipient@example.com', + subject: 'Re: Test', + body: 'My reply', + threadId: 'thread1', + quoteOriginal: true, + }); + + expect(mockGmailAPI.users.threads.get).toHaveBeenCalledWith( + expect.objectContaining({ + format: 'full', + }), + ); + + expect(MimeHelper.buildQuotedBlock).toHaveBeenCalledWith( + expect.objectContaining({ + originalBody: 'Original message body', + from: 'original@example.com', + date: 'Mon, 5 May 2026 10:00:00 +0000', + isHtml: false, + }), + ); + + expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'My reply\r\n\r\nQUOTED_BLOCK', + }), + ); + + const response = JSON.parse(result.content[0].text); + expect(response.status).toBe('draft_created'); + }); + + it('should prefer text/plain body for plain-text drafts when both exist', async () => { + const mockDraft = { id: 'draft1', message: { id: 'msg1', threadId: 'thread1' } }; + mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft }); + + mockGmailAPI.users.threads.get.mockResolvedValue({ + data: { + messages: [ + { + id: 'msg1', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Date', value: 'Date' }, + ], + parts: [ + { + mimeType: 'text/plain', + body: { data: Buffer.from('Plain text').toString('base64') }, + }, + { + mimeType: 'text/html', + body: { data: Buffer.from('

HTML

').toString('base64') }, + }, + ], + }, + }, + ], + }, + }); + + await gmailService.createDraft({ + to: 'test@example.com', + subject: 'Test', + body: 'Reply', + threadId: 'thread1', + quoteOriginal: true, + }); + + expect(MimeHelper.buildQuotedBlock).toHaveBeenCalledWith( + expect.objectContaining({ + originalBody: 'Plain text', + }), + ); + }); + + it('should fall back to stripped HTML when only HTML body available for plain-text draft', async () => { + const mockDraft = { id: 'draft1', message: { id: 'msg1', threadId: 'thread1' } }; + mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft }); + mockGmailAPI.users.threads.get.mockResolvedValue({ + data: { + messages: [ + { + id: 'msg1', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Date', value: 'Test Date' }, + { name: 'Message-ID', value: '' }, + ], + parts: [ + { + mimeType: 'text/html', + body: { + data: Buffer.from('

HTML content

').toString('base64'), + }, + }, + ], + }, + }, + ], + }, + }); + + await gmailService.createDraft({ + to: 'test@example.com', + subject: 'Test', + body: 'Reply', + threadId: 'thread1', + quoteOriginal: true, + isHtml: false, + }); + + expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('Reply'), + }), + ); + }); + + it('should prefer text/html body for HTML drafts', async () => { + const mockDraft = { id: 'draft1', message: { id: 'msg1', threadId: 'thread1' } }; + mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft }); + + mockGmailAPI.users.threads.get.mockResolvedValue({ + data: { + messages: [ + { + id: 'msg1', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Date', value: 'Date' }, + ], + parts: [ + { + mimeType: 'text/plain', + body: { data: Buffer.from('Plain').toString('base64') }, + }, + { + mimeType: 'text/html', + body: { data: Buffer.from('

HTML

').toString('base64') }, + }, + ], + }, + }, + ], + }, + }); + + await gmailService.createDraft({ + to: 'test@example.com', + subject: 'Test', + body: 'Reply', + threadId: 'thread1', + quoteOriginal: true, + isHtml: true, + }); + + expect(MimeHelper.buildQuotedBlock).toHaveBeenCalledWith( + expect.objectContaining({ + originalBody: '

HTML

', + isHtml: true, + }), + ); + }); + + it('should gracefully degrade if quoted message fetch fails', async () => { + const mockDraft = { id: 'draft1', message: { id: 'msg1', threadId: 'thread1' } }; + mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft }); + + mockGmailAPI.users.threads.get.mockRejectedValue(new Error('Failed to fetch')); + + const result = await gmailService.createDraft({ + to: 'test@example.com', + subject: 'Test', + body: 'Reply body', + threadId: 'thread1', + quoteOriginal: true, + }); + + expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'Reply body', + }), + ); + + const response = JSON.parse(result.content[0].text); + expect(response.status).toBe('draft_created'); + }); + + it('should work with both threadId and quoteOriginal simultaneously', async () => { + const mockDraft = { id: 'draft1', message: { id: 'msg1', threadId: 'thread1' } }; + mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft }); + + mockGmailAPI.users.threads.get.mockResolvedValue({ + data: { + messages: [ + { + id: 'msg1', + payload: { + headers: [ + { name: 'Message-ID', value: '' }, + { name: 'From', value: 'sender@example.com' }, + { name: 'Date', value: 'Date' }, + { name: 'References', value: '' }, + ], + parts: [ + { + mimeType: 'text/plain', + body: { data: Buffer.from('Original').toString('base64') }, + }, + ], + }, + }, + ], + }, + }); + + await gmailService.createDraft({ + to: 'test@example.com', + subject: 'Re: Test', + body: 'Reply', + threadId: 'thread1', + quoteOriginal: true, + }); + + expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith( + expect.objectContaining({ + inReplyTo: '', + references: ' ', + body: 'Reply\r\n\r\nQUOTED_BLOCK', + }), + ); + }); + + it('should not fetch full thread when quoteOriginal is false or absent', async () => { + const mockDraft = { id: 'draft1', message: { id: 'msg1', threadId: 'thread1' } }; + mockGmailAPI.users.drafts.create.mockResolvedValue({ data: mockDraft }); + + mockGmailAPI.users.threads.get.mockResolvedValue({ + data: { + messages: [ + { + id: 'msg1', + payload: { + headers: [ + { name: 'Message-ID', value: '' }, + ], + }, + }, + ], + }, + }); + + await gmailService.createDraft({ + to: 'test@example.com', + subject: 'Test', + body: 'Body', + threadId: 'thread1', + }); + + expect(mockGmailAPI.users.threads.get).toHaveBeenCalledWith( + expect.objectContaining({ + format: 'metadata', + metadataHeaders: ['Message-ID', 'References'], + }), + ); + }); }); describe('sendDraft', () => { diff --git a/workspace-server/src/__tests__/utils/MimeHelper.test.ts b/workspace-server/src/__tests__/utils/MimeHelper.test.ts index b3f14f2..675bab4 100644 --- a/workspace-server/src/__tests__/utils/MimeHelper.test.ts +++ b/workspace-server/src/__tests__/utils/MimeHelper.test.ts @@ -415,4 +415,132 @@ describe('MimeHelper', () => { expect(decoded).toContain('Test body'); }); }); + + describe('buildQuotedBlock', () => { + it('should format a plain-text quoted block with attribution and > prefix', () => { + const originalBody = 'Line 1\nLine 2\nLine 3'; + const from = 'sender@example.com'; + const date = 'Mon, 5 May 2026 10:00:00 +0000'; + + const result = MimeHelper.buildQuotedBlock({ + originalBody, + from, + date, + isHtml: false, + }); + + expect(result).toContain(`On ${date}, ${from} wrote:`); + expect(result).toContain('> Line 1'); + expect(result).toContain('> Line 2'); + expect(result).toContain('> Line 3'); + expect(result).toMatch(/\r\n\r\nOn/); + }); + + it('should handle empty body in plain-text mode', () => { + const from = 'sender@example.com'; + const date = 'Mon, 5 May 2026 10:00:00 +0000'; + + const result = MimeHelper.buildQuotedBlock({ + originalBody: '', + from, + date, + isHtml: false, + }); + + expect(result).toContain(`On ${date}, ${from} wrote:`); + expect(result).not.toMatch(/> /); + }); + + it('should preserve all lines in multiline plain-text body', () => { + const originalBody = 'First\nSecond\nThird\nFourth\nFifth'; + const from = 'test@example.com'; + const date = 'Date'; + + const result = MimeHelper.buildQuotedBlock({ + originalBody, + from, + date, + isHtml: false, + }); + + expect(result).toContain('> First'); + expect(result).toContain('> Second'); + expect(result).toContain('> Third'); + expect(result).toContain('> Fourth'); + expect(result).toContain('> Fifth'); + }); + + it('should format an HTML quoted block with blockquote and attribution', () => { + const originalBody = '

HTML content

'; + const from = 'sender@example.com'; + const date = 'Mon, 5 May 2026 10:00:00 +0000'; + + const result = MimeHelper.buildQuotedBlock({ + originalBody, + from, + date, + isHtml: true, + }); + + expect(result).toContain('class="gmail_quote"'); + expect(result).toContain(' separator in HTML mode, not CRLF', () => { + const originalBody = 'content'; + const from = 'sender@example.com'; + const date = 'Date'; + + const result = MimeHelper.buildQuotedBlock({ + originalBody, + from, + date, + isHtml: true, + }); + + expect(result).toContain('
'); + expect(result).not.toMatch(/\r\n\r\nOn/); + }); + }); + + describe('stripHtmlTags', () => { + it('should strip basic HTML tags', () => { + const html = '

Hello world

'; + const result = MimeHelper.stripHtmlTags(html); + + expect(result).toBe('Hello world'); + expect(result).not.toContain('<'); + expect(result).not.toContain('>'); + }); + + it('should convert
tags to newlines', () => { + const html = 'Line 1
Line 2
Line 3'; + const result = MimeHelper.stripHtmlTags(html); + + expect(result).toContain('Line 1\nLine 2\nLine 3'); + }); + + it('should convert

tags to newlines', () => { + const html = '

Para 1

Para 2

'; + const result = MimeHelper.stripHtmlTags(html); + + expect(result).toContain('Para 1\nPara 2'); + }); + + it('should decode HTML entities', () => { + const html = 'Hello & goodbye <test> "quoted"   space'; + const result = MimeHelper.stripHtmlTags(html); + + expect(result).toContain('Hello & goodbye "quoted"'); + }); + + it('should collapse excessive newlines', () => { + const html = 'Line1\n\n\n\nLine2'; + const result = MimeHelper.stripHtmlTags(html); + + expect(result).toBe('Line1\n\nLine2'); + }); + }); }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index dd5f56c..2b146a7 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -1299,6 +1299,12 @@ System labels that can be modified: .describe( 'The thread ID to create the draft as a reply to. When provided, the draft will be linked to the existing thread with appropriate reply headers.', ), + quoteOriginal: z + .boolean() + .optional() + .describe( + 'When true and threadId is provided, fetches the last message in the thread and appends it as a formatted quote block. Plain-text drafts use "> " line prefixes; HTML drafts use a Gmail-style blockquote.', + ), }, }, gmailService.createDraft, diff --git a/workspace-server/src/services/GmailService.ts b/workspace-server/src/services/GmailService.ts index 915a159..07b4ba5 100644 --- a/workspace-server/src/services/GmailService.ts +++ b/workspace-server/src/services/GmailService.ts @@ -30,6 +30,7 @@ type SendEmailParams = { type CreateDraftParams = SendEmailParams & { threadId?: string; + quoteOriginal?: boolean; }; interface GmailAttachment { @@ -491,26 +492,28 @@ export class GmailService { bcc, isHtml = false, threadId, + quoteOriginal, }: CreateDraftParams) => { try { logToFile(`Creating draft - to: ${to}, subject: ${subject}`); const gmail = await this.getGmailClient(); - // If threadId is provided, fetch the last message to get reply headers + // If threadId is provided, fetch the last message to get reply headers (and body if quoting) let inReplyTo: string | undefined; let references: string | undefined; + let lastMessage: gmail_v1.Schema$Message | undefined; if (threadId) { try { const threadResponse = await gmail.users.threads.get({ userId: 'me', id: threadId, - format: 'metadata', - metadataHeaders: ['Message-ID', 'References'], + format: quoteOriginal ? 'full' : 'metadata', + ...(quoteOriginal ? {} : { metadataHeaders: ['Message-ID', 'References'] }), }); const messages = threadResponse.data.messages || []; if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; + lastMessage = messages[messages.length - 1]; const headers = lastMessage.payload?.headers || []; const messageIdHeader = headers.find( (h) => h.name?.toLowerCase() === 'message-id', @@ -533,11 +536,31 @@ export class GmailService { } } + // Build final body, including quote if requested + let finalBody = body; + if (quoteOriginal && lastMessage?.payload) { + const headers = lastMessage.payload.headers || []; + const from = headers.find((h) => h.name?.toLowerCase() === 'from')?.value ?? ''; + const date = headers.find((h) => h.name?.toLowerCase() === 'date')?.value ?? ''; + const { textBody, htmlBody } = this.extractTextAndHtmlBodies(lastMessage.payload); + + let originalBody: string | null = null; + if (isHtml) { + originalBody = htmlBody ?? (textBody ? `
${textBody}
` : null); + } else { + originalBody = textBody ?? (htmlBody ? MimeHelper.stripHtmlTags(htmlBody) : null); + } + + if (originalBody) { + finalBody = body + MimeHelper.buildQuotedBlock({ originalBody, from, date, isHtml }); + } + } + // Create MIME message const mimeMessage = MimeHelper.createMimeMessage({ to: Array.isArray(to) ? to.join(', ') : to, subject, - body, + body: finalBody, cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined, bcc: bcc ? (Array.isArray(bcc) ? bcc.join(', ') : bcc) : undefined, isHtml, @@ -707,6 +730,44 @@ export class GmailService { } }; + private extractTextAndHtmlBodies( + payload: gmail_v1.Schema$MessagePart, + result: { textBody: string | null; htmlBody: string | null } = { + textBody: null, + htmlBody: null, + }, + ): { textBody: string | null; htmlBody: string | null } { + if (!payload) return result; + + // Handle body parts + if (payload.body?.data) { + // If it's the main body (and not an attachment) + if (!payload.filename || !payload.body.attachmentId) { + if (payload.mimeType?.startsWith('text/')) { + if (payload.mimeType === 'text/plain' && !result.textBody) { + result.textBody = Buffer.from( + payload.body.data, + 'base64url', + ).toString('utf-8'); + } else if (payload.mimeType === 'text/html' && !result.htmlBody) { + result.htmlBody = Buffer.from( + payload.body.data, + 'base64url', + ).toString('utf-8'); + } + } + } + } + + // Recurse into parts + if (payload.parts) { + for (const part of payload.parts) { + this.extractTextAndHtmlBodies(part, result); + } + } + return result; + } + private extractAttachmentsAndBody( payload: gmail_v1.Schema$MessagePart, result: { body: string; attachments: GmailAttachment[] } = { diff --git a/workspace-server/src/utils/MimeHelper.ts b/workspace-server/src/utils/MimeHelper.ts index 398f857..8456fbd 100644 --- a/workspace-server/src/utils/MimeHelper.ts +++ b/workspace-server/src/utils/MimeHelper.ts @@ -210,4 +210,55 @@ export class MimeHelper { } return Buffer.from(base64, 'base64').toString('utf-8'); } + + /** + * Strips HTML tags from a string, decoding common entities + */ + public static stripHtmlTags(html: string): string { + return html + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } + + /** + * Builds a quoted block for a reply, formatting based on content type + */ + public static buildQuotedBlock({ + originalBody, + from, + date, + isHtml = false, + }: { + originalBody: string; + from: string; + date: string; + isHtml?: boolean; + }): string { + const attribution = `On ${date}, ${from} wrote:`; + + if (isHtml) { + return ( + `
` + + `${attribution}
` + + `
` + + `${originalBody}` + + `
` + ); + } + + if (!originalBody.trim()) { + return `\r\n\r\n${attribution}`; + } + + const lines = originalBody.split(/\r?\n/).map((line) => `> ${line}`); + return `\r\n\r\n${attribution}\r\n${lines.join('\r\n')}`; + } }