diff --git a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts index 3620ab51a2e..41dccf21685 100644 --- a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts @@ -507,111 +507,6 @@ export function unescapeSnippets(items: INodeExecutionData[]) { return result; } -export async function replyToEmail( - this: IExecuteFunctions, - gmailId: string, - options: IDataObject, - itemIndex: number, -) { - let qs: IDataObject = {}; - - let cc = ''; - let bcc = ''; - - if (options.ccList) { - cc = prepareEmailsInput.call(this, options.ccList as string, 'CC', itemIndex); - } - - if (options.bccList) { - bcc = prepareEmailsInput.call(this, options.bccList as string, 'BCC', itemIndex); - } - let attachments: IDataObject[] = []; - if (options.attachmentsUi) { - attachments = await prepareEmailAttachments.call( - this, - options.attachmentsUi as IDataObject, - itemIndex, - ); - if (attachments.length) { - qs = { - userId: 'me', - uploadType: 'media', - }; - } - } - - const endpoint = `/gmail/v1/users/me/messages/${gmailId}`; - - qs.format = 'metadata'; - - const { payload, threadId } = await googleApiRequest.call(this, 'GET', endpoint, {}, qs); - - const subject = - payload.headers.filter( - (data: { [key: string]: string }) => data.name.toLowerCase() === 'subject', - )[0]?.value || ''; - - const messageIdGlobal = - payload.headers.filter( - (data: { [key: string]: string }) => data.name.toLowerCase() === 'message-id', - )[0]?.value || ''; - - const { emailAddress } = await googleApiRequest.call(this, 'GET', '/gmail/v1/users/me/profile'); - - let to = ''; - const replyToSenderOnly = - options.replyToSenderOnly === undefined ? false : (options.replyToSenderOnly as boolean); - - const prepareEmailString = (email: string) => { - if (email.includes(emailAddress as string)) return; - if (email.includes('<') && email.includes('>')) { - to += `${email}, `; - } else { - to += `<${email}>, `; - } - }; - - for (const header of payload.headers as IDataObject[]) { - if (((header.name as string) || '').toLowerCase() === 'from') { - const from = header.value as string; - if (from.includes('<') && from.includes('>')) { - to += `${from}, `; - } else { - to += `<${from}>, `; - } - } - - if (((header.name as string) || '').toLowerCase() === 'to' && !replyToSenderOnly) { - const toEmails = header.value as string; - toEmails.split(',').forEach(prepareEmailString); - } - } - - let from = ''; - if (options.senderName) { - from = `${options.senderName as string} <${emailAddress}>`; - } - - const email: IEmail = { - from, - to, - cc, - bcc, - subject, - attachments, - inReplyTo: messageIdGlobal, - reference: messageIdGlobal, - ...prepareEmailBody.call(this, itemIndex), - }; - - const body = { - raw: await encodeEmail(email), - threadId, - }; - - return await googleApiRequest.call(this, 'POST', '/gmail/v1/users/me/messages/send', body, qs); -} - export async function simplifyOutput( this: IExecuteFunctions | IPollFunctions, data: IDataObject[], diff --git a/packages/nodes-base/nodes/Google/Gmail/test/utils/replyToEmail.test.ts b/packages/nodes-base/nodes/Google/Gmail/test/utils/replyToEmail.test.ts new file mode 100644 index 00000000000..c49577883c7 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Gmail/test/utils/replyToEmail.test.ts @@ -0,0 +1,573 @@ +import { mock } from 'jest-mock-extended'; +import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; + +import { + encodeEmail, + googleApiRequest, + prepareEmailAttachments, + prepareEmailBody, + prepareEmailsInput, +} from '../../GenericFunctions'; +import type { GmailMessage, GmailMessageMetadata, GmailUserProfile } from '../../types'; +import { replyToEmail } from '../../utils/replyToEmail'; + +jest.mock('../../GenericFunctions', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + ...jest.requireActual('../../GenericFunctions'), + googleApiRequest: jest.fn(), + prepareEmailsInput: jest.fn(), + prepareEmailAttachments: jest.fn(), + prepareEmailBody: jest.fn(), + encodeEmail: jest.fn(), +})); + +const mockedGoogleApiRequest = jest.mocked(googleApiRequest); +const mockedPrepareEmailsInput = jest.mocked(prepareEmailsInput); +const mockedPrepareEmailAttachments = jest.mocked(prepareEmailAttachments); +const mockedPrepareEmailBody = jest.mocked(prepareEmailBody); +const mockedEncodeEmail = jest.mocked(encodeEmail); + +describe('replyToEmail', () => { + let mockExecuteFunctions: IExecuteFunctions; + + beforeEach(() => { + mockExecuteFunctions = mock(); + jest.clearAllMocks(); + + mockedPrepareEmailsInput.mockReturnValue('test@example.com, '); + mockedPrepareEmailAttachments.mockResolvedValue([]); + mockedPrepareEmailBody.mockReturnValue({ + body: 'Test message body', + htmlBody: '', + }); + mockedEncodeEmail.mockResolvedValue('rawString'); + }); + + const mockMessageMetadata: GmailMessageMetadata = { + id: 'message123', + threadId: 'thread123', + labelIds: ['INBOX'], + payload: { + partId: '', + mimeType: 'text/plain', + filename: '', + headers: [ + { name: 'Subject', value: 'Original Subject' }, + { name: 'Message-ID', value: '' }, + { name: 'From', value: 'John Doe ' }, + { name: 'To', value: 'recipient1@example.com,recipient2@example.com' }, + ], + body: { attachmentId: '', size: 0, data: '' }, + parts: [], + }, + }; + + const mockUserProfile: GmailUserProfile = { + emailAddress: 'user@gmail.com', + messagesTotal: 100, + threadsTotal: 50, + historyId: 'history123', + }; + + const mockSentMessage: GmailMessage = { + id: 'sent123', + threadId: 'thread123', + labelIds: ['SENT'], + snippet: 'Reply message...', + historyId: 'history124', + sizeEstimate: 1000, + raw: 'encoded-email-content', + payload: mockMessageMetadata.payload, + }; + + test('should reply to email with basic options', async () => { + mockedGoogleApiRequest + .mockResolvedValueOnce(mockMessageMetadata) // GET message metadata + .mockResolvedValueOnce(mockUserProfile) // GET user profile + .mockResolvedValueOnce(mockSentMessage); // POST send message + + const options: IDataObject = {}; + const result = await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + expect(mockedGoogleApiRequest).toHaveBeenNthCalledWith( + 1, + 'GET', + '/gmail/v1/users/me/messages/message123', + {}, + { format: 'metadata' }, + ); + expect(mockedGoogleApiRequest).toHaveBeenNthCalledWith(2, 'GET', '/gmail/v1/users/me/profile'); + + expect(mockedGoogleApiRequest).toHaveBeenNthCalledWith( + 3, + 'POST', + '/gmail/v1/users/me/messages/send', + expect.objectContaining({ + threadId: 'thread123', + raw: expect.any(String), + }), + { format: 'metadata' }, + ); + + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + from: '', + to: 'John Doe , , ', + cc: '', + bcc: '', + subject: 'Original Subject', + attachments: [], + inReplyTo: '', + reference: '', + body: 'Test message body', + htmlBody: '', + }), + ); + + expect(result).toEqual(mockSentMessage); + }); + + test('should handle CC list when provided', async () => { + mockedGoogleApiRequest + .mockResolvedValueOnce(mockMessageMetadata) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + mockedPrepareEmailsInput.mockReturnValue('cc@example.com, '); + + const options: IDataObject = { + ccList: 'cc@example.com', + }; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + expect(mockedPrepareEmailsInput).toHaveBeenCalledWith('cc@example.com', 'CC', 0); + + // Verify encodeEmail was called with CC list + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + cc: 'cc@example.com, ', + bcc: '', + }), + ); + }); + + test('should handle BCC list when provided', async () => { + mockedGoogleApiRequest + .mockResolvedValueOnce(mockMessageMetadata) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + mockedPrepareEmailsInput.mockReturnValue('bcc@example.com, '); + + const options: IDataObject = { + bccList: 'bcc@example.com', + }; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + expect(mockedPrepareEmailsInput).toHaveBeenCalledWith('bcc@example.com', 'BCC', 0); + + // Verify encodeEmail was called with BCC list + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + cc: '', + bcc: 'bcc@example.com, ', + }), + ); + }); + + test('should handle attachments when provided', async () => { + const mockAttachments = [ + { name: 'file1.txt', content: Buffer.from('content'), type: 'text/plain' }, + { name: 'file2.pdf', content: Buffer.from('pdf content'), type: 'application/pdf' }, + ]; + + mockedGoogleApiRequest + .mockResolvedValueOnce(mockMessageMetadata) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + mockedPrepareEmailAttachments.mockResolvedValue(mockAttachments); + + const options: IDataObject = { + attachmentsUi: { + attachmentsBinary: [{ property: 'attachment1' }, { property: 'attachment2' }], + }, + }; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + expect(mockedPrepareEmailAttachments).toHaveBeenCalledWith(options.attachmentsUi, 0); + + // Should use upload media endpoint when attachments are present + expect(mockedGoogleApiRequest).toHaveBeenNthCalledWith( + 3, + 'POST', + '/gmail/v1/users/me/messages/send', + expect.anything(), + expect.objectContaining({ + userId: 'me', + uploadType: 'media', + format: 'metadata', + }), + ); + + // Verify encodeEmail was called with attachments + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: mockAttachments, + }), + ); + }); + + test('should reply to sender only when replyToSenderOnly is true', async () => { + const messageWithMultipleRecipients = { + ...mockMessageMetadata, + payload: { + ...mockMessageMetadata.payload, + headers: [ + { name: 'Subject', value: 'Original Subject' }, + { name: 'Message-ID', value: '' }, + { name: 'From', value: 'John Doe ' }, + { name: 'To', value: 'recipient1@example.com, recipient2@example.com, user@gmail.com' }, + { name: 'Cc', value: 'cc1@example.com, cc2@example.com' }, + ], + }, + }; + + mockedGoogleApiRequest + .mockResolvedValueOnce(messageWithMultipleRecipients) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = { + replyToSenderOnly: true, + }; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + // Verify that only the sender is included in the "To" field + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + from: '', + to: 'John Doe ', + cc: '', + bcc: '', + subject: 'Original Subject', + inReplyTo: '', + reference: '', + }), + ); + }); + + test('should reply to recipients only when replyToRecipientsOnly is true', async () => { + const messageWithUserInTo = { + ...mockMessageMetadata, + payload: { + ...mockMessageMetadata.payload, + headers: [ + { name: 'Subject', value: 'Original Subject' }, + { name: 'Message-ID', value: '' }, + { name: 'From', value: '' }, + { name: 'To', value: 'recipient1@example.com,user@gmail.com,recipient2@example.com' }, + ], + }, + }; + + mockedGoogleApiRequest + .mockResolvedValueOnce(messageWithUserInTo) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = { + replyToRecipientsOnly: true, + }; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + // Should filter out the current user's email from recipients and exclude sender + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + from: '', + // Should include sender and original recipients but filter out current user + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + to: expect.stringContaining(''), + subject: 'Original Subject', + inReplyTo: '', + reference: '', + }), + ); + + // Should not include the current user's email address + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ', ', + }), + ); + }); + + test('should use custom sender name when provided', async () => { + mockedGoogleApiRequest + .mockResolvedValueOnce(mockMessageMetadata) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = { + senderName: 'Custom Sender Name', + }; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'Custom Sender Name ', + }), + ); + }); + + test('should handle emails with angle brackets correctly', async () => { + const messageWithBrackets = { + ...mockMessageMetadata, + payload: { + ...mockMessageMetadata.payload, + headers: [ + { name: 'Subject', value: 'Original Subject' }, + { name: 'Message-ID', value: '' }, + { name: 'From', value: 'John Doe ' }, + { name: 'To', value: 'Regular Email , plain@example.com' }, + ], + }, + }; + + mockedGoogleApiRequest + .mockResolvedValueOnce(messageWithBrackets) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = {}; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + // Should handle both formats correctly + expect(mockedGoogleApiRequest).toHaveBeenNthCalledWith( + 3, + 'POST', + '/gmail/v1/users/me/messages/send', + expect.objectContaining({ + threadId: 'thread123', + }), + { format: 'metadata' }, + ); + }); + + test('should filter out current user email from recipients', async () => { + const messageWithUserEmail = { + ...mockMessageMetadata, + payload: { + ...mockMessageMetadata.payload, + headers: [ + { name: 'Subject', value: 'Original Subject' }, + { name: 'Message-ID', value: '' }, + // user@gmail.com - current user, user_from@gmail.com - sender that should be excluded + { name: 'From', value: '' }, + { name: 'To', value: 'recipient@example.com,user@gmail.com' }, + ], + }, + }; + + mockedGoogleApiRequest + .mockResolvedValueOnce(messageWithUserEmail) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = {}; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + expect(mockedGoogleApiRequest).toHaveBeenCalledTimes(3); + + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ', ', + }), + ); + }); + + test('should handle missing subject header', async () => { + const messageWithoutSubject = { + ...mockMessageMetadata, + payload: { + ...mockMessageMetadata.payload, + headers: [ + { name: 'Message-ID', value: '' }, + { name: 'From', value: 'John Doe ' }, + { name: 'To', value: 'recipient@example.com' }, + ], + }, + }; + + mockedGoogleApiRequest + .mockResolvedValueOnce(messageWithoutSubject) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = {}; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + // Should handle missing subject gracefully (empty string) + expect(mockedGoogleApiRequest).toHaveBeenCalledTimes(3); + }); + + test('should handle missing message ID header', async () => { + const messageWithoutMessageId = { + ...mockMessageMetadata, + payload: { + ...mockMessageMetadata.payload, + headers: [ + { name: 'Subject', value: 'Original Subject' }, + { name: 'From', value: 'John Doe ' }, + { name: 'To', value: 'recipient@example.com' }, + ], + }, + }; + + mockedGoogleApiRequest + .mockResolvedValueOnce(messageWithoutMessageId) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = {}; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + // Should handle missing message ID gracefully (empty string) + expect(mockedGoogleApiRequest).toHaveBeenCalledTimes(3); + }); + + test('should use prepareEmailBody for message content', async () => { + mockedGoogleApiRequest + .mockResolvedValueOnce(mockMessageMetadata) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + mockedPrepareEmailBody.mockReturnValue({ + body: 'Custom message content', + htmlBody: '

Custom HTML content

', + }); + + const options: IDataObject = {}; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + expect(mockedPrepareEmailBody).toHaveBeenCalledWith(0); + }); + + test('should encode email with proper structure including all required fields', async () => { + mockedGoogleApiRequest + .mockResolvedValueOnce(mockMessageMetadata) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + mockedPrepareEmailBody.mockReturnValue({ + body: 'Reply message body', + htmlBody: '

Reply HTML body

', + }); + + const options: IDataObject = { + ccList: 'cc@example.com', + bccList: 'bcc@example.com', + senderName: 'Test Sender', + }; + + mockedPrepareEmailsInput + .mockReturnValueOnce('cc@example.com, ') + .mockReturnValueOnce('bcc@example.com, '); + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + expect(mockedEncodeEmail).toHaveBeenCalledWith({ + from: 'Test Sender ', + to: 'John Doe , , ', + cc: 'cc@example.com, ', + bcc: 'bcc@example.com, ', + subject: 'Original Subject', + attachments: [], + inReplyTo: '', + reference: '', + body: 'Reply message body', + htmlBody: '

Reply HTML body

', + }); + }); + + test('should handle missing headers gracefully in encodeEmail', async () => { + const messageWithoutHeaders = { + ...mockMessageMetadata, + payload: { + ...mockMessageMetadata.payload, + headers: [ + { name: 'From', value: 'sender@example.com' }, + // Missing Subject and Message-ID headers + ], + }, + }; + + mockedGoogleApiRequest + .mockResolvedValueOnce(messageWithoutHeaders) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = {}; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + // Should handle missing headers with empty strings + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: '', // Empty when header is missing + inReplyTo: '', // Empty when header is missing + reference: '', // Empty when header is missing + to: '', + }), + ); + }); + + test('should properly format email addresses with and without angle brackets', async () => { + const messageWithMixedEmailFormats = { + ...mockMessageMetadata, + payload: { + ...mockMessageMetadata.payload, + headers: [ + { name: 'Subject', value: 'Test Subject' }, + { name: 'Message-ID', value: '' }, + { name: 'From', value: 'plain@example.com' }, // Without brackets + { name: 'To', value: 'Name , plain2@example.com' }, + ], + }, + }; + + mockedGoogleApiRequest + .mockResolvedValueOnce(messageWithMixedEmailFormats) + .mockResolvedValueOnce(mockUserProfile) + .mockResolvedValueOnce(mockSentMessage); + + const options: IDataObject = {}; + + await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0); + + // Should properly format emails with brackets + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + to: expect.stringContaining(''), // Should add brackets + }), + ); + expect(mockedEncodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + to: expect.stringContaining('Name '), // Should keep existing brackets + }), + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Gmail/types.ts b/packages/nodes-base/nodes/Google/Gmail/types.ts index 545de29a750..b4d907010d2 100644 --- a/packages/nodes-base/nodes/Google/Gmail/types.ts +++ b/packages/nodes-base/nodes/Google/Gmail/types.ts @@ -68,3 +68,25 @@ export type GmailTriggerFilters = Partial<{ labelIds: string[]; receivedAfter: number; }>; + +export type GmailMessage = { + id: string; + threadId: string; + labelIds: string[]; + snippet: string; + historyId: string; + internalDate?: string; + headers?: Record; + sizeEstimate: number; + raw: string; + payload: MessagePart; +}; + +export type GmailMessageMetadata = Pick; + +export type GmailUserProfile = { + emailAddress: string; + messagesTotal: number; + threadsTotal: number; + historyId: string; +}; diff --git a/packages/nodes-base/nodes/Google/Gmail/utils/replyToEmail.ts b/packages/nodes-base/nodes/Google/Gmail/utils/replyToEmail.ts new file mode 100644 index 00000000000..80865595ced --- /dev/null +++ b/packages/nodes-base/nodes/Google/Gmail/utils/replyToEmail.ts @@ -0,0 +1,148 @@ +import uniq from 'lodash/uniq'; +import { NodeOperationError, type IDataObject, type IExecuteFunctions } from 'n8n-workflow'; + +import type { IEmail } from '@utils/sendAndWait/interfaces'; + +import { + encodeEmail, + googleApiRequest, + prepareEmailAttachments, + prepareEmailBody, + prepareEmailsInput, +} from '../GenericFunctions'; +import type { GmailMessage, GmailMessageMetadata, GmailUserProfile } from '../types'; + +export async function replyToEmail( + this: IExecuteFunctions, + gmailId: string, + options: IDataObject, + itemIndex: number, +) { + if (options.replyToSenderOnly && options.replyToRecipientsOnly) { + throw new NodeOperationError( + this.getNode(), + 'Both "Reply to Sender Only" and "Reply to Recipient Only" cannot be enabled at the same time. Please select only one option.', + { itemIndex }, + ); + } + + let qs: IDataObject = {}; + + let cc = ''; + let bcc = ''; + + if (options.ccList) { + cc = prepareEmailsInput.call(this, options.ccList as string, 'CC', itemIndex); + } + + if (options.bccList) { + bcc = prepareEmailsInput.call(this, options.bccList as string, 'BCC', itemIndex); + } + let attachments: IDataObject[] = []; + if (options.attachmentsUi) { + attachments = await prepareEmailAttachments.call( + this, + options.attachmentsUi as IDataObject, + itemIndex, + ); + if (attachments.length) { + qs = { + userId: 'me', + uploadType: 'media', + }; + } + } + + const endpoint = `/gmail/v1/users/me/messages/${gmailId}`; + + qs.format = 'metadata'; + const { payload, threadId } = (await googleApiRequest.call( + this, + 'GET', + endpoint, + {}, + qs, + )) as GmailMessageMetadata; + + const subject = + payload.headers.filter( + (data: { [key: string]: string }) => data.name.toLowerCase() === 'subject', + )[0]?.value || ''; + + const messageIdGlobal = + payload.headers.filter( + (data: { [key: string]: string }) => data.name.toLowerCase() === 'message-id', + )[0]?.value || ''; + + const { emailAddress } = (await googleApiRequest.call( + this, + 'GET', + '/gmail/v1/users/me/profile', + )) as GmailUserProfile; + + const to: string[] = []; + const replyToSenderOnly = + options.replyToSenderOnly === undefined ? false : (options.replyToSenderOnly as boolean); + const replyToRecipientsOnly = + options.replyToRecipientsOnly === undefined + ? false + : (options.replyToRecipientsOnly as boolean); + + const prepareEmailString = (email: string) => { + if (email.includes(emailAddress)) return; + if (email.includes('<') && email.includes('>')) { + to.push(email); + } else { + to.push(`<${email}>`); + } + }; + + for (const header of payload.headers) { + const headerName = (header.name || '').toLowerCase(); + if (headerName === 'from' && !replyToRecipientsOnly) { + const from = header.value; + if (from.includes('<') && from.includes('>')) { + to.push(from); + } else { + to.push(`<${from}>`); + } + } + + if (headerName === 'to' && !replyToSenderOnly) { + const toEmails = header.value; + toEmails.split(',').forEach(prepareEmailString); + } + } + + let from = ''; + if (options.senderName) { + from = `${options.senderName as string} <${emailAddress}>`; + } + + const toString = uniq(to).join(', '); + + const email: IEmail = { + from, + to: toString, + cc, + bcc, + subject, + attachments, + inReplyTo: messageIdGlobal, + reference: messageIdGlobal, + ...prepareEmailBody.call(this, itemIndex), + }; + + const body = { + raw: await encodeEmail(email), + threadId, + }; + + return (await googleApiRequest.call( + this, + 'POST', + '/gmail/v1/users/me/messages/send', + body, + qs, + )) as GmailMessage; +} 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 88e0a762a7d..6640db1f723 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts @@ -32,10 +32,10 @@ import { prepareEmailBody, prepareEmailsInput, prepareQuery, - replyToEmail, simplifyOutput, unescapeSnippets, } from '../GenericFunctions'; +import { replyToEmail } from '../utils/replyToEmail'; const preBuiltAgentsCallout: INodeProperties = { // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased diff --git a/packages/nodes-base/nodes/Google/Gmail/v2/ThreadDescription.ts b/packages/nodes-base/nodes/Google/Gmail/v2/ThreadDescription.ts index e5723a0519b..d0a71f97b25 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v2/ThreadDescription.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v2/ThreadDescription.ts @@ -205,6 +205,23 @@ export const threadFields: INodeProperties[] = [ type: 'boolean', default: false, description: 'Whether to reply to the sender only or to the entire list of recipients', + displayOptions: { + hide: { + replyToRecipientsOnly: [true], + }, + }, + }, + { + displayName: 'Reply to Recipients Only', + name: 'replyToRecipientsOnly', + type: 'boolean', + default: false, + description: 'Whether to exclude the sender from the reply', + displayOptions: { + hide: { + replyToSenderOnly: [true], + }, + }, }, ], },