From 795343ef1c2a9981f5d31ea6f722c725f83d338a Mon Sep 17 00:00:00 2001
From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com>
Date: Tue, 5 May 2026 15:22:19 +1000
Subject: [PATCH 1/2] feat: add quoting support to gmail.createDraft tool
Add quoteOriginal boolean parameter to gmail.createDraft. When true and threadId
is provided, the tool fetches the last message in the thread, extracts its body
and sender metadata, and appends it as a formatted quote block. Plain-text drafts
use "> " line prefixes; HTML drafts use a Gmail-style blockquote.
Changes:
- MimeHelper.ts: add buildQuotedBlock and stripHtmlTags static methods
- GmailService.ts: add extractTextAndHtmlBodies helper, integrate quoting logic
- index.ts: add quoteOriginal parameter to gmail.createDraft schema
- Tests: 11 new test cases covering quoting behavior, HTML stripping, and edge cases
Co-Authored-By: Claude Haiku 4.5
---
.../__tests__/services/GmailService.test.ts | 305 ++++++++++++++++++
.../src/__tests__/utils/MimeHelper.test.ts | 128 ++++++++
workspace-server/src/index.ts | 6 +
workspace-server/src/services/GmailService.ts | 69 +++-
workspace-server/src/utils/MimeHelper.ts | 51 +++
5 files changed, 554 insertions(+), 5 deletions(-)
diff --git a/workspace-server/src/__tests__/services/GmailService.test.ts b/workspace-server/src/__tests__/services/GmailService.test.ts
index 6c777602..d85d436f 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 b3f14f20..675bab4f 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 dd5f56c2..2b146a79 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 915a1599..d55f330e 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,42 @@ 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, 'base64').toString(
+ 'utf-8',
+ );
+ } else if (payload.mimeType === 'text/html' && !result.htmlBody) {
+ result.htmlBody = Buffer.from(payload.body.data, 'base64').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 398f857b..e485fbd9 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('\n').map((line) => `> ${line}`);
+ return `\r\n\r\n${attribution}\r\n${lines.join('\r\n')}`;
+ }
}
From d3186adc93a1adb79fbf0a9106935ad2a976093a Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 5 May 2026 06:41:50 +0000
Subject: [PATCH 2/2] fix: use base64url and tolerate CRLF in createDraft
quoting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Decode Gmail message bodies with base64url per RFC 4648 ยง5
- Split original body on /\r?\n/ to avoid \r\r\n in quoted lines
---
workspace-server/src/services/GmailService.ts | 14 ++++++++------
workspace-server/src/utils/MimeHelper.ts | 2 +-
2 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/workspace-server/src/services/GmailService.ts b/workspace-server/src/services/GmailService.ts
index d55f330e..07b4ba53 100644
--- a/workspace-server/src/services/GmailService.ts
+++ b/workspace-server/src/services/GmailService.ts
@@ -745,13 +745,15 @@ export class GmailService {
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, 'base64').toString(
- 'utf-8',
- );
+ 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, 'base64').toString(
- 'utf-8',
- );
+ result.htmlBody = Buffer.from(
+ payload.body.data,
+ 'base64url',
+ ).toString('utf-8');
}
}
}
diff --git a/workspace-server/src/utils/MimeHelper.ts b/workspace-server/src/utils/MimeHelper.ts
index e485fbd9..8456fbde 100644
--- a/workspace-server/src/utils/MimeHelper.ts
+++ b/workspace-server/src/utils/MimeHelper.ts
@@ -258,7 +258,7 @@ export class MimeHelper {
return `\r\n\r\n${attribution}`;
}
- const lines = originalBody.split('\n').map((line) => `> ${line}`);
+ const lines = originalBody.split(/\r?\n/).map((line) => `> ${line}`);
return `\r\n\r\n${attribution}\r\n${lines.join('\r\n')}`;
}
}