From 113d94cea2956dafcecff2bb33df15d417daeb40 Mon Sep 17 00:00:00 2001 From: Dana <152518854+dana-gill@users.noreply.github.com> Date: Mon, 16 Jun 2025 10:03:10 +0200 Subject: [PATCH] fix(Gmail Node): Do not break threads while creating drafts (#16272) Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../nodes/Google/Gmail/GenericFunctions.ts | 2 +- .../Google/Gmail/test/v2/GmailV2.node.test.ts | 9 +++ .../nodes/Google/Gmail/test/v2/utils.test.ts | 74 +++++++++++++++++++ .../nodes/Google/Gmail/v2/DraftDescription.ts | 7 ++ .../nodes/Google/Gmail/v2/GmailV2.node.ts | 7 ++ .../nodes/Google/Gmail/v2/utils/draft.ts | 30 ++++++++ 6 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 packages/nodes-base/nodes/Google/Gmail/v2/utils/draft.ts diff --git a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts index 7e6b7bd2ef..3620ab51a2 100644 --- a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts @@ -250,7 +250,7 @@ export async function encodeEmail(email: IEmail) { const mailBody = await mail.build(); - return mailBody.toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); + return mailBody.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } export async function googleApiRequestAllItems( diff --git a/packages/nodes-base/nodes/Google/Gmail/test/v2/GmailV2.node.test.ts b/packages/nodes-base/nodes/Google/Gmail/test/v2/GmailV2.node.test.ts index abd758b56c..a040f1a288 100644 --- a/packages/nodes-base/nodes/Google/Gmail/test/v2/GmailV2.node.test.ts +++ b/packages/nodes-base/nodes/Google/Gmail/test/v2/GmailV2.node.test.ts @@ -193,6 +193,15 @@ describe('Test Gmail Node v2', () => { .query({ userId: 'me', uploadType: 'media' }) .reply(200, messages[0]); gmailNock.delete('/v1/users/me/drafts/test-draft-id').reply(200, messages[0]); + gmailNock + .get('/v1/users/me/threads/test-thread-id') + .query({ + format: 'metadata', + metadataHeaders: 'Message-ID', + }) + .reply(200, { + messages: [{ payload: { headers: ['jjkjkjkf@reply.com'] } }], + }); gmailNock .get('/v1/users/me/drafts/test-draft-id') .query({ format: 'raw' }) diff --git a/packages/nodes-base/nodes/Google/Gmail/test/v2/utils.test.ts b/packages/nodes-base/nodes/Google/Gmail/test/v2/utils.test.ts index 3564092e85..e98166f5c9 100644 --- a/packages/nodes-base/nodes/Google/Gmail/test/v2/utils.test.ts +++ b/packages/nodes-base/nodes/Google/Gmail/test/v2/utils.test.ts @@ -2,7 +2,11 @@ import { mock } from 'jest-mock-extended'; import { DateTime } from 'luxon'; import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import type { IEmail } from '@utils/sendAndWait/interfaces'; + +import * as GenericFunctions from '../../GenericFunctions'; import { parseRawEmail, prepareTimestamp } from '../../GenericFunctions'; +import { addThreadHeadersToEmail } from '../../v2/utils/draft'; const node: INode = { id: '1', @@ -128,3 +132,73 @@ describe('parseRawEmail', () => { expect(typeof json.date).toBe('string'); }); }); + +describe('addThreadHeadersToEmail', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should set inReplyTo and reference on the email object', async () => { + const mockThreadId = 'thread123'; + const mockMessageId = ''; + const mockThread = { + messages: [ + { payload: { headers: [{ value: '' }] } }, + { payload: { headers: [{ value: mockMessageId }] } }, + ], + }; + + jest.spyOn(GenericFunctions, 'googleApiRequest').mockImplementation(async function () { + return mockThread; + }); + + const email = mock({}); + + const thisArg = mock({}); + + await addThreadHeadersToEmail.call(thisArg, email, mockThreadId); + + expect(email.inReplyTo).toBe(mockMessageId); + expect(email.reference).toBe(mockMessageId); + }); + + it('should set inReplyTo and reference on the email object even if the message has only one item', async () => { + const mockThreadId = 'thread123'; + const mockMessageId = ''; + const mockThread = { + messages: [{ payload: { headers: [{ value: mockMessageId }] } }], + }; + + jest.spyOn(GenericFunctions, 'googleApiRequest').mockImplementation(async function () { + return mockThread; + }); + + const email = mock({}); + + const thisArg = mock({}); + + await addThreadHeadersToEmail.call(thisArg, email, mockThreadId); + + expect(email.inReplyTo).toBe(mockMessageId); + expect(email.reference).toBe(mockMessageId); + }); + + it('should not do anything if the thread has no messages', async () => { + const mockThreadId = 'thread123'; + const mockThread = {}; + + jest.spyOn(GenericFunctions, 'googleApiRequest').mockImplementation(async function () { + return mockThread; + }); + + const email = mock({}); + + const thisArg = mock({}); + + await addThreadHeadersToEmail.call(thisArg, email, mockThreadId); + + // We are using mock({}) which means the value of these will be a mock function + expect(typeof email.inReplyTo).toBe('function'); + expect(typeof email.reference).toBe('function'); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Gmail/v2/DraftDescription.ts b/packages/nodes-base/nodes/Google/Gmail/v2/DraftDescription.ts index 7d330329fd..db12571bbf 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v2/DraftDescription.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v2/DraftDescription.ts @@ -66,6 +66,13 @@ export const draftFields: INodeProperties[] = [ }, placeholder: 'Hello World!', }, + { + displayName: 'To reply to an existing thread, specify the exact subject title of that thread.', + name: 'threadNotice', + type: 'notice', + default: '', + displayOptions: { show: { resource: ['draft'], operation: ['create'] } }, + }, { displayName: 'Email Type', name: 'emailType', diff --git a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts index b21558acf0..0195d54439 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts @@ -13,6 +13,7 @@ import { labelFields, labelOperations } from './LabelDescription'; import { getGmailAliases, getLabels, getThreadMessages } from './loadOptions'; import { messageFields, messageOperations } from './MessageDescription'; import { threadFields, threadOperations } from './ThreadDescription'; +import { addThreadHeadersToEmail } from './utils/draft'; import { configureWaitTillDate } from '../../../../utils/sendAndWait/configureWaitTillDate.util'; import { sendAndWaitWebhooksDescription } from '../../../../utils/sendAndWait/descriptions'; import type { IEmail } from '../../../../utils/sendAndWait/interfaces'; @@ -560,6 +561,12 @@ export class GmailV2 implements INodeType { attachments, }; + if (threadId && options.replyTo) { + // If a threadId is set, we need to add the Message-ID of the last message in the thread + // to the email so that Gmail can correctly associate the draft with the thread + await addThreadHeadersToEmail.call(this, email, threadId); + } + const body = { message: { raw: await encodeEmail(email), diff --git a/packages/nodes-base/nodes/Google/Gmail/v2/utils/draft.ts b/packages/nodes-base/nodes/Google/Gmail/v2/utils/draft.ts new file mode 100644 index 0000000000..e50500255b --- /dev/null +++ b/packages/nodes-base/nodes/Google/Gmail/v2/utils/draft.ts @@ -0,0 +1,30 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; + +import type { IEmail } from '@utils/sendAndWait/interfaces'; + +import { googleApiRequest } from '../../GenericFunctions'; + +/** + * Adds inReplyTo and reference headers to the email if threadId is provided. + */ +export async function addThreadHeadersToEmail( + this: IExecuteFunctions, + email: IEmail, + threadId: string, +): Promise { + const thread = await googleApiRequest.call( + this, + 'GET', + `/gmail/v1/users/me/threads/${threadId}`, + {}, + { format: 'metadata', metadataHeaders: ['Message-ID'] }, + ); + + if (thread?.messages) { + const lastMessage = thread.messages.length - 1; + const messageId: string = thread.messages[lastMessage].payload.headers[0].value; + + email.inReplyTo = messageId; + email.reference = messageId; + } +}