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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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,