Files
alighasami 3d5eaf9445
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
first commit
2026-03-17 16:22:57 +03:30

632 lines
19 KiB
TypeScript

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<IExecuteFunctions>();
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: '<original@example.com>' },
{ name: 'From', value: 'John Doe <john@example.com>' },
{ 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, 2.2);
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 <john@example.com>, <recipient1@example.com>, <recipient2@example.com>',
cc: '',
bcc: '',
subject: 'Original Subject',
attachments: [],
inReplyTo: '<original@example.com>',
reference: '<original@example.com>',
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, 2.2);
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, 2.2);
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, 2.2);
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: '<original@example.com>' },
{ name: 'From', value: 'John Doe <john@example.com>' },
{ 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, 2.2);
// Verify that only the sender is included in the "To" field
expect(mockedEncodeEmail).toHaveBeenCalledWith(
expect.objectContaining({
from: '',
to: 'John Doe <john@example.com>',
cc: '',
bcc: '',
subject: 'Original Subject',
inReplyTo: '<original@example.com>',
reference: '<original@example.com>',
}),
);
});
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: '<original@example.com>' },
{ name: 'From', value: '<john@example.com>' },
{ 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, 2.2);
// 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('<recipient1@example.com>'),
subject: 'Original Subject',
inReplyTo: '<original@example.com>',
reference: '<original@example.com>',
}),
);
// Should not include the current user's email address
expect(mockedEncodeEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: '<recipient1@example.com>, <recipient2@example.com>',
}),
);
});
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, 2.2);
expect(mockedEncodeEmail).toHaveBeenCalledWith(
expect.objectContaining({
from: 'Custom Sender Name <user@gmail.com>',
}),
);
});
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: '<original@example.com>' },
{ name: 'From', value: 'John Doe <john@example.com>' },
{ name: 'To', value: 'Regular Email <regular@example.com>, plain@example.com' },
],
},
};
mockedGoogleApiRequest
.mockResolvedValueOnce(messageWithBrackets)
.mockResolvedValueOnce(mockUserProfile)
.mockResolvedValueOnce(mockSentMessage);
const options: IDataObject = {};
await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0, 2.2);
// 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: '<original@example.com>' },
// user@gmail.com - current user, user_from@gmail.com - sender that should be excluded
{ name: 'From', value: '<user_from@gmail.com>' },
{ 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, 2.2);
expect(mockedGoogleApiRequest).toHaveBeenCalledTimes(3);
expect(mockedEncodeEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: '<user_from@gmail.com>, <recipient@example.com>',
}),
);
});
test('should handle missing subject header', async () => {
const messageWithoutSubject = {
...mockMessageMetadata,
payload: {
...mockMessageMetadata.payload,
headers: [
{ name: 'Message-ID', value: '<original@example.com>' },
{ name: 'From', value: 'John Doe <john@example.com>' },
{ name: 'To', value: 'recipient@example.com' },
],
},
};
mockedGoogleApiRequest
.mockResolvedValueOnce(messageWithoutSubject)
.mockResolvedValueOnce(mockUserProfile)
.mockResolvedValueOnce(mockSentMessage);
const options: IDataObject = {};
await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0, 2.2);
// 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 <john@example.com>' },
{ name: 'To', value: 'recipient@example.com' },
],
},
};
mockedGoogleApiRequest
.mockResolvedValueOnce(messageWithoutMessageId)
.mockResolvedValueOnce(mockUserProfile)
.mockResolvedValueOnce(mockSentMessage);
const options: IDataObject = {};
await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0, 2.2);
// 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: '<p>Custom HTML content</p>',
});
const options: IDataObject = {};
await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0, 2.2);
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: '<p>Reply HTML body</p>',
});
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, 2.2);
expect(mockedEncodeEmail).toHaveBeenCalledWith({
from: 'Test Sender <user@gmail.com>',
to: 'John Doe <john@example.com>, <recipient1@example.com>, <recipient2@example.com>',
cc: 'cc@example.com, ',
bcc: 'bcc@example.com, ',
subject: 'Original Subject',
attachments: [],
inReplyTo: '<original@example.com>',
reference: '<original@example.com>',
body: 'Reply message body',
htmlBody: '<p>Reply HTML body</p>',
});
});
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, 2.2);
// 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: '<sender@example.com>',
}),
);
});
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: '<test@example.com>' },
{ name: 'From', value: 'plain@example.com' }, // Without brackets
{ name: 'To', value: 'Name <with@brackets.com>, plain2@example.com' },
],
},
};
mockedGoogleApiRequest
.mockResolvedValueOnce(messageWithMixedEmailFormats)
.mockResolvedValueOnce(mockUserProfile)
.mockResolvedValueOnce(mockSentMessage);
const options: IDataObject = {};
await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0, 2.2);
// Should properly format emails with brackets
expect(mockedEncodeEmail).toHaveBeenCalledWith(
expect.objectContaining({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
to: expect.stringContaining('<plain@example.com>'), // Should add brackets
}),
);
expect(mockedEncodeEmail).toHaveBeenCalledWith(
expect.objectContaining({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
to: expect.stringContaining('Name <with@brackets.com>'), // Should keep existing brackets
}),
);
});
test('should use ignore Reply-To header, when Reply-To header is provided for version < 2.2', async () => {
const messageWithReplyToHeader = {
...mockMessageMetadata,
payload: {
...mockMessageMetadata.payload,
headers: [
...mockMessageMetadata.payload.headers,
{ name: 'Reply-To', value: 'reply-to@example.com' },
],
},
};
mockedGoogleApiRequest
.mockResolvedValueOnce(messageWithReplyToHeader)
.mockResolvedValueOnce(mockUserProfile)
.mockResolvedValueOnce(mockSentMessage);
const options: IDataObject = {};
await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0, 2.1);
expect(mockedEncodeEmail).toHaveBeenCalledWith(
expect.objectContaining({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
to: expect.stringContaining('<john@example.com>'),
}),
);
});
test('should use Reply-To header instead of From, when Reply-To header is provided for version >= 2.2', async () => {
const messageWithReplyToHeader = {
...mockMessageMetadata,
payload: {
...mockMessageMetadata.payload,
headers: [
...mockMessageMetadata.payload.headers,
{ name: 'Reply-To', value: 'reply-to@example.com' },
],
},
};
mockedGoogleApiRequest
.mockResolvedValueOnce(messageWithReplyToHeader)
.mockResolvedValueOnce(mockUserProfile)
.mockResolvedValueOnce(mockSentMessage);
const options: IDataObject = {};
await replyToEmail.call(mockExecuteFunctions, 'message123', options, 0, 2.2);
expect(mockedEncodeEmail).toHaveBeenCalledWith(
expect.objectContaining({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
to: expect.stringContaining('<reply-to@example.com>'),
}),
);
});
});