From ae5ab2d772eac4a16b542c0ccfbe1a4677f7da0c Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:00:35 +0200 Subject: [PATCH 01/17] Run CI on Node.js 20 --- .github/workflows/test.yml | 2 +- .tool-versions | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f9efab..1eaec4c 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 ef93526..39847f7 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -nodejs 18.12.1 +nodejs 20.20.2 yarn 1.22.17 From e71d5978f87b6ba21a5ea2a953f7ccf4e039fec0 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:00:35 +0200 Subject: [PATCH 02/17] Support nodemailer 10 --- package.json | 4 +- src/__tests__/adapters/attachment.test.ts | 19 +++++ src/__tests__/adapters/headers.test.ts | 54 ++++++++++++++ src/__tests__/adapters/mail.test.ts | 8 +-- src/__tests__/adapters/recipients.test.ts | 68 ++++++++++++++++-- src/adapters/attachement.ts | 16 ++--- src/adapters/content.ts | 13 +++- src/adapters/headers.ts | 85 +++++++++++++++++------ src/adapters/mail.ts | 13 ++-- src/adapters/recipients.ts | 80 +++++++++++++-------- src/types/transport.ts | 2 +- yarn.lock | 8 +-- 12 files changed, 283 insertions(+), 87 deletions(-) diff --git a/package.json b/package.json index d2c8c68..7d86d5c 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 f4a8166..cd7c4f5 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"; @@ -91,5 +92,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 85eb40b..01c9d6e 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -61,5 +61,59 @@ 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: [], + 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 7c118fa..05660a4 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; @@ -37,7 +37,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 +63,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 +93,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 b39aac1..3a18fe1 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 b1321e7..4257589 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,15 +28,9 @@ export default function adaptAttachment( 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 da47a17..6aa2fd4 100644 --- a/src/adapters/content.ts +++ b/src/adapters/content.ts @@ -1,6 +1,15 @@ import { readFileSync } from "node:fs"; import { Readable } from "node:stream"; -import { AttachmentLike } from "nodemailer/lib/mailer"; + +/** + * 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 | ContentObject; + +interface ContentObject { + content?: NodemailerContent | undefined; + path?: unknown; +} /** * Checks if content type is rather string or buffer, returns content. @@ -9,7 +18,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 526d85f..3245d76 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -1,42 +1,83 @@ -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); + } + + if (value instanceof Date) { + return value.toUTCString(); + } + + if (Array.isArray(value)) { + return adaptHeaderValue(value[0]); // TODO: support multiple value per header + } + + if (typeof value === "object") { + if ("value" in value) { + return adaptHeaderValue(value.value); + } + + if ("address" in value) { + const { name, address } = value as Mail.Address; - return headerKeys.reduce((acc, key) => { - const value = nodemailerHeaders[key]; + return name ? `${name} <${address ?? ""}>` : address; + } + } - if (typeof value === "string") { - acc[key] = value; + 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. + */ +export default function adaptHeaders( + nodemailerHeaders: Headers +): MailtrapHeaders { + const entries: Array<[string, unknown]> = (() => { + if (Array.isArray(nodemailerHeaders)) { + return nodemailerHeaders.map(({ key, value }) => [key, value]); } - if (Array.isArray(value)) { - [acc[key]] = value; // TODO: support multiple value per header + // Single `{ key, value }` header, handled the same way nodemailer does in `setHeader`. + const { key, value } = nodemailerHeaders as { + key?: unknown; + value?: unknown; + }; - return acc; + if (typeof key === "string" && "value" in nodemailerHeaders) { + return [[key, value]]; } - acc[key] = value.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 547001e..0f20c81 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) { 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 458122e..37f5fb4 100644 --- a/src/adapters/recipients.ts +++ b/src/adapters/recipients.ts @@ -1,10 +1,43 @@ -import { Address as NodemailerAddress } from "nodemailer/lib/mailer"; - import { Address } from "../types/mailtrap"; +/** + * Address object as nodemailer accepts it. Declared structurally so it matches both the types bundled with nodemailer >= 10 and `@types/nodemailer`. + */ +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[]; + +/** + * 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 +46,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 c01468a..7a391f6 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -1,4 +1,4 @@ -import NodemailerMail = require("nodemailer/lib/mailer"); +import NodemailerMail from "nodemailer/lib/mailer"; import { Transport, Transporter } from "nodemailer"; import { diff --git a/yarn.lock b/yarn.lock index 4a718fc..89f5926 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.0.1" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-9.0.1.tgz#871c04d8423fc0c790289f4a8f6f71f1b3563e8c" - integrity sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw== +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" From 4ea431862d5b20526e3e8ef0f25442b65280a214 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:00:35 +0200 Subject: [PATCH 03/17] Document nodemailer 10 support in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 39750c1..ad0d92e 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 From a27b91ff08c4d7a648cfdc21d67dabaf71c1e8a7 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:12:58 +0200 Subject: [PATCH 04/17] Move nodemailer input types to transport types --- src/adapters/content.ts | 10 +--------- src/adapters/recipients.ts | 18 +----------------- src/types/transport.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src/adapters/content.ts b/src/adapters/content.ts index 6aa2fd4..ee900d7 100644 --- a/src/adapters/content.ts +++ b/src/adapters/content.ts @@ -1,15 +1,7 @@ import { readFileSync } from "node:fs"; import { Readable } from "node:stream"; -/** - * 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 | ContentObject; - -interface ContentObject { - content?: NodemailerContent | undefined; - path?: unknown; -} +import { NodemailerContent } from "../types/transport"; /** * Checks if content type is rather string or buffer, returns content. diff --git a/src/adapters/recipients.ts b/src/adapters/recipients.ts index 37f5fb4..a1acc8e 100644 --- a/src/adapters/recipients.ts +++ b/src/adapters/recipients.ts @@ -1,21 +1,5 @@ import { Address } from "../types/mailtrap"; - -/** - * Address object as nodemailer accepts it. Declared structurally so it matches both the types bundled with nodemailer >= 10 and `@types/nodemailer`. - */ -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[]; +import { NodemailerAddress, NodemailerRecipients } from "../types/transport"; /** * Flattens nodemailer recipients into a plain list of string or address objects. diff --git a/src/types/transport.ts b/src/types/transport.ts index 7a391f6..1b3aca2 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -1,5 +1,6 @@ import NodemailerMail from "nodemailer/lib/mailer"; +import { Readable } from "node:stream"; import { Transport, Transporter } from "nodemailer"; import { SendResponse, @@ -8,6 +9,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?: unknown; +}; + +/** + * 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; From 8e7b19168047ecea9a7d20dbc83cb0f7053d4d3e Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:35:11 +0200 Subject: [PATCH 05/17] Reject empty addresses in from and headers --- src/__tests__/adapters/headers.test.ts | 1 + src/__tests__/adapters/mail.test.ts | 11 +++++++++++ src/adapters/headers.ts | 6 +++++- src/adapters/mail.ts | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/__tests__/adapters/headers.test.ts b/src/__tests__/adapters/headers.test.ts index 01c9d6e..2369a75 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -105,6 +105,7 @@ describe("adapters/headers: ", () => { mockNull: null, mockUndefined: undefined, mockEmptyArray: [], + mockAddressWithoutAddress: { name: "mock-name" }, mockKey: "mock-value", }; diff --git a/src/__tests__/adapters/mail.test.ts b/src/__tests__/adapters/mail.test.ts index 05660a4..5bf2331 100644 --- a/src/__tests__/adapters/mail.test.ts +++ b/src/__tests__/adapters/mail.test.ts @@ -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", diff --git a/src/adapters/headers.ts b/src/adapters/headers.ts index 3245d76..e5818f5 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -39,7 +39,11 @@ function adaptHeaderValue(value: unknown): string | undefined { if ("address" in value) { const { name, address } = value as Mail.Address; - return name ? `${name} <${address ?? ""}>` : address; + if (!address) { + return undefined; + } + + return name ? `${name} <${address}>` : address; } } diff --git a/src/adapters/mail.ts b/src/adapters/mail.ts index 0f20c81..1256b21 100644 --- a/src/adapters/mail.ts +++ b/src/adapters/mail.ts @@ -20,7 +20,7 @@ const { SUBJECT_REQUIRED, FROM_REQUIRED } = ERRORS; export default function adaptMail(data: MailtrapMailOptions): Mail | SendError { const from = adaptFirstRecipient(data.from); - if (!from) { + if (!from?.email) { return { success: false, errors: [FROM_REQUIRED] }; } From d3feae79d67de7a1320f81690c441cb3468207a4 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:16:02 +0200 Subject: [PATCH 06/17] Trim header addresses and narrow content path type --- src/__tests__/adapters/headers.test.ts | 1 + src/adapters/headers.ts | 5 +++-- src/types/transport.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/__tests__/adapters/headers.test.ts b/src/__tests__/adapters/headers.test.ts index 2369a75..7ddfea9 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -106,6 +106,7 @@ describe("adapters/headers: ", () => { mockUndefined: undefined, mockEmptyArray: [], mockAddressWithoutAddress: { name: "mock-name" }, + mockAddressWithBlankAddress: { name: "mock-name", address: " " }, mockKey: "mock-value", }; diff --git a/src/adapters/headers.ts b/src/adapters/headers.ts index e5818f5..14bf6be 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -38,12 +38,13 @@ function adaptHeaderValue(value: unknown): string | undefined { if ("address" in value) { const { name, address } = value as Mail.Address; + const email = address?.trim(); - if (!address) { + if (!email) { return undefined; } - return name ? `${name} <${address}>` : address; + return name ? `${name} <${email}>` : email; } } diff --git a/src/types/transport.ts b/src/types/transport.ts index 1b3aca2..db9cc2d 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -1,6 +1,7 @@ import NodemailerMail from "nodemailer/lib/mailer"; import { Readable } from "node:stream"; +import { Url } from "node:url"; import { Transport, Transporter } from "nodemailer"; import { SendResponse, @@ -31,7 +32,7 @@ export type NodemailerRecipients = */ type NodemailerContentObject = { content?: NodemailerContent | undefined; - path?: unknown; + path?: string | false | Url | undefined; }; /** From 513692e96edc60b2a138e75f3549adcc576cab2f Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:40:54 +0200 Subject: [PATCH 07/17] Reject empty adapted attachment content --- src/__tests__/adapters/attachment.test.ts | 15 +++++++++++++++ src/adapters/attachement.ts | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/__tests__/adapters/attachment.test.ts b/src/__tests__/adapters/attachment.test.ts index cd7c4f5..011f964 100644 --- a/src/__tests__/adapters/attachment.test.ts +++ b/src/__tests__/adapters/attachment.test.ts @@ -28,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", diff --git a/src/adapters/attachement.ts b/src/adapters/attachement.ts index 4257589..e12f159 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -28,9 +28,15 @@ export default function adaptAttachment( throw new Error(CONTENT_REQUIRED); } + const content = adaptContent(nodemailerAttachment.content); + + if (!content) { + throw new Error(CONTENT_REQUIRED); + } + return { filename: nodemailerAttachment.filename, - content: adaptContent(nodemailerAttachment.content), + content, disposition: nodemailerAttachment.contentDisposition, content_id: nodemailerAttachment.cid, type: nodemailerAttachment.contentType, From 0d424793599d4178923112020fe14164078cbe36 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:11:37 +0200 Subject: [PATCH 08/17] Import nodemailer types from the package root --- src/adapters/attachement.ts | 5 +---- src/adapters/headers.ts | 9 +++------ src/types/transport.ts | 22 ++++++++++++++++------ 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/adapters/attachement.ts b/src/adapters/attachement.ts index e12f159..2feaeab 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -1,12 +1,9 @@ -import Mail from "nodemailer/lib/mailer"; - import adaptContent from "./content"; import CONFIG from "../config"; import { Attachment } from "../types/mailtrap"; - -type NodemailerAttachment = Mail.Attachment; +import { NodemailerAttachment } from "../types/transport"; const { ERRORS } = CONFIG; const { FILENAME_REQUIRED, CONTENT_REQUIRED } = ERRORS; diff --git a/src/adapters/headers.ts b/src/adapters/headers.ts index 14bf6be..1b22f62 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -1,8 +1,5 @@ -import Mail from "nodemailer/lib/mailer"; - import { MailtrapHeaders } from "../types/mailtrap"; - -type Headers = Mail.Headers; +import { NodemailerAddress, NodemailerHeaders } from "../types/transport"; /** * Converts a single nodemailer header value to a string. @@ -37,7 +34,7 @@ function adaptHeaderValue(value: unknown): string | undefined { } if ("address" in value) { - const { name, address } = value as Mail.Address; + const { name, address } = value as NodemailerAddress; const email = address?.trim(); if (!email) { @@ -57,7 +54,7 @@ function adaptHeaderValue(value: unknown): string | undefined { * Otherwise iterates over the object keys, converting each value to string. */ export default function adaptHeaders( - nodemailerHeaders: Headers + nodemailerHeaders: NodemailerHeaders ): MailtrapHeaders { const entries: Array<[string, unknown]> = (() => { if (Array.isArray(nodemailerHeaders)) { diff --git a/src/types/transport.ts b/src/types/transport.ts index db9cc2d..40d8bed 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -1,8 +1,6 @@ -import NodemailerMail from "nodemailer/lib/mailer"; - import { Readable } from "node:stream"; import { Url } from "node:url"; -import { Transport, Transporter } from "nodemailer"; +import { SendMailOptions, Transport, Transporter } from "nodemailer"; import { SendResponse, SendError, @@ -44,6 +42,18 @@ export type NodemailerContent = | 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; @@ -52,7 +62,7 @@ type AdditionalFields = { }; export type NormalizeCallbackData = - | (NodemailerMail.Options & AdditionalFields) + | (SendMailOptions & AdditionalFields) | undefined; export type NormalizeCallbackError = Error | null | undefined; @@ -62,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; From 8d7c26ac5f4f89c2f382aa91b83e62e7efdd169b Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:20:12 +0200 Subject: [PATCH 09/17] Pass adapter errors to the send callback --- src/__tests__/lib/normalizer.test.ts | 34 +++++++++++++++++++++++++++- src/lib/normalizer.ts | 21 +++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/__tests__/lib/normalizer.test.ts b/src/__tests__/lib/normalizer.test.ts index d9dd9e3..f42477c 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/lib/normalizer.ts b/src/lib/normalizer.ts index 94a984b..f316db8 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), { From cd095dd0ce0bc4aae2d9e879bffc7558e51bc381 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:26:37 +0200 Subject: [PATCH 10/17] Reject attachment content nodemailer did not resolve --- src/__tests__/adapters/attachment.test.ts | 70 +++++------------------ src/adapters/attachement.ts | 18 +++--- 2 files changed, 24 insertions(+), 64 deletions(-) diff --git a/src/__tests__/adapters/attachment.test.ts b/src/__tests__/adapters/attachment.test.ts index 011f964..aca571c 100644 --- a/src/__tests__/adapters/attachment.test.ts +++ b/src/__tests__/adapters/attachment.test.ts @@ -1,5 +1,4 @@ import { Readable } from "stream"; -import { readFileSync } from "node:fs"; import adaptAttachment from "../../adapters/attachement"; @@ -28,21 +27,6 @@ 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", @@ -61,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.", () => { @@ -107,23 +85,5 @@ 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/adapters/attachement.ts b/src/adapters/attachement.ts index 2feaeab..6d0efeb 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -1,5 +1,3 @@ -import adaptContent from "./content"; - import CONFIG from "../config"; import { Attachment } from "../types/mailtrap"; @@ -11,7 +9,7 @@ const { FILENAME_REQUIRED, CONTENT_REQUIRED } = ERRORS; /** * Adopts Nodemailer attachment to Mailtrap. * Checks if filename or content are missing, then rejects with error. - * Otherwise adapts the 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( @@ -21,13 +19,15 @@ export default function adaptAttachment( throw new Error(FILENAME_REQUIRED); } - if (!nodemailerAttachment.content) { - throw new Error(CONTENT_REQUIRED); - } - - const content = adaptContent(nodemailerAttachment.content); + /** + * 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`. + */ + const { content } = nodemailerAttachment; - if (!content) { + if ( + !content || + (typeof content !== "string" && !(content instanceof Buffer)) + ) { throw new Error(CONTENT_REQUIRED); } From 33ce2028558a07819609d3a8b70bc77b2d2c5084 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:36:31 +0200 Subject: [PATCH 11/17] Match nodemailer when adapting header values --- src/__tests__/adapters/headers.test.ts | 54 ++++++++++++++++++++++---- src/adapters/headers.ts | 41 ++++++++++++++----- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/__tests__/adapters/headers.test.ts b/src/__tests__/adapters/headers.test.ts index 7ddfea9..496147b 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -77,22 +77,19 @@ describe("adapters/headers: ", () => { }); 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" }, + mockAddress: { name: "mockname", address: "mock@mail.com" }, + mockAddressWithoutName: { address: "mock@mail.com" }, mockNested: [[{ prepared: true, value: 7 }]], }; const expectedResult = { mockNumber: "42", mockBoolean: "true", - mockDate: date.toUTCString(), - mockAddress: "mock-name ", - mockAddressWithoutName: "mock-email", + mockAddress: "mockname ", + mockAddressWithoutName: "mock@mail.com", mockNested: "7", }; const result = adaptHeaders(headers); @@ -100,11 +97,54 @@ describe("adapters/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", diff --git a/src/adapters/headers.ts b/src/adapters/headers.ts index 1b22f62..0872e88 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -1,29 +1,46 @@ import { MailtrapHeaders } from "../types/mailtrap"; import { NodemailerAddress, NodemailerHeaders } from "../types/transport"; +/** + * 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, dates, address objects, `{ prepared, value }` objects and arrays of these. Returns `undefined` - * for empty values so the header gets skipped. + * 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. * @todo support multiple value per header */ function adaptHeaderValue(value: unknown): string | undefined { - if (value === null || value === undefined) { + if (!value) { return undefined; } if (typeof value === "string") { - return value; + return adaptLineBreaks(value).trim() || undefined; } if (typeof value === "number" || typeof value === "boolean") { return String(value); } - if (value instanceof Date) { - return value.toUTCString(); - } - if (Array.isArray(value)) { return adaptHeaderValue(value[0]); // TODO: support multiple value per header } @@ -35,13 +52,17 @@ function adaptHeaderValue(value: unknown): string | undefined { if ("address" in value) { const { name, address } = value as NodemailerAddress; - const email = address?.trim(); + const email = adaptHeaderValue(address); if (!email) { return undefined; } - return name ? `${name} <${email}>` : email; + const displayName = adaptHeaderValue(name); + + return displayName + ? `${adaptAddressName(displayName)} <${email}>` + : email; } } From d5fe39708686c660adf11ee3a0862c7e98e4c133 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:46:47 +0200 Subject: [PATCH 12/17] Read key/value headers the way nodemailer does --- src/__tests__/adapters/headers.test.ts | 26 ++++++++++++++++++++++++++ src/adapters/headers.ts | 15 ++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/__tests__/adapters/headers.test.ts b/src/__tests__/adapters/headers.test.ts index 496147b..c5e389f 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -76,6 +76,32 @@ describe("adapters/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("converts non-string header values to strings.", () => { const headers = { mockNumber: 42, diff --git a/src/adapters/headers.ts b/src/adapters/headers.ts index 0872e88..2e49198 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -79,17 +79,22 @@ export default function adaptHeaders( ): MailtrapHeaders { const entries: Array<[string, unknown]> = (() => { if (Array.isArray(nodemailerHeaders)) { - return nodemailerHeaders.map(({ key, value }) => [key, value]); + return nodemailerHeaders.map(({ key, value }) => [ + key ? String(key) : "", + value, + ]); } - // Single `{ key, value }` header, handled the same way nodemailer does in `setHeader`. + /** + * 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 (typeof key === "string" && "value" in nodemailerHeaders) { - return [[key, value]]; + if (key && value) { + return [[String(key), value]]; } return Object.entries(nodemailerHeaders); @@ -98,7 +103,7 @@ export default function adaptHeaders( return entries.reduce((acc, [key, value]) => { const adaptedValue = adaptHeaderValue(value); - if (adaptedValue !== undefined) { + if (key.trim() && adaptedValue !== undefined) { acc[key] = adaptedValue; } From ab49ea5c3623e87ccf53fe4ffe7fe0671156507e Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:50:31 +0200 Subject: [PATCH 13/17] Keep the address of a group that carries one --- src/__tests__/adapters/recipients.test.ts | 15 +++++++++++++++ src/adapters/recipients.ts | 8 ++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/__tests__/adapters/recipients.test.ts b/src/__tests__/adapters/recipients.test.ts index 3a18fe1..5827896 100644 --- a/src/__tests__/adapters/recipients.test.ts +++ b/src/__tests__/adapters/recipients.test.ts @@ -115,6 +115,21 @@ describe("adapters/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("expands address groups into their members.", () => { const recipients = { name: "mock-group", diff --git a/src/adapters/recipients.ts b/src/adapters/recipients.ts index a1acc8e..dcb3489 100644 --- a/src/adapters/recipients.ts +++ b/src/adapters/recipients.ts @@ -3,7 +3,7 @@ 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. + * An address group (`{ name, group: [...] }`) is expanded into its members, unless it carries an address of its own, which nodemailer keeps instead. */ function flattenRecipients( recipients: NodemailerRecipients @@ -12,7 +12,11 @@ function flattenRecipients( return recipients.flatMap(flattenRecipients); } - if (typeof recipients !== "string" && recipients.group) { + if ( + typeof recipients !== "string" && + !recipients.address && + recipients.group + ) { return flattenRecipients(recipients.group); } From bab9b8963cbf5baf841cf6cdfe278c884896ab28 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:53:33 +0200 Subject: [PATCH 14/17] Skip recipients without an address --- src/__tests__/adapters/recipients.test.ts | 33 ++++++++++++++++------- src/adapters/recipients.ts | 24 ++++++++--------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/__tests__/adapters/recipients.test.ts b/src/__tests__/adapters/recipients.test.ts index 5827896..9e9cd9a 100644 --- a/src/__tests__/adapters/recipients.test.ts +++ b/src/__tests__/adapters/recipients.test.ts @@ -32,16 +32,14 @@ describe("adapters/recipients: ", () => { }); 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); + const expectedResult = { email: "mock-email" }; + + expect(adaptSingleRecipient({ address: "mock-email" })).toEqual( + expectedResult + ); + expect( + adaptSingleRecipient({ name: " ", address: "mock-email" }) + ).toEqual(expectedResult); }); }); @@ -130,6 +128,14 @@ describe("adapters/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("expands address groups into their members.", () => { const recipients = { name: "mock-group", @@ -204,6 +210,13 @@ describe("adapters/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"]]; diff --git a/src/adapters/recipients.ts b/src/adapters/recipients.ts index dcb3489..8060995 100644 --- a/src/adapters/recipients.ts +++ b/src/adapters/recipients.ts @@ -31,18 +31,21 @@ export function adaptSingleRecipient( recipient: string | NodemailerAddress ): Address { if (typeof recipient === "string") { - return { email: recipient }; + return { email: recipient.trim() }; } + const name = recipient.name?.trim(); + return { - ...(recipient.name !== undefined && { name: recipient.name }), - email: recipient.address ?? "", + ...(name && { name }), + email: recipient.address?.trim() ?? "", }; } /** * If there is no recipient, then returns empty array. * 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: NodemailerRecipients | undefined @@ -51,22 +54,19 @@ export default function adaptRecipients( return []; } - return flattenRecipients(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. - * Otherwise, if several recipients are given as nodemailer allows, we pick the first one. + * 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 adaptFirstRecipient( recipients: NodemailerRecipients | undefined ): Address | undefined { - if (!recipients) { - return undefined; - } - - const [first] = flattenRecipients(recipients); + const [first] = adaptRecipients(recipients); - return first === undefined ? undefined : adaptSingleRecipient(first); + return first; } From d1176dbb075f673479871160fd22b6ebcea8c5d4 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:01:11 +0200 Subject: [PATCH 15/17] Stop following nested values past a depth limit --- src/__tests__/adapters/headers.test.ts | 7 +++++++ src/__tests__/adapters/recipients.test.ts | 12 ++++++++++++ src/adapters/headers.ts | 19 ++++++++++++------- src/adapters/recipients.ts | 20 ++++++++++++++++---- src/config/index.ts | 4 ++++ 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/__tests__/adapters/headers.test.ts b/src/__tests__/adapters/headers.test.ts index c5e389f..1eb25c5 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -102,6 +102,13 @@ describe("adapters/headers: ", () => { ).toEqual({ "X-One": "mock-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, diff --git a/src/__tests__/adapters/recipients.test.ts b/src/__tests__/adapters/recipients.test.ts index 9e9cd9a..fcd645b 100644 --- a/src/__tests__/adapters/recipients.test.ts +++ b/src/__tests__/adapters/recipients.test.ts @@ -1,3 +1,5 @@ +import { NodemailerAddress } from "../../types/transport"; + import adaptRecipients, { adaptSingleRecipient, adaptFirstRecipient, @@ -136,6 +138,16 @@ describe("adapters/recipients: ", () => { 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", diff --git a/src/adapters/headers.ts b/src/adapters/headers.ts index 2e49198..ac98397 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -1,6 +1,11 @@ +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; + /** * Display name that nodemailer leaves as it is in its own address headers. */ @@ -25,11 +30,11 @@ function adaptAddressName(name: string): string { /** * 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. + * 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 */ -function adaptHeaderValue(value: unknown): string | undefined { - if (!value) { +function adaptHeaderValue(value: unknown, depth = 0): string | undefined { + if (!value || depth > MAX_NESTING_DEPTH) { return undefined; } @@ -42,23 +47,23 @@ function adaptHeaderValue(value: unknown): string | undefined { } if (Array.isArray(value)) { - return adaptHeaderValue(value[0]); // TODO: support multiple value per header + return adaptHeaderValue(value[0], depth + 1); // TODO: support multiple value per header } if (typeof value === "object") { if ("value" in value) { - return adaptHeaderValue(value.value); + return adaptHeaderValue(value.value, depth + 1); } if ("address" in value) { const { name, address } = value as NodemailerAddress; - const email = adaptHeaderValue(address); + const email = adaptHeaderValue(address, depth + 1); if (!email) { return undefined; } - const displayName = adaptHeaderValue(name); + const displayName = adaptHeaderValue(name, depth + 1); return displayName ? `${adaptAddressName(displayName)} <${email}>` diff --git a/src/adapters/recipients.ts b/src/adapters/recipients.ts index 8060995..b672cb0 100644 --- a/src/adapters/recipients.ts +++ b/src/adapters/recipients.ts @@ -1,15 +1,27 @@ +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. + * 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 + recipients: NodemailerRecipients, + depth = 0 ): Array { + if (depth > MAX_NESTING_DEPTH) { + return []; + } + if (Array.isArray(recipients)) { - return recipients.flatMap(flattenRecipients); + return recipients.flatMap((recipient) => + flattenRecipients(recipient, depth + 1) + ); } if ( @@ -17,7 +29,7 @@ function flattenRecipients( !recipients.address && recipients.group ) { - return flattenRecipients(recipients.group); + return flattenRecipients(recipients.group, depth + 1); } return [recipients]; diff --git a/src/config/index.ts b/src/config/index.ts index 6f5d0e1..6c94d3d 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, }, }; From 1a803cc38a03b39ba41ac26aeccc3a8d3829fa73 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:01:46 +0200 Subject: [PATCH 16/17] Augment nodemailer types for ESM consumers --- package.json | 2 +- src/lib/transport-esm.mts | 12 ++++++++++++ src/lib/transport.ts | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/lib/transport-esm.mts diff --git a/package.json b/package.json index 7d86d5c..24e99f7 100644 --- a/package.json +++ b/package.json @@ -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/lib/transport-esm.mts b/src/lib/transport-esm.mts new file mode 100644 index 0000000..8f1eb8e --- /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 f9ac4bf..750fb98 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); From d4d576fdad242eedd983b869eec687d2d1502e6b Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:03:17 +0200 Subject: [PATCH 17/17] Align content and header names with nodemailer --- src/__tests__/adapters/content.test.ts | 50 +++++++++++--------------- src/__tests__/adapters/headers.test.ts | 9 +++++ src/adapters/attachement.ts | 18 +++------- src/adapters/content.ts | 31 +++++++--------- src/adapters/headers.ts | 6 ++-- 5 files changed, 51 insertions(+), 63 deletions(-) diff --git a/src/__tests__/adapters/content.test.ts b/src/__tests__/adapters/content.test.ts index 2736c3f..fafc44b 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 1eb25c5..1ad187f 100644 --- a/src/__tests__/adapters/headers.test.ts +++ b/src/__tests__/adapters/headers.test.ts @@ -102,6 +102,15 @@ describe("adapters/headers: ", () => { ).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; diff --git a/src/adapters/attachement.ts b/src/adapters/attachement.ts index 6d0efeb..d7ccfc6 100644 --- a/src/adapters/attachement.ts +++ b/src/adapters/attachement.ts @@ -1,10 +1,12 @@ +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. @@ -19,21 +21,9 @@ export default function adaptAttachment( throw new Error(FILENAME_REQUIRED); } - /** - * 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`. - */ - const { content } = nodemailerAttachment; - - if ( - !content || - (typeof content !== "string" && !(content instanceof Buffer)) - ) { - throw new Error(CONTENT_REQUIRED); - } - 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 ee900d7..2434c77 100644 --- a/src/adapters/content.ts +++ b/src/adapters/content.ts @@ -1,28 +1,23 @@ -import { readFileSync } from "node:fs"; -import { Readable } from "node:stream"; +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: NodemailerContent + 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 ac98397..ca5d70a 100644 --- a/src/adapters/headers.ts +++ b/src/adapters/headers.ts @@ -78,6 +78,7 @@ function adaptHeaderValue(value: unknown, depth = 0): string | 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. + * Header names are trimmed as nodemailer does, and a name left blank drops the header. */ export default function adaptHeaders( nodemailerHeaders: NodemailerHeaders @@ -106,10 +107,11 @@ export default function adaptHeaders( })(); return entries.reduce((acc, [key, value]) => { + const name = key.trim(); const adaptedValue = adaptHeaderValue(value); - if (key.trim() && adaptedValue !== undefined) { - acc[key] = adaptedValue; + if (name && adaptedValue !== undefined) { + acc[name] = adaptedValue; } return acc;