node_modules: update (#349)

Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
Dawid Dziurlaanddawidd6 authored and GitHub committed 2026-10-18 15:39:30 +02:00
1 parent 9d0c26ec8b
commit 0a1f5db16c
36 files changed
+1934 -648

No files matched your search

+3 -3
View File
@@ -94,9 +94,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "10.1.1", "version": "10.1.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.6.tgz",
"integrity": "sha512-pbqZRoeYUO+1E8TJ/11WeY4D2EVujj1hsYjz3qff/vUFDtGnmi7p3iqzTFslYOfQ6PnjVcWxx/CIUSHCSrO/QA==", "integrity": "sha512-8j/UEmA4AXr51URr3Ob0h+RCBtr0NKJ76Y2P9wJT6Eu3dRyruj2ULh2e6aiKrGg8nlUj1oX6N5h8dHA82EouaQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+44
View File
@@ -1,5 +1,49 @@
# CHANGELOG # CHANGELOG
## [10.1.6](https://github.com/nodemailer/nodemailer/compare/v10.1.5...v10.1.6) (2026-10-18)
### Bug Fixes
* **addressparser:** read groups in the same pass as the rest of the list ([ddf0de1](https://github.com/nodemailer/nodemailer/commit/ddf0de1e7615908101bf593c62edcadccc05c8d3))
## [10.1.5](https://github.com/nodemailer/nodemailer/compare/v10.1.4...v10.1.5) (2026-10-18)
### Bug Fixes
* **addressparser:** handle an address list without copying it per address ([6bd09b3](https://github.com/nodemailer/nodemailer/commit/6bd09b395cb7ffd1b0ae95f960f3985ba9c25afb))
* **addressparser:** tokenize into offsets instead of an object per token ([3fd3f50](https://github.com/nodemailer/nodemailer/commit/3fd3f50ce86671884896ef1eaddafff5e137f820))
## [10.1.4](https://github.com/nodemailer/nodemailer/compare/v10.1.3...v10.1.4) (2026-10-18)
### Bug Fixes
* **addressparser:** apply the unexpected "&lt;" rule outside quotes and comments ([ee35f57](https://github.com/nodemailer/nodemailer/commit/ee35f5745658bf46952651fa53207255e9dc3406))
* **addressparser:** bound group nesting at ten levels ([ce63303](https://github.com/nodemailer/nodemailer/commit/ce633031d58f8d2e9c9b0f5b671944a1afe71f95))
* **addressparser:** drop the obsolete route from an angle-addr ([3dbc730](https://github.com/nodemailer/nodemailer/commit/3dbc7304b0cfa06551543fa2dc01222447c2b554))
* **addressparser:** end a comment only at the ")" that closes the outermost one ([0a87617](https://github.com/nodemailer/nodemailer/commit/0a8761762ac78f2db2ba37f712f2a52a404ce011))
* **addressparser:** honour quoted strings and comments inside an angle-addr and a group ([1cd57d2](https://github.com/nodemailer/nodemailer/commit/1cd57d2b8c49dff048c21cdbafdb54439a7f07e2))
* **addressparser:** never read a mailbox out of a comment ([a1538ea](https://github.com/nodemailer/nodemailer/commit/a1538ea39c76c48a4134806c41e31e59a258b1e5))
* **addressparser:** read CFWS beside the "@" of an addr-spec as nothing ([ba3c095](https://github.com/nodemailer/nodemailer/commit/ba3c0952542581f95cb20d9322cc9e27baa470a2))
* **addressparser:** read the first addr-spec of the display text ([038940a](https://github.com/nodemailer/nodemailer/commit/038940a069a2cecb64a70d0e81b17eb8ce200d3a))
* split a domain literal off at the "@" in front of it ([d5983b0](https://github.com/nodemailer/nodemailer/commit/d5983b0ae234cd22ab45be817fefd434e9a53d58))
## [10.1.3](https://github.com/nodemailer/nodemailer/compare/v10.1.2...v10.1.3) (2026-10-18)
### Bug Fixes
* format References and strip URLs in linear time, release any number of streams ([2f18641](https://github.com/nodemailer/nodemailer/commit/2f186418cf6c8fff18f235d4318892fd42f810fa))
## [10.1.2](https://github.com/nodemailer/nodemailer/compare/v10.1.1...v10.1.2) (2026-10-16)
### Bug Fixes
* bound the work a message can make the mailer do and keep per-message auth off the transport's token endpoint ([e347fa2](https://github.com/nodemailer/nodemailer/commit/e347fa2260a7e342e772a7e187937aa87ac96364))
## [10.1.1](https://github.com/nodemailer/nodemailer/compare/v10.1.0...v10.1.1) (2026-10-14) ## [10.1.1](https://github.com/nodemailer/nodemailer/compare/v10.1.0...v10.1.1) (2026-10-14)
+699 -271
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -133,8 +133,9 @@ class DKIMSigner {
} }
createWriteCache() { createWriteCache() {
this.output.usingCache = true; this.output.usingCache = true;
// pipe remainings to cache file // pipe remainings to cache file. It holds the message body and cacheDir is often a
this.cache = node_fs_1.default.createWriteStream(this.cachePath); // shared directory such as /tmp, so nobody but the owner gets to read it
this.cache = node_fs_1.default.createWriteStream(this.cachePath, { mode: 0o600 });
this.cache.once('error', err => { this.cache.once('error', err => {
// drain input // drain input
this.relaxedBody.unpipe(this.cache); this.relaxedBody.unpipe(this.cache);
+5
View File
@@ -27,6 +27,8 @@ export interface FetchOptions {
} | undefined; } | undefined;
/** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */ /** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */
timeout?: number | undefined; timeout?: number | undefined;
/** Time limit for the whole request in milliseconds, redirects included, defaults to 120000, 0 disables it */
maxTime?: number | undefined;
/** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */ /** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */
maxBytes?: number | undefined; maxBytes?: number | undefined;
/** Maximum number of redirects to follow (default 5) */ /** Maximum number of redirects to follow (default 5) */
@@ -37,6 +39,8 @@ export interface FetchOptions {
redirects?: number | undefined; redirects?: number | undefined;
/** Response stream shared across redirects, set by nmfetch itself */ /** Response stream shared across redirects, set by nmfetch itself */
fetchRes?: FetchResponse | undefined; fetchRes?: FetchResponse | undefined;
/** Timestamp the whole request has to be done by, shared across redirects, set by nmfetch itself */
deadline?: number | undefined;
} }
/** /**
* The stream nmfetch returns. The response body is piped into it, the status code and * The stream nmfetch returns. The response body is piped into it, the status code and
@@ -50,6 +54,7 @@ declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
declare namespace nmfetch { declare namespace nmfetch {
var Cookies: typeof import("./cookies.js").default; var Cookies: typeof import("./cookies.js").default;
var DEFAULT_TIMEOUT: number; var DEFAULT_TIMEOUT: number;
var DEFAULT_MAX_TIME: number;
} }
type CookiesJar = Cookies; type CookiesJar = Cookies;
/** /**
+30
View File
@@ -49,6 +49,10 @@ const objects_js_1 = require("../shared/objects.js");
const MAX_REDIRECTS = 5; const MAX_REDIRECTS = 5;
// a stalled server would otherwise hold the request, and whatever waits on it, open forever // a stalled server would otherwise hold the request, and whatever waits on it, open forever
const DEFAULT_TIMEOUT = 60 * 1000; const DEFAULT_TIMEOUT = 60 * 1000;
// the inactivity timeout above is reset by every byte, so a server dripping one within each
// window could still hold the request, and the message being built from it, open for as
// long as it liked. The whole request, redirects included, has to be done within this
const DEFAULT_MAX_TIME = 2 * 60 * 1000;
// the body is usually buffered in memory by the caller, so an unbounded download is refused // the body is usually buffered in memory by the caller, so an unbounded download is refused
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
// Only genuine TLS settings are taken from options.tls. That object reaches us straight // Only genuine TLS settings are taken from options.tls. That object reaches us straight
@@ -126,6 +130,12 @@ function nmfetch(url, options) {
options.cookies = options.cookies || new cookies_js_1.default(); options.cookies = options.cookies || new cookies_js_1.default();
options.redirects = options.redirects || 0; options.redirects = options.redirects || 0;
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects; options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
if (!options.deadline) {
options.maxTime = typeof options.maxTime === 'number' && options.maxTime >= 0 ? options.maxTime : DEFAULT_MAX_TIME;
// the one deadline is shared with the requests the redirects lead to, every hop
// gets the time that is left rather than starting over
options.deadline = options.maxTime ? Date.now() + options.maxTime : 0;
}
const fetchRes = options.fetchRes; const fetchRes = options.fetchRes;
const parsed = parseFetchUrl(url); const parsed = parseFetchUrl(url);
if (!parsed) { if (!parsed) {
@@ -271,12 +281,22 @@ function nmfetch(url, options) {
}); });
return fetchRes; return fetchRes;
} }
// the whole request has to be done by options.deadline, see maxTime. The timer is
// released on every way out of the request, so it never outlives it
let deadlineTimer;
const clearDeadline = () => {
if (deadlineTimer) {
clearTimeout(deadlineTimer);
deadlineTimer = undefined;
}
};
// reports the first failure of this request on fetchRes and releases the request // reports the first failure of this request on fetchRes and releases the request
const fail = (err, sourceUrl = url) => { const fail = (err, sourceUrl = url) => {
if (finished) { if (finished) {
return; return;
} }
finished = true; finished = true;
clearDeadline();
setFetchCode(err); setFetchCode(err);
err.sourceUrl = sourceUrl; err.sourceUrl = sourceUrl;
fetchRes.emit('error', err); fetchRes.emit('error', err);
@@ -286,10 +306,16 @@ function nmfetch(url, options) {
if (timeout) { if (timeout) {
req.setTimeout(timeout, () => fail(new Error('Request Timeout'))); req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
} }
if (options.deadline) {
deadlineTimer = setTimeout(() => fail(new Error('Request exceeded the allowed ' + options.maxTime + ' ms')), Math.max(options.deadline - Date.now(), 1));
// a pending deadline is no reason to keep the process alive
deadlineTimer.unref();
}
req.on('error', (err) => fail(err)); req.on('error', (err) => fail(err));
// a consumer that destroys the response stream before it ended does not want the rest of // a consumer that destroys the response stream before it ended does not want the rest of
// the body, release the request now instead of when the timeout fires // the body, release the request now instead of when the timeout fires
fetchRes.once('close', () => { fetchRes.once('close', () => {
clearDeadline();
if (finished || fetchRes.readableEnded) { if (finished || fetchRes.readableEnded) {
return; return;
} }
@@ -358,6 +384,7 @@ function nmfetch(url, options) {
// this request is done with, release it so its socket and timeout do not // this request is done with, release it so its socket and timeout do not
// outlive it and report a late error on the shared fetchRes // outlive it and report a late error on the shared fetchRes
finished = true; finished = true;
clearDeadline();
res.resume(); res.resume();
req.abort(); req.abort();
return nmfetch(redirectUrl, options); return nmfetch(redirectUrl, options);
@@ -368,6 +395,8 @@ function nmfetch(url, options) {
return fail(new Error('Invalid status code ' + res.statusCode)); return fail(new Error('Invalid status code ' + res.statusCode));
} }
res.on('error', (err) => fail(err)); res.on('error', (err) => fail(err));
// the response is in, whatever is left of it is in memory and no longer on the network
res.once('end', () => clearDeadline());
const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES; const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
const source = inflate || res; const source = inflate || res;
let received = 0; let received = 0;
@@ -405,6 +434,7 @@ function nmfetch(url, options) {
} }
nmfetch.Cookies = cookies_js_1.default; nmfetch.Cookies = cookies_js_1.default;
nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT; nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME;
exports.default = nmfetch; exports.default = nmfetch;
module.exports = exports.default; module.exports = exports.default;
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true }); Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
+4 -1
View File
@@ -398,9 +398,12 @@ class Mail extends node_events_1.EventEmitter {
return callback(err); return callback(err);
} }
let cidCounter = 0; let cidCounter = 0;
// the media type is a bounded token: a quote, whitespace, '>' or '<' ends the
// attribute value and with it the type, so one img tag never scans ahead to a
// ';' that belongs to a later tag, which was quadratic
html = (html || '') html = (html || '')
.toString() .toString()
.replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]+);[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => { .replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;"'<>\s]{1,255});[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
const cid = node_crypto_1.default.randomBytes(10).toString('hex') + '@localhost'; const cid = node_crypto_1.default.randomBytes(10).toString('hex') + '@localhost';
if (!mail.data.attachments) { if (!mail.data.attachments) {
mail.data.attachments = []; mail.data.attachments = [];
+5 -1
View File
@@ -23,7 +23,11 @@ export interface SendMailOptions extends MailComposerOptions {
dsn?: SMTPEnvelopeDsn | undefined; dsn?: SMTPEnvelopeDsn | undefined;
/** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */ /** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */
requireTLSExtensionEnabled?: boolean | undefined; requireTLSExtensionEnabled?: boolean | undefined;
/** SMTP transports: per-message authentication settings, used instead of the transport level auth */ /**
* SMTP transports: per-message authentication settings, used instead of the transport level auth.
* Only the credential fields are taken from it (type, user, pass, accessToken, refreshToken, expires),
* the token endpoint and client settings stay those of the transport
*/
auth?: SMTPTransportAuthOptions | undefined; auth?: SMTPTransportAuthOptions | undefined;
/** SES transport: extra SendEmailCommand parameters merged into the API call */ /** SES transport: extra SendEmailCommand parameters merged into the API call */
ses?: SESSendEmailRequest | undefined; ses?: SESSendEmailRequest | undefined;
+4 -1
View File
@@ -183,7 +183,10 @@ class MailMessage {
const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent]; const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent];
for (const key of ['attachments', 'alternatives']) { for (const key of ['attachments', 'alternatives']) {
if (Array.isArray(data[key])) { if (Array.isArray(data[key])) {
values.push(...data[key]); // one at a time, spreading a long enough list into the arguments throws
for (const entry of data[key]) {
values.push(entry);
}
} }
} }
for (const value of values) { for (const value of values) {
+25 -13
View File
@@ -186,26 +186,38 @@ function encodeWord(data, mimeWordEncoding, maxLength) {
*/ */
function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) { function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) {
maxLength = maxLength || 0; maxLength = maxLength || 0;
// find first word with a non-printable ascii or special symbol in it // find the first and the last character that needs encoding, a quote or anything outside
const firstMatch = value.match(/(?:^|\s)([^\s]*["\u0080-\uFFFF])/); // printable ascii. A scan rather than a regex: matching the last such word with a
if (!firstMatch) { // backtracking pattern took quadratic time on a long word of non-ascii characters
let firstSpecial = -1;
let lastSpecial = -1;
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code === 0x22 || code >= 0x80) {
if (firstSpecial < 0) {
firstSpecial = i;
}
lastSpecial = i;
}
}
if (firstSpecial < 0) {
return value; return value;
} }
if (encodeAll) { if (encodeAll) {
// if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything // if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything
return encodeWord(value, mimeWordEncoding, maxLength); return encodeWord(value, mimeWordEncoding, maxLength);
} }
// find the last word with a non-printable ascii in it // encode from the start of the word holding the first such character to the end of the
const lastMatch = value.match(/(["\u0080-\uFFFF][^\s]*)[^"\u0080-\uFFFF]*$/); // word holding the last one, so the encoded words stay separated from the plain text by
if (!lastMatch) { // the whitespace that was already there
// should not happen let startIndex = firstSpecial;
return value; while (startIndex > 0 && !/\s/.test(value.charAt(startIndex - 1))) {
startIndex--;
}
let endIndex = lastSpecial + 1;
while (endIndex < value.length && !/\s/.test(value.charAt(endIndex))) {
endIndex++;
} }
const startIndex = firstMatch.index +
(firstMatch[0].match(/[^\s]/) || {
index: 0
}).index;
const endIndex = lastMatch.index + (lastMatch[1] || '').length;
return ((startIndex ? value.substr(0, startIndex) : '') + return ((startIndex ? value.substr(0, startIndex) : '') +
encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) + encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
(endIndex < value.length ? value.substr(endIndex) : '')); (endIndex < value.length ? value.substr(endIndex) : ''));
+97 -22
View File
@@ -64,6 +64,35 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
// else goes into angle brackets so that the header can not be read as more addresses than // else goes into angle brackets so that the header can not be read as more addresses than
// the envelope carries // the envelope carries
const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/; const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
/**
* A domain literal that ends an address, see _normalizeAddress
*/
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
/**
* Removes the whitespace inside every <...> of a References value. A "<" with no ">" after
* it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value
* from every such "<"
*
* @param value References header value
* @returns The value with the msg-ids closed up
*/
function stripWhitespaceInAngleBrackets(value) {
let result = '';
let pos = 0;
for (;;) {
const open = value.indexOf('<', pos);
if (open < 0) {
break;
}
const close = value.indexOf('>', open + 1);
if (close < 0) {
break;
}
result += value.slice(pos, open) + value.slice(open, close + 1).replace(/\s/g, '');
pos = close + 1;
}
return result + value.slice(pos);
}
// domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA // domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
// mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0 // mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
// controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the // controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
@@ -71,6 +100,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
// unroutable garbage into mail for a domain the sender never named. None of these // unroutable garbage into mail for a domain the sender never named. None of these
// characters are legal in a domain, so keep them away from the mapper. // characters are legal in a domain, so keep them away from the mapper.
const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/; const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
// RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
// the code points it encodes, so anything past these bounds could not have become a valid
// domain whichever way it is encoded. The bundled codec is super-linear in the length of a
// label, a 60 KB label of distinct code points took seconds to encode, several times per
// address, so it is only handed what could be a domain
const MAX_DOMAIN_LENGTH = 253;
const MAX_LABEL_LENGTH = 63;
// pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway // pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
const PIPELINE_NOOP = () => false; const PIPELINE_NOOP = () => false;
// The error a node streaming into an output that was destroyed stops with // The error a node streaming into an output that was destroyed stops with
@@ -79,6 +115,27 @@ function abortedError() {
err.code = errors.ESTREAM; err.code = errors.ESTREAM;
return err; return err;
} }
/**
* Checks a domain against the DNS length limits, counted in code points per label and in
* total. The label separators are the ones the bundled codec splits on.
*
* @param domain Domain to check
* @return true if a label or the domain as a whole is too long to be a valid domain
*/
function exceedsDomainLimits(domain) {
let total = 0;
let label = 0;
for (const char of domain) {
total++;
if (char === '.' || char === '。' || char === '.' || char === '。') {
label = 0;
}
else if (++label > MAX_LABEL_LENGTH) {
return true;
}
}
return total > MAX_DOMAIN_LENGTH;
}
/** /**
* Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do, * Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
* which is with UTS-46 mapping applied before the Punycode step. * which is with UTS-46 mapping applied before the Punycode step.
@@ -110,6 +167,10 @@ function normalizeDomain(domain, toUnicode) {
return mapped; return mapped;
} }
} }
if (exceedsDomainLimits(domain)) {
// too long to be a domain whichever way it is encoded, keep it as supplied
return domain;
}
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain); return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
} }
/** /**
@@ -614,6 +675,17 @@ class MimeNode {
/[\u0080-\uFFFF]/.test(this.content)) { /[\u0080-\uFFFF]/.test(this.content)) {
structured.params.charset = 'utf-8'; structured.params.charset = 'utf-8';
} }
if (this.filename) {
// the name parameter is derived from the filename below, so a name that
// arrived inside the content type is dropped first (a plain parameter or
// rfc2231 continuation sections), otherwise the header carries two and a
// receiver that reads the first sees a name the filename does not match
for (const paramKey of Object.keys(structured.params)) {
if (/^name(\*|$)/i.test(paramKey)) {
delete structured.params[paramKey];
}
}
}
value = mimeFuncs.buildHeaderValue(structured); value = mimeFuncs.buildHeaderValue(structured);
if (this.filename) { if (this.filename) {
// add support for non-compliant clients like QQ webmail // add support for non-compliant clients like QQ webmail
@@ -1223,12 +1295,12 @@ class MimeNode {
_normalizeHeaderKey(key) { _normalizeHeaderKey(key) {
key = (key || '') key = (key || '')
.toString() .toString()
// no newlines in keys // a field name is printable ascii without the colon, so a control char, DEL,
.replace(/\r?\n|\r/g, ' ') // whitespace or a colon can only be dropped, there is no quoting construct
// a field name is printable ascii without the colon, so a control char or DEL // around a field name. A colon or a space left in place ends the name early on
// can only be dropped, there is no quoting construct around a field name // the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
.replace(/[\x00-\x1f\x7f]/g, '') // second From header that neither setHeader() nor the Bcc filter could match
.trim() .replace(/[\s\x00-\x20\x7f:]/g, '')
.toLowerCase() .toLowerCase()
// use uppercase words, except MIME // use uppercase words, except MIME
.replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase()) .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
@@ -1314,26 +1386,26 @@ class MimeNode {
} }
return value; return value;
// space separated list of values enclosed in <> // space separated list of values enclosed in <>
case 'References': case 'References': {
value = [].concat const ids = [];
.apply([], [].concat(value || '').map(elm => { for (const entry of [].concat(value || '')) {
elm = (elm || '') const elm = (entry || '')
.toString() .toString()
.replace(/\r?\n|\r/g, ' ') .replace(/\r?\n|\r/g, ' ')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.trim(); .trim();
return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/); for (let id of stripWhitespaceInAngleBrackets(elm).split(/\s+/)) {
})) if (id.charAt(0) !== '<') {
.map(elm => { id = '<' + id;
if (elm.charAt(0) !== '<') { }
elm = '<' + elm; if (id.charAt(id.length - 1) !== '>') {
id = id + '>';
}
ids.push(id);
} }
if (elm.charAt(elm.length - 1) !== '>') { }
elm = elm + '>'; return ids.join(' ').trim();
} }
return elm;
});
return value.join(' ').trim();
case 'Date': case 'Date':
if (Object.prototype.toString.call(value) === '[object Date]') { if (Object.prototype.toString.call(value) === '[object Date]') {
return value.toUTCString().replace(/GMT/, '+0000'); return value.toUTCString().replace(/GMT/, '+0000');
@@ -1412,7 +1484,10 @@ class MimeNode {
// callers use an empty value to detect a missing address // callers use an empty value to detect a missing address
return address; return address;
} }
const lastAt = address.lastIndexOf('@'); // dtext includes '@', so a domain literal is split off at the '@' in front of it and
// not at the last one, which delivered 'a@[x@evil.com]' to evil.com
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
const lastAt = literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
if (lastAt < 0) { if (lastAt < 0) {
// Bare username, there is no domain to split off // Bare username, there is no domain to split off
return this._normalizeLocalPart(address); return this._normalizeLocalPart(address);
+1 -1
View File
@@ -1,3 +1,3 @@
export declare const name = "nodemailer"; export declare const name = "nodemailer";
export declare const version = "10.1.1"; export declare const version = "10.1.6";
export declare const homepage = "https://nodemailer.com/"; export declare const homepage = "https://nodemailer.com/";
+1 -1
View File
@@ -3,5 +3,5 @@
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.homepage = exports.version = exports.name = void 0; exports.homepage = exports.version = exports.name = void 0;
exports.name = 'nodemailer'; exports.name = 'nodemailer';
exports.version = '10.1.1'; exports.version = '10.1.6';
exports.homepage = 'https://nodemailer.com/'; exports.homepage = 'https://nodemailer.com/';
+22 -5
View File
@@ -58,10 +58,27 @@ const punycode = __importStar(require("../punycode/index.js"));
// Matches a "scheme:" not followed by "//" (and with something after it), used // Matches a "scheme:" not followed by "//" (and with something after it), used
// to re-insert the authority separator the legacy parser did not require. // to re-insert the authority separator the legacy parser did not require.
const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/; const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/;
// Leading and trailing C0 controls and spaces, which the WHATWG parser strips before it /**
// looks at the input. Stripped up front so that the slash-less form is recognized in a * Drops the leading and trailing C0 controls and spaces, which the WHATWG parser strips
// value read from a file with a trailing newline as well. * before it looks at the input. Stripped up front so that the slash-less form is recognized
const SURROUNDING_WHITESPACE = /^[\x00-\x20]+|[\x00-\x20]+$/g; * in a value read from a file with a trailing newline as well. An index scan, the regex
* /^[\x00-\x20]+|[\x00-\x20]+$/g retried a run of spaces inside the value from every
* position in it
*
* @param input URL string
* @returns The string without the surrounding controls and spaces
*/
function stripSurroundingWhitespace(input) {
let start = 0;
let end = input.length;
while (start < end && input.charCodeAt(start) <= 0x20) {
start++;
}
while (end > start && input.charCodeAt(end - 1) <= 0x20) {
end--;
}
return input.slice(start, end);
}
// Leading characters legacy url.parse() skips before it reads the scheme // Leading characters legacy url.parse() skips before it reads the scheme
const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/; const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/;
// The authority of a "scheme://authority/..." or a scheme-relative "//authority/..." // The authority of a "scheme://authority/..." or a scheme-relative "//authority/..."
@@ -156,7 +173,7 @@ function normalizeHostname(raw, href) {
return mapped; return mapped;
} }
const parse = (input, parseQueryString) => { const parse = (input, parseQueryString) => {
input = (input || '').replace(SURROUNDING_WHITESPACE, ''); input = stripSurroundingWhitespace(input || '');
// Legacy url.parse() parses a "user:pass@host:port" authority that follows // Legacy url.parse() parses a "user:pass@host:port" authority that follows
// the scheme even without the "//" separator, for schemes outside its // the scheme even without the "//" separator, for schemes outside its
// built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG // built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG
+16 -1
View File
@@ -114,6 +114,21 @@ function isPartialResponse(str) {
function isPartialLine(line) { function isPartialLine(line) {
return /^\d+-/.test(line); return /^\d+-/.test(line);
} }
/**
* The log form of a command a custom authentication handler sends. Such a command carries
* the credentials in whatever shape its mechanism uses, so only an AUTH verb with its
* mechanism name is kept, the way the built-in mechanisms hide their own secrets.
*
* @param cmd Command the handler sends
* @return What the transaction log gets instead
*/
function maskAuthCommand(cmd) {
const match = (cmd || '').toString().match(/^AUTH\s+[^\s]+/i);
if (!match) {
return '/* secret */';
}
return match[0].length === cmd.length ? cmd : match[0] + ' /* secret */';
}
/** /**
* Generates a SMTP connection object * Generates a SMTP connection object
* *
@@ -601,7 +616,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
} }
done(null, data); done(null, data);
}); });
setImmediate(() => this._sendCommand(cmd)); setImmediate(() => this._sendCommand(cmd, maskAuthCommand(cmd)));
return promise; return promise;
}; };
const handlerResponse = handler({ const handlerResponse = handler({
+12 -1
View File
@@ -43,6 +43,13 @@ const shared = __importStar(require("../shared/index.js"));
const index_js_3 = __importDefault(require("../xoauth2/index.js")); const index_js_3 = __importDefault(require("../xoauth2/index.js"));
const errors = __importStar(require("../errors.js")); const errors = __importStar(require("../errors.js"));
const packageData = __importStar(require("../package-info.js")); const packageData = __importStar(require("../package-info.js"));
// The keys a message's `auth` may set on top of the transport's auth settings: the ones that
// say who logs in and with what. Everything else in the auth block (accessUrl, clientId,
// clientSecret, customHeaders, customParams, tls, privateKey, serviceClient and the rest)
// names the endpoint the credentials go to or is a secret of the application itself, and
// a message that could set `auth.accessUrl` and force a refresh would have the transport's
// client secret and refresh token posted to a URL of its choosing
const MESSAGE_AUTH_KEYS = ['type', 'user', 'pass', 'accessToken', 'refreshToken', 'expires'];
/** /**
* Creates a SMTP transport object for Nodemailer * Creates a SMTP transport object for Nodemailer
* *
@@ -102,7 +109,11 @@ class SMTPTransport extends node_events_1.EventEmitter {
} }
return this.auth; return this.auth;
} }
const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}, typeof authOpts === 'object' ? authOpts : {}); const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {});
if (typeof authOpts === 'object') {
// see MESSAGE_AUTH_KEYS
shared.copyOwnKeys(authData, authOpts, key => !MESSAGE_AUTH_KEYS.includes(key));
}
if (Object.keys(authData).length === 0) { if (Object.keys(authData).length === 0) {
return false; return false;
} }
+7
View File
@@ -363,6 +363,13 @@ exports.services = {
"port": 587, "port": 587,
"secure": false "secure": false
}, },
"Oveyon": {
"description": "Oveyon",
"host": "smtp.oveyon.com",
"port": 587,
"secure": false,
"requireTLS": true
},
"Postmark": { "Postmark": {
"description": "Postmark", "description": "Postmark",
"aliases": [ "aliases": [
+8 -2
View File
@@ -264,8 +264,14 @@ class XOAuth2 extends node_stream_1.Stream {
grant_type: 'refresh_token' grant_type: 'refresh_token'
}; };
} }
Object.assign(urlOptions, this.options.customParams); const customParams = this.options.customParams || {};
Object.assign(loggedUrlOptions, this.options.customParams); Object.assign(urlOptions, customParams);
// a custom param is as likely to be a client assertion or an API key as a tenant
// name, so the log gets the same glimpse of it that it gets of the client secret
for (const key of Object.keys(customParams)) {
const param = customParams[key];
loggedUrlOptions[key] = (param === undefined || param === null ? '' : String(param)).substr(0, 6) + '...';
}
this.logger.debug({ this.logger.debug({
tnx: 'OAUTH2', tnx: 'OAUTH2',
user: this.options.user, user: this.options.user,
+699 -271
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -128,8 +128,9 @@ class DKIMSigner {
} }
createWriteCache() { createWriteCache() {
this.output.usingCache = true; this.output.usingCache = true;
// pipe remainings to cache file // pipe remainings to cache file. It holds the message body and cacheDir is often a
this.cache = fs.createWriteStream(this.cachePath); // shared directory such as /tmp, so nobody but the owner gets to read it
this.cache = fs.createWriteStream(this.cachePath, { mode: 0o600 });
this.cache.once('error', err => { this.cache.once('error', err => {
// drain input // drain input
this.relaxedBody.unpipe(this.cache); this.relaxedBody.unpipe(this.cache);
+5
View File
@@ -27,6 +27,8 @@ export interface FetchOptions {
} | undefined; } | undefined;
/** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */ /** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */
timeout?: number | undefined; timeout?: number | undefined;
/** Time limit for the whole request in milliseconds, redirects included, defaults to 120000, 0 disables it */
maxTime?: number | undefined;
/** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */ /** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */
maxBytes?: number | undefined; maxBytes?: number | undefined;
/** Maximum number of redirects to follow (default 5) */ /** Maximum number of redirects to follow (default 5) */
@@ -37,6 +39,8 @@ export interface FetchOptions {
redirects?: number | undefined; redirects?: number | undefined;
/** Response stream shared across redirects, set by nmfetch itself */ /** Response stream shared across redirects, set by nmfetch itself */
fetchRes?: FetchResponse | undefined; fetchRes?: FetchResponse | undefined;
/** Timestamp the whole request has to be done by, shared across redirects, set by nmfetch itself */
deadline?: number | undefined;
} }
/** /**
* The stream nmfetch returns. The response body is piped into it, the status code and * The stream nmfetch returns. The response body is piped into it, the status code and
@@ -50,6 +54,7 @@ declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
declare namespace nmfetch { declare namespace nmfetch {
var Cookies: typeof import("./cookies.js").default; var Cookies: typeof import("./cookies.js").default;
var DEFAULT_TIMEOUT: number; var DEFAULT_TIMEOUT: number;
var DEFAULT_MAX_TIME: number;
} }
type CookiesJar = Cookies; type CookiesJar = Cookies;
/** /**
+30
View File
@@ -11,6 +11,10 @@ import { isProtoKey } from '../shared/objects.js';
const MAX_REDIRECTS = 5; const MAX_REDIRECTS = 5;
// a stalled server would otherwise hold the request, and whatever waits on it, open forever // a stalled server would otherwise hold the request, and whatever waits on it, open forever
const DEFAULT_TIMEOUT = 60 * 1000; const DEFAULT_TIMEOUT = 60 * 1000;
// the inactivity timeout above is reset by every byte, so a server dripping one within each
// window could still hold the request, and the message being built from it, open for as
// long as it liked. The whole request, redirects included, has to be done within this
const DEFAULT_MAX_TIME = 2 * 60 * 1000;
// the body is usually buffered in memory by the caller, so an unbounded download is refused // the body is usually buffered in memory by the caller, so an unbounded download is refused
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
// Only genuine TLS settings are taken from options.tls. That object reaches us straight // Only genuine TLS settings are taken from options.tls. That object reaches us straight
@@ -88,6 +92,12 @@ function nmfetch(url, options) {
options.cookies = options.cookies || new Cookies(); options.cookies = options.cookies || new Cookies();
options.redirects = options.redirects || 0; options.redirects = options.redirects || 0;
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects; options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
if (!options.deadline) {
options.maxTime = typeof options.maxTime === 'number' && options.maxTime >= 0 ? options.maxTime : DEFAULT_MAX_TIME;
// the one deadline is shared with the requests the redirects lead to, every hop
// gets the time that is left rather than starting over
options.deadline = options.maxTime ? Date.now() + options.maxTime : 0;
}
const fetchRes = options.fetchRes; const fetchRes = options.fetchRes;
const parsed = parseFetchUrl(url); const parsed = parseFetchUrl(url);
if (!parsed) { if (!parsed) {
@@ -233,12 +243,22 @@ function nmfetch(url, options) {
}); });
return fetchRes; return fetchRes;
} }
// the whole request has to be done by options.deadline, see maxTime. The timer is
// released on every way out of the request, so it never outlives it
let deadlineTimer;
const clearDeadline = () => {
if (deadlineTimer) {
clearTimeout(deadlineTimer);
deadlineTimer = undefined;
}
};
// reports the first failure of this request on fetchRes and releases the request // reports the first failure of this request on fetchRes and releases the request
const fail = (err, sourceUrl = url) => { const fail = (err, sourceUrl = url) => {
if (finished) { if (finished) {
return; return;
} }
finished = true; finished = true;
clearDeadline();
setFetchCode(err); setFetchCode(err);
err.sourceUrl = sourceUrl; err.sourceUrl = sourceUrl;
fetchRes.emit('error', err); fetchRes.emit('error', err);
@@ -248,10 +268,16 @@ function nmfetch(url, options) {
if (timeout) { if (timeout) {
req.setTimeout(timeout, () => fail(new Error('Request Timeout'))); req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
} }
if (options.deadline) {
deadlineTimer = setTimeout(() => fail(new Error('Request exceeded the allowed ' + options.maxTime + ' ms')), Math.max(options.deadline - Date.now(), 1));
// a pending deadline is no reason to keep the process alive
deadlineTimer.unref();
}
req.on('error', (err) => fail(err)); req.on('error', (err) => fail(err));
// a consumer that destroys the response stream before it ended does not want the rest of // a consumer that destroys the response stream before it ended does not want the rest of
// the body, release the request now instead of when the timeout fires // the body, release the request now instead of when the timeout fires
fetchRes.once('close', () => { fetchRes.once('close', () => {
clearDeadline();
if (finished || fetchRes.readableEnded) { if (finished || fetchRes.readableEnded) {
return; return;
} }
@@ -320,6 +346,7 @@ function nmfetch(url, options) {
// this request is done with, release it so its socket and timeout do not // this request is done with, release it so its socket and timeout do not
// outlive it and report a late error on the shared fetchRes // outlive it and report a late error on the shared fetchRes
finished = true; finished = true;
clearDeadline();
res.resume(); res.resume();
req.abort(); req.abort();
return nmfetch(redirectUrl, options); return nmfetch(redirectUrl, options);
@@ -330,6 +357,8 @@ function nmfetch(url, options) {
return fail(new Error('Invalid status code ' + res.statusCode)); return fail(new Error('Invalid status code ' + res.statusCode));
} }
res.on('error', (err) => fail(err)); res.on('error', (err) => fail(err));
// the response is in, whatever is left of it is in memory and no longer on the network
res.once('end', () => clearDeadline());
const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES; const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
const source = inflate || res; const source = inflate || res;
let received = 0; let received = 0;
@@ -367,4 +396,5 @@ function nmfetch(url, options) {
} }
nmfetch.Cookies = Cookies; nmfetch.Cookies = Cookies;
nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT; nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME;
export default nmfetch; export default nmfetch;
+4 -1
View File
@@ -360,9 +360,12 @@ class Mail extends EventEmitter {
return callback(err); return callback(err);
} }
let cidCounter = 0; let cidCounter = 0;
// the media type is a bounded token: a quote, whitespace, '>' or '<' ends the
// attribute value and with it the type, so one img tag never scans ahead to a
// ';' that belongs to a later tag, which was quadratic
html = (html || '') html = (html || '')
.toString() .toString()
.replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]+);[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => { .replace(/(<img\b[^<>]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;"'<>\s]{1,255});[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
const cid = crypto.randomBytes(10).toString('hex') + '@localhost'; const cid = crypto.randomBytes(10).toString('hex') + '@localhost';
if (!mail.data.attachments) { if (!mail.data.attachments) {
mail.data.attachments = []; mail.data.attachments = [];
+5 -1
View File
@@ -23,7 +23,11 @@ export interface SendMailOptions extends MailComposerOptions {
dsn?: SMTPEnvelopeDsn | undefined; dsn?: SMTPEnvelopeDsn | undefined;
/** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */ /** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */
requireTLSExtensionEnabled?: boolean | undefined; requireTLSExtensionEnabled?: boolean | undefined;
/** SMTP transports: per-message authentication settings, used instead of the transport level auth */ /**
* SMTP transports: per-message authentication settings, used instead of the transport level auth.
* Only the credential fields are taken from it (type, user, pass, accessToken, refreshToken, expires),
* the token endpoint and client settings stay those of the transport
*/
auth?: SMTPTransportAuthOptions | undefined; auth?: SMTPTransportAuthOptions | undefined;
/** SES transport: extra SendEmailCommand parameters merged into the API call */ /** SES transport: extra SendEmailCommand parameters merged into the API call */
ses?: SESSendEmailRequest | undefined; ses?: SESSendEmailRequest | undefined;
+4 -1
View File
@@ -145,7 +145,10 @@ export default class MailMessage {
const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent]; const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent];
for (const key of ['attachments', 'alternatives']) { for (const key of ['attachments', 'alternatives']) {
if (Array.isArray(data[key])) { if (Array.isArray(data[key])) {
values.push(...data[key]); // one at a time, spreading a long enough list into the arguments throws
for (const entry of data[key]) {
values.push(entry);
}
} }
} }
for (const value of values) { for (const value of values) {
+25 -13
View File
@@ -137,26 +137,38 @@ export function encodeWord(data, mimeWordEncoding, maxLength) {
*/ */
export function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) { export function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) {
maxLength = maxLength || 0; maxLength = maxLength || 0;
// find first word with a non-printable ascii or special symbol in it // find the first and the last character that needs encoding, a quote or anything outside
const firstMatch = value.match(/(?:^|\s)([^\s]*["\u0080-\uFFFF])/); // printable ascii. A scan rather than a regex: matching the last such word with a
if (!firstMatch) { // backtracking pattern took quadratic time on a long word of non-ascii characters
let firstSpecial = -1;
let lastSpecial = -1;
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code === 0x22 || code >= 0x80) {
if (firstSpecial < 0) {
firstSpecial = i;
}
lastSpecial = i;
}
}
if (firstSpecial < 0) {
return value; return value;
} }
if (encodeAll) { if (encodeAll) {
// if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything // if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything
return encodeWord(value, mimeWordEncoding, maxLength); return encodeWord(value, mimeWordEncoding, maxLength);
} }
// find the last word with a non-printable ascii in it // encode from the start of the word holding the first such character to the end of the
const lastMatch = value.match(/(["\u0080-\uFFFF][^\s]*)[^"\u0080-\uFFFF]*$/); // word holding the last one, so the encoded words stay separated from the plain text by
if (!lastMatch) { // the whitespace that was already there
// should not happen let startIndex = firstSpecial;
return value; while (startIndex > 0 && !/\s/.test(value.charAt(startIndex - 1))) {
startIndex--;
}
let endIndex = lastSpecial + 1;
while (endIndex < value.length && !/\s/.test(value.charAt(endIndex))) {
endIndex++;
} }
const startIndex = firstMatch.index +
(firstMatch[0].match(/[^\s]/) || {
index: 0
}).index;
const endIndex = lastMatch.index + (lastMatch[1] || '').length;
return ((startIndex ? value.substr(0, startIndex) : '') + return ((startIndex ? value.substr(0, startIndex) : '') +
encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) + encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
(endIndex < value.length ? value.substr(endIndex) : '')); (endIndex < value.length ? value.substr(endIndex) : ''));
+97 -22
View File
@@ -26,6 +26,35 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
// else goes into angle brackets so that the header can not be read as more addresses than // else goes into angle brackets so that the header can not be read as more addresses than
// the envelope carries // the envelope carries
const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/; const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
/**
* A domain literal that ends an address, see _normalizeAddress
*/
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
/**
* Removes the whitespace inside every <...> of a References value. A "<" with no ">" after
* it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value
* from every such "<"
*
* @param value References header value
* @returns The value with the msg-ids closed up
*/
function stripWhitespaceInAngleBrackets(value) {
let result = '';
let pos = 0;
for (;;) {
const open = value.indexOf('<', pos);
if (open < 0) {
break;
}
const close = value.indexOf('>', open + 1);
if (close < 0) {
break;
}
result += value.slice(pos, open) + value.slice(open, close + 1).replace(/\s/g, '');
pos = close + 1;
}
return result + value.slice(pos);
}
// domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA // domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
// mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0 // mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
// controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the // controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
@@ -33,6 +62,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
// unroutable garbage into mail for a domain the sender never named. None of these // unroutable garbage into mail for a domain the sender never named. None of these
// characters are legal in a domain, so keep them away from the mapper. // characters are legal in a domain, so keep them away from the mapper.
const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/; const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
// RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than
// the code points it encodes, so anything past these bounds could not have become a valid
// domain whichever way it is encoded. The bundled codec is super-linear in the length of a
// label, a 60 KB label of distinct code points took seconds to encode, several times per
// address, so it is only handed what could be a domain
const MAX_DOMAIN_LENGTH = 253;
const MAX_LABEL_LENGTH = 63;
// pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway // pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway
const PIPELINE_NOOP = () => false; const PIPELINE_NOOP = () => false;
// The error a node streaming into an output that was destroyed stops with // The error a node streaming into an output that was destroyed stops with
@@ -41,6 +77,27 @@ function abortedError() {
err.code = errors.ESTREAM; err.code = errors.ESTREAM;
return err; return err;
} }
/**
* Checks a domain against the DNS length limits, counted in code points per label and in
* total. The label separators are the ones the bundled codec splits on.
*
* @param domain Domain to check
* @return true if a label or the domain as a whole is too long to be a valid domain
*/
function exceedsDomainLimits(domain) {
let total = 0;
let label = 0;
for (const char of domain) {
total++;
if (char === '.' || char === '。' || char === '.' || char === '。') {
label = 0;
}
else if (++label > MAX_LABEL_LENGTH) {
return true;
}
}
return total > MAX_DOMAIN_LENGTH;
}
/** /**
* Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do, * Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
* which is with UTS-46 mapping applied before the Punycode step. * which is with UTS-46 mapping applied before the Punycode step.
@@ -72,6 +129,10 @@ function normalizeDomain(domain, toUnicode) {
return mapped; return mapped;
} }
} }
if (exceedsDomainLimits(domain)) {
// too long to be a domain whichever way it is encoded, keep it as supplied
return domain;
}
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain); return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
} }
/** /**
@@ -576,6 +637,17 @@ class MimeNode {
/[\u0080-\uFFFF]/.test(this.content)) { /[\u0080-\uFFFF]/.test(this.content)) {
structured.params.charset = 'utf-8'; structured.params.charset = 'utf-8';
} }
if (this.filename) {
// the name parameter is derived from the filename below, so a name that
// arrived inside the content type is dropped first (a plain parameter or
// rfc2231 continuation sections), otherwise the header carries two and a
// receiver that reads the first sees a name the filename does not match
for (const paramKey of Object.keys(structured.params)) {
if (/^name(\*|$)/i.test(paramKey)) {
delete structured.params[paramKey];
}
}
}
value = mimeFuncs.buildHeaderValue(structured); value = mimeFuncs.buildHeaderValue(structured);
if (this.filename) { if (this.filename) {
// add support for non-compliant clients like QQ webmail // add support for non-compliant clients like QQ webmail
@@ -1185,12 +1257,12 @@ class MimeNode {
_normalizeHeaderKey(key) { _normalizeHeaderKey(key) {
key = (key || '') key = (key || '')
.toString() .toString()
// no newlines in keys // a field name is printable ascii without the colon, so a control char, DEL,
.replace(/\r?\n|\r/g, ' ') // whitespace or a colon can only be dropped, there is no quoting construct
// a field name is printable ascii without the colon, so a control char or DEL // around a field name. A colon or a space left in place ends the name early on
// can only be dropped, there is no quoting construct around a field name // the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a
.replace(/[\x00-\x1f\x7f]/g, '') // second From header that neither setHeader() nor the Bcc filter could match
.trim() .replace(/[\s\x00-\x20\x7f:]/g, '')
.toLowerCase() .toLowerCase()
// use uppercase words, except MIME // use uppercase words, except MIME
.replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase()) .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
@@ -1276,26 +1348,26 @@ class MimeNode {
} }
return value; return value;
// space separated list of values enclosed in <> // space separated list of values enclosed in <>
case 'References': case 'References': {
value = [].concat const ids = [];
.apply([], [].concat(value || '').map(elm => { for (const entry of [].concat(value || '')) {
elm = (elm || '') const elm = (entry || '')
.toString() .toString()
.replace(/\r?\n|\r/g, ' ') .replace(/\r?\n|\r/g, ' ')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.trim(); .trim();
return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/); for (let id of stripWhitespaceInAngleBrackets(elm).split(/\s+/)) {
})) if (id.charAt(0) !== '<') {
.map(elm => { id = '<' + id;
if (elm.charAt(0) !== '<') { }
elm = '<' + elm; if (id.charAt(id.length - 1) !== '>') {
id = id + '>';
}
ids.push(id);
} }
if (elm.charAt(elm.length - 1) !== '>') { }
elm = elm + '>'; return ids.join(' ').trim();
} }
return elm;
});
return value.join(' ').trim();
case 'Date': case 'Date':
if (Object.prototype.toString.call(value) === '[object Date]') { if (Object.prototype.toString.call(value) === '[object Date]') {
return value.toUTCString().replace(/GMT/, '+0000'); return value.toUTCString().replace(/GMT/, '+0000');
@@ -1374,7 +1446,10 @@ class MimeNode {
// callers use an empty value to detect a missing address // callers use an empty value to detect a missing address
return address; return address;
} }
const lastAt = address.lastIndexOf('@'); // dtext includes '@', so a domain literal is split off at the '@' in front of it and
// not at the last one, which delivered 'a@[x@evil.com]' to evil.com
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
const lastAt = literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
if (lastAt < 0) { if (lastAt < 0) {
// Bare username, there is no domain to split off // Bare username, there is no domain to split off
return this._normalizeLocalPart(address); return this._normalizeLocalPart(address);
+1 -1
View File
@@ -1,3 +1,3 @@
export declare const name = "nodemailer"; export declare const name = "nodemailer";
export declare const version = "10.1.1"; export declare const version = "10.1.6";
export declare const homepage = "https://nodemailer.com/"; export declare const homepage = "https://nodemailer.com/";
+1 -1
View File
@@ -1,4 +1,4 @@
// Generated by scripts/build.js from package.json. Do not edit by hand. // Generated by scripts/build.js from package.json. Do not edit by hand.
export const name = 'nodemailer'; export const name = 'nodemailer';
export const version = '10.1.1'; export const version = '10.1.6';
export const homepage = 'https://nodemailer.com/'; export const homepage = 'https://nodemailer.com/';
+22 -5
View File
@@ -19,10 +19,27 @@ import * as punycode from '../punycode/index.js';
// Matches a "scheme:" not followed by "//" (and with something after it), used // Matches a "scheme:" not followed by "//" (and with something after it), used
// to re-insert the authority separator the legacy parser did not require. // to re-insert the authority separator the legacy parser did not require.
const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/; const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/;
// Leading and trailing C0 controls and spaces, which the WHATWG parser strips before it /**
// looks at the input. Stripped up front so that the slash-less form is recognized in a * Drops the leading and trailing C0 controls and spaces, which the WHATWG parser strips
// value read from a file with a trailing newline as well. * before it looks at the input. Stripped up front so that the slash-less form is recognized
const SURROUNDING_WHITESPACE = /^[\x00-\x20]+|[\x00-\x20]+$/g; * in a value read from a file with a trailing newline as well. An index scan, the regex
* /^[\x00-\x20]+|[\x00-\x20]+$/g retried a run of spaces inside the value from every
* position in it
*
* @param input URL string
* @returns The string without the surrounding controls and spaces
*/
function stripSurroundingWhitespace(input) {
let start = 0;
let end = input.length;
while (start < end && input.charCodeAt(start) <= 0x20) {
start++;
}
while (end > start && input.charCodeAt(end - 1) <= 0x20) {
end--;
}
return input.slice(start, end);
}
// Leading characters legacy url.parse() skips before it reads the scheme // Leading characters legacy url.parse() skips before it reads the scheme
const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/; const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/;
// The authority of a "scheme://authority/..." or a scheme-relative "//authority/..." // The authority of a "scheme://authority/..." or a scheme-relative "//authority/..."
@@ -117,7 +134,7 @@ function normalizeHostname(raw, href) {
return mapped; return mapped;
} }
export const parse = (input, parseQueryString) => { export const parse = (input, parseQueryString) => {
input = (input || '').replace(SURROUNDING_WHITESPACE, ''); input = stripSurroundingWhitespace(input || '');
// Legacy url.parse() parses a "user:pass@host:port" authority that follows // Legacy url.parse() parses a "user:pass@host:port" authority that follows
// the scheme even without the "//" separator, for schemes outside its // the scheme even without the "//" separator, for schemes outside its
// built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG // built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG
+16 -1
View File
@@ -76,6 +76,21 @@ function isPartialResponse(str) {
function isPartialLine(line) { function isPartialLine(line) {
return /^\d+-/.test(line); return /^\d+-/.test(line);
} }
/**
* The log form of a command a custom authentication handler sends. Such a command carries
* the credentials in whatever shape its mechanism uses, so only an AUTH verb with its
* mechanism name is kept, the way the built-in mechanisms hide their own secrets.
*
* @param cmd Command the handler sends
* @return What the transaction log gets instead
*/
function maskAuthCommand(cmd) {
const match = (cmd || '').toString().match(/^AUTH\s+[^\s]+/i);
if (!match) {
return '/* secret */';
}
return match[0].length === cmd.length ? cmd : match[0] + ' /* secret */';
}
/** /**
* Generates a SMTP connection object * Generates a SMTP connection object
* *
@@ -563,7 +578,7 @@ class SMTPConnection extends EventEmitter {
} }
done(null, data); done(null, data);
}); });
setImmediate(() => this._sendCommand(cmd)); setImmediate(() => this._sendCommand(cmd, maskAuthCommand(cmd)));
return promise; return promise;
}; };
const handlerResponse = handler({ const handlerResponse = handler({
+12 -1
View File
@@ -5,6 +5,13 @@ import * as shared from '../shared/index.js';
import XOAuth2 from '../xoauth2/index.js'; import XOAuth2 from '../xoauth2/index.js';
import * as errors from '../errors.js'; import * as errors from '../errors.js';
import * as packageData from '../package-info.js'; import * as packageData from '../package-info.js';
// The keys a message's `auth` may set on top of the transport's auth settings: the ones that
// say who logs in and with what. Everything else in the auth block (accessUrl, clientId,
// clientSecret, customHeaders, customParams, tls, privateKey, serviceClient and the rest)
// names the endpoint the credentials go to or is a secret of the application itself, and
// a message that could set `auth.accessUrl` and force a refresh would have the transport's
// client secret and refresh token posted to a URL of its choosing
const MESSAGE_AUTH_KEYS = ['type', 'user', 'pass', 'accessToken', 'refreshToken', 'expires'];
/** /**
* Creates a SMTP transport object for Nodemailer * Creates a SMTP transport object for Nodemailer
* *
@@ -64,7 +71,11 @@ class SMTPTransport extends EventEmitter {
} }
return this.auth; return this.auth;
} }
const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}, typeof authOpts === 'object' ? authOpts : {}); const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {});
if (typeof authOpts === 'object') {
// see MESSAGE_AUTH_KEYS
shared.copyOwnKeys(authData, authOpts, key => !MESSAGE_AUTH_KEYS.includes(key));
}
if (Object.keys(authData).length === 0) { if (Object.keys(authData).length === 0) {
return false; return false;
} }
+7
View File
@@ -360,6 +360,13 @@ export const services = {
"port": 587, "port": 587,
"secure": false "secure": false
}, },
"Oveyon": {
"description": "Oveyon",
"host": "smtp.oveyon.com",
"port": 587,
"secure": false,
"requireTLS": true
},
"Postmark": { "Postmark": {
"description": "Postmark", "description": "Postmark",
"aliases": [ "aliases": [
+8 -2
View File
@@ -226,8 +226,14 @@ class XOAuth2 extends Stream {
grant_type: 'refresh_token' grant_type: 'refresh_token'
}; };
} }
Object.assign(urlOptions, this.options.customParams); const customParams = this.options.customParams || {};
Object.assign(loggedUrlOptions, this.options.customParams); Object.assign(urlOptions, customParams);
// a custom param is as likely to be a client assertion or an API key as a tenant
// name, so the log gets the same glimpse of it that it gets of the client secret
for (const key of Object.keys(customParams)) {
const param = customParams[key];
loggedUrlOptions[key] = (param === undefined || param === null ? '' : String(param)).substr(0, 6) + '...';
}
this.logger.debug({ this.logger.debug({
tnx: 'OAUTH2', tnx: 'OAUTH2',
user: this.options.user, user: this.options.user,
+8
View File
@@ -333,6 +333,14 @@
"secure": false "secure": false
}, },
"Oveyon": {
"description": "Oveyon",
"host": "smtp.oveyon.com",
"port": 587,
"secure": false,
"requireTLS": true
},
"Postmark": { "Postmark": {
"description": "Postmark", "description": "Postmark",
"aliases": ["PostmarkApp"], "aliases": ["PostmarkApp"],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodemailer", "name": "nodemailer",
"version": "10.1.1", "version": "10.1.6",
"description": "Easy as cake e-mail sending from your Node.js applications", "description": "Easy as cake e-mail sending from your Node.js applications",
"type": "module", "type": "module",
"main": "./dist/cjs/nodemailer.js", "main": "./dist/cjs/nodemailer.js",