diff --git a/src/__tests__/adapters/attachment.test.ts b/src/__tests__/adapters/attachment.test.ts index aca571c3..1bdf2a25 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,69 @@ 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 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")); + } + ); + }); + + 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__/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..fb86ce6b 100644 --- a/src/__tests__/lib/transport.test.ts +++ b/src/__tests__/lib/transport.test.ts @@ -1,10 +1,17 @@ +import { readFileSync } from "fs"; + +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 +86,97 @@ 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"] }) + .onAny() + .reply(404); + }); + + 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]; + }; + + 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 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("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({ + content: "aGVsbG8=", + encoding: "base64", + }); + + 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 d7ccfc64..ccb9849c 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -8,6 +8,48 @@ 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. + */ +function encodeContent( + content: string | Buffer, + encoding: string | undefined +): string | Buffer { + 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. @@ -21,11 +63,14 @@ export default function adaptAttachment( throw new Error(FILENAME_REQUIRED); } + const content = adaptContent(nodemailerAttachment.content); + const { encoding } = nodemailerAttachment; + return { filename: nodemailerAttachment.filename, - content: adaptContent(nodemailerAttachment.content), + content: encodeContent(content, encoding), disposition: nodemailerAttachment.contentDisposition, content_id: nodemailerAttachment.cid, - type: nodemailerAttachment.contentType, + type: adaptType(nodemailerAttachment.contentType, content, encoding), }; }