fix(Brevo Node): Send valid email attachments with correct filenames (#28816)

This commit is contained in:
Bernhard Wittmann
2026-04-23 06:27:10 +00:00
committed by GitHub
parent 4ce186a14d
commit 7d6f777808
2 changed files with 236 additions and 25 deletions
@@ -6,7 +6,7 @@ import type {
IWebhookFunctions,
JsonObject,
} from 'n8n-workflow';
import { jsonParse, NodeOperationError } from 'n8n-workflow';
import { BINARY_ENCODING, jsonParse, NodeOperationError } from 'n8n-workflow';
import MailComposer from 'nodemailer/lib/mail-composer';
export namespace BrevoNode {
type ValidEmailFields = { to: string } | { sender: string } | { cc: string } | { bcc: string };
@@ -54,19 +54,18 @@ export namespace BrevoNode {
function getFileName(
itemIndex: number,
mimeType: string,
fileExt: string,
fileName: string,
fileExt: string | undefined,
fileName: string | undefined,
): string {
let ext = fileExt;
if (fileExt === undefined) {
ext = mimeType.split('/')[1];
const ext = fileExt ?? mimeType.split('/')[1];
if (!fileName) {
return `file-${itemIndex}.${ext}`;
}
let name = `${fileName}.${ext}`;
if (fileName === undefined) {
name = `file-${itemIndex}.${ext}`;
}
return name;
return fileName.toLowerCase().endsWith(`.${ext.toLowerCase()}`)
? fileName
: `${fileName}.${ext}`;
}
export async function validateAndCompileAttachmentsData(
@@ -84,22 +83,15 @@ export namespace BrevoNode {
const { binaryPropertyName } = dataPropertyList;
const dataMappingList = (binaryPropertyName as string).split(',');
for (const attachmentDataName of dataMappingList) {
const binaryData = this.helpers.assertBinaryData(attachmentDataName);
const bufferFromIncomingData = await this.helpers.getBinaryDataBuffer(attachmentDataName);
const binaryData = this.helpers.assertBinaryData(attachmentDataName.trim());
const buffer = await this.helpers.getBinaryDataBuffer(attachmentDataName.trim());
const {
data: content,
mimeType,
fileName,
fileExtension,
} = await this.helpers.prepareBinaryData(bufferFromIncomingData);
const itemIndex = this.getItemIndex();
const content = buffer.toString(BINARY_ENCODING);
const name = getFileName(
itemIndex,
mimeType,
fileExtension!,
fileName ?? binaryData.fileName!,
this.getItemIndex(),
binaryData.mimeType,
binaryData.fileExtension,
binaryData.fileName,
);
attachment.push({ content, name });
@@ -0,0 +1,219 @@
import type { IBinaryData, IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { BrevoNode } from '../GenericFunctions';
type AttachmentEntry = { content: string; name: string };
function makeContext(overrides: {
binaryPropertyName: string;
binaries: Record<string, { metadata: IBinaryData; buffer: Buffer }>;
itemIndex?: number;
}): IExecuteSingleFunctions {
const getNodeParameter = jest.fn().mockReturnValue({
binaryPropertyName: overrides.binaryPropertyName,
});
const assertBinaryData = jest.fn((propertyName: string) => {
const entry = overrides.binaries[propertyName];
if (!entry) throw new Error(`No binary named ${propertyName}`);
return entry.metadata;
});
// eslint-disable-next-line @typescript-eslint/require-await
const getBinaryDataBuffer = jest.fn(async (propertyName: string) => {
const entry = overrides.binaries[propertyName];
if (!entry) throw new Error(`No binary named ${propertyName}`);
return entry.buffer;
});
return {
getNodeParameter,
getItemIndex: jest.fn().mockReturnValue(overrides.itemIndex ?? 0),
getNode: jest.fn().mockReturnValue({ name: 'Brevo' }),
helpers: {
assertBinaryData,
getBinaryDataBuffer,
},
} as unknown as IExecuteSingleFunctions;
}
describe('Brevo - validateAndCompileAttachmentsData', () => {
const validate = BrevoNode.Validators.validateAndCompileAttachmentsData;
it('base64-encodes the attachment buffer even when binary storage mode is "filesystem" (data property holds a mode marker, not the payload)', async () => {
const buffer = Buffer.from('real spreadsheet bytes');
const ctx = makeContext({
binaryPropertyName: 'data',
binaries: {
data: {
buffer,
metadata: {
// In filesystem/database mode, core sets .data to the mode marker string.
data: 'filesystem',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileName: 'Report.xlsx',
fileExtension: 'xlsx',
} as IBinaryData,
},
},
});
const requestOptions: IHttpRequestOptions = { url: '', body: {} };
const result = await validate.call(ctx, requestOptions);
const attachments = (result.body as { attachment: AttachmentEntry[] }).attachment;
expect(attachments).toHaveLength(1);
expect(attachments[0].content).toBe(buffer.toString('base64'));
expect(attachments[0].content).not.toBe('filesystem');
});
it('base64-encodes the attachment buffer in database binary storage mode', async () => {
const buffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]); // XLSX magic bytes
const ctx = makeContext({
binaryPropertyName: 'data',
binaries: {
data: {
buffer,
metadata: {
data: 'database',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileName: 'Report.xlsx',
fileExtension: 'xlsx',
} as IBinaryData,
},
},
});
const result = await validate.call(ctx, { url: '', body: {} });
const attachments = (result.body as { attachment: AttachmentEntry[] }).attachment;
expect(attachments[0].content).toBe(buffer.toString('base64'));
});
it('uses the filename as-is when it already contains the extension (no doubling)', async () => {
const buffer = Buffer.from('payload');
const ctx = makeContext({
binaryPropertyName: 'data',
binaries: {
data: {
buffer,
metadata: {
data: 'database',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileName: 'Report.xlsx',
fileExtension: 'xlsx',
} as IBinaryData,
},
},
});
const result = await validate.call(ctx, { url: '', body: {} });
const attachments = (result.body as { attachment: AttachmentEntry[] }).attachment;
expect(attachments[0].name).toBe('Report.xlsx');
});
it('appends the extension when filename has none', async () => {
const buffer = Buffer.from('payload');
const ctx = makeContext({
binaryPropertyName: 'data',
binaries: {
data: {
buffer,
metadata: {
data: '',
mimeType: 'text/plain',
fileName: 'notes',
fileExtension: 'txt',
} as IBinaryData,
},
},
});
const result = await validate.call(ctx, { url: '', body: {} });
const attachments = (result.body as { attachment: AttachmentEntry[] }).attachment;
expect(attachments[0].name).toBe('notes.txt');
});
it('falls back to file-<itemIndex>.<ext> when fileName is missing', async () => {
const buffer = Buffer.from('payload');
const ctx = makeContext({
itemIndex: 3,
binaryPropertyName: 'data',
binaries: {
data: {
buffer,
metadata: {
data: '',
mimeType: 'text/plain',
fileExtension: 'txt',
} as IBinaryData,
},
},
});
const result = await validate.call(ctx, { url: '', body: {} });
const attachments = (result.body as { attachment: AttachmentEntry[] }).attachment;
expect(attachments[0].name).toBe('file-3.txt');
});
it('derives the extension from mimeType when fileExtension is missing', async () => {
const buffer = Buffer.from('payload');
const ctx = makeContext({
binaryPropertyName: 'data',
binaries: {
data: {
buffer,
metadata: {
data: '',
mimeType: 'application/pdf',
fileName: 'invoice',
} as IBinaryData,
},
},
});
const result = await validate.call(ctx, { url: '', body: {} });
const attachments = (result.body as { attachment: AttachmentEntry[] }).attachment;
expect(attachments[0].name).toBe('invoice.pdf');
});
it('processes comma-separated binary property names', async () => {
const bufferA = Buffer.from('AAAA');
const bufferB = Buffer.from('BBBB');
const ctx = makeContext({
binaryPropertyName: 'fileA,fileB',
binaries: {
fileA: {
buffer: bufferA,
metadata: {
data: 'database',
mimeType: 'text/plain',
fileName: 'a.txt',
fileExtension: 'txt',
} as IBinaryData,
},
fileB: {
buffer: bufferB,
metadata: {
data: 'database',
mimeType: 'text/plain',
fileName: 'b.txt',
fileExtension: 'txt',
} as IBinaryData,
},
},
});
const result = await validate.call(ctx, { url: '', body: {} });
const attachments = (result.body as { attachment: AttachmentEntry[] }).attachment;
expect(attachments).toEqual([
{ content: bufferA.toString('base64'), name: 'a.txt' },
{ content: bufferB.toString('base64'), name: 'b.txt' },
]);
});
});