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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: 18.12.1
node-version: 20.20.2
Comment thread
oshchyhol marked this conversation as resolved.
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
Expand Down
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
nodejs 18.12.1
nodejs 20.20.2
yarn 1.22.17
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ mailtrap

## Nodemailer Transport

> NOTE: [Nodemailer](https://www.npmjs.com/package/nodemailer) is needed as a dependency.
> NOTE: [Nodemailer](https://www.npmjs.com/package/nodemailer) is needed as a dependency. Versions 9 and 10 are supported (Nodemailer 10 requires Node.js 20 or newer).

```sh
npm install nodemailer
Expand All @@ -207,7 +207,7 @@ npm install nodemailer
yarn add nodemailer
```

If you're using TypeScript, install `@types/nodemailer` as a `devDependency`:
If you're using TypeScript with Nodemailer 9, install `@types/nodemailer` as a `devDependency` (Nodemailer 10 ships its own type definitions, so this is not needed there):

```sh
npm install -D @types/nodemailer
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^29.3.1",
"nodemailer": "^9.0.1",
"nodemailer": "^10.0.0",
"prettier": "^2.6.2",
"rimraf": "^5.0.5",
"ts-node": "^10.2.1",
Expand All @@ -48,7 +48,7 @@
},
"peerDependencies": {
"@types/nodemailer": "^6.4.9",
"nodemailer": "^9.0.1"
"nodemailer": "^9.0.1 || ^10.0.0"
Comment thread
thoda-dev marked this conversation as resolved.
},
"peerDependenciesMeta": {
"nodemailer": {
Expand All @@ -60,7 +60,7 @@
},
"repository": "https://github.com/railsware/mailtrap-nodejs",
"scripts": {
"build": "rimraf dist && tsc --project tsconfig.build.json",
"build": "rimraf dist && tsc --project tsconfig.build.json && rimraf dist/lib/transport-esm.mjs",
"lint": "yarn lint:eslint && yarn lint:tsc",
"lint:eslint": "yarn run eslint . --ext .js,.ts",
"lint:tsc": "tsc -p . --noEmit --incremental false",
Expand Down
36 changes: 15 additions & 21 deletions src/__tests__/adapters/attachment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,33 +45,27 @@ describe("adapters/attachment: ", () => {
expect(result).toEqual(expectedAttachment);
});

it("returns adapted attachment object in case if content is readable.", () => {
const content = "mock-content";
it("throws `content required` error for content nodemailer did not resolve.", () => {
const readableStream = new Readable({
read() {
this.push(content);
this.push("mock-content");
this.push(null);
},
});

const attachment = {
filename: "mock-filename",
content: readableStream,
};

const expectedAttachment = {
filename: attachment.filename,
disposition: undefined,
content_id: undefined,
type: undefined,
};
const result = adaptAttachment(attachment);

expect(result.filename).toEqual(expectedAttachment.filename);
expect(result.disposition).toEqual(expectedAttachment.disposition);
expect(result.content_id).toEqual(expectedAttachment.content_id);
expect(result.type).toEqual(expectedAttachment.type);
expect(result.content.toString()).toEqual(content);
const unresolvedContents = [
"",
readableStream,
{ path: __filename },
{ content: { path: __filename } },
{ filename: "mock-filename", content: "", contentType: "text/plain" },
];

unresolvedContents.forEach((content) => {
expect(() =>
adaptAttachment({ filename: "mock-filename", content })
).toThrowError(new Error(CONTENT_REQUIRED));
});
});

it("returns adapted attachment object.", () => {
Expand Down
50 changes: 21 additions & 29 deletions src/__tests__/adapters/content.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Readable } from "stream";
import fs from "node:fs";

import adaptContent from "../../adapters/content";

jest.mock("node:fs");
import config from "../../config";

const { ERRORS } = config;
const { CONTENT_REQUIRED } = ERRORS;

describe("adapters/content: ", () => {
describe("adaptContent(): ", () => {
Expand All @@ -23,39 +25,29 @@ describe("adapters/content: ", () => {
expect(result).toBe(content);
});

it("checks if read method has been called if content is readable.", () => {
const content = "mock-content";
it("throws `content required` error for content nodemailer did not resolve.", () => {
const readableStream = new Readable({
read() {
this.push(content);
this.push("mock-content");
this.push(null);
},
});

const result = adaptContent(readableStream);

expect(result.toString()).toEqual(content);
});

it("recursively checks the content if content has content property.", () => {
const content = {
content: "mock-content",
};

const result = adaptContent(content);

expect(result).toBe(content.content);
});

it("reads file in case if content is file and has path.", () => {
const content = {
path: "mock-path",
};

adaptContent(content);

expect(fs.readFileSync).toBeCalledTimes(1);
expect(fs.readFileSync).toBeCalledWith(content.path);
const unresolvedContents = [
undefined,
"",
readableStream,
{ path: __filename },
{ content: "mock-content" },
{ content: { path: __filename } },
{ content: { content: { path: __filename } } },
];

unresolvedContents.forEach((content) => {
expect(() => adaptContent(content)).toThrowError(
new Error(CONTENT_REQUIRED)
);
});
});
});
});
138 changes: 138 additions & 0 deletions src/__tests__/adapters/headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,143 @@ describe("adapters/headers: ", () => {

expect(result).toEqual(expectedResult);
});

it("returns object if headers is a single `{ key, value }` pair.", () => {
const headers = {
key: "mock-key",
value: "mock-value",
};

const expectedResult = {
[headers.key]: headers.value,
};
const result = adaptHeaders(headers);

expect(result).toEqual(expectedResult);
});

it("reads `{ key, value }` as a pair only when both are set.", () => {
expect(adaptHeaders({ key: "X-Custom", value: "mock-value" })).toEqual({
"X-Custom": "mock-value",
});
expect(adaptHeaders({ key: "", value: "mock-value" })).toEqual({
value: "mock-value",
});
expect(adaptHeaders({ key: "X-Count", value: 0 })).toEqual({
key: "X-Count",
});
expect(adaptHeaders({ key: "X-Only" })).toEqual({ key: "X-Only" });
expect(adaptHeaders({ value: "mock-value" })).toEqual({
value: "mock-value",
});
});

it("skips headers with a blank name.", () => {
expect(adaptHeaders({ key: " ", value: "mock-value" })).toEqual({});
expect(
adaptHeaders([
{ key: "X-One", value: "mock-value" },
{ key: "", value: "mock-other-value" },
])
).toEqual({ "X-One": "mock-value" });
});

it("trims header names, as nodemailer does.", () => {
expect(adaptHeaders({ " X-One ": "mock-value" })).toEqual({
"X-One": "mock-value",
});
expect(
adaptHeaders([{ key: " X-Two ", value: "mock-other-value" }])
).toEqual({ "X-Two": "mock-other-value" });
});

it("skips a value that refers to itself.", () => {
const selfReferencing: { value?: unknown } = {};
selfReferencing.value = selfReferencing;

expect(adaptHeaders({ mockKey: selfReferencing })).toEqual({});
});

it("converts non-string header values to strings.", () => {
const headers = {
mockNumber: 42,
mockBoolean: true,
mockAddress: { name: "mockname", address: "mock@mail.com" },
mockAddressWithoutName: { address: "mock@mail.com" },
mockNested: [[{ prepared: true, value: 7 }]],
};

const expectedResult = {
mockNumber: "42",
mockBoolean: "true",
mockAddress: "mockname <mock@mail.com>",
mockAddressWithoutName: "mock@mail.com",
mockNested: "7",
};
const result = adaptHeaders(headers);

expect(result).toEqual(expectedResult);
});

it("quotes display names nodemailer would not leave as they are.", () => {
const headers = {
mockPlain: { name: "John Doe", address: "j@mail.com" },
mockComma: { name: "Doe, John", address: "j@mail.com" },
mockQuote: { name: 'He said "hi"', address: "j@mail.com" },
mockBackslash: { name: "back\\slash", address: "j@mail.com" },
mockUnicode: { name: "Ünïcode", address: "j@mail.com" },
};

const expectedResult = {
mockPlain: "John Doe <j@mail.com>",
mockComma: '"Doe, John" <j@mail.com>',
mockQuote: '"He said \\"hi\\"" <j@mail.com>',
mockBackslash: '"back\\\\slash" <j@mail.com>',
mockUnicode: '"Ünïcode" <j@mail.com>',
};
const result = adaptHeaders(headers);

expect(result).toEqual(expectedResult);
});

it("replaces line breaks in header values.", () => {
const headers = {
mockInjection: "mock-value\r\nInjected: yes",
mockAddress: {
name: "Eve\r\nBcc: victim@mail.com",
address: "j@mail.com",
},
};

const expectedResult = {
mockInjection: "mock-value Injected: yes",
mockAddress: '"Eve Bcc: victim@mail.com" <j@mail.com>',
};
const result = adaptHeaders(headers);

expect(result).toEqual(expectedResult);
});

it("skips headers with empty values.", () => {
const headers = {
mockNull: null,
mockUndefined: undefined,
mockEmptyArray: [],
mockFalse: false,
mockZero: 0,
mockBlank: " ",
mockDate: new Date("2026-01-02T03:04:05Z"),
mockAddressWithoutAddress: { name: "mock-name" },
mockAddressWithBlankAddress: { name: "mock-name", address: " " },
mockKey: "mock-value",
};

const expectedResult = {
mockKey: "mock-value",
};
const result = adaptHeaders(headers);

expect(result).toEqual(expectedResult);
});
});
});
19 changes: 15 additions & 4 deletions src/__tests__/adapters/mail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import adaptMail from "../../adapters/mail";
import config from "../../config";
import {
adaptSingleRecipient,
adaptReplyToRecipient,
adaptFirstRecipient,
} from "../../adapters/recipients";

const { ERRORS } = config;
Expand All @@ -20,6 +20,17 @@ describe("adapters/mail: ", () => {
expect(result).toEqual(expectedResult);
});

it("returns object with error `from is required` if from has no address.", () => {
const expectedResult = { success: false, errors: [FROM_REQUIRED] };

expect(adaptMail({ from: "" })).toEqual(expectedResult);
expect(adaptMail({ from: [] })).toEqual(expectedResult);
expect(adaptMail({ from: { address: "" } })).toEqual(expectedResult);
expect(adaptMail({ from: { name: "mock-name" } })).toEqual(
expectedResult
);
});

it("returns `mail` object with basic info + headers.", () => {
const data = {
from: "mock-from",
Expand All @@ -37,7 +48,7 @@ describe("adapters/mail: ", () => {
bcc: [],
headers: data.headers,
subject: data.subject,
reply_to: adaptReplyToRecipient(data.replyTo),
reply_to: adaptFirstRecipient(data.replyTo),
};
const result = adaptMail(data);

Expand All @@ -63,7 +74,7 @@ describe("adapters/mail: ", () => {
bcc: [],
headers: data.headers,
attachments: data.attachments,
reply_to: adaptReplyToRecipient(data.replyTo),
reply_to: adaptFirstRecipient(data.replyTo),
};
const result = adaptMail(data);

Expand Down Expand Up @@ -93,7 +104,7 @@ describe("adapters/mail: ", () => {
headers: data.headers,
attachments: data.attachments,
custom_variables: data.customVariables,
reply_to: adaptReplyToRecipient(data.replyTo),
reply_to: adaptFirstRecipient(data.replyTo),
};
const result = adaptMail(data);

Expand Down
Loading
Loading