Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions src/__tests__/adapters/attachment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,15 @@ 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",
};

const expectedAttachment = {
filename: attachment.filename,
content: attachment.content,
content: Buffer.from(attachment.content).toString("base64"),
disposition: undefined,
content_id: undefined,
type: undefined,
Expand All @@ -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);
});
});
});
});
18 changes: 14 additions & 4 deletions src/__tests__/adapters/mail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
101 changes: 100 additions & 1 deletion src/__tests__/lib/transport.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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"));
});
});
});
});
49 changes: 47 additions & 2 deletions src/adapters/attachement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
!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.
Expand All @@ -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),
};
}
Loading