node_modules: update (#297)

Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
Dawid Dziurla
2026-06-15 07:32:52 +02:00
committed by GitHub
parent d86d472c50
commit 1369c5b90d
27 changed files with 662 additions and 159 deletions
+6 -6
View File
@@ -58,9 +58,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -94,9 +94,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.0.tgz",
"integrity": "sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
+1 -1
View File
@@ -155,7 +155,7 @@ function expand_(str, max, isTop) {
}
const pad = n.some(isPadded);
N = [];
for (let i = x; test(i, y); i += incr) {
for (let i = x; test(i, y) && N.length < max; i += incr) {
let c;
if (isAlphaSequence) {
c = String.fromCharCode(i);
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -151,7 +151,7 @@ function expand_(str, max, isTop) {
}
const pad = n.some(isPadded);
N = [];
for (let i = x; test(i, y); i += incr) {
for (let i = x; test(i, y) && N.length < max; i += incr) {
let c;
if (isAlphaSequence) {
c = String.fromCharCode(i);
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "brace-expansion",
"description": "Brace expansion as known from sh/bash",
"version": "5.0.5",
"version": "5.0.6",
"files": [
"dist"
],
+47
View File
@@ -1,5 +1,52 @@
# CHANGELOG
## [9.0.0](https://github.com/nodemailer/nodemailer/compare/v8.0.11...v9.0.0) (2026-06-14)
### ⚠ BREAKING CHANGES
* HTTPS requests made while fetching remote content (attachment href/path URLs, OAuth2 token endpoints, HTTP/HTTPS proxy CONNECT) now validate the server's TLS certificate by default. Requests to hosts with self-signed, expired, or hostname-mismatched certificates that previously succeeded will now fail. Opt back out per request with tls.rejectUnauthorized=false (transport options, or a per-attachment `tls` option).
### Bug Fixes
* replace deprecated url.parse with a WHATWG URL wrapper ([0c080fb](https://github.com/nodemailer/nodemailer/commit/0c080fbf3278926f013a5c2ad06f5f6f0e18f5ed))
* validate TLS certificates by default when fetching remote content ([6a947ac](https://github.com/nodemailer/nodemailer/commit/6a947ac7114a16da1e6a50d9a6f4e17026ce145d))
## [8.0.11](https://github.com/nodemailer/nodemailer/compare/v8.0.10...v8.0.11) (2026-06-10)
### Bug Fixes
* apply the transport-level newline option in stream and sendmail transports ([cb4f904](https://github.com/nodemailer/nodemailer/commit/cb4f904a53d2c2feeaf327203c92378d46304398))
* include icalEvent path/href content in the application/ics attachment ([b801c48](https://github.com/nodemailer/nodemailer/commit/b801c48fab8e9b71bc7e0ea1fb32ce6b34675b15))
* parse Ethereal response props without polynomial regex backtracking ([067aebe](https://github.com/nodemailer/nodemailer/commit/067aebec83b8cbe7682905e89b30ab19d260b503))
* resolve oauth2_provision_cb at send time for non-pooled SMTP transports ([203c8ec](https://github.com/nodemailer/nodemailer/commit/203c8ecf97594ac2e69919b0f3ba966c0f86750e))
* return the promise from every resolveContent branch ([07ffe8c](https://github.com/nodemailer/nodemailer/commit/07ffe8cfd97f0486b8c7b541f398922ddab47882))
* strip the url scheme from List-ID header values ([77e5885](https://github.com/nodemailer/nodemailer/commit/77e5885cfa0c6723ea7749c1ee74b1c11aeb78bd))
* tag AWS SES transport errors with the ESES code ([efa647a](https://github.com/nodemailer/nodemailer/commit/efa647a125dd698413a7cf6813b8e36881a06f91))
## [8.0.10](https://github.com/nodemailer/nodemailer/compare/v8.0.9...v8.0.10) (2026-05-29)
### Bug Fixes
* fall back to lower-severity handler when custom logger lacks a level method ([6d849df](https://github.com/nodemailer/nodemailer/commit/6d849df59a56184b48844ed10b5fb7b8e9f74634))
## [8.0.9](https://github.com/nodemailer/nodemailer/compare/v8.0.8...v8.0.9) (2026-05-26)
### Bug Fixes
* two pending security advisories (jsonTransport access bypass, List-* CRLF injection) ([#1820](https://github.com/nodemailer/nodemailer/issues/1820)) ([5f69497](https://github.com/nodemailer/nodemailer/commit/5f694977da2e0e13dc947037566e8e689a01217e))
## [8.0.8](https://github.com/nodemailer/nodemailer/compare/v8.0.7...v8.0.8) (2026-05-23)
### Bug Fixes
* enforce strict TLS for OAuth2 and Ethereal credential requests ([#1818](https://github.com/nodemailer/nodemailer/issues/1818)) ([833d6e5](https://github.com/nodemailer/nodemailer/commit/833d6e58c8b717962bbb1b23e16923cd267c3bc9))
* four listener/stream leaks in SMTP transport, connection, pool ([#1817](https://github.com/nodemailer/nodemailer/issues/1817)) ([850bb91](https://github.com/nodemailer/nodemailer/commit/850bb91bff7707ed498c1424df01c4e5b30ea14b))
## [8.0.7](https://github.com/nodemailer/nodemailer/compare/v8.0.6...v8.0.7) (2026-04-27)
+1
View File
@@ -50,6 +50,7 @@ Conventional Commit prefixes used in this repo: `fix:`, `feat:`, `chore:`, `docs
## Security
This is a widely-deployed library — security-sensitive changes get extra scrutiny:
- SMTP command injection: any user-controllable value that flows into a written SMTP command (envelope addresses, sizes, the `name`/EHLO option, headers) must be CRLF-stripped or rejected at the boundary. Sanitize at the assignment, not at every call site.
- Server reply parsing in `lib/smtp-connection/index.js` uses a `'binary'` byte-container intermediate to reassemble multi-byte UTF-8 across socket chunks; the actual decode happens at line boundaries via `decodeServerResponse`. Don't change the chunk-buffering encoding without understanding why.
- Reference the GHSA ID in commit messages for advisories.
+65
View File
@@ -0,0 +1,65 @@
# Security Policy
Nodemailer is a widely deployed, zero-dependency e-mail library. We take security
reports seriously and aim to respond quickly.
## Supported Versions
Security fixes are released only against the latest major version. We do not
backport patches to older majors — upgrading to the current release line is the
supported way to receive security updates.
| Version | Supported |
| ------- | ------------------ |
| 8.x | :white_check_mark: |
| < 8.0 | :x: |
If you are on an older major, please upgrade. See the migration notes at
<https://nodemailer.com/> before updating.
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues,
pull requests, or discussions.**
Report privately through one of the following channels:
1. **GitHub Security Advisories (preferred).** Open a private report at
<https://github.com/nodemailer/nodemailer/security/advisories/new>. This keeps
the discussion private until a fix is published and lets us credit you.
2. **Email.** Send details to **andris@reinman.eu** (the contact listed in
[`SECURITY.txt`](SECURITY.txt)). Encrypt sensitive details if possible.
When reporting, please include as much of the following as you can:
- The affected version(s) and environment (Node.js version, OS).
- The component involved (e.g. SMTP connection, address parsing, MIME/header
generation, DKIM).
- A clear description of the issue and its impact (e.g. header/SMTP command
injection, information disclosure, DoS).
- A minimal proof of concept or reproduction steps.
- Any suggested remediation, if you have one.
Nodemailer is maintained by a single person, so there is no guaranteed response
time — sometimes reports are handled within hours, sometimes they take longer.
Accepted issues are fixed in a new release and coordinated through a GitHub
Security Advisory, and reporters who wish to be named are credited.
## CVEs
We track and disclose vulnerabilities through GitHub Security Advisories. We do
not request or manage CVE identifiers ourselves. If you need a CVE assigned for a
reported issue, please request one yourself — for example, through GitHub's own
CVE request flow on the published advisory, or another CNA.
## Scope
In scope: the `nodemailer` package source in this repository — message and MIME
generation, SMTP/LMTP client behaviour, address parsing, header handling, DKIM
signing, and the bundled transports.
Out of scope: vulnerabilities in your own application code, misconfiguration of
your mail server or credentials, social-engineering reports, and issues in
third-party services Nodemailer connects to.
Thank you for helping keep Nodemailer and its users safe.
+1 -1
View File
@@ -2,7 +2,7 @@
// module to handle cookies
const urllib = require('url');
const urllib = require('../shared/url');
const SESSION_TIMEOUT = 1800; // 30 min
+26 -3
View File
@@ -2,7 +2,7 @@
const http = require('http');
const https = require('https');
const urllib = require('url');
const urllib = require('../shared/url');
const zlib = require('zlib');
const { PassThrough } = require('stream');
const Cookies = require('./cookies');
@@ -123,7 +123,10 @@ function nmfetch(url, options) {
path: parsed.path,
port: parsed.port ? parsed.port : parsed.protocol === 'https:' ? 443 : 80,
headers,
rejectUnauthorized: false,
// Validate TLS certificates by default. Callers that genuinely need to
// reach a self-signed/internal host opt out explicitly with
// options.tls = { rejectUnauthorized: false }.
rejectUnauthorized: true,
agent: false
};
@@ -212,7 +215,27 @@ function nmfetch(url, options) {
// redirect does not include POST body
options.method = 'GET';
options.body = false;
return nmfetch(urllib.resolve(url, res.headers.location), options);
const redirectUrl = urllib.resolve(url, res.headers.location);
const redirectParsed = urllib.parse(redirectUrl);
// Do not forward credentials when the redirect leaves the original
// security context: a different host, or a downgrade from https to
// http (which would otherwise put them on the wire in cleartext).
// Strip sensitive request headers so an attacker who controls the
// redirect target cannot harvest them.
const crossHost = redirectParsed.hostname !== parsed.hostname;
const downgrade = parsed.protocol === 'https:' && redirectParsed.protocol === 'http:';
if (options.headers && (crossHost || downgrade)) {
const sensitive = ['authorization', 'cookie', 'proxy-authorization'];
Object.keys(options.headers).forEach(key => {
if (sensitive.includes(key.toLowerCase())) {
delete options.headers[key];
}
});
}
return nmfetch(redirectUrl, options);
}
fetchRes.statusCode = res.statusCode;
+67 -33
View File
@@ -83,7 +83,7 @@ class MailComposer {
* @returns {Object} An object of arrays (`related` and `attached`)
*/
getAttachments(findRelated) {
let icalEvent, eventObject;
let eventObject;
const attachments = [].concat(this.mail.attachments || []).map((attachment, i) => {
if (/^data:/i.test(attachment.path || attachment.href)) {
attachment = this._processDataUrl(attachment);
@@ -142,7 +142,8 @@ class MailComposer {
} else if (attachment.href) {
data.content = {
href: attachment.href,
httpHeaders: attachment.httpHeaders
httpHeaders: attachment.httpHeaders,
tls: attachment.tls
};
} else {
data.content = attachment.content || '';
@@ -160,18 +161,7 @@ class MailComposer {
});
if (this.mail.icalEvent) {
if (
typeof this.mail.icalEvent === 'object' &&
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
) {
icalEvent = this.mail.icalEvent;
} else {
icalEvent = {
content: this.mail.icalEvent
};
}
eventObject = Object.assign({}, icalEvent);
eventObject = Object.assign({}, this._getIcalEvent());
eventObject.contentType = 'application/ics';
if (!eventObject.headers) {
@@ -195,6 +185,67 @@ class MailComposer {
};
}
/**
* Returns the icalEvent value with `path`/`href`/data uri input normalized into
* a `content` entry, the same way as for regular attachments. The same event is
* included twice (as a text/calendar alternative and as an application/ics
* attachment), so the shared content object is marked to be resolved just once
* and the buffered result is reused by the second node.
*
* @returns {Object} Normalized icalEvent data
*/
_getIcalEvent() {
if (!this._icalEvent) {
let icalEvent;
if (
typeof this.mail.icalEvent === 'object' &&
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
) {
icalEvent = Object.assign({}, this.mail.icalEvent);
} else {
icalEvent = {
content: this.mail.icalEvent
};
}
if (/^data:/i.test(icalEvent.path || icalEvent.href)) {
icalEvent = this._processDataUrl(icalEvent);
}
if (/^https?:\/\//i.test(icalEvent.path)) {
icalEvent.href = icalEvent.path;
icalEvent.path = undefined;
}
if (!icalEvent.raw) {
// map file path and URL values into `content`, otherwise the content
// nodes would render an empty body
if (icalEvent.path) {
icalEvent.content = {
path: icalEvent.path
};
icalEvent.path = undefined;
} else if (icalEvent.href) {
icalEvent.content = {
href: icalEvent.href,
httpHeaders: icalEvent.httpHeaders
};
icalEvent.href = undefined;
}
}
if (icalEvent.content && typeof icalEvent.content === 'object') {
// we are going to have the same attachment twice, so mark this to be
// resolved just once
icalEvent.content._resolve = true;
}
this._icalEvent = icalEvent;
}
return this._icalEvent;
}
/**
* List alternatives. Resulting objects can be used as input for MimeNode nodes
*
@@ -202,7 +253,7 @@ class MailComposer {
*/
getAlternatives() {
const alternatives = [];
let text, html, watchHtml, amp, icalEvent, eventObject;
let text, html, watchHtml, amp, eventObject;
if (this.mail.text) {
if (
@@ -248,24 +299,7 @@ class MailComposer {
// NB! when including attachments with a calendar alternative you might end up in a blank screen on some clients
if (this.mail.icalEvent) {
if (
typeof this.mail.icalEvent === 'object' &&
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
) {
icalEvent = this.mail.icalEvent;
} else {
icalEvent = {
content: this.mail.icalEvent
};
}
eventObject = Object.assign({}, icalEvent);
if (eventObject.content && typeof eventObject.content === 'object') {
// we are going to have the same attachment twice, so mark this to be
// resolved just once
eventObject.content._resolve = true;
}
eventObject = Object.assign({}, this._getIcalEvent());
eventObject.filename = false;
eventObject.contentType =
+34 -26
View File
@@ -8,7 +8,7 @@ const DKIM = require('../dkim');
const httpProxyClient = require('../smtp-connection/http-proxy-client');
const errors = require('../errors');
const util = require('util');
const urllib = require('url');
const urllib = require('../shared/url');
const packageData = require('../../package.json');
const MailMessage = require('./mail-message');
const net = require('net');
@@ -324,7 +324,7 @@ class Mail extends EventEmitter {
// Connect using a HTTP CONNECT method
case 'http':
case 'https':
httpProxyClient(proxy.href, options.port, options.host, (err, socket) => {
httpProxyClient(proxy.href, options.port, options.host, this.options.tls || {}, (err, socket) => {
if (err) {
return callback(err);
}
@@ -407,31 +407,39 @@ class Mail extends EventEmitter {
if ((!this.options.attachDataUrls && !mail.data.attachDataUrls) || !mail.data.html) {
return callback();
}
mail.resolveContent(mail.data, 'html', (err, html) => {
if (err) {
return callback(err);
mail.resolveContent(
mail.data,
'html',
{ disableFileAccess: mail.data.disableFileAccess, disableUrlAccess: mail.data.disableUrlAccess },
(err, html) => {
if (err) {
return callback(err);
}
let cidCounter = 0;
html = (html || '')
.toString()
.replace(
/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]+);[^"'>\s]+)/gi,
(match, prefix, dataUri, mimeType) => {
const cid = crypto.randomBytes(10).toString('hex') + '@localhost';
if (!mail.data.attachments) {
mail.data.attachments = [];
}
if (!Array.isArray(mail.data.attachments)) {
mail.data.attachments = [].concat(mail.data.attachments || []);
}
mail.data.attachments.push({
path: dataUri,
cid,
filename: 'image-' + ++cidCounter + '.' + mimeTypes.detectExtension(mimeType)
});
return prefix + 'cid:' + cid;
}
);
mail.data.html = html;
callback();
}
let cidCounter = 0;
html = (html || '')
.toString()
.replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]+);[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
const cid = crypto.randomBytes(10).toString('hex') + '@localhost';
if (!mail.data.attachments) {
mail.data.attachments = [];
}
if (!Array.isArray(mail.data.attachments)) {
mail.data.attachments = [].concat(mail.data.attachments || []);
}
mail.data.attachments.push({
path: dataUri,
cid,
filename: 'image-' + ++cidCounter + '.' + mimeTypes.detectExtension(mimeType)
});
return prefix + 'cid:' + cid;
});
mail.data.html = html;
callback();
});
);
}
set(key, value) {
+30 -20
View File
@@ -111,25 +111,29 @@ class MailMessage {
if (!args[0] || !args[0][args[1]]) {
return resolveNext();
}
shared.resolveContent(...args, (err, value) => {
if (err) {
return callback(err);
}
shared.resolveContent(
...args,
{ disableFileAccess: this.data.disableFileAccess, disableUrlAccess: this.data.disableUrlAccess },
(err, value) => {
if (err) {
return callback(err);
}
const node = {
content: value
};
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
Object.keys(args[0][args[1]]).forEach(key => {
if (!(key in node) && !['content', 'path', 'href', 'raw'].includes(key)) {
node[key] = args[0][args[1]][key];
}
});
}
const node = {
content: value
};
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
Object.keys(args[0][args[1]]).forEach(key => {
if (!(key in node) && !['content', 'path', 'href', 'raw'].includes(key)) {
node[key] = args[0][args[1]][key];
}
});
}
args[0][args[1]] = node;
resolveNext();
});
args[0][args[1]] = node;
resolveNext();
}
);
};
setImmediate(() => resolveNext());
@@ -269,18 +273,24 @@ class MailMessage {
if (value && value.url) {
if (key.toLowerCase().trim() === 'id') {
// List-ID: "comment" <domain>
let comment = value.comment || '';
// strip CR/LF so a comment can't inject extra header lines
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
if (mimeFuncs.isPlainText(comment)) {
comment = '"' + comment + '"';
} else {
comment = mimeFuncs.encodeWord(comment);
}
return (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+\/{,2}/, '');
// List-ID expects a bare domain-like identifier, so strip the
// scheme prefix that _formatListUrl adds or passes through
return (
(value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<')
);
}
// List-*: <http://domain> (comment)
let comment = value.comment || '';
// strip CR/LF so a comment can't inject extra header lines
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
if (!mimeFuncs.isPlainText(comment)) {
comment = mimeFuncs.encodeWord(comment);
}
+1 -1
View File
@@ -2087,7 +2087,7 @@ module.exports = {
if (!mimeType) {
return defaultExtension;
}
const parts = (mimeType || '').toLowerCase().trim().split('/');
const parts = mimeType.toLowerCase().trim().split('/');
const rootType = parts.shift().trim();
const subType = parts.join('/').trim();
+1 -1
View File
@@ -1006,7 +1006,7 @@ class MimeNode {
return contentStream;
}
// fetch URL
return nmfetch(content.href, { headers: content.httpHeaders });
return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls });
}
// pass string or buffer content as a stream
+26 -7
View File
@@ -95,12 +95,22 @@ module.exports.createTestAccount = function (apiUrl, callback) {
requestHeaders.Authorization = 'Bearer ' + ETHEREAL_API_KEY;
}
const req = nmfetch(apiUrl + '/user', {
const fetchOptions = {
contentType: 'application/json',
method: 'POST',
headers: requestHeaders,
body: Buffer.from(JSON.stringify(requestBody))
});
};
// Credential-bearing request to the Ethereal API. lib/fetch already
// validates certs by default; pin rejectUnauthorized:true here so this
// call stays strict regardless of any future default change and is never
// relaxed for a real-cert endpoint.
if (/^https:/i.test(apiUrl)) {
fetchOptions.tls = { rejectUnauthorized: true };
}
const req = nmfetch(apiUrl + '/user', fetchOptions);
req.on('readable', () => {
let chunk;
@@ -137,11 +147,20 @@ module.exports.getTestMessageUrl = function (info) {
}
const infoProps = new Map();
info.response.replace(/\[([^\]]+)\]$/, (m, props) => {
props.replace(/\b([A-Z0-9]+)=([^\s]+)/g, (m, key, value) => {
infoProps.set(key, value);
});
});
// Extract the trailing "[...]" part of the response (no "]" allowed inside)
// with linear string scanning; the equivalent regex /\[([^\]]+)\]$/ was
// flagged for polynomial backtracking on adversarial server responses
const response = info.response.toString();
if (response.length > 2 && response.charAt(response.length - 1) === ']') {
const open = response.indexOf('[', response.lastIndexOf(']', response.length - 2) + 1);
if (open >= 0 && open < response.length - 2) {
const props = response.substring(open + 1, response.length - 1);
props.replace(/\b([A-Z0-9]+)=([^\s]+)/g, (m, key, value) => {
infoProps.set(key, value);
});
}
}
if (infoProps.has('STATUS') && infoProps.has('MSGID')) {
return (testAccount.web || ETHEREAL_WEB) + '/message/' + infoProps.get('MSGID');
+14 -2
View File
@@ -4,6 +4,8 @@ const { spawn } = require('child_process');
const packageData = require('../../package.json');
const shared = require('../shared');
const errors = require('../errors');
const LeWindows = require('../mime-node/le-windows');
const LeUnix = require('../mime-node/le-unix');
/**
* Generates a Transport object for Sendmail
@@ -46,6 +48,8 @@ class SendmailTransport {
this.args = options.args;
}
}
this.winbreak = ['win', 'windows', 'dos', '\r\n'].includes((options.newline || '').toString().toLowerCase());
}
/**
@@ -178,7 +182,15 @@ class SendmailTransport {
);
const sourceStream = mail.message.createReadStream();
sourceStream.once('error', err => {
let stream = sourceStream;
if (this.options.newline) {
// apply the transport-level line ending transform; the message-level
// `newline` option is handled by MimeNode in createReadStream()
stream = sourceStream.pipe(this.winbreak ? new LeWindows() : new LeUnix());
sourceStream.once('error', err => stream.emit('error', err));
}
stream.once('error', err => {
this.logger.error(
{
err,
@@ -193,7 +205,7 @@ class SendmailTransport {
callback(err);
});
sourceStream.pipe(sendmail.stdin);
stream.pipe(sendmail.stdin);
} else {
const err = new Error('sendmail was not found');
err.code = errors.ESENDMAIL;
+19 -7
View File
@@ -3,9 +3,22 @@
const EventEmitter = require('events');
const packageData = require('../../package.json');
const shared = require('../shared');
const errors = require('../errors');
const LeWindows = require('../mime-node/le-windows');
const MimeNode = require('../mime-node');
/**
* Tags AWS SDK rejections that carry no `code` property (SDK v3 errors only
* have a `name`) with the generic SES transport error code, keeping the
* original error object intact
*/
function tagSesError(err) {
if (err && typeof err === 'object' && !err.code) {
err.code = errors.ESES;
}
return err;
}
/**
* Generates a Transport object for AWS SES
*
@@ -157,6 +170,7 @@ class SESTransport extends EventEmitter {
});
})
.catch(err => {
tagSesError(err);
this.logger.error(
{
err,
@@ -188,7 +202,7 @@ class SESTransport extends EventEmitter {
const cb = err => {
if (err && !['InvalidParameterValue', 'MessageRejected'].includes(err.code || err.Code || err.name)) {
return callback(err);
return callback(tagSesError(err));
}
return callback(null, true);
};
@@ -205,15 +219,13 @@ class SESTransport extends EventEmitter {
}
};
this.getRegion((err, region) => {
if (err || !region) {
region = 'us-east-1';
}
// the region value is not used for anything when verifying, but the lookup
// exercises the client configuration the same way as send() does
this.getRegion(() => {
const command = new this.ses.SendEmailCommand(sesMessage);
const sendPromise = this.ses.sesClient.send(command);
sendPromise.then(data => cb(null, data)).catch(err => cb(err));
sendPromise.then(() => cb(null)).catch(err => cb(err));
});
return promise;
+44 -11
View File
@@ -2,10 +2,11 @@
'use strict';
const urllib = require('url');
const urllib = require('./url');
const util = require('util');
const fs = require('fs');
const nmfetch = require('../fetch');
const errors = require('../errors');
const dns = require('dns');
const net = require('net');
const os = require('os');
@@ -366,7 +367,16 @@ module.exports._logFunc = (logger, level, defaults, data, message, ...args) => {
const entry = Object.assign({}, defaults || {}, data || {});
delete entry.level;
logger[level](entry, message, ...args);
let logLevel = level;
if (typeof logger[logLevel] !== 'function') {
// Provided logger does not implement this level. Fall back to a
// lower-severity handler instead of throwing.
logLevel = ['info', 'debug', 'log', 'trace', 'warn', 'error'].find(name => typeof logger[name] === 'function');
}
if (logLevel) {
logger[logLevel](entry, message, ...args);
}
};
/**
@@ -501,9 +511,17 @@ module.exports.parseDataURI = uri => {
*
* @param {Object} data An object or an Array you want to resolve an element for
* @param {String|Number} key Property name or an Array index
* @param {Object} [options] Optional access policy: { disableFileAccess, disableUrlAccess }
* @param {Function} callback Callback function with (err, value)
*/
module.exports.resolveContent = (data, key, callback) => {
module.exports.resolveContent = (data, key, options, callback) => {
// options is optional; support the legacy resolveContent(data, key, callback) signature
if (!callback && typeof options === 'function') {
callback = options;
options = false;
}
options = options || {};
let promise;
if (!callback) {
@@ -512,6 +530,12 @@ module.exports.resolveContent = (data, key, callback) => {
});
}
resolveContentValue(data, key, options, callback);
return promise;
};
function resolveContentValue(data, key, options, callback) {
let content = (data && data[key] && data[key].content) || data[key];
const encoding = ((typeof data[key] === 'object' && data[key].encoding) || 'utf8')
.toString()
@@ -538,15 +562,26 @@ module.exports.resolveContent = (data, key, callback) => {
callback(null, value);
});
} else if (/^https?:\/\//i.test(content.path || content.href)) {
return resolveStream(nmfetch(content.path || content.href), callback);
if (options.disableUrlAccess) {
return setImmediate(() => {
const err = new Error('Url access rejected for ' + (content.path || content.href));
err.code = errors.EURLACCESS;
callback(err);
});
}
return resolveStream(nmfetch(content.path || content.href, { headers: content.httpHeaders, tls: content.tls }), callback);
} else if (/^data:/i.test(content.path || content.href)) {
const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
if (!parsedDataUri || !parsedDataUri.data) {
return callback(null, Buffer.from(0));
}
return callback(null, parsedDataUri.data);
return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
} else if (content.path) {
if (options.disableFileAccess) {
return setImmediate(() => {
const err = new Error('File access rejected for ' + content.path);
err.code = errors.EFILEACCESS;
callback(err);
});
}
return resolveStream(fs.createReadStream(content.path), callback);
}
}
@@ -557,9 +592,7 @@ module.exports.resolveContent = (data, key, callback) => {
// default action, return as is
setImmediate(() => callback(null, content));
return promise;
};
}
/**
* Copies properties from source objects to target objects
+151
View File
@@ -0,0 +1,151 @@
'use strict';
// URL parsing wrapper. Prefers the WHATWG `URL` (a global on Node 10+, and
// available as `require('url').URL` since Node 6.13+) and only falls back to the
// legacy, deprecation-warning-emitting `url.parse()` / `url.resolve()` on ancient
// Node versions that predate the WHATWG implementation.
//
// The WHATWG `URL` exposes a different shape than the legacy parser, so results
// are normalized back into the legacy field names the rest of the codebase reads
// (`protocol`, `hostname`, `port`, `pathname`, `path`, `search`, `auth`, `query`,
// `href`). This keeps every existing call site unchanged.
//
// Known, accepted divergences from the legacy parser:
// - non-special schemes (smtp:/smtps:/direct:) are not host-lowercased by
// WHATWG; cosmetic only, SMTP/DNS hosts are case-insensitive. (IDNA mapping
// and IPv6 brackets are normalized back by normalizeHostname below.)
// - a literal unescaped ':' inside a password is percent-encoded by WHATWG;
// such passwords should be percent-encoded by the caller anyway.
const urllib = require('url');
const punycode = require('../punycode');
// WHATWG URL constructor if available, otherwise undefined (Node < 6.13).
const URLImpl = (typeof URL !== 'undefined' && URL) || urllib.URL;
// Matches a "scheme:" not followed by "//" (and with something after it), used
// to re-insert the authority separator the legacy parser did not require.
const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)(.+)$/;
// decodeURIComponent that never throws. Legacy url.parse() decodes the auth
// component but tolerates malformed percent sequences, so mirror that.
function safeDecode(str) {
try {
return decodeURIComponent(str);
} catch (_err) {
return str;
}
}
// Derives the legacy-shaped bare hostname from a WHATWG URL. WHATWG keeps IPv6
// literals bracketed ('[::1]') and, for non-special schemes (smtp:/smtps:/socks:),
// percent-encodes a non-ASCII host instead of IDNA-mapping it. Both forms are
// un-resolvable when handed to net/dns/http.request — which is what every call
// site does — so map them back to what legacy url.parse() returned: the bare
// address and the punycode form. Idempotent on plain ASCII and already-punycode
// hosts, so special-scheme hosts (already IDNA-mapped by WHATWG) pass through.
function normalizeHostname(raw) {
let hostname = raw || '';
if (!hostname) {
// Host-less URL (e.g. 'direct:'): legacy returned '' here, not null;
// consumers do `hostname.length` / `'.' + hostname`, so keep it a string.
return '';
}
if (hostname.charAt(0) === '[' && hostname.charAt(hostname.length - 1) === ']') {
return hostname.slice(1, -1);
}
return punycode.toASCII(safeDecode(hostname));
}
module.exports.parse = (input, parseQueryString) => {
input = input || '';
if (!URLImpl) {
// Node < 6.13: no WHATWG URL available, use the legacy parser.
return urllib.parse(input, parseQueryString);
}
// Legacy url.parse() parses a "user:pass@host:port" authority that follows
// the scheme even without the "//" separator, for schemes outside its
// built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG
// parser instead treats a scheme not followed by "//" as an opaque path.
// Re-insert the "//" so slash-less connection/proxy URLs keep resolving to
// an authority, as they did before. This assumes a slash-authority scheme,
// which every consumer here uses (http/https/smtp/smtps/socks/direct); an
// opaque scheme like mailto:/data:/tel: would be mis-split, but none reach
// this module.
const slashless = SLASHLESS_AUTHORITY.exec(input);
const normalized = slashless ? slashless[1] + '//' + slashless[2] : input;
let u;
try {
u = new URLImpl(normalized);
} catch (_err) {
// WHATWG rejects some input the legacy parser tolerated (empty/relative
// strings, scheme-relative '//host/path', out-of-range ports, ...). Fall
// back to the legacy parser so behavior — including the downstream errors
// callers rely on — is preserved. This is the only path that can still
// emit a deprecation warning; it fires for anything WHATWG cannot
// represent, including legitimate relative URLs, not just malformed input.
return urllib.parse(input, parseQueryString);
}
const hostname = normalizeHostname(u.hostname);
const port = u.port || null;
const pathname = u.pathname || null;
const search = u.search || null;
// Legacy `.auth` is the decoded "user[:pass]" string; WHATWG keeps the
// username/password percent-encoded, so decode to stay byte-compatible with
// existing consumers (parseConnectionUrl, Basic/Proxy-Authorization headers).
let auth = null;
if (u.username || u.password) {
// Gate on password too: legacy url.parse('smtps://:pass@host').auth was
// ':pass'. Dropping it would silently connect unauthenticated.
auth = safeDecode(u.username) + (u.password ? ':' + safeDecode(u.password) : '');
}
let query;
if (parseQueryString) {
// Mirror querystring.parse(): null-prototype object, repeated keys → array.
query = Object.create(null);
u.searchParams.forEach((value, key) => {
if (Object.prototype.hasOwnProperty.call(query, key)) {
if (Array.isArray(query[key])) {
query[key].push(value);
} else {
query[key] = [query[key], value];
}
} else {
query[key] = value;
}
});
} else {
query = search ? search.slice(1) : null;
}
return {
protocol: u.protocol || null,
host: u.host || null,
hostname,
port,
pathname,
search,
path: (pathname || '') + (search || '') || null,
href: u.href,
auth,
query
};
};
module.exports.resolve = (from, to) => {
if (!URLImpl) {
return urllib.resolve(from, to);
}
try {
return new URLImpl(to, from).href;
} catch (_err) {
// Malformed target — fall back to the legacy resolver.
return urllib.resolve(from, to);
}
};
+15 -6
View File
@@ -6,7 +6,7 @@
const net = require('net');
const tls = require('tls');
const urllib = require('url');
const urllib = require('../shared/url');
const errors = require('../errors');
/**
@@ -19,20 +19,29 @@ const errors = require('../errors');
* @param {String} proxyUrl proxy configuration, etg "http://proxy.host:3128/"
* @param {Number} destinationPort Port to open in destination host
* @param {String} destinationHost Destination hostname
* @param {Object} [tlsOptions] Optional TLS options for an HTTPS proxy (e.g. { rejectUnauthorized: false })
* @param {Function} callback Callback to run with the rocket object once connection is established
*/
function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) {
if (typeof tlsOptions === 'function') {
callback = tlsOptions;
tlsOptions = {};
}
tlsOptions = tlsOptions || {};
const proxy = urllib.parse(proxyUrl);
const options = {
const connectOptions = {
host: proxy.hostname,
port: Number(proxy.port) ? Number(proxy.port) : proxy.protocol === 'https:' ? 443 : 80
};
let connect;
if (proxy.protocol === 'https:') {
// we can use untrusted proxies as long as we verify actual SMTP certificates
options.rejectUnauthorized = false;
// Validate the proxy's TLS certificate by default. A caller that uses a
// self-signed proxy (e.g. integration tests) opts out explicitly with
// tls.rejectUnauthorized === false.
connectOptions.rejectUnauthorized = tlsOptions.rejectUnauthorized !== false;
connect = tls.connect.bind(tls);
} else {
connect = net.connect.bind(net);
@@ -62,7 +71,7 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
tempSocketErr(err);
};
socket = connect(options, () => {
socket = connect(connectOptions, () => {
if (finished) {
return;
}
+40 -9
View File
@@ -51,9 +51,9 @@ function decodeServerResponse(str) {
* * **requireTLS** - forces the client to use STARTTLS
* * **name** - the name of the client server
* * **localAddress** - outbound address to bind to (see: http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener)
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 10000)
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 1 hour)
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
* * **lmtp** - if true, uses LMTP instead of SMTP protocol
* * **logger** - bunyan compatible logger interface
@@ -211,6 +211,13 @@ class SMTPConnection extends EventEmitter {
*/
this._closing = false;
/**
* Message DATA stream currently piped to the socket, if any. Tracked so
* close() can unpipe it before tearing the socket down.
* @private
*/
this._currentDataStream = false;
/**
* Callbacks for socket's listeners
*/
@@ -470,6 +477,17 @@ class SMTPConnection extends EventEmitter {
const socket = (this._socket && this._socket.socket) || this._socket;
// Detach any in-flight DATA stream from the socket so the source stream
// can be garbage-collected once the socket is gone.
if (this._currentDataStream) {
try {
this._currentDataStream.unpipe(this._socket);
} catch (_E) {
// ignore
}
this._currentDataStream = false;
}
if (socket && !socket.destroyed) {
try {
// Clear socket timeout to prevent timer leaks
@@ -820,7 +838,7 @@ class SMTPConnection extends EventEmitter {
return;
}
let data = (chunk || '').toString('binary');
let data = chunk.toString('binary');
let lines = (this._remainder + data).split(/\r?\n/);
let lastline;
@@ -953,7 +971,9 @@ class SMTPConnection extends EventEmitter {
*/
_onEnd() {
if (this._socket && !this._socket.destroyed) {
this._socket.destroy();
// Peer sent FIN — finish our half of the close gracefully rather
// than destroying. 'close' fires after the OS finalizes teardown.
this._socket.end();
}
}
@@ -1005,6 +1025,15 @@ class SMTPConnection extends EventEmitter {
opts.servername = this.servername;
}
// Remove all listeners from the plain socket to allow proper garbage
// collection. Used on both the TLS-success path and the synchronous
// tls.connect() throw path; either way the plain socket is done.
const removePlainSocketListeners = () => {
socketPlain.removeListener('close', this._onSocketClose);
socketPlain.removeListener('end', this._onSocketEnd);
socketPlain.removeListener('error', this._onSocketError);
};
this.upgrading = true;
// tls.connect is not an asynchronous function however it may still throw errors and requires to be wrapped with try/catch
try {
@@ -1013,14 +1042,12 @@ class SMTPConnection extends EventEmitter {
this.upgrading = false;
this._socket.on('data', this._onSocketData);
// Remove all listeners from the plain socket to allow proper garbage collection
socketPlain.removeListener('close', this._onSocketClose);
socketPlain.removeListener('end', this._onSocketEnd);
socketPlain.removeListener('error', this._onSocketError);
removePlainSocketListeners();
return callback(null, true);
});
} catch (err) {
removePlainSocketListeners();
return callback(err);
}
@@ -1297,6 +1324,7 @@ class SMTPConnection extends EventEmitter {
});
}
this._currentDataStream = dataStream;
dataStream.pipe(this._socket, {
end: false
});
@@ -1318,6 +1346,9 @@ class SMTPConnection extends EventEmitter {
}
dataStream.once('end', () => {
if (this._currentDataStream === dataStream) {
this._currentDataStream = false;
}
this.logger.info(
{
tnx: 'message',
+38 -11
View File
@@ -71,13 +71,19 @@ class SMTPTransport extends EventEmitter {
getAuth(authOpts) {
if (!authOpts) {
if (this.auth && this.auth.oauth2 && this.mailer) {
// Transport-level auth is resolved in the constructor, before the Mail wrapper
// assigns `this.mailer`, so a provision callback registered with
// `transporter.set('oauth2_provision_cb', ...)` has to be re-checked here
this.auth.oauth2.provisionCallback = this.mailer.get('oauth2_provision_cb') || this.auth.oauth2.provisionCallback;
}
return this.auth;
}
const authData = Object.assign(
{},
this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {},
authOpts && typeof authOpts === 'object' ? authOpts : {}
typeof authOpts === 'object' ? authOpts : {}
);
if (Object.keys(authData).length === 0) {
@@ -151,11 +157,20 @@ class SMTPTransport extends EventEmitter {
const connection = new SMTPConnection(options);
let perCallAuth;
const cleanupPerCallAuth = () => {
if (perCallAuth && perCallAuth !== this.auth && perCallAuth.oauth2) {
perCallAuth.oauth2.removeAllListeners();
}
perCallAuth = null;
};
connection.once('error', err => {
if (returned) {
return;
}
returned = true;
cleanupPerCallAuth();
connection.close();
return callback(err);
});
@@ -170,6 +185,7 @@ class SMTPTransport extends EventEmitter {
return;
}
returned = true;
cleanupPerCallAuth();
// still have not returned, this means we have an unexpected connection close
const err = new Error('Unexpected socket close');
if (connection && connection._socket && connection._socket.upgrading) {
@@ -216,6 +232,7 @@ class SMTPTransport extends EventEmitter {
connection.send(envelope, mail.message.createReadStream(), (err, info) => {
returned = true;
cleanupPerCallAuth();
connection.close();
if (err) {
this.logger.error(
@@ -255,13 +272,11 @@ class SMTPTransport extends EventEmitter {
return;
}
const auth = this.getAuth(mail.data.auth);
perCallAuth = this.getAuth(mail.data.auth);
if (auth && (connection.allowsAuth || options.forceAuth)) {
connection.login(auth, err => {
if (auth && auth !== this.auth && auth.oauth2) {
auth.oauth2.removeAllListeners();
}
if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
connection.login(perCallAuth, err => {
cleanupPerCallAuth();
if (returned) {
return;
}
@@ -323,12 +338,20 @@ class SMTPTransport extends EventEmitter {
const connection = new SMTPConnection(options);
let returned = false;
let perCallAuth;
const cleanupPerCallAuth = () => {
if (perCallAuth && perCallAuth !== this.auth && perCallAuth.oauth2) {
perCallAuth.oauth2.removeAllListeners();
}
perCallAuth = null;
};
connection.once('error', err => {
if (returned) {
return;
}
returned = true;
cleanupPerCallAuth();
connection.close();
return callback(err);
});
@@ -338,6 +361,7 @@ class SMTPTransport extends EventEmitter {
return;
}
returned = true;
cleanupPerCallAuth();
return callback(new Error('Connection closed'));
});
@@ -346,6 +370,7 @@ class SMTPTransport extends EventEmitter {
return;
}
returned = true;
cleanupPerCallAuth();
connection.quit();
return callback(null, true);
};
@@ -355,10 +380,11 @@ class SMTPTransport extends EventEmitter {
return;
}
const authData = this.getAuth({});
perCallAuth = this.getAuth({});
if (authData && (connection.allowsAuth || options.forceAuth)) {
connection.login(authData, err => {
if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
connection.login(perCallAuth, err => {
cleanupPerCallAuth();
if (returned) {
return;
}
@@ -371,11 +397,12 @@ class SMTPTransport extends EventEmitter {
finalize();
});
} else if (!authData && connection.allowsAuth && options.forceAuth) {
} else if (!perCallAuth && connection.allowsAuth && options.forceAuth) {
const err = new Error('Authentication info was not provided');
err.code = errors.ENOAUTH;
returned = true;
cleanupPerCallAuth();
connection.close();
return callback(err);
} else {
+9
View File
@@ -2,6 +2,8 @@
const packageData = require('../../package.json');
const shared = require('../shared');
const LeWindows = require('../mime-node/le-windows');
const LeUnix = require('../mime-node/le-unix');
/**
* Generates a Transport object for streaming
@@ -63,6 +65,13 @@ class StreamTransport {
try {
stream = mail.message.createReadStream();
if (this.options.newline) {
// apply the transport-level line ending transform; the message-level
// `newline` option is handled by MimeNode in createReadStream()
const sourceStream = stream;
stream = sourceStream.pipe(this.winbreak ? new LeWindows() : new LeUnix());
sourceStream.once('error', err => stream.emit('error', err));
}
} catch (E) {
this.logger.error(
{
+14 -2
View File
@@ -33,6 +33,7 @@ const errors = require('../errors');
* @param {Number} options.expires Optional Access Token expire time in ms
* @param {Number} options.timeout Optional TTL for Access Token in seconds
* @param {Function} options.provisionCallback Function to run when a new access token is required
* @param {Object} options.tls Optional TLS options forwarded to the HTTPS token request. Defaults to strict cert validation; supply { rejectUnauthorized: false } only for self-hosted OAuth providers on private CAs.
*/
class XOAuth2 extends Stream {
constructor(options, logger) {
@@ -370,12 +371,23 @@ class XOAuth2 extends Stream {
const chunks = [];
let chunklen = 0;
const req = nmfetch(url, {
const fetchOptions = {
method: 'post',
headers: params.customHeaders,
body: payload,
allowErrorResponse: true
});
};
// OAuth2 token endpoints are credential-bearing. lib/fetch already
// validates certs by default; pin rejectUnauthorized:true here so the
// token fetch stays strict, while still layering params.tls (the
// user's options.tls) on top so callers with a self-hosted provider on
// a private CA can override.
if (/^https:/i.test(url)) {
fetchOptions.tls = Object.assign({ rejectUnauthorized: true }, params.tls || {});
}
const req = nmfetch(url, fetchOptions);
req.on('readable', () => {
let chunk;
+8 -8
View File
@@ -1,11 +1,11 @@
{
"name": "nodemailer",
"version": "8.0.7",
"version": "9.0.0",
"description": "Easy as cake e-mail sending from your Node.js applications",
"main": "lib/nodemailer.js",
"scripts": {
"test": "node --test --test-concurrency=1 test/**/*.test.js test/**/*-test.js",
"test:coverage": "c8 node --test --test-concurrency=1 test/**/*.test.js test/**/*-test.js",
"test": "node --test --test-concurrency=1 $(find test \\( -name '*-test.js' -o -name '*.test.js' \\))",
"test:coverage": "c8 node --test --test-concurrency=1 $(find test \\( -name '*-test.js' -o -name '*.test.js' \\))",
"format": "prettier --write \"**/*.{js,json,md}\"",
"format:check": "prettier --check \"**/*.{js,json,md}\"",
"lint": "eslint .",
@@ -27,19 +27,19 @@
},
"homepage": "https://nodemailer.com/",
"devDependencies": {
"@aws-sdk/client-sesv2": "3.1037.0",
"@aws-sdk/client-sesv2": "3.1065.0",
"bunyan": "1.8.15",
"c8": "11.0.0",
"eslint": "10.2.1",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
"globals": "17.5.0",
"globals": "17.6.0",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libqp": "2.1.1",
"prettier": "3.8.3",
"prettier": "3.8.4",
"proxy": "1.0.2",
"proxy-test-server": "1.0.0",
"smtp-server": "3.18.4"
"smtp-server": "3.18.5"
},
"engines": {
"node": ">=6.0.0"