From e8abe7c567cec80fc9d6614d5c606638ef2a7230 Mon Sep 17 00:00:00 2001 From: IzikAJ Date: Fri, 30 Oct 2026 16:29:30 +0200 Subject: [PATCH 1/3] Encode string attachment content to base64 in the transport Nodemailer converts a Buffer attachment to base64, but sends a string as it is. The send API expects base64, so a plain string such as "hello" failed, and a string that was valid base64 by chance arrived as a corrupted file. - Decode the string with the attachment's `encoding` (UTF-8 by default) and send it as base64. - Send a string with `encoding: "base64"` unchanged. - Rely on Node's Buffer for encoding names. Spellings that only nodemailer accepts, such as "us-ascii", make sendMail reject. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/adapters/attachment.test.ts | 48 ++++++++++++++++- src/__tests__/adapters/mail.test.ts | 18 +++++-- src/__tests__/lib/transport.test.ts | 66 ++++++++++++++++++++++- src/adapters/attachement.ts | 22 +++++++- 4 files changed, 146 insertions(+), 8 deletions(-) diff --git a/src/__tests__/adapters/attachment.test.ts b/src/__tests__/adapters/attachment.test.ts index aca571c3..231138c2 100644 --- a/src/__tests__/adapters/attachment.test.ts +++ b/src/__tests__/adapters/attachment.test.ts @@ -68,7 +68,7 @@ describe("adapters/attachment: ", () => { }); }); - it("returns adapted attachment object.", () => { + it("returns adapted attachment object with string content encoded to base64.", () => { const attachment = { filename: "mock-filename", content: "mock-content", @@ -76,7 +76,7 @@ describe("adapters/attachment: ", () => { const expectedAttachment = { filename: attachment.filename, - content: attachment.content, + content: Buffer.from(attachment.content).toString("base64"), disposition: undefined, content_id: undefined, type: undefined, @@ -85,5 +85,49 @@ describe("adapters/attachment: ", () => { expect(result).toEqual(expectedAttachment); }); + + it("keeps string content that is already base64 encoded.", () => { + ["base64", "BASE64"].forEach((encoding) => { + const result = adaptAttachment({ + filename: "mock-filename", + content: "abcd", + encoding, + }); + + expect(result.content).toBe("abcd"); + }); + }); + + it("encodes string content as UTF-8 by default.", () => { + [undefined, "utf8", "UTF-8"].forEach((encoding) => { + const result = adaptAttachment({ + filename: "mock-filename", + content: "héllo", + encoding, + }); + + expect(result.content).toBe(Buffer.from("héllo").toString("base64")); + }); + }); + + it("decodes string content with its encoding before encoding to base64.", () => { + const result = adaptAttachment({ + filename: "mock-filename", + content: "68656c6c6f", + encoding: "hex", + }); + + expect(result.content).toBe(Buffer.from("hello").toString("base64")); + }); + + it("throws for an unknown encoding.", () => { + expect(() => + adaptAttachment({ + filename: "mock-filename", + content: "mock-content", + encoding: "mock-encoding", + }) + ).toThrowError("Unknown encoding: mock-encoding"); + }); }); }); diff --git a/src/__tests__/adapters/mail.test.ts b/src/__tests__/adapters/mail.test.ts index 5bf23315..01c35613 100644 --- a/src/__tests__/adapters/mail.test.ts +++ b/src/__tests__/adapters/mail.test.ts @@ -73,7 +73,12 @@ describe("adapters/mail: ", () => { cc: [], bcc: [], headers: data.headers, - attachments: data.attachments, + attachments: [ + { + filename: "mock-filename", + content: Buffer.from("mock-content").toString("base64"), + }, + ], reply_to: adaptFirstRecipient(data.replyTo), }; const result = adaptMail(data); @@ -102,7 +107,12 @@ describe("adapters/mail: ", () => { cc: [], bcc: [], headers: data.headers, - attachments: data.attachments, + attachments: [ + { + filename: "mock-filename", + content: Buffer.from("mock-content").toString("base64"), + }, + ], custom_variables: data.customVariables, reply_to: adaptFirstRecipient(data.replyTo), }; @@ -133,7 +143,7 @@ describe("adapters/mail: ", () => { attachments: [ { filename: data.attachments[0].filename, - content: data.attachments[0].content, + content: Buffer.from("mock-content").toString("base64"), disposition: undefined, content_id: undefined, type: undefined, @@ -173,7 +183,7 @@ describe("adapters/mail: ", () => { attachments: [ { filename: data.attachments[0].filename, - content: data.attachments[0].content, + content: Buffer.from("mock-content").toString("base64"), disposition: undefined, content_id: undefined, type: undefined, diff --git a/src/__tests__/lib/transport.test.ts b/src/__tests__/lib/transport.test.ts index 4f59cd9a..7ac70a9e 100644 --- a/src/__tests__/lib/transport.test.ts +++ b/src/__tests__/lib/transport.test.ts @@ -1,10 +1,15 @@ +import axios from "axios"; +import AxiosMockAdapter from "axios-mock-adapter"; +import nodemailer from "nodemailer"; + import mailtrapTransport from "../../lib/transport"; import config from "../../config"; const packageData = require("../../../package.json"); -const { TRANSPORT_SETTINGS } = config; +const { CLIENT_SETTINGS, TRANSPORT_SETTINGS } = config; +const { SENDING_ENDPOINT } = CLIENT_SETTINGS; const { NAME } = TRANSPORT_SETTINGS; const { version } = packageData; @@ -79,5 +84,64 @@ describe("lib/transport: ", () => { // @ts-ignore transport.send(message, callback); }); + + describe("attachments: ", () => { + let mock: AxiosMockAdapter; + + beforeAll(() => { + mock = new AxiosMockAdapter(axios); + mock.onPost(`${SENDING_ENDPOINT}/api/send`).reply(200, { + success: true, + message_ids: ["mock-message-id"], + }); + }); + + afterEach(() => { + mock.resetHistory(); + }); + + afterAll(() => { + mock.restore(); + }); + + const sendAttachment = async (attachment: object) => { + const transport = nodemailer.createTransport( + mailtrapTransport({ token: "mock-token" }) + ); + + await transport.sendMail({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "mock-subject", + text: "mock-text", + attachments: [{ filename: "mock.txt", ...attachment }], + }); + + const [request] = mock.history.post; + + return JSON.parse(request.data).attachments[0].content; + }; + + it("sends string content encoded to base64.", async () => { + const content = await sendAttachment({ content: "hello" }); + + expect(content).toBe(Buffer.from("hello").toString("base64")); + }); + + it("sends string content with `base64` encoding as it is.", async () => { + const content = await sendAttachment({ + content: "aGVsbG8=", + encoding: "base64", + }); + + expect(content).toBe("aGVsbG8="); + }); + + it("sends Buffer content encoded to base64.", async () => { + const content = await sendAttachment({ content: Buffer.from("hello") }); + + expect(content).toBe(Buffer.from("hello").toString("base64")); + }); + }); }); }); diff --git a/src/adapters/attachement.ts b/src/adapters/attachement.ts index d7ccfc64..431ab5b4 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -8,6 +8,23 @@ import { NodemailerAttachment } from "../types/transport"; const { ERRORS } = CONFIG; const { FILENAME_REQUIRED } = ERRORS; +/** + * Returns string content in base64, as the Mailtrap API takes it. + * Nodemailer encodes a Buffer to base64 itself but leaves a string as it is, so the string is decoded with its `encoding` first. + */ +function encodeContent( + content: string | Buffer, + encoding: string | undefined +): string | Buffer { + if (typeof content !== "string" || encoding?.toLowerCase() === "base64") { + return content; + } + + return Buffer.from(content, (encoding || "utf8") as BufferEncoding).toString( + "base64" + ); +} + /** * Adopts Nodemailer attachment to Mailtrap. * Checks if filename or content are missing, then rejects with error. @@ -23,7 +40,10 @@ export default function adaptAttachment( return { filename: nodemailerAttachment.filename, - content: adaptContent(nodemailerAttachment.content), + content: encodeContent( + adaptContent(nodemailerAttachment.content), + nodemailerAttachment.encoding + ), disposition: nodemailerAttachment.contentDisposition, content_id: nodemailerAttachment.cid, type: nodemailerAttachment.contentType, From 1963df3eaec63b8e8274825eec5aba204d7ee074 Mon Sep 17 00:00:00 2001 From: IzikAJ Date: Fri, 30 Oct 2026 17:19:43 +0200 Subject: [PATCH 2/3] Send unmarked string attachments as UTF-8 text Nodemailer already decodes a string with a non-text `encoding` (such as hex or latin1) and marks it base64. Any other string that reaches the adapter is text, which nodemailer's own SMTP path sends as UTF-8. - Encode such strings as UTF-8 instead of passing their `encoding` to Buffer. This stops "us-ascii" and "utf_8" from rejecting and sends "ascii" strings as UTF-8, as SMTP does. - Test us-ascii, hex and `path` through the transport, and fail an unmocked request in those tests. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/adapters/attachment.test.ts | 42 +++++++---------------- src/__tests__/lib/transport.test.ts | 35 ++++++++++++++++--- src/adapters/attachement.ts | 6 ++-- 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/__tests__/adapters/attachment.test.ts b/src/__tests__/adapters/attachment.test.ts index 231138c2..41e9c6e0 100644 --- a/src/__tests__/adapters/attachment.test.ts +++ b/src/__tests__/adapters/attachment.test.ts @@ -98,36 +98,18 @@ describe("adapters/attachment: ", () => { }); }); - it("encodes string content as UTF-8 by default.", () => { - [undefined, "utf8", "UTF-8"].forEach((encoding) => { - const result = adaptAttachment({ - filename: "mock-filename", - content: "héllo", - encoding, - }); - - expect(result.content).toBe(Buffer.from("héllo").toString("base64")); - }); - }); - - it("decodes string content with its encoding before encoding to base64.", () => { - const result = adaptAttachment({ - filename: "mock-filename", - content: "68656c6c6f", - encoding: "hex", - }); - - expect(result.content).toBe(Buffer.from("hello").toString("base64")); - }); - - it("throws for an unknown encoding.", () => { - expect(() => - adaptAttachment({ - filename: "mock-filename", - content: "mock-content", - encoding: "mock-encoding", - }) - ).toThrowError("Unknown encoding: mock-encoding"); + it("encodes string content as UTF-8 text for text encodings.", () => { + [undefined, "utf8", "UTF-8", "utf_8", "ascii", "us-ascii"].forEach( + (encoding) => { + const result = adaptAttachment({ + filename: "mock-filename", + content: "héllo", + encoding, + }); + + expect(result.content).toBe(Buffer.from("héllo").toString("base64")); + } + ); }); }); }); diff --git a/src/__tests__/lib/transport.test.ts b/src/__tests__/lib/transport.test.ts index 7ac70a9e..b73c6e1a 100644 --- a/src/__tests__/lib/transport.test.ts +++ b/src/__tests__/lib/transport.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "fs"; + import axios from "axios"; import AxiosMockAdapter from "axios-mock-adapter"; import nodemailer from "nodemailer"; @@ -90,10 +92,11 @@ describe("lib/transport: ", () => { beforeAll(() => { mock = new AxiosMockAdapter(axios); - mock.onPost(`${SENDING_ENDPOINT}/api/send`).reply(200, { - success: true, - message_ids: ["mock-message-id"], - }); + mock + .onPost(`${SENDING_ENDPOINT}/api/send`) + .reply(200, { success: true, message_ids: ["mock-message-id"] }) + .onAny() + .reply(404); }); afterEach(() => { @@ -128,6 +131,15 @@ describe("lib/transport: ", () => { expect(content).toBe(Buffer.from("hello").toString("base64")); }); + it("sends string content with a text encoding as UTF-8.", async () => { + const content = await sendAttachment({ + content: "héllo", + encoding: "us-ascii", + }); + + expect(content).toBe(Buffer.from("héllo").toString("base64")); + }); + it("sends string content with `base64` encoding as it is.", async () => { const content = await sendAttachment({ content: "aGVsbG8=", @@ -137,11 +149,26 @@ describe("lib/transport: ", () => { expect(content).toBe("aGVsbG8="); }); + it("sends string content with another encoding decoded.", async () => { + const content = await sendAttachment({ + content: "68656c6c6f", + encoding: "hex", + }); + + expect(content).toBe(Buffer.from("hello").toString("base64")); + }); + it("sends Buffer content encoded to base64.", async () => { const content = await sendAttachment({ content: Buffer.from("hello") }); expect(content).toBe(Buffer.from("hello").toString("base64")); }); + + it("sends file content from `path` encoded to base64.", async () => { + const content = await sendAttachment({ path: __filename }); + + expect(content).toBe(readFileSync(__filename).toString("base64")); + }); }); }); }); diff --git a/src/adapters/attachement.ts b/src/adapters/attachement.ts index 431ab5b4..e6ee6e18 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -10,7 +10,7 @@ const { FILENAME_REQUIRED } = ERRORS; /** * Returns string content in base64, as the Mailtrap API takes it. - * Nodemailer encodes a Buffer to base64 itself but leaves a string as it is, so the string is decoded with its `encoding` first. + * Nodemailer decodes a string with a non-text `encoding` and marks it `base64`, so any other string is text, which it sends as UTF-8. */ function encodeContent( content: string | Buffer, @@ -20,9 +20,7 @@ function encodeContent( return content; } - return Buffer.from(content, (encoding || "utf8") as BufferEncoding).toString( - "base64" - ); + return Buffer.from(content, "utf8").toString("base64"); } /** From df9ae98db24996650cc15934af4b32ab1d9c2477 Mon Sep 17 00:00:00 2001 From: IzikAJ Date: Tue, 3 Nov 2026 09:26:56 +0200 Subject: [PATCH 3/3] Label non-ASCII text/plain attachments as UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter sends string content as UTF-8 bytes, but the type stayed "text/plain" with no charset. A mail client can then show "héllo" as "héllo". Nodemailer's SMTP path sets "charset=utf-8" in this case. - Set "charset=utf-8" on a text/plain type when the string content has non-ASCII text. Replace a charset that is already there, because the bytes are always UTF-8. - Use nodemailer's own non-ASCII check. - Test the type through the transport with no explicit contentType. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/adapters/attachment.test.ts | 38 ++++++++++++++++++++++ src/__tests__/lib/transport.test.ts | 22 +++++++++---- src/adapters/attachement.ts | 39 +++++++++++++++++++---- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/__tests__/adapters/attachment.test.ts b/src/__tests__/adapters/attachment.test.ts index 41e9c6e0..1bdf2a25 100644 --- a/src/__tests__/adapters/attachment.test.ts +++ b/src/__tests__/adapters/attachment.test.ts @@ -111,5 +111,43 @@ describe("adapters/attachment: ", () => { } ); }); + + it("labels non-ASCII text/plain string content as UTF-8.", () => { + [ + ["text/plain", "text/plain; charset=utf-8"], + ["TEXT/PLAIN", "TEXT/PLAIN; charset=utf-8"], + ["text/plain; charset=iso-8859-1", "text/plain; charset=utf-8"], + [ + "text/plain; format=flowed", + "text/plain; format=flowed; charset=utf-8", + ], + ].forEach(([contentType, type]) => { + const result = adaptAttachment({ + filename: "mock-filename", + content: "héllo", + contentType, + }); + + expect(result.type).toBe(type); + }); + }); + + it("keeps the type for ASCII, base64, Buffer, or non-text/plain content.", () => { + [ + { content: "hello", contentType: "text/plain" }, + { content: "héllo", contentType: "text/csv" }, + { content: "héllo", contentType: "text/plain-other" }, + { content: "aMOpbGxv", contentType: "text/plain", encoding: "base64" }, + { content: Buffer.from("héllo"), contentType: "text/plain" }, + { content: "héllo", contentType: undefined }, + ].forEach((attachment) => { + const result = adaptAttachment({ + filename: "mock-filename", + ...attachment, + }); + + expect(result.type).toBe(attachment.contentType); + }); + }); }); }); diff --git a/src/__tests__/lib/transport.test.ts b/src/__tests__/lib/transport.test.ts index b73c6e1a..fb86ce6b 100644 --- a/src/__tests__/lib/transport.test.ts +++ b/src/__tests__/lib/transport.test.ts @@ -122,17 +122,17 @@ describe("lib/transport: ", () => { const [request] = mock.history.post; - return JSON.parse(request.data).attachments[0].content; + return JSON.parse(request.data).attachments[0]; }; it("sends string content encoded to base64.", async () => { - const content = await sendAttachment({ content: "hello" }); + const { content } = await sendAttachment({ content: "hello" }); expect(content).toBe(Buffer.from("hello").toString("base64")); }); it("sends string content with a text encoding as UTF-8.", async () => { - const content = await sendAttachment({ + const { content } = await sendAttachment({ content: "héllo", encoding: "us-ascii", }); @@ -140,8 +140,14 @@ describe("lib/transport: ", () => { expect(content).toBe(Buffer.from("héllo").toString("base64")); }); + it("labels non-ASCII string content with the UTF-8 charset.", async () => { + const { type } = await sendAttachment({ content: "héllo" }); + + expect(type).toBe("text/plain; charset=utf-8"); + }); + it("sends string content with `base64` encoding as it is.", async () => { - const content = await sendAttachment({ + const { content } = await sendAttachment({ content: "aGVsbG8=", encoding: "base64", }); @@ -150,7 +156,7 @@ describe("lib/transport: ", () => { }); it("sends string content with another encoding decoded.", async () => { - const content = await sendAttachment({ + const { content } = await sendAttachment({ content: "68656c6c6f", encoding: "hex", }); @@ -159,13 +165,15 @@ describe("lib/transport: ", () => { }); it("sends Buffer content encoded to base64.", async () => { - const content = await sendAttachment({ content: Buffer.from("hello") }); + const { content } = await sendAttachment({ + content: Buffer.from("hello"), + }); expect(content).toBe(Buffer.from("hello").toString("base64")); }); it("sends file content from `path` encoded to base64.", async () => { - const content = await sendAttachment({ path: __filename }); + const { content } = await sendAttachment({ path: __filename }); expect(content).toBe(readFileSync(__filename).toString("base64")); }); diff --git a/src/adapters/attachement.ts b/src/adapters/attachement.ts index e6ee6e18..ccb9849c 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -8,6 +8,13 @@ import { NodemailerAttachment } from "../types/transport"; const { ERRORS } = CONFIG; const { FILENAME_REQUIRED } = ERRORS; +function isUtf8Text( + content: string | Buffer, + encoding: string | undefined +): content is string { + return typeof content === "string" && encoding?.toLowerCase() !== "base64"; +} + /** * Returns string content in base64, as the Mailtrap API takes it. * Nodemailer decodes a string with a non-text `encoding` and marks it `base64`, so any other string is text, which it sends as UTF-8. @@ -16,13 +23,33 @@ function encodeContent( content: string | Buffer, encoding: string | undefined ): string | Buffer { - if (typeof content !== "string" || encoding?.toLowerCase() === "base64") { + if (!isUtf8Text(content, encoding)) { return content; } return Buffer.from(content, "utf8").toString("base64"); } +/** + * Labels non-ASCII text/plain as UTF-8, the bytes encodeContent produces, as Nodemailer's SMTP path does. + */ +function adaptType( + type: string | undefined, + content: string | Buffer, + encoding: string | undefined +): string | undefined { + if ( + !type || + !/^text\/plain\s*(;|$)/i.test(type) || + !isUtf8Text(content, encoding) || + !/[\u0080-￿]/.test(content) + ) { + return type; + } + + return `${type.replace(/\s*;\s*charset=[^;]*/i, "")}; charset=utf-8`; +} + /** * Adopts Nodemailer attachment to Mailtrap. * Checks if filename or content are missing, then rejects with error. @@ -36,14 +63,14 @@ export default function adaptAttachment( throw new Error(FILENAME_REQUIRED); } + const content = adaptContent(nodemailerAttachment.content); + const { encoding } = nodemailerAttachment; + return { filename: nodemailerAttachment.filename, - content: encodeContent( - adaptContent(nodemailerAttachment.content), - nodemailerAttachment.encoding - ), + content: encodeContent(content, encoding), disposition: nodemailerAttachment.contentDisposition, content_id: nodemailerAttachment.cid, - type: nodemailerAttachment.contentType, + type: adaptType(nodemailerAttachment.contentType, content, encoding), }; }