diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f9efab8..1eaec4c7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 cache: 'yarn' - run: yarn install --frozen-lockfile - run: yarn lint diff --git a/.tool-versions b/.tool-versions index ef935268..39847f70 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -nodejs 18.12.1 +nodejs 20.20.2 yarn 1.22.17 diff --git a/README.md b/README.md index 39750c1f..ad0d92e3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/package.json b/package.json index d2c8c68a..24e99f7d 100644 --- a/package.json +++ b/package.json @@ -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", @@ -48,7 +48,7 @@ }, "peerDependencies": { "@types/nodemailer": "^6.4.9", - "nodemailer": "^9.0.1" + "nodemailer": "^9.0.1 || ^10.0.0" }, "peerDependenciesMeta": { "nodemailer": { @@ -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", diff --git a/src/__tests__/adapters/attachment.test.ts b/src/__tests__/adapters/attachment.test.ts index f4a81664..aca571c3 100644 --- a/src/__tests__/adapters/attachment.test.ts +++ b/src/__tests__/adapters/attachment.test.ts @@ -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.", () => { diff --git a/src/__tests__/adapters/content.test.ts b/src/__tests__/adapters/content.test.ts index 2736c3ff..fafc44b3 100644 --- a/src/__tests__/adapters/content.test.ts +++ b/src/__tests__/adapters/content.test.ts @@ -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(): ", () => { @@ -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) + ); + }); }); }); }); diff --git a/src/__tests__/adapters/headers.test.ts b/src/__tests__/adapters/headers.test.ts index 85eb40bf..1ad187fe 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -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 ", + 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 ", + mockComma: '"Doe, John" ', + mockQuote: '"He said \\"hi\\"" ', + mockBackslash: '"back\\\\slash" ', + mockUnicode: '"Ünïcode" ', + }; + 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" ', + }; + 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); + }); }); }); diff --git a/src/__tests__/adapters/mail.test.ts b/src/__tests__/adapters/mail.test.ts index 7c118faa..5bf23315 100644 --- a/src/__tests__/adapters/mail.test.ts +++ b/src/__tests__/adapters/mail.test.ts @@ -3,7 +3,7 @@ import adaptMail from "../../adapters/mail"; import config from "../../config"; import { adaptSingleRecipient, - adaptReplyToRecipient, + adaptFirstRecipient, } from "../../adapters/recipients"; const { ERRORS } = config; @@ -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", @@ -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); @@ -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); @@ -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); diff --git a/src/__tests__/adapters/recipients.test.ts b/src/__tests__/adapters/recipients.test.ts index b39aac1b..fcd645b4 100644 --- a/src/__tests__/adapters/recipients.test.ts +++ b/src/__tests__/adapters/recipients.test.ts @@ -1,6 +1,8 @@ +import { NodemailerAddress } from "../../types/transport"; + import adaptRecipients, { adaptSingleRecipient, - adaptReplyToRecipient, + adaptFirstRecipient, } from "../../adapters/recipients"; describe("adapters/recipients: ", () => { @@ -30,6 +32,17 @@ describe("adapters/recipients: ", () => { expect(result).toEqual(expectedResult); }); + + it("omits name if Nodemailer address has no name.", () => { + const expectedResult = { email: "mock-email" }; + + expect(adaptSingleRecipient({ address: "mock-email" })).toEqual( + expectedResult + ); + expect( + adaptSingleRecipient({ name: " ", address: "mock-email" }) + ).toEqual(expectedResult); + }); }); describe("adaptRecipients(): ", () => { @@ -85,14 +98,81 @@ describe("adapters/recipients: ", () => { expect(result).toEqual(expectedResult); }); + + it("flattens nested recipients arrays.", () => { + const recipients = [ + "mock-email-1", + [{ name: "mock-name-2", address: "mock-email-2" }, ["mock-email-3"]], + ]; + + const expectedResult = [ + { email: "mock-email-1" }, + { name: "mock-name-2", email: "mock-email-2" }, + { email: "mock-email-3" }, + ]; + const result = adaptRecipients(recipients); + + expect(result).toEqual(expectedResult); + }); + + it("keeps the address of a group that carries one.", () => { + const recipients = { + name: "mock-group", + address: "mock-group@mail.com", + group: [{ address: "mock-member@mail.com" }], + }; + + const expectedResult = [ + { name: "mock-group", email: "mock-group@mail.com" }, + ]; + const result = adaptRecipients(recipients); + + expect(result).toEqual(expectedResult); + }); + + it("skips recipients without an address.", () => { + expect(adaptRecipients([{ name: "mock-name" }, "mock-email"])).toEqual([ + { email: "mock-email" }, + ]); + expect(adaptRecipients({ address: " " })).toEqual([]); + expect(adaptRecipients({ name: "mock-group", group: [] })).toEqual([]); + }); + + it("skips recipients that refer to themselves.", () => { + const selfReferencing: NodemailerAddress = { + name: "mock-group", + group: [], + }; + selfReferencing.group?.push(selfReferencing); + + expect(adaptRecipients(selfReferencing)).toEqual([]); + }); + + it("expands address groups into their members.", () => { + const recipients = { + name: "mock-group", + group: [ + { name: "mock-name-1", address: "mock-email-1" }, + { address: "mock-email-2" }, + ], + }; + + const expectedResult = [ + { name: "mock-name-1", email: "mock-email-1" }, + { email: "mock-email-2" }, + ]; + const result = adaptRecipients(recipients); + + expect(result).toEqual(expectedResult); + }); }); - describe("adaptReplyToRecipient(): ", () => { + describe("adaptFirstRecipient(): ", () => { it("returns undefined if recipients is invalid.", () => { const recipients = undefined; const expectedResult = undefined; - const result = adaptReplyToRecipient(recipients); + const result = adaptFirstRecipient(recipients); expect(result).toEqual(expectedResult); }); @@ -101,7 +181,7 @@ describe("adapters/recipients: ", () => { const recipients: any = []; const expectedResult = undefined; - const result = adaptReplyToRecipient(recipients); + const result = adaptFirstRecipient(recipients); expect(result).toEqual(expectedResult); }); @@ -116,7 +196,7 @@ describe("adapters/recipients: ", () => { name: recipients.name, email: recipients.address, }; - const result = adaptReplyToRecipient(recipients); + const result = adaptFirstRecipient(recipients); expect(result).toEqual(expectedResult); }); @@ -137,7 +217,23 @@ describe("adapters/recipients: ", () => { name: recipients[0].name, email: recipients[0].address, }; - const result = adaptReplyToRecipient(recipients); + const result = adaptFirstRecipient(recipients); + + expect(result).toEqual(expectedResult); + }); + + it("returns the first recipient that has an address.", () => { + expect( + adaptFirstRecipient([{ name: "mock-name" }, "mock-email"]) + ).toEqual({ email: "mock-email" }); + expect(adaptFirstRecipient([{ name: "mock-name" }])).toBeUndefined(); + }); + + it("returns first adapted recipient if it's a nested array.", () => { + const recipients = [[], ["mock-email-1", "mock-email-2"]]; + + const expectedResult = { email: "mock-email-1" }; + const result = adaptFirstRecipient(recipients); expect(result).toEqual(expectedResult); }); diff --git a/src/__tests__/lib/normalizer.test.ts b/src/__tests__/lib/normalizer.test.ts index d9dd9e35..f42477cc 100644 --- a/src/__tests__/lib/normalizer.test.ts +++ b/src/__tests__/lib/normalizer.test.ts @@ -5,7 +5,8 @@ import config from "../../config"; import { SendError, SendResponse } from "../../types/mailtrap"; const { ERRORS } = config; -const { SENDING_FAILED, NO_DATA_ERROR, FROM_REQUIRED } = ERRORS; +const { SENDING_FAILED, NO_DATA_ERROR, FROM_REQUIRED, CONTENT_REQUIRED } = + ERRORS; describe("lib/normalizer: ", () => { describe("normalizeCallback(): ", () => { @@ -107,6 +108,37 @@ describe("lib/normalizer: ", () => { cb(null, mailData); }); + it("passes error to callback in case if adapter throws.", () => { + expect.assertions(3); + + const mockClient = { + send: () => Promise.resolve("mock-result"), + }; + const callback = (error: Error, data: SendError) => { + expect(error).toBeInstanceOf(Error); + expect(data.success).toBeFalsy(); + expect(data.errors[0]).toEqual(CONTENT_REQUIRED); + }; + const mailData = { + text: "mock-text", + to: { + address: "mock@mail.com", + name: "mock-name", + }, + from: { + address: "mock@mail.com", + name: "mock-name", + }, + subject: "mock-subject", + attachments: [{ filename: "mock-filename" }], + }; + + // @ts-ignore + const cb = normalizeCallback(mockClient, callback); + + cb(null, mailData); + }); + it("passes error to callback in case if no data.", () => { expect.assertions(3); diff --git a/src/adapters/attachement.ts b/src/adapters/attachement.ts index b1321e7c..d7ccfc64 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -1,16 +1,17 @@ -import { Attachment as NodemailerAttachment } from "nodemailer/lib/mailer"; +import adaptContent from "./content"; import CONFIG from "../config"; import { Attachment } from "../types/mailtrap"; +import { NodemailerAttachment } from "../types/transport"; const { ERRORS } = CONFIG; -const { FILENAME_REQUIRED, CONTENT_REQUIRED } = ERRORS; +const { FILENAME_REQUIRED } = ERRORS; /** * Adopts Nodemailer attachment to Mailtrap. * Checks if filename or content are missing, then rejects with error. - * Otherwise specifies type of content, then builds attachment object for Mailtrap. + * Otherwise builds attachment object for Mailtrap. * @todo throw error when only filename is provided */ export default function adaptAttachment( @@ -20,19 +21,9 @@ export default function adaptAttachment( throw new Error(FILENAME_REQUIRED); } - if (!nodemailerAttachment.content) { - throw new Error(CONTENT_REQUIRED); - } - - const content = - typeof nodemailerAttachment.content === "string" || - nodemailerAttachment.content instanceof Buffer - ? nodemailerAttachment.content - : nodemailerAttachment.content.read(); - return { filename: nodemailerAttachment.filename, - content, + content: adaptContent(nodemailerAttachment.content), disposition: nodemailerAttachment.contentDisposition, content_id: nodemailerAttachment.cid, type: nodemailerAttachment.contentType, diff --git a/src/adapters/content.ts b/src/adapters/content.ts index da47a17f..2434c770 100644 --- a/src/adapters/content.ts +++ b/src/adapters/content.ts @@ -1,27 +1,23 @@ -import { readFileSync } from "node:fs"; -import { Readable } from "node:stream"; -import { AttachmentLike } from "nodemailer/lib/mailer"; +import CONFIG from "../config"; + +import { NodemailerContent } from "../types/transport"; + +const { ERRORS } = CONFIG; +const { CONTENT_REQUIRED } = ERRORS; /** - * Checks if content type is rather string or buffer, returns content. - * If content is Readble stream, then calls .read(). - * If content has recursive content property then calls the same function recursively. - * Otherwise reads file. + * Returns the content in the form Mailtrap takes it. + * Nodemailer resolves every content form it supports into a string or a Buffer before the transport runs, so anything left is not a content we can read: reading it here would bypass options like `disableFileAccess`. */ export default function adaptContent( - content: string | Buffer | Readable | AttachmentLike + content: NodemailerContent | undefined ): string | Buffer { - if (typeof content === "string" || content instanceof Buffer) { - return content; - } - - if (content instanceof Readable) { - return content.read(); - } - - if (content.content) { - return adaptContent(content.content); + if ( + !content || + (typeof content !== "string" && !(content instanceof Buffer)) + ) { + throw new Error(CONTENT_REQUIRED); } - return readFileSync(content.path as string); + return content; } diff --git a/src/adapters/headers.ts b/src/adapters/headers.ts index 526d85f9..ca5d70a4 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -1,42 +1,118 @@ -import { Headers } from "nodemailer/lib/mailer"; +import CONFIG from "../config"; import { MailtrapHeaders } from "../types/mailtrap"; +import { NodemailerAddress, NodemailerHeaders } from "../types/transport"; + +const { TRANSPORT_SETTINGS } = CONFIG; +const { MAX_NESTING_DEPTH } = TRANSPORT_SETTINGS; /** - * Adapts nodemailer headers to mailtrap compatible form. - * If `nodemailerHeaders` is array of { key, value } objects, then converts to object. - * Otherwise if value is string, keeps as is. If it's an array, first value. + * Display name that nodemailer leaves as it is in its own address headers. + */ +const UNQUOTED_NAME = /^[\w ]*$/; + +/** + * Replaces the line breaks a header value can't carry with spaces, as nodemailer does. + */ +function adaptLineBreaks(value: string): string { + return value.replace(/[\r\n]+/g, " "); +} + +/** + * Quotes and escapes a display name unless nodemailer would leave it as it is. + * A non-ASCII name is quoted rather than turned into a MIME encoded word, since the Mailtrap API takes it as UTF-8. + */ +function adaptAddressName(name: string): string { + return UNQUOTED_NAME.test(name) + ? name + : `"${name.replace(/([\\"])/g, "\\$1")}"`; +} + +/** + * Converts a single nodemailer header value to a string. + * Nodemailer accepts strings, numbers, booleans, address objects, `{ prepared, value }` objects and arrays of these. Values nodemailer drops itself, like `false`, `0`, blank strings and dates, return `undefined` so the header gets skipped, and so does a value nested deeper than `MAX_NESTING_DEPTH`. * @todo support multiple value per header */ -export default function adaptHeaders( - nodemailerHeaders: Headers -): MailtrapHeaders { - if (Array.isArray(nodemailerHeaders)) { - return nodemailerHeaders.reduce((acc, header) => { - acc[header.key] = header.value; +function adaptHeaderValue(value: unknown, depth = 0): string | undefined { + if (!value || depth > MAX_NESTING_DEPTH) { + return undefined; + } - return acc; - }, {} as MailtrapHeaders); + if (typeof value === "string") { + return adaptLineBreaks(value).trim() || undefined; } - const headerKeys = Object.keys(nodemailerHeaders); + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } - return headerKeys.reduce((acc, key) => { - const value = nodemailerHeaders[key]; + if (Array.isArray(value)) { + return adaptHeaderValue(value[0], depth + 1); // TODO: support multiple value per header + } - if (typeof value === "string") { - acc[key] = value; + if (typeof value === "object") { + if ("value" in value) { + return adaptHeaderValue(value.value, depth + 1); + } + + if ("address" in value) { + const { name, address } = value as NodemailerAddress; + const email = adaptHeaderValue(address, depth + 1); + + if (!email) { + return undefined; + } - return acc; + const displayName = adaptHeaderValue(name, depth + 1); + + return displayName + ? `${adaptAddressName(displayName)} <${email}>` + : email; } + } - if (Array.isArray(value)) { - [acc[key]] = value; // TODO: support multiple value per header + return undefined; +} - return acc; +/** + * Adapts nodemailer headers to mailtrap compatible form. + * If `nodemailerHeaders` is a { key, value } object or an array of them, then converts to object. + * Otherwise iterates over the object keys, converting each value to string. + * Header names are trimmed as nodemailer does, and a name left blank drops the header. + */ +export default function adaptHeaders( + nodemailerHeaders: NodemailerHeaders +): MailtrapHeaders { + const entries: Array<[string, unknown]> = (() => { + if (Array.isArray(nodemailerHeaders)) { + return nodemailerHeaders.map(({ key, value }) => [ + key ? String(key) : "", + value, + ]); + } + + /** + * Single `{ key, value }` header. Nodemailer applies custom headers through `addHeader`, which reads the object as a pair only when both are set, and as plain headers otherwise. + */ + const { key, value } = nodemailerHeaders as { + key?: unknown; + value?: unknown; + }; + + if (key && value) { + return [[String(key), value]]; } - acc[key] = value.value; + return Object.entries(nodemailerHeaders); + })(); + + return entries.reduce((acc, [key, value]) => { + const name = key.trim(); + const adaptedValue = adaptHeaderValue(value); + + if (name && adaptedValue !== undefined) { + acc[name] = adaptedValue; + } return acc; }, {} as MailtrapHeaders); diff --git a/src/adapters/mail.ts b/src/adapters/mail.ts index 547001ef..1256b211 100644 --- a/src/adapters/mail.ts +++ b/src/adapters/mail.ts @@ -1,10 +1,7 @@ import adaptAttachment from "./attachement"; import adaptContent from "./content"; import adaptHeaders from "./headers"; -import adaptRecipients, { - adaptSingleRecipient, - adaptReplyToRecipient, -} from "./recipients"; +import adaptRecipients, { adaptFirstRecipient } from "./recipients"; import CONFIG from "../config"; @@ -21,16 +18,18 @@ const { SUBJECT_REQUIRED, FROM_REQUIRED } = ERRORS; * Then returns mail with all params needed. */ export default function adaptMail(data: MailtrapMailOptions): Mail | SendError { - if (!data.from) { + const from = adaptFirstRecipient(data.from); + + if (!from?.email) { return { success: false, errors: [FROM_REQUIRED] }; } const mail: CommonMail = { - from: adaptSingleRecipient(data.from), + from, to: adaptRecipients(data.to), cc: adaptRecipients(data.cc), bcc: adaptRecipients(data.bcc), - reply_to: adaptReplyToRecipient(data.replyTo), + reply_to: adaptFirstRecipient(data.replyTo), }; if (data.headers) { diff --git a/src/adapters/recipients.ts b/src/adapters/recipients.ts index 458122e0..b672cb0c 100644 --- a/src/adapters/recipients.ts +++ b/src/adapters/recipients.ts @@ -1,64 +1,84 @@ -import { Address as NodemailerAddress } from "nodemailer/lib/mailer"; +import CONFIG from "../config"; import { Address } from "../types/mailtrap"; +import { NodemailerAddress, NodemailerRecipients } from "../types/transport"; + +const { TRANSPORT_SETTINGS } = CONFIG; +const { MAX_NESTING_DEPTH } = TRANSPORT_SETTINGS; + +/** + * Flattens nodemailer recipients into a plain list of string or address objects. + * An address group (`{ name, group: [...] }`) is expanded into its members, unless it carries an address of its own, which nodemailer keeps instead. Recipients nested deeper than `MAX_NESTING_DEPTH` are left out, so input that refers to itself doesn't overflow the stack. + */ +function flattenRecipients( + recipients: NodemailerRecipients, + depth = 0 +): Array { + if (depth > MAX_NESTING_DEPTH) { + return []; + } + + if (Array.isArray(recipients)) { + return recipients.flatMap((recipient) => + flattenRecipients(recipient, depth + 1) + ); + } + + if ( + typeof recipients !== "string" && + !recipients.address && + recipients.group + ) { + return flattenRecipients(recipients.group, depth + 1); + } + + return [recipients]; +} /** * If type of `recipient` is string, then wraps it into email object. - * Otherwise maps into { `name`, `email` } pair. + * Otherwise maps into { `name`, `email` } pair, `name` being optional in nodemailer. */ export function adaptSingleRecipient( recipient: string | NodemailerAddress ): Address { if (typeof recipient === "string") { - return { email: recipient }; + return { email: recipient.trim() }; } - return { name: recipient.name, email: recipient.address }; + const name = recipient.name?.trim(); + + return { + ...(name && { name }), + email: recipient.address?.trim() ?? "", + }; } /** * If there is no recipient, then returns empty array. - * If it's not array, then adopts recipient and wraps into array. - * Otherwise maps trough recipients and adopts each one for Mailtrap. + * Otherwise flattens recipients and adopts each one for Mailtrap. + * Recipients without an address are left out, as nodemailer leaves them out of the envelope and the headers. */ export default function adaptRecipients( - recipients: - | string - | NodemailerAddress - | Array - | undefined + recipients: NodemailerRecipients | undefined ): Address[] { if (!recipients) { return []; } - if (!Array.isArray(recipients)) { - return [adaptSingleRecipient(recipients)]; - } - - return recipients.map(adaptSingleRecipient); + return flattenRecipients(recipients) + .map(adaptSingleRecipient) + .filter(({ email }) => email); } /** - * If there is no recipient or empty array is passed, then return undefined since it is an optional field. - * If it's not array, then adapt recipient and returns it. - * Otherwise, if type is array as nodemailer allows, we pick the first recipient - * as Mailtrap doesn't support multiple reply-to recipients. + * Returns the first recipient that has an address, or undefined when there is none, since it is an optional field. + * Used for `from` and `reply_to` as Mailtrap supports a single address for both. */ -export function adaptReplyToRecipient( - recipients: - | string - | NodemailerAddress - | Array - | undefined +export function adaptFirstRecipient( + recipients: NodemailerRecipients | undefined ): Address | undefined { - if (!recipients || (Array.isArray(recipients) && recipients.length === 0)) { - return undefined; - } - - if (!Array.isArray(recipients)) { - return adaptSingleRecipient(recipients); - } + const [first] = adaptRecipients(recipients); - return adaptSingleRecipient(recipients[0]); + return first; } diff --git a/src/config/index.ts b/src/config/index.ts index 6f5d0e16..6c94d3d5 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -25,5 +25,9 @@ export default { }, TRANSPORT_SETTINGS: { NAME: "MailtrapTransport", + /** + * How deep an adapter follows a nested value before giving up, so input that refers to itself doesn't overflow the stack. + */ + MAX_NESTING_DEPTH: 10, }, }; diff --git a/src/lib/normalizer.ts b/src/lib/normalizer.ts index 94a984b2..f316db82 100644 --- a/src/lib/normalizer.ts +++ b/src/lib/normalizer.ts @@ -4,7 +4,7 @@ import adaptMail from "../adapters/mail"; import CONFIG from "../config"; -import { Mail as MailtrapMail } from "../types/mailtrap"; +import { Mail as MailtrapMail, SendError } from "../types/mailtrap"; import { NormalizeCallbackData, NormalizeCallbackError, @@ -14,6 +14,23 @@ import { const { ERRORS } = CONFIG; const { SENDING_FAILED, NO_DATA_ERROR } = ERRORS; +/** + * Adapts the mail, turning an error thrown by an adapter into a `SendError`. + * The callback runs inside `Nodemailer`, which doesn't catch it, so a throw would crash the process instead of rejecting the `sendMail` promise. + */ +function adaptMailSafely( + data: NonNullable +): MailtrapMail | SendError { + try { + return adaptMail(data); + } catch (error) { + return { + success: false, + errors: [error instanceof Error ? error.message : String(error)], + }; + } +} + /** * Callback function for `Nodemailer.normalize` method which introduces Mailtrap integration. * Uses function curring to inject dependencies like `transport client` and `nodemailer default callback object`. @@ -28,7 +45,7 @@ export default function normalizeCallback( } if (data) { - const mail = adaptMail(data); + const mail = adaptMailSafely(data); if ("errors" in mail) { return callback(new Error(...mail.errors), { diff --git a/src/lib/transport-esm.mts b/src/lib/transport-esm.mts new file mode 100644 index 00000000..8f1eb8e2 --- /dev/null +++ b/src/lib/transport-esm.mts @@ -0,0 +1,12 @@ +import type { MailtrapTransporter } from "../types/transport.js"; +import type { MailtrapTransportInstance } from "./transport.js"; + +/** + * Same augmentation as in `transport.ts`, for consumers resolving `nodemailer` through its `import` condition. + * Nodemailer >= 10 ships one set of types per condition, and an augmentation only reaches the copy resolved from the file it is declared in. + */ +declare module "nodemailer" { + export function createTransport( + transport: MailtrapTransportInstance + ): MailtrapTransporter; +} diff --git a/src/lib/transport.ts b/src/lib/transport.ts index f9ac4bfb..750fb984 100644 --- a/src/lib/transport.ts +++ b/src/lib/transport.ts @@ -1,3 +1,4 @@ +/// import { Transport } from "nodemailer"; import MailtrapClient from "./MailtrapClient"; @@ -60,5 +61,7 @@ declare module "nodemailer" { ): MailtrapTransporter; } +export type { MailtrapTransport as MailtrapTransportInstance }; + export default (options: MailtrapClientConfig) => new MailtrapTransport(options); diff --git a/src/types/transport.ts b/src/types/transport.ts index c01468a8..40d8bed9 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -1,6 +1,6 @@ -import NodemailerMail = require("nodemailer/lib/mailer"); - -import { Transport, Transporter } from "nodemailer"; +import { Readable } from "node:stream"; +import { Url } from "node:url"; +import { SendMailOptions, Transport, Transporter } from "nodemailer"; import { SendResponse, SendError, @@ -8,6 +8,52 @@ import { TemplateVariables, } from "./mailtrap"; +/** + * Address object as nodemailer accepts it. Declared structurally so it matches both the types bundled with nodemailer >= 10 and `@types/nodemailer`. + */ +export type NodemailerAddress = { + name?: string | undefined; + address?: string | undefined; + group?: NodemailerAddress[] | undefined; +}; + +/** + * Recipients as nodemailer accepts them: a string, an address object, or an array of these (nested arrays included). + */ +export type NodemailerRecipients = + | string + | NodemailerAddress + | NodemailerRecipients[]; + +/** + * Object pointing to a content instead of carrying it. + */ +type NodemailerContentObject = { + content?: NodemailerContent | undefined; + path?: string | false | Url | undefined; +}; + +/** + * Content as nodemailer accepts it for `text`, `html` and attachments: a string, a Buffer, a readable stream or an object pointing to the content. + */ +export type NodemailerContent = + | string + | Buffer + | Readable + | NodemailerContentObject; + +/** + * Headers as nodemailer accepts them, derived from the message options so the shape follows whichever nodemailer version is installed. + */ +export type NodemailerHeaders = NonNullable; + +/** + * Attachment as nodemailer accepts it, derived from the message options. + */ +export type NodemailerAttachment = NonNullable< + SendMailOptions["attachments"] +>[number]; + type AdditionalFields = { category?: string; custom_variables?: CustomVariables; @@ -16,7 +62,7 @@ type AdditionalFields = { }; export type NormalizeCallbackData = - | (NodemailerMail.Options & AdditionalFields) + | (SendMailOptions & AdditionalFields) | undefined; export type NormalizeCallbackError = Error | null | undefined; @@ -26,13 +72,13 @@ export type NormalizeCallback = ( info: SendResponse | SendError ) => void; -interface MailtrapMailOptionsSandbox extends NodemailerMail.Options { +interface MailtrapMailOptionsSandbox extends SendMailOptions { customVariables?: CustomVariables; category?: string; sandbox: boolean; } -export interface MailtrapMailOptions extends NodemailerMail.Options { +export interface MailtrapMailOptions extends SendMailOptions { customVariables?: CustomVariables; category?: string; templateUuid?: string; diff --git a/yarn.lock b/yarn.lock index a7ebc400..89f59269 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4003,10 +4003,10 @@ node-releases@^2.0.53: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe" integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ== -nodemailer@^9.0.1: - version "9.1.1" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-9.1.1.tgz#fb991992723657f34dbe1d829139aa66be008e96" - integrity sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ== +nodemailer@^10.0.0: + version "10.0.9" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-10.0.9.tgz#1860325627921b3c6e160d941f5d525ff9a8ca1e" + integrity sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw== normalize-path@^3.0.0: version "3.0.0"