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..7d86d5c1 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": { diff --git a/src/__tests__/adapters/attachment.test.ts b/src/__tests__/adapters/attachment.test.ts index f4a81664..011f9646 100644 --- a/src/__tests__/adapters/attachment.test.ts +++ b/src/__tests__/adapters/attachment.test.ts @@ -1,4 +1,5 @@ import { Readable } from "stream"; +import { readFileSync } from "node:fs"; import adaptAttachment from "../../adapters/attachement"; @@ -27,6 +28,21 @@ describe("adapters/attachment: ", () => { ); }); + it("throws `content required` error if adapted content is empty.", () => { + const emptyStream = new Readable({ + read() { + this.push(null); + }, + }); + + expect(() => + adaptAttachment({ filename: "mock-filename", content: "" }) + ).toThrowError(new Error(CONTENT_REQUIRED)); + expect(() => + adaptAttachment({ filename: "mock-filename", content: emptyStream }) + ).toThrowError(new Error(CONTENT_REQUIRED)); + }); + it("returns adapted attachment object in case if content is buffer.", () => { const attachment = { filename: "mock-filename", @@ -91,5 +107,23 @@ describe("adapters/attachment: ", () => { expect(result).toEqual(expectedAttachment); }); + + it("returns adapted attachment object in case if content is a content object.", () => { + const attachment = { + filename: "mock-filename", + content: { path: __filename }, + }; + + const expectedAttachment = { + filename: attachment.filename, + content: readFileSync(__filename), + disposition: undefined, + content_id: undefined, + type: undefined, + }; + const result = adaptAttachment(attachment); + + expect(result).toEqual(expectedAttachment); + }); }); }); diff --git a/src/__tests__/adapters/headers.test.ts b/src/__tests__/adapters/headers.test.ts index 85eb40bf..7ddfea94 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -61,5 +61,61 @@ 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("converts non-string header values to strings.", () => { + const date = new Date("2026-01-02T03:04:05Z"); + const headers = { + mockNumber: 42, + mockBoolean: true, + mockDate: date, + mockAddress: { name: "mock-name", address: "mock-email" }, + mockAddressWithoutName: { address: "mock-email" }, + mockNested: [[{ prepared: true, value: 7 }]], + }; + + const expectedResult = { + mockNumber: "42", + mockBoolean: "true", + mockDate: date.toUTCString(), + mockAddress: "mock-name ", + mockAddressWithoutName: "mock-email", + mockNested: "7", + }; + const result = adaptHeaders(headers); + + expect(result).toEqual(expectedResult); + }); + + it("skips headers with empty values.", () => { + const headers = { + mockNull: null, + mockUndefined: undefined, + mockEmptyArray: [], + 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..3a18fe18 100644 --- a/src/__tests__/adapters/recipients.test.ts +++ b/src/__tests__/adapters/recipients.test.ts @@ -1,6 +1,6 @@ import adaptRecipients, { adaptSingleRecipient, - adaptReplyToRecipient, + adaptFirstRecipient, } from "../../adapters/recipients"; describe("adapters/recipients: ", () => { @@ -30,6 +30,19 @@ describe("adapters/recipients: ", () => { expect(result).toEqual(expectedResult); }); + + it("omits name if Nodemailer address has no name.", () => { + const recipient = { + address: "mock-email", + }; + + const expectedResult = { + email: recipient.address, + }; + const result = adaptSingleRecipient(recipient); + + expect(result).toEqual(expectedResult); + }); }); describe("adaptRecipients(): ", () => { @@ -85,14 +98,48 @@ 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("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 +148,7 @@ describe("adapters/recipients: ", () => { const recipients: any = []; const expectedResult = undefined; - const result = adaptReplyToRecipient(recipients); + const result = adaptFirstRecipient(recipients); expect(result).toEqual(expectedResult); }); @@ -116,7 +163,7 @@ describe("adapters/recipients: ", () => { name: recipients.name, email: recipients.address, }; - const result = adaptReplyToRecipient(recipients); + const result = adaptFirstRecipient(recipients); expect(result).toEqual(expectedResult); }); @@ -137,7 +184,16 @@ 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 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/adapters/attachement.ts b/src/adapters/attachement.ts index b1321e7c..e12f1590 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -1,16 +1,20 @@ -import { Attachment as NodemailerAttachment } from "nodemailer/lib/mailer"; +import Mail from "nodemailer/lib/mailer"; + +import adaptContent from "./content"; import CONFIG from "../config"; import { Attachment } from "../types/mailtrap"; +type NodemailerAttachment = Mail.Attachment; + const { ERRORS } = CONFIG; const { FILENAME_REQUIRED, CONTENT_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 adapts the content, then builds attachment object for Mailtrap. * @todo throw error when only filename is provided */ export default function adaptAttachment( @@ -24,11 +28,11 @@ export default function adaptAttachment( throw new Error(CONTENT_REQUIRED); } - const content = - typeof nodemailerAttachment.content === "string" || - nodemailerAttachment.content instanceof Buffer - ? nodemailerAttachment.content - : nodemailerAttachment.content.read(); + const content = adaptContent(nodemailerAttachment.content); + + if (!content) { + throw new Error(CONTENT_REQUIRED); + } return { filename: nodemailerAttachment.filename, diff --git a/src/adapters/content.ts b/src/adapters/content.ts index da47a17f..ee900d74 100644 --- a/src/adapters/content.ts +++ b/src/adapters/content.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { Readable } from "node:stream"; -import { AttachmentLike } from "nodemailer/lib/mailer"; + +import { NodemailerContent } from "../types/transport"; /** * Checks if content type is rather string or buffer, returns content. @@ -9,7 +10,7 @@ import { AttachmentLike } from "nodemailer/lib/mailer"; * Otherwise reads file. */ export default function adaptContent( - content: string | Buffer | Readable | AttachmentLike + content: NodemailerContent ): string | Buffer { if (typeof content === "string" || content instanceof Buffer) { return content; diff --git a/src/adapters/headers.ts b/src/adapters/headers.ts index 526d85f9..14bf6be3 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -1,42 +1,88 @@ -import { Headers } from "nodemailer/lib/mailer"; +import Mail from "nodemailer/lib/mailer"; import { MailtrapHeaders } from "../types/mailtrap"; +type Headers = Mail.Headers; + /** - * 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. + * Converts a single nodemailer header value to a string. + * Nodemailer accepts strings, numbers, booleans, dates, address objects, `{ prepared, value }` objects and arrays of these. Returns `undefined` + * for empty values so the header gets skipped. * @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): string | undefined { + if (value === null || value === undefined) { + return undefined; + } - return acc; - }, {} as MailtrapHeaders); + if (typeof value === "string") { + return value; } - 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 (value instanceof Date) { + return value.toUTCString(); + } - if (typeof value === "string") { - acc[key] = value; + if (Array.isArray(value)) { + return adaptHeaderValue(value[0]); // TODO: support multiple value per header + } - return acc; + if (typeof value === "object") { + if ("value" in value) { + return adaptHeaderValue(value.value); } - if (Array.isArray(value)) { - [acc[key]] = value; // TODO: support multiple value per header + if ("address" in value) { + const { name, address } = value as Mail.Address; + const email = address?.trim(); - return acc; + if (!email) { + return undefined; + } + + return name ? `${name} <${email}>` : email; } + } - acc[key] = value.value; + return undefined; +} + +/** + * 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. + */ +export default function adaptHeaders( + nodemailerHeaders: Headers +): MailtrapHeaders { + const entries: Array<[string, unknown]> = (() => { + if (Array.isArray(nodemailerHeaders)) { + return nodemailerHeaders.map(({ key, value }) => [key, value]); + } + + // Single `{ key, value }` header, handled the same way nodemailer does in `setHeader`. + const { key, value } = nodemailerHeaders as { + key?: unknown; + value?: unknown; + }; + + if (typeof key === "string" && "value" in nodemailerHeaders) { + return [[key, value]]; + } + + return Object.entries(nodemailerHeaders); + })(); + + return entries.reduce((acc, [key, value]) => { + const adaptedValue = adaptHeaderValue(value); + + if (adaptedValue !== undefined) { + acc[key] = 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..a1acc8e5 100644 --- a/src/adapters/recipients.ts +++ b/src/adapters/recipients.ts @@ -1,10 +1,27 @@ -import { Address as NodemailerAddress } from "nodemailer/lib/mailer"; - import { Address } from "../types/mailtrap"; +import { NodemailerAddress, NodemailerRecipients } from "../types/transport"; + +/** + * Flattens nodemailer recipients into a plain list of string or address objects. + * Address groups (`{ name, group: [...] }`) are expanded into their members. + */ +function flattenRecipients( + recipients: NodemailerRecipients +): Array { + if (Array.isArray(recipients)) { + return recipients.flatMap(flattenRecipients); + } + + if (typeof recipients !== "string" && recipients.group) { + return flattenRecipients(recipients.group); + } + + 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 @@ -13,52 +30,39 @@ export function adaptSingleRecipient( return { email: recipient }; } - return { name: recipient.name, email: recipient.address }; + return { + ...(recipient.name !== undefined && { name: recipient.name }), + email: recipient.address ?? "", + }; } /** * 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. */ 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); } /** * 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. + * Otherwise, if several recipients are given as nodemailer allows, we pick the first one. + * 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)) { + if (!recipients) { return undefined; } - if (!Array.isArray(recipients)) { - return adaptSingleRecipient(recipients); - } + const [first] = flattenRecipients(recipients); - return adaptSingleRecipient(recipients[0]); + return first === undefined ? undefined : adaptSingleRecipient(first); } diff --git a/src/types/transport.ts b/src/types/transport.ts index c01468a8..db9cc2d2 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -1,5 +1,7 @@ -import NodemailerMail = require("nodemailer/lib/mailer"); +import NodemailerMail from "nodemailer/lib/mailer"; +import { Readable } from "node:stream"; +import { Url } from "node:url"; import { Transport, Transporter } from "nodemailer"; import { SendResponse, @@ -8,6 +10,40 @@ 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; + type AdditionalFields = { category?: string; custom_variables?: CustomVariables; 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"