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')}`;
+ }
}