node_modules: update (#352)

Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
Dawid Dziurlaanddawidd6 authored and GitHub committed 2026-10-22 08:43:32 +02:00
1 parent 4026a70a6b
commit 47af4a10b5
53 files changed
+630 -217

No files matched your search

+3 -3
View File
@@ -94,9 +94,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "10.1.6", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.6.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.0.tgz",
"integrity": "sha512-8j/UEmA4AXr51URr3Ob0h+RCBtr0NKJ76Y2P9wJT6Eu3dRyruj2ULh2e6aiKrGg8nlUj1oX6N5h8dHA82EouaQ==", "integrity": "sha512-/XyGw6kX2stMdKGUJdAQmYAeY1FscWfdRydfd2NtK3A3KDfUUXCqZzExSNAt2xhh90X2qLCR+o2Gd48YPJcJwA==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+23
View File
@@ -1,5 +1,28 @@
# CHANGELOG # CHANGELOG
## [10.2.0](https://github.com/nodemailer/nodemailer/compare/v10.1.7...v10.2.0) (2026-10-21)
### Features
* add info.serverMessageId from the final 250 reply ([108dddd](https://github.com/nodemailer/nodemailer/commit/108dddd88d9d71f708630b7aba7fa2a7d2f14076))
* add pendingCount() to the SMTP pool ([6cffb70](https://github.com/nodemailer/nodemailer/commit/6cffb7037269a6012d992c263a8927fcaf0563f2))
* make header value encoding public with MimeNode encodeHeaderValue() ([3b048c7](https://github.com/nodemailer/nodemailer/commit/3b048c7a0fb60f946985b09b3143b0680194e47b))
* mark SMTP errors as permanent or transient with err.permanent ([6a20e2a](https://github.com/nodemailer/nodemailer/commit/6a20e2ae109eeb5662235d72d1e5a2d38515997a))
### Bug Fixes
* label an ASCII message/rfc822 attachment 7bit instead of 8bit ([ad66fa1](https://github.com/nodemailer/nodemailer/commit/ad66fa1a048135867a629bcb7b6dc308ee06a945))
* report the recipients an LMTP server refuses after DATA in rejectedErrors ([dd64eb3](https://github.com/nodemailer/nodemailer/commit/dd64eb3171e3ab5629828084d3944a7859790968))
## [10.1.7](https://github.com/nodemailer/nodemailer/compare/v10.1.6...v10.1.7) (2026-10-18)
### Bug Fixes
* **mime-funcs:** encode parameter values that look like encoded words ([651d79b](https://github.com/nodemailer/nodemailer/commit/651d79b0e1067b93b0ae8fc1afbdf383a46c214d))
## [10.1.6](https://github.com/nodemailer/nodemailer/compare/v10.1.5...v10.1.6) (2026-10-18) ## [10.1.6](https://github.com/nodemailer/nodemailer/compare/v10.1.5...v10.1.6) (2026-10-18)
+2 -5
View File
@@ -693,9 +693,7 @@ function _handleAddress(tokens, from, to) {
// A run that is a quoted local part with its '@' and domain is an addr-spec of // A run that is a quoted local part with its '@' and domain is an addr-spec of
// its own, with the quoting put back on, and takes its turn like any other // its own, with the quoting put back on, and takes its turn like any other
const local = data.quotedLocalAt[i]; const local = data.quotedLocalAt[i];
const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) ? _quoteLocalPart(data.text[i]) : '';
? _quoteLocalPart(data.text[i])
: '';
if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) { if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
data.address = [quotedLocal || candidates[i]]; data.address = [quotedLocal || candidates[i]];
data.text.splice(i, 1); data.text.splice(i, 1);
@@ -1020,8 +1018,7 @@ class Tokenizer {
return; return;
} }
if (this.node < 0) { if (this.node < 0) {
this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i);
(this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i);
} }
if (chr === '\n') { if (chr === '\n') {
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually // Convert newlines to spaces. Carriage return is ignored as \r and \n usually
+1 -3
View File
@@ -25,9 +25,7 @@ class DKIMSigner {
this.chunks = []; this.chunks = [];
this.chunklen = 0; this.chunklen = 0;
this.readPos = 0; this.readPos = 0;
this.cachePath = this.cacheDir this.cachePath = this.cacheDir ? node_path_1.default.join(this.cacheDir, 'message.' + Date.now() + '-' + node_crypto_1.default.randomBytes(14).toString('hex')) : false;
? node_path_1.default.join(this.cacheDir, 'message.' + Date.now() + '-' + node_crypto_1.default.randomBytes(14).toString('hex'))
: false;
this.cache = false; this.cache = false;
this.headers = false; this.headers = false;
this.bodyHash = false; this.bodyHash = false;
+19
View File
@@ -68,6 +68,18 @@ export declare const isTransientError: (err: {
code?: string | undefined; code?: string | undefined;
responseCode?: number | undefined; responseCode?: number | undefined;
}) => boolean; }) => boolean;
/**
* Tells whether an SMTP reply code is a permanent rejection, one that retrying the same
* message would get again. RFC 5321 makes every 5yz reply a permanent negative completion
* and every 4yz reply a transient one. 503 (bad sequence of commands) is the exception: it
* says the session was in the wrong state for the command rather than that the message was
* refused, so the same message can go through on a new connection. 2yz and 3yz replies are
* only an error when the client expected something else, and are not permanent either.
*
* @param responseCode SMTP reply code
* @returns true for a permanent rejection
*/
export declare const isPermanentResponseCode: (responseCode: number) => boolean;
/** /**
* Code Node.js sets on an error when its permission model (`--permission`) denies an * Code Node.js sets on an error when its permission model (`--permission`) denies an
* operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`,
@@ -89,6 +101,13 @@ export interface NodemailerError extends NodeJS.ErrnoException {
response?: string | undefined; response?: string | undefined;
/** Numeric SMTP response code */ /** Numeric SMTP response code */
responseCode?: number | undefined; responseCode?: number | undefined;
/**
* Set together with responseCode, from the reply class: true when the server rejected the
* message for good (5xx except 503), false when the same message may be accepted on a later
* attempt (4xx, 503, or an unexpected 2xx or 3xx). Not set on an error that carries no SMTP
* reply, such as a connection failure or missing credentials, see isPermanentResponseCode
*/
permanent?: boolean | undefined;
/** URL of the resource that could not be fetched */ /** URL of the resource that could not be fetched */
sourceUrl?: string | undefined; sourceUrl?: string | undefined;
/** Recipient address the error applies to */ /** Recipient address the error applies to */
+14 -1
View File
@@ -10,7 +10,7 @@
* err.code = errors.ECONNECTION; * err.code = errors.ECONNECTION;
*/ */
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.ERR_ACCESS_DENIED = exports.isTransientError = exports.EFETCH = exports.EURLACCESS = exports.EFILEACCESS = exports.EPROXY = exports.ECONFIG = exports.ESES = exports.ESENDMAIL = exports.EMAXRECIPIENTS = exports.EMAXLIMIT = exports.EOAUTH2 = exports.ENOAUTH = exports.EAUTH = exports.ESTREAM = exports.EMESSAGE = exports.EENVELOPE = exports.EPROTOCOL = exports.EREQUIRETLS = exports.ETLS = exports.EDNS = exports.ESOCKET = exports.ETIMEDOUT = exports.ECONNECTION = exports.ERROR_CODES = void 0; exports.ERR_ACCESS_DENIED = exports.isPermanentResponseCode = exports.isTransientError = exports.EFETCH = exports.EURLACCESS = exports.EFILEACCESS = exports.EPROXY = exports.ECONFIG = exports.ESES = exports.ESENDMAIL = exports.EMAXRECIPIENTS = exports.EMAXLIMIT = exports.EOAUTH2 = exports.ENOAUTH = exports.EAUTH = exports.ESTREAM = exports.EMESSAGE = exports.EENVELOPE = exports.EPROTOCOL = exports.EREQUIRETLS = exports.ETLS = exports.EDNS = exports.ESOCKET = exports.ETIMEDOUT = exports.ECONNECTION = exports.ERROR_CODES = void 0;
/** /**
* Error code descriptions for documentation and debugging * Error code descriptions for documentation and debugging
*/ */
@@ -73,6 +73,19 @@ exports.EFETCH = 'EFETCH';
*/ */
const isTransientError = (err) => err.responseCode === 421 || err.code === exports.ECONNECTION || err.code === exports.ESOCKET || err.code === exports.ETIMEDOUT; const isTransientError = (err) => err.responseCode === 421 || err.code === exports.ECONNECTION || err.code === exports.ESOCKET || err.code === exports.ETIMEDOUT;
exports.isTransientError = isTransientError; exports.isTransientError = isTransientError;
/**
* Tells whether an SMTP reply code is a permanent rejection, one that retrying the same
* message would get again. RFC 5321 makes every 5yz reply a permanent negative completion
* and every 4yz reply a transient one. 503 (bad sequence of commands) is the exception: it
* says the session was in the wrong state for the command rather than that the message was
* refused, so the same message can go through on a new connection. 2yz and 3yz replies are
* only an error when the client expected something else, and are not permanent either.
*
* @param responseCode SMTP reply code
* @returns true for a permanent rejection
*/
const isPermanentResponseCode = (responseCode) => responseCode >= 500 && responseCode < 600 && responseCode !== 503;
exports.isPermanentResponseCode = isPermanentResponseCode;
/** /**
* Code Node.js sets on an error when its permission model (`--permission`) denies an * Code Node.js sets on an error when its permission model (`--permission`) denies an
* operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`,
+2 -4
View File
@@ -198,16 +198,14 @@ class Cookies {
// check if hostname matches // check if hostname matches
// .foo.com also matches subdomains, foo.com does not // .foo.com also matches subdomains, foo.com does not
if (urlparts.hostname !== cookie.domain && if (urlparts.hostname !== cookie.domain &&
(cookie.domain.charAt(0) !== '.' || (cookie.domain.charAt(0) !== '.' || ('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) {
('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) {
return false; return false;
} }
// check if the request path path-matches the cookie path (RFC 6265 section 5.1.4): // check if the request path path-matches the cookie path (RFC 6265 section 5.1.4):
// identical paths match, otherwise the cookie path must be a directory prefix // identical paths match, otherwise the cookie path must be a directory prefix
const pathname = urlparts.pathname || '/'; const pathname = urlparts.pathname || '/';
const cookiePath = cookie.path; const cookiePath = cookie.path;
const pathMatches = pathname === cookiePath || const pathMatches = pathname === cookiePath || (pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/'));
(pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/'));
if (!pathMatches) { if (!pathMatches) {
return false; return false;
} }
+2 -8
View File
@@ -262,11 +262,7 @@ function nmfetch(url, options) {
} }
}); });
} }
if (parsed.protocol === 'https:' && if (parsed.protocol === 'https:' && parsed.hostname && parsed.hostname !== reqOptions.host && !node_net_1.default.isIP(parsed.hostname) && !reqOptions.servername) {
parsed.hostname &&
parsed.hostname !== reqOptions.host &&
!node_net_1.default.isIP(parsed.hostname) &&
!reqOptions.servername) {
reqOptions.servername = parsed.hostname; reqOptions.servername = parsed.hostname;
} }
try { try {
@@ -369,9 +365,7 @@ function nmfetch(url, options) {
// downgrade would also put them on the wire in cleartext). Strip sensitive // downgrade would also put them on the wire in cleartext). Strip sensitive
// request headers so an attacker who controls the redirect target cannot // request headers so an attacker who controls the redirect target cannot
// harvest them. // harvest them.
const crossOrigin = redirectParsed.protocol !== parsed.protocol || const crossOrigin = redirectParsed.protocol !== parsed.protocol || redirectParsed.hostname !== parsed.hostname || fetchPort(redirectParsed) !== fetchPort(parsed);
redirectParsed.hostname !== parsed.hostname ||
fetchPort(redirectParsed) !== fetchPort(parsed);
if (options.headers && crossOrigin) { if (options.headers && crossOrigin) {
const sensitive = ['authorization', 'cookie', 'proxy-authorization']; const sensitive = ['authorization', 'cookie', 'proxy-authorization'];
Object.keys(options.headers).forEach(key => { Object.keys(options.headers).forEach(key => {
+42 -9
View File
@@ -54,6 +54,40 @@ function isContentObject(value) {
const content = value; const content = value;
return typeof value === 'object' && !!(content.content || content.path || content.href || content.raw); return typeof value === 'object' && !!(content.content || content.path || content.href || content.raw);
} }
/**
* Tells whether attachment content is a string or a Buffer that 7bit can describe: ASCII
* only, no NUL and no line over the 998 octets of RFC 5322. Anything else, a stream or a
* file path included, can not be checked here
*/
function fitsSevenBit(content) {
let at;
let length;
if (typeof content === 'string') {
at = i => content.charCodeAt(i);
length = content.length;
}
else if (Buffer.isBuffer(content)) {
at = i => content[i];
length = content.length;
}
else {
return false;
}
let lineLength = 0;
for (let i = 0; i < length; i++) {
const c = at(i);
if (c === 0 || c > 0x7f) {
return false;
}
if (c === 0x0a || c === 0x0d) {
lineLength = 0;
}
else if (++lineLength > 998) {
return false;
}
}
return true;
}
class MailComposer { class MailComposer {
constructor(mail) { constructor(mail) {
this.mail = mail || {}; this.mail = mail || {};
@@ -136,8 +170,11 @@ class MailComposer {
contentTransferEncoding = attachment.contentTransferEncoding; contentTransferEncoding = attachment.contentTransferEncoding;
} }
else if (isMessageNode) { else if (isMessageNode) {
// the content might include non-ASCII bytes but at this point we do not know it yet // a message/* part can not be encoded (RFC 2046 5.2.1), so the label has to describe
contentTransferEncoding = '8bit'; // the content as it is. A stream or a file might hold non-ASCII bytes that can not be
// checked here, so only content at hand that fits 7bit is labelled as such
// content with an `encoding` is decoded later, so what it decodes to is not known here either
contentTransferEncoding = !attachment.encoding && fitsSevenBit(attachment.content) ? '7bit' : '8bit';
} }
else { else {
contentTransferEncoding = 'base64'; // the default contentTransferEncoding = 'base64'; // the default
@@ -152,9 +189,7 @@ class MailComposer {
} }
else if (!isMessageNode && attachment.filename !== false) { else if (!isMessageNode && attachment.filename !== false) {
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers // a backslash separates as well, so a Windows path does not put the sender's directories in the headers
data.filename = data.filename = (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1);
(attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
'attachment-' + (i + 1);
if (data.filename.indexOf('.') < 0) { if (data.filename.indexOf('.') < 0) {
data.filename += '.' + mimeFuncs.detectExtension(data.contentType); data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
} }
@@ -315,8 +350,7 @@ class MailComposer {
if (this.mail.icalEvent) { if (this.mail.icalEvent) {
eventObject = Object.assign({}, this._getIcalEvent()); eventObject = Object.assign({}, this._getIcalEvent());
eventObject.filename = false; eventObject.filename = false;
eventObject.contentType = eventObject.contentType = 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
if (!eventObject.headers) { if (!eventObject.headers) {
eventObject.headers = {}; eventObject.headers = {};
} }
@@ -344,8 +378,7 @@ class MailComposer {
alternative = this._processDataUrl(alternative); alternative = this._processDataUrl(alternative);
} }
const data = { const data = {
contentType: alternative.contentType || contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
contentTransferEncoding: alternative.contentTransferEncoding contentTransferEncoding: alternative.contentTransferEncoding
}; };
if (alternative.filename) { if (alternative.filename) {
+5 -6
View File
@@ -109,8 +109,7 @@ class MailMessage {
if (!attachment.filename) { if (!attachment.filename) {
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers // a backslash separates as well, so a Windows path does not put the sender's directories in the headers
attachment.filename = attachment.filename =
(attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1);
'attachment-' + (i + 1);
if (attachment.filename.indexOf('.') < 0) { if (attachment.filename.indexOf('.') < 0) {
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
} }
@@ -245,7 +244,7 @@ class MailMessage {
value = (value && value.value) || value; value = (value && value.value) || value;
if (value) { if (value) {
if (['references', 'in-reply-to', 'message-id', 'content-id'].includes(key)) { if (['references', 'in-reply-to', 'message-id', 'content-id'].includes(key)) {
value = this.message._encodeHeaderValue(key, value); value = this.message.encodeHeaderValue(key, value);
} }
data.normalizedHeaders[key] = value; data.normalizedHeaders[key] = value;
} }
@@ -257,10 +256,10 @@ class MailMessage {
}); });
} }
if (data.references) { if (data.references) {
data.normalizedHeaders.references = this.message._encodeHeaderValue('references', data.references); data.normalizedHeaders.references = this.message.encodeHeaderValue('references', data.references);
} }
if (data.inReplyTo) { if (data.inReplyTo) {
data.normalizedHeaders['in-reply-to'] = this.message._encodeHeaderValue('in-reply-to', data.inReplyTo); data.normalizedHeaders['in-reply-to'] = this.message.encodeHeaderValue('in-reply-to', data.inReplyTo);
} }
return callback(null, data); return callback(null, data);
}); });
@@ -329,7 +328,7 @@ class MailMessage {
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment); comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
// List-ID expects a bare domain-like identifier, so strip the // List-ID expects a bare domain-like identifier, so strip the
// scheme prefix that _formatListUrl adds or passes through // scheme prefix that _formatListUrl adds or passes through
return ((value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<')); return (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<');
} }
// List-*: <http://domain> (comment) // List-*: <http://domain> (comment)
// the ctext specials go out as quoted-pairs, otherwise a ")" closes the // the ctext specials go out as quoted-pairs, otherwise a ")" closes the
+8 -2
View File
@@ -70,6 +70,12 @@ function isPlainText(value, isParam) {
const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/; const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
return typeof value === 'string' && !re.test(value); return typeof value === 'string' && !re.test(value);
} }
// A parameter value goes out as is only when it is plain text that does not look like an encoded
// word. Receivers decode encoded words found in parameter values (libmime does so for unencoded
// continuations), so as plain text it would come back as something else
function isPlainParamValue(value) {
return isPlainText(value, true) && !value.includes('=?');
}
/** /**
* Wraps a value into a quoted-string. Inside one a quote would end the string early * Wraps a value into a quoted-string. Inside one a quote would end the string early
* and a backslash would escape whatever follows it, so both go out as quoted-pairs. * and a backslash would escape whatever follows it, so both go out as quoted-pairs.
@@ -237,7 +243,7 @@ function buildHeaderValue(structured) {
const value = structured.params[key]; const value = structured.params[key];
// a parameter name is a token too and it is emitted without any quoting around it // a parameter name is a token too and it is emitted without any quoting around it
const param = key.replace(/[\x00-\x1f\x7f]/g, ''); const param = key.replace(/[\x00-\x1f\x7f]/g, '');
if (!isPlainText(value, true) || value.length >= 75) { if (!isPlainParamValue(value) || value.length >= 75) {
buildHeaderParam(param, value, 50).forEach(encodedParam => { buildHeaderParam(param, value, 50).forEach(encodedParam => {
if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') { if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
paramsArray.push(encodedParam.key + '=' + encodedParam.value); paramsArray.push(encodedParam.key + '=' + encodedParam.value);
@@ -286,7 +292,7 @@ function buildHeaderParam(key, data, maxLength) {
let i, len; let i, len;
maxLength = maxLength || 50; maxLength = maxLength || 50;
// process ascii only text // process ascii only text
if (isPlainText(data, true)) { if (isPlainParamValue(data)) {
// check if conversion is even needed // check if conversion is even needed
if (encodedStr.length <= maxLength) { if (encodedStr.length <= maxLength) {
return [ return [
+3 -12
View File
@@ -1104,10 +1104,7 @@ const extensions = new Map([
['bdm', 'application/vnd.syncml.dm+wbxml'], ['bdm', 'application/vnd.syncml.dm+wbxml'],
['bed', 'application/vnd.realvnc.bed'], ['bed', 'application/vnd.realvnc.bed'],
['bh2', 'application/vnd.fujitsu.oasysprs'], ['bh2', 'application/vnd.fujitsu.oasysprs'],
[ ['bin', ['application/octet-stream', 'application/mac-binary', 'application/macbinary', 'application/x-macbinary', 'application/x-binary']],
'bin',
['application/octet-stream', 'application/mac-binary', 'application/macbinary', 'application/x-macbinary', 'application/x-binary']
],
['bm', 'image/bmp'], ['bm', 'image/bmp'],
['bmi', 'application/vnd.bmi'], ['bmi', 'application/vnd.bmi'],
['bmp', ['image/bmp', 'image/x-windows-bmp']], ['bmp', ['image/bmp', 'image/x-windows-bmp']],
@@ -1152,10 +1149,7 @@ const extensions = new Map([
['cii', 'application/vnd.anser-web-certificate-issue-initiation'], ['cii', 'application/vnd.anser-web-certificate-issue-initiation'],
['cil', 'application/vnd.ms-artgalry'], ['cil', 'application/vnd.ms-artgalry'],
['cla', 'application/vnd.claymore'], ['cla', 'application/vnd.claymore'],
[ ['class', ['application/octet-stream', 'application/java', 'application/java-byte-code', 'application/java-vm', 'application/x-java-class']],
'class',
['application/octet-stream', 'application/java', 'application/java-byte-code', 'application/java-vm', 'application/x-java-class']
],
['clkk', 'application/vnd.crick.clicker.keyboard'], ['clkk', 'application/vnd.crick.clicker.keyboard'],
['clkp', 'application/vnd.crick.clicker.palette'], ['clkp', 'application/vnd.crick.clicker.palette'],
['clkt', 'application/vnd.crick.clicker.template'], ['clkt', 'application/vnd.crick.clicker.template'],
@@ -1760,10 +1754,7 @@ const extensions = new Map([
['sbml', 'application/sbml+xml'], ['sbml', 'application/sbml+xml'],
['sc', 'application/vnd.ibm.secure-container'], ['sc', 'application/vnd.ibm.secure-container'],
['scd', 'application/x-msschedule'], ['scd', 'application/x-msschedule'],
[ ['scm', ['application/vnd.lotus-screencam', 'video/x-scm', 'text/x-script.guile', 'application/x-lotusscreencam', 'text/x-script.scheme']],
'scm',
['application/vnd.lotus-screencam', 'video/x-scm', 'text/x-script.guile', 'application/x-lotusscreencam', 'text/x-script.scheme']
],
['scq', 'application/scvp-cv-request'], ['scq', 'application/scvp-cv-request'],
['scs', 'application/scvp-cv-response'], ['scs', 'application/scvp-cv-response'],
['sct', 'text/scriptlet'], ['sct', 'text/scriptlet'],
+34 -1
View File
@@ -58,6 +58,13 @@ export interface MimeNodePreparedHeaderValue {
* objects (for the address headers), a prepared value object, or a list of these * objects (for the address headers), a prepared value object, or a list of these
*/ */
export type MimeNodeHeaderValue = string | number | boolean | Date | MimeNodeAddress | MimeNodePreparedHeaderValue | MimeNodeHeaderValue[] | null | undefined; export type MimeNodeHeaderValue = string | number | boolean | Date | MimeNodeAddress | MimeNodePreparedHeaderValue | MimeNodeHeaderValue[] | null | undefined;
/**
* Options for the static MimeNode.encodeHeaderValue
*/
export interface MimeNodeHeaderEncodingOptions {
/** Either 'Q' (the default) or 'B' */
textEncoding?: string | undefined;
}
/** /**
* A single header, the shape the headers are stored in and one of the list forms * A single header, the shape the headers are stored in and one of the list forms
* setHeader and addHeader accept * setHeader and addHeader accept
@@ -319,6 +326,31 @@ declare class MimeNode {
* @param raw Raw MIME contents * @param raw Raw MIME contents
*/ */
setRaw(raw: MimeNodeContent): this; setRaw(raw: MimeNodeContent): this;
/**
* Encodes a header value the way this node encodes it in the generated message:
* address headers are punycoded and mime encoded, message id headers get their
* angle brackets, a Date object is formatted and any other value is mime encoded
* when it holds non-ascii or control characters (using the node's `textEncoding`).
*
* The result is a single unfolded line without the key. Fold it before writing it
* into a message, `key + ': ' + value` folded at 76 characters is what the node does.
*
* @param key Header key, case insensitive
* @param value Header value
* @returns Encoded header value
*/
encodeHeaderValue(key: string, value: MimeNodeHeaderValue): string;
/**
* Encodes a header value without a node at hand. Same as the instance method of a
* node created with the given options.
*
* @param key Header key, case insensitive
* @param value Header value
* @param [options] Encoding options
* @param [options.textEncoding] either 'Q' (the default) or 'B'
* @returns Encoded header value
*/
static encodeHeaderValue(key: string, value: MimeNodeHeaderValue, options?: MimeNodeHeaderEncodingOptions): string;
} }
/** /**
* Type aliases in the layout of @types/nodemailer, so `MimeNode.Options` style references keep working * Type aliases in the layout of @types/nodemailer, so `MimeNode.Options` style references keep working
@@ -327,7 +359,8 @@ declare namespace MimeNode {
type Options = MimeNodeOptions; type Options = MimeNodeOptions;
type Addresses = MimeNodeAddresses; type Addresses = MimeNodeAddresses;
type Envelope = MimeNodeEnvelope; type Envelope = MimeNodeEnvelope;
type HeaderEncodingOptions = MimeNodeHeaderEncodingOptions;
} }
/** The same aliases as module level exports, for `import * as MimeNode` and `import MimeNode = require()` */ /** The same aliases as module level exports, for `import * as MimeNode` and `import MimeNode = require()` */
export type { MimeNodeOptions as Options, MimeNodeAddresses as Addresses, MimeNodeEnvelope as Envelope }; export type { MimeNodeOptions as Options, MimeNodeAddresses as Addresses, MimeNodeEnvelope as Envelope, MimeNodeHeaderEncodingOptions as HeaderEncodingOptions };
export default MimeNode; export default MimeNode;
+35 -13
View File
@@ -155,9 +155,7 @@ function exceedsDomainLimits(domain) {
*/ */
function normalizeDomain(domain, toUnicode) { function normalizeDomain(domain, toUnicode) {
// domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6 // domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6
if (typeof node_url_1.default.domainToASCII === 'function' && if (typeof node_url_1.default.domainToASCII === 'function' && typeof node_url_1.default.domainToUnicode === 'function' && !URL_PARSER_UNSAFE.test(domain)) {
typeof node_url_1.default.domainToUnicode === 'function' &&
!URL_PARSER_UNSAFE.test(domain)) {
// The U-label form is decoded from the A-label form rather than from the input: // The U-label form is decoded from the A-label form rather than from the input:
// Deno's domainToASCII returns an empty string for a label it cannot decode, while // Deno's domainToASCII returns an empty string for a label it cannot decode, while
// its domainToUnicode returns the label with U+FFFD in place of the bad part // its domainToUnicode returns the label with U+FFFD in place of the bad part
@@ -670,9 +668,7 @@ class MimeNode {
// the checks miss and the header ends up claiming a type it is not set up for // the checks miss and the header ends up claiming a type it is not set up for
structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, ''); structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, '');
this._handleContentType(structured); this._handleContentType(structured);
if (structured.value.match(/^text\/plain\b/) && if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) {
typeof this.content === 'string' &&
/[\u0080-\uFFFF]/.test(this.content)) {
structured.params.charset = 'utf-8'; structured.params.charset = 'utf-8';
} }
if (this.filename) { if (this.filename) {
@@ -713,7 +709,7 @@ class MimeNode {
} }
break; break;
} }
value = this._encodeHeaderValue(key, value); value = this.encodeHeaderValue(key, value);
// skip empty lines // skip empty lines
if (!(value || '').toString().trim()) { if (!(value || '').toString().trim()) {
return; return;
@@ -1331,8 +1327,7 @@ class MimeNode {
// where it came from. MimeNode is exported and subclassable, so the generator is // where it came from. MimeNode is exported and subclassable, so the generator is
// not necessarily the one below. Stripping twice costs nothing, it is idempotent. // not necessarily the one below. Stripping twice costs nothing, it is idempotent.
const declared = _stripBoundaryControls(structured.params.boundary || this.boundary || ''); const declared = _stripBoundaryControls(structured.params.boundary || this.boundary || '');
this.boundary = structured.params.boundary = this.boundary = structured.params.boundary = declared || _stripBoundaryControls(this._generateBoundary());
declared || _stripBoundaryControls(this._generateBoundary());
} }
else { else {
this.boundary = false; this.boundary = false;
@@ -1350,13 +1345,19 @@ class MimeNode {
return _stripBoundaryControls(this.rootNode.boundaryPrefix + '-' + this.rootNode.baseBoundary) + '-Part_' + this._nodeId; return _stripBoundaryControls(this.rootNode.boundaryPrefix + '-' + this.rootNode.baseBoundary) + '-Part_' + this._nodeId;
} }
/** /**
* Encodes a header value for use in the generated rfc2822 email. * Encodes a header value the way this node encodes it in the generated message:
* address headers are punycoded and mime encoded, message id headers get their
* angle brackets, a Date object is formatted and any other value is mime encoded
* when it holds non-ascii or control characters (using the node's `textEncoding`).
* *
* @param key Header key * The result is a single unfolded line without the key. Fold it before writing it
* into a message, `key + ': ' + value` folded at 76 characters is what the node does.
*
* @param key Header key, case insensitive
* @param value Header value * @param value Header value
* @internal * @returns Encoded header value
*/ */
_encodeHeaderValue(key, value) { encodeHeaderValue(key, value) {
key = this._normalizeHeaderKey(key); key = this._normalizeHeaderKey(key);
switch (key) { switch (key) {
// Structured headers // Structured headers
@@ -1421,6 +1422,27 @@ class MimeNode {
return this._encodeHeaderText(value); return this._encodeHeaderText(value);
} }
} }
/**
* Encodes a header value without a node at hand. Same as the instance method of a
* node created with the given options.
*
* @param key Header key, case insensitive
* @param value Header value
* @param [options] Encoding options
* @param [options.textEncoding] either 'Q' (the default) or 'B'
* @returns Encoded header value
*/
static encodeHeaderValue(key, value, options) {
return new MimeNode(false, { textEncoding: options?.textEncoding }).encodeHeaderValue(key, value);
}
/**
* Alias of encodeHeaderValue, kept for callers that used the method before it was public
*
* @internal
*/
_encodeHeaderValue(key, value) {
return this.encodeHeaderValue(key, value);
}
/** /**
* Rebuilds address object using punycode and other adjustments * Rebuilds address object using punycode and other adjustments
* *
+1 -1
View File
@@ -1,3 +1,3 @@
export declare const name = "nodemailer"; export declare const name = "nodemailer";
export declare const version = "10.1.6"; export declare const version = "10.2.0";
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.6'; exports.version = '10.2.0';
exports.homepage = 'https://nodemailer.com/'; exports.homepage = 'https://nodemailer.com/';
+3 -12
View File
@@ -50,15 +50,9 @@ function encodeBytes(buffer, next, binary, previous) {
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const ord = buffer[i]; const ord = buffer[i];
const following = i + 1 < len ? buffer[i + 1] : next; const following = i + 1 < len ? buffer[i + 1] : next;
const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) ? (ord === 0x0d ? following === 0x0a : (i > 0 ? buffer[i - 1] : previous) === 0x0d) : true;
? ord === 0x0d
? following === 0x0a
: (i > 0 ? buffer[i - 1] : previous) === 0x0d
: true;
// if the char is in allowed range, then keep as is, unless it is a WS in the end of a line // if the char is in allowed range, then keep as is, unless it is a WS in the end of a line
if (QP_LITERAL[ord] && if (QP_LITERAL[ord] && lineBreakByte && !(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) {
lineBreakByte &&
!(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) {
output[pos++] = ord; output[pos++] = ord;
continue; continue;
} }
@@ -119,10 +113,7 @@ function wrap(str, lineLength) {
line = line.substr(0, line.length - match[0].length); line = line.substr(0, line.length - match[0].length);
} }
// ensure that utf-8 sequences are not split // ensure that utf-8 sequences are not split
while (line.length > 3 && while (line.length > 3 && line.length < len - pos && !line.match(/^(?:=[\da-f]{2}){1,4}$/i) && (match = line.match(/[=][\da-f]{2}$/gi))) {
line.length < len - pos &&
!line.match(/^(?:=[\da-f]{2}){1,4}$/i) &&
(match = line.match(/[=][\da-f]{2}$/gi))) {
code = parseInt(match[0].substr(1, 2), 16); code = parseInt(match[0].substr(1, 2), 16);
if (code < 128) { if (code < 128) {
break; break;
+1 -3
View File
@@ -109,9 +109,7 @@ class SendmailTransport {
return done(err); return done(err);
} }
// force -i to keep single dots // force -i to keep single dots
const args = this.args const args = this.args ? ['-i'].concat(this.args).concat(envelope.to) : ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to);
? ['-i'].concat(this.args).concat(envelope.to)
: ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to);
const callback = (err) => { const callback = (err) => {
if (returned) { if (returned) {
// ignore any additional responses, already done // ignore any additional responses, already done
+2 -6
View File
@@ -263,9 +263,7 @@ const resolveHostname = (options, callback) => {
return respond(err); return respond(err);
} }
// Get all supported addresses from dns.lookup // Get all supported addresses from dns.lookup
const supportedAddresses = addresses const supportedAddresses = addresses ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) : [];
? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address)
: [];
if (addresses && addresses.length && !supportedAddresses.length) { if (addresses && addresses.length && !supportedAddresses.length) {
// there are addresses but none can be used // there are addresses but none can be used
console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`); console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`);
@@ -601,9 +599,7 @@ const assign = function (...args) {
if ((0, objects_js_1.isProtoKey)(key)) { if ((0, objects_js_1.isProtoKey)(key)) {
return; return;
} }
if (['tls', 'auth'].includes(key) && if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') {
source[key] &&
typeof source[key] === 'object') {
// tls and auth are special keys that need to be enumerated separately // tls and auth are special keys that need to be enumerated separately
// other objects are passed as is. Enumerating is a copy of user supplied // other objects are passed as is. Enumerating is a copy of user supplied
// keys just like the loop above, so it gets the same treatment // keys just like the loop above, so it gets the same treatment
+1 -1
View File
@@ -47,7 +47,7 @@ const errors = __importStar(require("../errors.js"));
// never sends \r\n\r\n cannot grow memory unboundedly before the socket times out. // never sends \r\n\r\n cannot grow memory unboundedly before the socket times out.
const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
// URL hostnames keep the brackets around an IPv6 literal, socket options and net.isIPv6 take it without // URL hostnames keep the brackets around an IPv6 literal, socket options and net.isIPv6 take it without
const unbracket = (host) => typeof host === 'string' && host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; const unbracket = (host) => (typeof host === 'string' && host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host);
function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) { function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) {
if (typeof tlsOptions === 'function') { if (typeof tlsOptions === 'function') {
callback = tlsOptions; callback = tlsOptions;
+6
View File
@@ -245,6 +245,12 @@ export interface SMTPConnectionSendInfo extends SMTPConnectionEnvelopeInfo {
messageSize: number; messageSize: number;
/** Final server response for the message */ /** Final server response for the message */
response: string; response: string;
/**
* Message-ID the server gave the message, with angle brackets, when its final reply names
* one in a known format (Microsoft 365 and Outlook.com, Amazon SES). Such a server replaces
* the Message-ID header, so the delivered copy carries this id instead of messageId
*/
serverMessageId?: string | undefined;
} }
/** /**
* Callback for send(), receives the result once the server accepted the message. The error * Callback for send(), receives the result once the server accepted the message. The error
+15 -12
View File
@@ -45,6 +45,7 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
const data_stream_js_1 = __importDefault(require("./data-stream.js")); const data_stream_js_1 = __importDefault(require("./data-stream.js"));
const node_stream_1 = require("node:stream"); const node_stream_1 = require("node:stream");
const shared = __importStar(require("../shared/index.js")); const shared = __importStar(require("../shared/index.js"));
const server_message_id_js_1 = require("./server-message-id.js");
const errors_js_1 = require("../errors.js"); const errors_js_1 = require("../errors.js");
// default timeout values in ms // default timeout values in ms
const CONNECTION_TIMEOUT = 2 * 60 * 1000; // how much to wait for the connection to be established const CONNECTION_TIMEOUT = 2 * 60 * 1000; // how much to wait for the connection to be established
@@ -399,9 +400,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
if (this._destroyed || this._closing) { if (this._destroyed || this._closing) {
return; return;
} }
const connectFn = secure const connectFn = secure ? node_tls_1.default.connect : node_net_1.default.connect;
? node_tls_1.default.connect
: node_net_1.default.connect;
try { try {
this._socket = connectFn(opts, () => this._onConnect()); this._socket = connectFn(opts, () => this._onConnect());
this._setupConnectionHandlers(); this._setupConnectionHandlers();
@@ -739,6 +738,16 @@ class SMTPConnection extends node_events_1.EventEmitter {
result.messageTime = Date.now() - envelopeTime; result.messageTime = Date.now() - envelopeTime;
result.messageSize = stream.outByteCount; result.messageSize = stream.outByteCount;
result.response = str; result.response = str;
const serverMessageId = (0, server_message_id_js_1.parseServerMessageId)(result.response, this.options.host);
if (serverMessageId) {
result.serverMessageId = serverMessageId;
}
// an LMTP server answers the message once per recipient, so recipients can be
// refused after the envelope info was built without any rejected errors on it
const rejectedErrors = this._envelope?.rejectedErrors;
if (!result.rejectedErrors && rejectedErrors && rejectedErrors.length) {
result.rejectedErrors = rejectedErrors;
}
return callback(null, result); return callback(null, result);
}); });
if (typeof message.pipe === 'function') { if (typeof message.pipe === 'function') {
@@ -966,6 +975,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
const responseCode = (typeof response === 'string' && Number((response.match(/^\d+/) || [])[0])) || false; const responseCode = (typeof response === 'string' && Number((response.match(/^\d+/) || [])[0])) || false;
if (responseCode) { if (responseCode) {
err.responseCode = responseCode; err.responseCode = responseCode;
err.permanent = (0, errors_js_1.isPermanentResponseCode)(responseCode);
} }
if (command) { if (command) {
err.command = command; err.command = command;
@@ -1008,12 +1018,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
this.close(); this.close();
return; return;
} }
if (!failureResponse && if (!failureResponse && this.stage === 'connected' && !this._responseActions.length && !this._pendingSend && !this._destroyed && !this._closing) {
this.stage === 'connected' &&
!this._responseActions.length &&
!this._pendingSend &&
!this._destroyed &&
!this._closing) {
// nothing was waiting for the server, so this is the server ending an idle session // nothing was waiting for the server, so this is the server ending an idle session
// (usually after its idle timeout), not a failure // (usually after its idle timeout), not a failure
this.logger.info({ this.logger.info({
@@ -1234,9 +1239,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
const args = []; const args = [];
let useSmtpUtf8 = false; let useSmtpUtf8 = false;
this._envelope = (envelope || {}); this._envelope = (envelope || {});
this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '') this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '').toString().trim();
.toString()
.trim();
this._envelope.to = [] this._envelope.to = []
.concat(this._envelope.to || []) .concat(this._envelope.to || [])
.map(to => ((to && to.address) || to || '').toString().trim()); .map(to => ((to && to.address) || to || '').toString().trim());
@@ -0,0 +1,14 @@
/**
* Reads the Message-ID a receiving server gave the message out of its final 250 reply. Some
* servers replace the Message-ID header of a submitted message with one of their own, and
* the copy the recipients and the sent folder get carries that one instead of the id the
* message was built with. Only replies in a format known to carry such an id are read.
*/
/**
* Reads the server assigned Message-ID out of the final reply to a message
*
* @param response Final server reply for the message
* @param [host] Hostname the connection was made to, SES replies are only read for an SES host
* @returns The Message-ID with its angle brackets, or undefined when the reply names none
*/
export declare function parseServerMessageId(response: string | undefined, host?: string): string | undefined;
+53
View File
@@ -0,0 +1,53 @@
"use strict";
/**
* Reads the Message-ID a receiving server gave the message out of its final 250 reply. Some
* servers replace the Message-ID header of a submitted message with one of their own, and
* the copy the recipients and the sent folder get carries that one instead of the id the
* message was built with. Only replies in a format known to carry such an id are read.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseServerMessageId = parseServerMessageId;
// Microsoft 365 and Outlook.com: "250 2.0.0 OK <id@host.prod.outlook.com> [Hostname=...]"
const OUTLOOK_REPLY = /^250 2\.0\.0 OK (<[^<>\s]+\.prod\.outlook\.com>)/i;
// Amazon SES: "250 Ok 0100018b5c3c0a4f-...-000000", the local part of the Message-ID it sets
const SES_REPLY = /^250 Ok ([0-9a-f]+(?:-[0-9a-f]+)+)$/i;
// an AWS region name such as us-east-1 or ap-southeast-2
const AWS_REGION = /^[a-z]{2}(?:-[a-z]+)+-\d+$/;
/**
* Finds the AWS region an SES SMTP endpoint is in from its hostname, such as
* email-smtp.eu-west-1.amazonaws.com or a VPC endpoint name under it
*
* @param host SMTP hostname
* @returns Region name, or undefined for a host outside amazonaws.com and awsapps.com
*/
function sesRegion(host) {
const labels = host.toLowerCase().replace(/\.$/, '').split('.');
const domain = labels.slice(-2).join('.');
if (domain !== 'amazonaws.com' && domain !== 'awsapps.com') {
return undefined;
}
return labels.slice(0, -2).find(label => AWS_REGION.test(label));
}
/**
* Reads the server assigned Message-ID out of the final reply to a message
*
* @param response Final server reply for the message
* @param [host] Hostname the connection was made to, SES replies are only read for an SES host
* @returns The Message-ID with its angle brackets, or undefined when the reply names none
*/
function parseServerMessageId(response, host) {
const reply = (response || '').toString().trim();
const outlook = reply.match(OUTLOOK_REPLY);
if (outlook) {
return outlook[1];
}
const ses = reply.match(SES_REPLY);
if (ses && host) {
const region = sesRegion(host);
if (region) {
// the SES Message-ID domain is email.amazonses.com in us-east-1 and the region elsewhere
return '<' + ses[1].toLowerCase() + '@' + (region === 'us-east-1' ? 'email' : region) + '.amazonses.com>';
}
}
return undefined;
}
+11
View File
@@ -110,6 +110,17 @@ declare class SMTPPool extends EventEmitter {
* Returns true if there are free slots in the queue * Returns true if there are free slots in the queue
*/ */
isIdle(): boolean; isIdle(): boolean;
/**
* Counts the messages the pool has not finished with yet: the ones waiting in the queue
* plus the ones a connection is transmitting right now. isIdle() only tells whether the
* pool can take another message, it is already true while a message is still being sent,
* so use this to check that nothing would be lost by closing the pool. A message that is
* re-queued after its connection dropped is counted until its callback runs, also while it waits to be put back on the queue, and
* it is no longer counted by the time its callback runs.
*
* @returns Number of queued and in-flight messages
*/
pendingCount(): number;
/** /**
* Verifies SMTP configuration * Verifies SMTP configuration
* *
+25 -1
View File
@@ -100,6 +100,7 @@ class SMTPPool extends node_events_1.EventEmitter {
this._queue = []; this._queue = [];
this._connections = []; this._connections = [];
this._connectionCounter = 0; this._connectionCounter = 0;
this._pending = 0;
this.idling = true; this.idling = true;
setImmediate(() => { setImmediate(() => {
if (this.idling) { if (this.idling) {
@@ -132,10 +133,20 @@ class SMTPPool extends node_events_1.EventEmitter {
setImmediate(() => callback(err)); setImmediate(() => callback(err));
return false; return false;
} }
// counted until the callback runs, whichever path runs it: delivered, failed, requeued
// until it gives up, or drained by close()
let counted = true;
this._pending++;
this._queue.push({ this._queue.push({
mail, mail,
requeueAttempts: 0, requeueAttempts: 0,
callback callback: (err, info) => {
if (counted) {
counted = false;
this._pending--;
}
callback(err, info);
}
}); });
if (this.idling && this._queue.length >= this.options.maxConnections) { if (this.idling && this._queue.length >= this.options.maxConnections) {
this.idling = false; this.idling = false;
@@ -479,6 +490,19 @@ class SMTPPool extends node_events_1.EventEmitter {
isIdle() { isIdle() {
return this.idling; return this.idling;
} }
/**
* Counts the messages the pool has not finished with yet: the ones waiting in the queue
* plus the ones a connection is transmitting right now. isIdle() only tells whether the
* pool can take another message, it is already true while a message is still being sent,
* so use this to check that nothing would be lost by closing the pool. A message that is
* re-queued after its connection dropped is counted until its callback runs, also while it waits to be put back on the queue, and
* it is no longer counted by the time its callback runs.
*
* @returns Number of queued and in-flight messages
*/
pendingCount() {
return this._pending;
}
verify(callback) { verify(callback) {
let promise; let promise;
if (!callback) { if (!callback) {
+2 -5
View File
@@ -57,8 +57,7 @@ class PoolResource extends node_events_1.EventEmitter {
switch ((this.options.auth.type || '').toString().toUpperCase()) { switch ((this.options.auth.type || '').toString().toUpperCase()) {
case 'OAUTH2': { case 'OAUTH2': {
const oauth2 = new index_js_3.default(this.options.auth, this.logger); const oauth2 = new index_js_3.default(this.options.auth, this.logger);
oauth2.provisionCallback = oauth2.provisionCallback = (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
(this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
this.auth = { this.auth = {
type: 'OAUTH2', type: 'OAUTH2',
user: this.options.auth.user, user: this.options.auth.user,
@@ -246,9 +245,7 @@ class PoolResource extends node_events_1.EventEmitter {
this.connection.close(); this.connection.close();
return; return;
} }
if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && err.responseCode !== 421 && !this.connection._destroyed) {
err.responseCode !== 421 &&
!this.connection._destroyed) {
// The server refused this message, the connection itself is fine. Reset the // The server refused this message, the connection itself is fine. Reset the
// session and keep using it instead of opening a new one // session and keep using it instead of opening a new one
this.connection.reset(resetErr => { this.connection.reset(resetErr => {
+2 -5
View File
@@ -691,9 +691,7 @@ function _handleAddress(tokens, from, to) {
// A run that is a quoted local part with its '@' and domain is an addr-spec of // A run that is a quoted local part with its '@' and domain is an addr-spec of
// its own, with the quoting put back on, and takes its turn like any other // its own, with the quoting put back on, and takes its turn like any other
const local = data.quotedLocalAt[i]; const local = data.quotedLocalAt[i];
const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) ? _quoteLocalPart(data.text[i]) : '';
? _quoteLocalPart(data.text[i])
: '';
if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) { if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
data.address = [quotedLocal || candidates[i]]; data.address = [quotedLocal || candidates[i]];
data.text.splice(i, 1); data.text.splice(i, 1);
@@ -1018,8 +1016,7 @@ class Tokenizer {
return; return;
} }
if (this.node < 0) { if (this.node < 0) {
this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i);
(this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i);
} }
if (chr === '\n') { if (chr === '\n') {
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually // Convert newlines to spaces. Carriage return is ignored as \r and \n usually
+1 -3
View File
@@ -20,9 +20,7 @@ class DKIMSigner {
this.chunks = []; this.chunks = [];
this.chunklen = 0; this.chunklen = 0;
this.readPos = 0; this.readPos = 0;
this.cachePath = this.cacheDir this.cachePath = this.cacheDir ? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex')) : false;
? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex'))
: false;
this.cache = false; this.cache = false;
this.headers = false; this.headers = false;
this.bodyHash = false; this.bodyHash = false;
+19
View File
@@ -68,6 +68,18 @@ export declare const isTransientError: (err: {
code?: string | undefined; code?: string | undefined;
responseCode?: number | undefined; responseCode?: number | undefined;
}) => boolean; }) => boolean;
/**
* Tells whether an SMTP reply code is a permanent rejection, one that retrying the same
* message would get again. RFC 5321 makes every 5yz reply a permanent negative completion
* and every 4yz reply a transient one. 503 (bad sequence of commands) is the exception: it
* says the session was in the wrong state for the command rather than that the message was
* refused, so the same message can go through on a new connection. 2yz and 3yz replies are
* only an error when the client expected something else, and are not permanent either.
*
* @param responseCode SMTP reply code
* @returns true for a permanent rejection
*/
export declare const isPermanentResponseCode: (responseCode: number) => boolean;
/** /**
* Code Node.js sets on an error when its permission model (`--permission`) denies an * Code Node.js sets on an error when its permission model (`--permission`) denies an
* operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`,
@@ -89,6 +101,13 @@ export interface NodemailerError extends NodeJS.ErrnoException {
response?: string | undefined; response?: string | undefined;
/** Numeric SMTP response code */ /** Numeric SMTP response code */
responseCode?: number | undefined; responseCode?: number | undefined;
/**
* Set together with responseCode, from the reply class: true when the server rejected the
* message for good (5xx except 503), false when the same message may be accepted on a later
* attempt (4xx, 503, or an unexpected 2xx or 3xx). Not set on an error that carries no SMTP
* reply, such as a connection failure or missing credentials, see isPermanentResponseCode
*/
permanent?: boolean | undefined;
/** URL of the resource that could not be fetched */ /** URL of the resource that could not be fetched */
sourceUrl?: string | undefined; sourceUrl?: string | undefined;
/** Recipient address the error applies to */ /** Recipient address the error applies to */
+12
View File
@@ -69,6 +69,18 @@ export const EFETCH = 'EFETCH';
* Tells whether the error ended the connection rather than rejected what was sent over it * Tells whether the error ended the connection rather than rejected what was sent over it
*/ */
export const isTransientError = (err) => err.responseCode === 421 || err.code === ECONNECTION || err.code === ESOCKET || err.code === ETIMEDOUT; export const isTransientError = (err) => err.responseCode === 421 || err.code === ECONNECTION || err.code === ESOCKET || err.code === ETIMEDOUT;
/**
* Tells whether an SMTP reply code is a permanent rejection, one that retrying the same
* message would get again. RFC 5321 makes every 5yz reply a permanent negative completion
* and every 4yz reply a transient one. 503 (bad sequence of commands) is the exception: it
* says the session was in the wrong state for the command rather than that the message was
* refused, so the same message can go through on a new connection. 2yz and 3yz replies are
* only an error when the client expected something else, and are not permanent either.
*
* @param responseCode SMTP reply code
* @returns true for a permanent rejection
*/
export const isPermanentResponseCode = (responseCode) => responseCode >= 500 && responseCode < 600 && responseCode !== 503;
/** /**
* Code Node.js sets on an error when its permission model (`--permission`) denies an * Code Node.js sets on an error when its permission model (`--permission`) denies an
* operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`,
+2 -4
View File
@@ -160,16 +160,14 @@ class Cookies {
// check if hostname matches // check if hostname matches
// .foo.com also matches subdomains, foo.com does not // .foo.com also matches subdomains, foo.com does not
if (urlparts.hostname !== cookie.domain && if (urlparts.hostname !== cookie.domain &&
(cookie.domain.charAt(0) !== '.' || (cookie.domain.charAt(0) !== '.' || ('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) {
('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) {
return false; return false;
} }
// check if the request path path-matches the cookie path (RFC 6265 section 5.1.4): // check if the request path path-matches the cookie path (RFC 6265 section 5.1.4):
// identical paths match, otherwise the cookie path must be a directory prefix // identical paths match, otherwise the cookie path must be a directory prefix
const pathname = urlparts.pathname || '/'; const pathname = urlparts.pathname || '/';
const cookiePath = cookie.path; const cookiePath = cookie.path;
const pathMatches = pathname === cookiePath || const pathMatches = pathname === cookiePath || (pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/'));
(pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/'));
if (!pathMatches) { if (!pathMatches) {
return false; return false;
} }
+2 -8
View File
@@ -224,11 +224,7 @@ function nmfetch(url, options) {
} }
}); });
} }
if (parsed.protocol === 'https:' && if (parsed.protocol === 'https:' && parsed.hostname && parsed.hostname !== reqOptions.host && !net.isIP(parsed.hostname) && !reqOptions.servername) {
parsed.hostname &&
parsed.hostname !== reqOptions.host &&
!net.isIP(parsed.hostname) &&
!reqOptions.servername) {
reqOptions.servername = parsed.hostname; reqOptions.servername = parsed.hostname;
} }
try { try {
@@ -331,9 +327,7 @@ function nmfetch(url, options) {
// downgrade would also put them on the wire in cleartext). Strip sensitive // downgrade would also put them on the wire in cleartext). Strip sensitive
// request headers so an attacker who controls the redirect target cannot // request headers so an attacker who controls the redirect target cannot
// harvest them. // harvest them.
const crossOrigin = redirectParsed.protocol !== parsed.protocol || const crossOrigin = redirectParsed.protocol !== parsed.protocol || redirectParsed.hostname !== parsed.hostname || fetchPort(redirectParsed) !== fetchPort(parsed);
redirectParsed.hostname !== parsed.hostname ||
fetchPort(redirectParsed) !== fetchPort(parsed);
if (options.headers && crossOrigin) { if (options.headers && crossOrigin) {
const sensitive = ['authorization', 'cookie', 'proxy-authorization']; const sensitive = ['authorization', 'cookie', 'proxy-authorization'];
Object.keys(options.headers).forEach(key => { Object.keys(options.headers).forEach(key => {
+42 -9
View File
@@ -16,6 +16,40 @@ function isContentObject(value) {
const content = value; const content = value;
return typeof value === 'object' && !!(content.content || content.path || content.href || content.raw); return typeof value === 'object' && !!(content.content || content.path || content.href || content.raw);
} }
/**
* Tells whether attachment content is a string or a Buffer that 7bit can describe: ASCII
* only, no NUL and no line over the 998 octets of RFC 5322. Anything else, a stream or a
* file path included, can not be checked here
*/
function fitsSevenBit(content) {
let at;
let length;
if (typeof content === 'string') {
at = i => content.charCodeAt(i);
length = content.length;
}
else if (Buffer.isBuffer(content)) {
at = i => content[i];
length = content.length;
}
else {
return false;
}
let lineLength = 0;
for (let i = 0; i < length; i++) {
const c = at(i);
if (c === 0 || c > 0x7f) {
return false;
}
if (c === 0x0a || c === 0x0d) {
lineLength = 0;
}
else if (++lineLength > 998) {
return false;
}
}
return true;
}
class MailComposer { class MailComposer {
constructor(mail) { constructor(mail) {
this.mail = mail || {}; this.mail = mail || {};
@@ -98,8 +132,11 @@ class MailComposer {
contentTransferEncoding = attachment.contentTransferEncoding; contentTransferEncoding = attachment.contentTransferEncoding;
} }
else if (isMessageNode) { else if (isMessageNode) {
// the content might include non-ASCII bytes but at this point we do not know it yet // a message/* part can not be encoded (RFC 2046 5.2.1), so the label has to describe
contentTransferEncoding = '8bit'; // the content as it is. A stream or a file might hold non-ASCII bytes that can not be
// checked here, so only content at hand that fits 7bit is labelled as such
// content with an `encoding` is decoded later, so what it decodes to is not known here either
contentTransferEncoding = !attachment.encoding && fitsSevenBit(attachment.content) ? '7bit' : '8bit';
} }
else { else {
contentTransferEncoding = 'base64'; // the default contentTransferEncoding = 'base64'; // the default
@@ -114,9 +151,7 @@ class MailComposer {
} }
else if (!isMessageNode && attachment.filename !== false) { else if (!isMessageNode && attachment.filename !== false) {
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers // a backslash separates as well, so a Windows path does not put the sender's directories in the headers
data.filename = data.filename = (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1);
(attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
'attachment-' + (i + 1);
if (data.filename.indexOf('.') < 0) { if (data.filename.indexOf('.') < 0) {
data.filename += '.' + mimeFuncs.detectExtension(data.contentType); data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
} }
@@ -277,8 +312,7 @@ class MailComposer {
if (this.mail.icalEvent) { if (this.mail.icalEvent) {
eventObject = Object.assign({}, this._getIcalEvent()); eventObject = Object.assign({}, this._getIcalEvent());
eventObject.filename = false; eventObject.filename = false;
eventObject.contentType = eventObject.contentType = 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
if (!eventObject.headers) { if (!eventObject.headers) {
eventObject.headers = {}; eventObject.headers = {};
} }
@@ -306,8 +340,7 @@ class MailComposer {
alternative = this._processDataUrl(alternative); alternative = this._processDataUrl(alternative);
} }
const data = { const data = {
contentType: alternative.contentType || contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
contentTransferEncoding: alternative.contentTransferEncoding contentTransferEncoding: alternative.contentTransferEncoding
}; };
if (alternative.filename) { if (alternative.filename) {
+5 -6
View File
@@ -71,8 +71,7 @@ export default class MailMessage {
if (!attachment.filename) { if (!attachment.filename) {
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers // a backslash separates as well, so a Windows path does not put the sender's directories in the headers
attachment.filename = attachment.filename =
(attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1);
'attachment-' + (i + 1);
if (attachment.filename.indexOf('.') < 0) { if (attachment.filename.indexOf('.') < 0) {
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
} }
@@ -207,7 +206,7 @@ export default class MailMessage {
value = (value && value.value) || value; value = (value && value.value) || value;
if (value) { if (value) {
if (['references', 'in-reply-to', 'message-id', 'content-id'].includes(key)) { if (['references', 'in-reply-to', 'message-id', 'content-id'].includes(key)) {
value = this.message._encodeHeaderValue(key, value); value = this.message.encodeHeaderValue(key, value);
} }
data.normalizedHeaders[key] = value; data.normalizedHeaders[key] = value;
} }
@@ -219,10 +218,10 @@ export default class MailMessage {
}); });
} }
if (data.references) { if (data.references) {
data.normalizedHeaders.references = this.message._encodeHeaderValue('references', data.references); data.normalizedHeaders.references = this.message.encodeHeaderValue('references', data.references);
} }
if (data.inReplyTo) { if (data.inReplyTo) {
data.normalizedHeaders['in-reply-to'] = this.message._encodeHeaderValue('in-reply-to', data.inReplyTo); data.normalizedHeaders['in-reply-to'] = this.message.encodeHeaderValue('in-reply-to', data.inReplyTo);
} }
return callback(null, data); return callback(null, data);
}); });
@@ -291,7 +290,7 @@ export default class MailMessage {
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment); comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
// List-ID expects a bare domain-like identifier, so strip the // List-ID expects a bare domain-like identifier, so strip the
// scheme prefix that _formatListUrl adds or passes through // scheme prefix that _formatListUrl adds or passes through
return ((value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<')); return (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<');
} }
// List-*: <http://domain> (comment) // List-*: <http://domain> (comment)
// the ctext specials go out as quoted-pairs, otherwise a ")" closes the // the ctext specials go out as quoted-pairs, otherwise a ")" closes the
+8 -2
View File
@@ -21,6 +21,12 @@ export function isPlainText(value, isParam) {
const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/; const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
return typeof value === 'string' && !re.test(value); return typeof value === 'string' && !re.test(value);
} }
// A parameter value goes out as is only when it is plain text that does not look like an encoded
// word. Receivers decode encoded words found in parameter values (libmime does so for unencoded
// continuations), so as plain text it would come back as something else
function isPlainParamValue(value) {
return isPlainText(value, true) && !value.includes('=?');
}
/** /**
* Wraps a value into a quoted-string. Inside one a quote would end the string early * Wraps a value into a quoted-string. Inside one a quote would end the string early
* and a backslash would escape whatever follows it, so both go out as quoted-pairs. * and a backslash would escape whatever follows it, so both go out as quoted-pairs.
@@ -188,7 +194,7 @@ export function buildHeaderValue(structured) {
const value = structured.params[key]; const value = structured.params[key];
// a parameter name is a token too and it is emitted without any quoting around it // a parameter name is a token too and it is emitted without any quoting around it
const param = key.replace(/[\x00-\x1f\x7f]/g, ''); const param = key.replace(/[\x00-\x1f\x7f]/g, '');
if (!isPlainText(value, true) || value.length >= 75) { if (!isPlainParamValue(value) || value.length >= 75) {
buildHeaderParam(param, value, 50).forEach(encodedParam => { buildHeaderParam(param, value, 50).forEach(encodedParam => {
if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') { if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
paramsArray.push(encodedParam.key + '=' + encodedParam.value); paramsArray.push(encodedParam.key + '=' + encodedParam.value);
@@ -237,7 +243,7 @@ export function buildHeaderParam(key, data, maxLength) {
let i, len; let i, len;
maxLength = maxLength || 50; maxLength = maxLength || 50;
// process ascii only text // process ascii only text
if (isPlainText(data, true)) { if (isPlainParamValue(data)) {
// check if conversion is even needed // check if conversion is even needed
if (encodedStr.length <= maxLength) { if (encodedStr.length <= maxLength) {
return [ return [
+3 -12
View File
@@ -1097,10 +1097,7 @@ const extensions = new Map([
['bdm', 'application/vnd.syncml.dm+wbxml'], ['bdm', 'application/vnd.syncml.dm+wbxml'],
['bed', 'application/vnd.realvnc.bed'], ['bed', 'application/vnd.realvnc.bed'],
['bh2', 'application/vnd.fujitsu.oasysprs'], ['bh2', 'application/vnd.fujitsu.oasysprs'],
[ ['bin', ['application/octet-stream', 'application/mac-binary', 'application/macbinary', 'application/x-macbinary', 'application/x-binary']],
'bin',
['application/octet-stream', 'application/mac-binary', 'application/macbinary', 'application/x-macbinary', 'application/x-binary']
],
['bm', 'image/bmp'], ['bm', 'image/bmp'],
['bmi', 'application/vnd.bmi'], ['bmi', 'application/vnd.bmi'],
['bmp', ['image/bmp', 'image/x-windows-bmp']], ['bmp', ['image/bmp', 'image/x-windows-bmp']],
@@ -1145,10 +1142,7 @@ const extensions = new Map([
['cii', 'application/vnd.anser-web-certificate-issue-initiation'], ['cii', 'application/vnd.anser-web-certificate-issue-initiation'],
['cil', 'application/vnd.ms-artgalry'], ['cil', 'application/vnd.ms-artgalry'],
['cla', 'application/vnd.claymore'], ['cla', 'application/vnd.claymore'],
[ ['class', ['application/octet-stream', 'application/java', 'application/java-byte-code', 'application/java-vm', 'application/x-java-class']],
'class',
['application/octet-stream', 'application/java', 'application/java-byte-code', 'application/java-vm', 'application/x-java-class']
],
['clkk', 'application/vnd.crick.clicker.keyboard'], ['clkk', 'application/vnd.crick.clicker.keyboard'],
['clkp', 'application/vnd.crick.clicker.palette'], ['clkp', 'application/vnd.crick.clicker.palette'],
['clkt', 'application/vnd.crick.clicker.template'], ['clkt', 'application/vnd.crick.clicker.template'],
@@ -1753,10 +1747,7 @@ const extensions = new Map([
['sbml', 'application/sbml+xml'], ['sbml', 'application/sbml+xml'],
['sc', 'application/vnd.ibm.secure-container'], ['sc', 'application/vnd.ibm.secure-container'],
['scd', 'application/x-msschedule'], ['scd', 'application/x-msschedule'],
[ ['scm', ['application/vnd.lotus-screencam', 'video/x-scm', 'text/x-script.guile', 'application/x-lotusscreencam', 'text/x-script.scheme']],
'scm',
['application/vnd.lotus-screencam', 'video/x-scm', 'text/x-script.guile', 'application/x-lotusscreencam', 'text/x-script.scheme']
],
['scq', 'application/scvp-cv-request'], ['scq', 'application/scvp-cv-request'],
['scs', 'application/scvp-cv-response'], ['scs', 'application/scvp-cv-response'],
['sct', 'text/scriptlet'], ['sct', 'text/scriptlet'],
+34 -1
View File
@@ -58,6 +58,13 @@ export interface MimeNodePreparedHeaderValue {
* objects (for the address headers), a prepared value object, or a list of these * objects (for the address headers), a prepared value object, or a list of these
*/ */
export type MimeNodeHeaderValue = string | number | boolean | Date | MimeNodeAddress | MimeNodePreparedHeaderValue | MimeNodeHeaderValue[] | null | undefined; export type MimeNodeHeaderValue = string | number | boolean | Date | MimeNodeAddress | MimeNodePreparedHeaderValue | MimeNodeHeaderValue[] | null | undefined;
/**
* Options for the static MimeNode.encodeHeaderValue
*/
export interface MimeNodeHeaderEncodingOptions {
/** Either 'Q' (the default) or 'B' */
textEncoding?: string | undefined;
}
/** /**
* A single header, the shape the headers are stored in and one of the list forms * A single header, the shape the headers are stored in and one of the list forms
* setHeader and addHeader accept * setHeader and addHeader accept
@@ -319,6 +326,31 @@ declare class MimeNode {
* @param raw Raw MIME contents * @param raw Raw MIME contents
*/ */
setRaw(raw: MimeNodeContent): this; setRaw(raw: MimeNodeContent): this;
/**
* Encodes a header value the way this node encodes it in the generated message:
* address headers are punycoded and mime encoded, message id headers get their
* angle brackets, a Date object is formatted and any other value is mime encoded
* when it holds non-ascii or control characters (using the node's `textEncoding`).
*
* The result is a single unfolded line without the key. Fold it before writing it
* into a message, `key + ': ' + value` folded at 76 characters is what the node does.
*
* @param key Header key, case insensitive
* @param value Header value
* @returns Encoded header value
*/
encodeHeaderValue(key: string, value: MimeNodeHeaderValue): string;
/**
* Encodes a header value without a node at hand. Same as the instance method of a
* node created with the given options.
*
* @param key Header key, case insensitive
* @param value Header value
* @param [options] Encoding options
* @param [options.textEncoding] either 'Q' (the default) or 'B'
* @returns Encoded header value
*/
static encodeHeaderValue(key: string, value: MimeNodeHeaderValue, options?: MimeNodeHeaderEncodingOptions): string;
} }
/** /**
* Type aliases in the layout of @types/nodemailer, so `MimeNode.Options` style references keep working * Type aliases in the layout of @types/nodemailer, so `MimeNode.Options` style references keep working
@@ -327,7 +359,8 @@ declare namespace MimeNode {
type Options = MimeNodeOptions; type Options = MimeNodeOptions;
type Addresses = MimeNodeAddresses; type Addresses = MimeNodeAddresses;
type Envelope = MimeNodeEnvelope; type Envelope = MimeNodeEnvelope;
type HeaderEncodingOptions = MimeNodeHeaderEncodingOptions;
} }
/** The same aliases as module level exports, for `import * as MimeNode` and `import MimeNode = require()` */ /** The same aliases as module level exports, for `import * as MimeNode` and `import MimeNode = require()` */
export type { MimeNodeOptions as Options, MimeNodeAddresses as Addresses, MimeNodeEnvelope as Envelope }; export type { MimeNodeOptions as Options, MimeNodeAddresses as Addresses, MimeNodeEnvelope as Envelope, MimeNodeHeaderEncodingOptions as HeaderEncodingOptions };
export default MimeNode; export default MimeNode;
+35 -13
View File
@@ -117,9 +117,7 @@ function exceedsDomainLimits(domain) {
*/ */
function normalizeDomain(domain, toUnicode) { function normalizeDomain(domain, toUnicode) {
// domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6 // domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6
if (typeof urlModule.domainToASCII === 'function' && if (typeof urlModule.domainToASCII === 'function' && typeof urlModule.domainToUnicode === 'function' && !URL_PARSER_UNSAFE.test(domain)) {
typeof urlModule.domainToUnicode === 'function' &&
!URL_PARSER_UNSAFE.test(domain)) {
// The U-label form is decoded from the A-label form rather than from the input: // The U-label form is decoded from the A-label form rather than from the input:
// Deno's domainToASCII returns an empty string for a label it cannot decode, while // Deno's domainToASCII returns an empty string for a label it cannot decode, while
// its domainToUnicode returns the label with U+FFFD in place of the bad part // its domainToUnicode returns the label with U+FFFD in place of the bad part
@@ -632,9 +630,7 @@ class MimeNode {
// the checks miss and the header ends up claiming a type it is not set up for // the checks miss and the header ends up claiming a type it is not set up for
structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, ''); structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, '');
this._handleContentType(structured); this._handleContentType(structured);
if (structured.value.match(/^text\/plain\b/) && if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) {
typeof this.content === 'string' &&
/[\u0080-\uFFFF]/.test(this.content)) {
structured.params.charset = 'utf-8'; structured.params.charset = 'utf-8';
} }
if (this.filename) { if (this.filename) {
@@ -675,7 +671,7 @@ class MimeNode {
} }
break; break;
} }
value = this._encodeHeaderValue(key, value); value = this.encodeHeaderValue(key, value);
// skip empty lines // skip empty lines
if (!(value || '').toString().trim()) { if (!(value || '').toString().trim()) {
return; return;
@@ -1293,8 +1289,7 @@ class MimeNode {
// where it came from. MimeNode is exported and subclassable, so the generator is // where it came from. MimeNode is exported and subclassable, so the generator is
// not necessarily the one below. Stripping twice costs nothing, it is idempotent. // not necessarily the one below. Stripping twice costs nothing, it is idempotent.
const declared = _stripBoundaryControls(structured.params.boundary || this.boundary || ''); const declared = _stripBoundaryControls(structured.params.boundary || this.boundary || '');
this.boundary = structured.params.boundary = this.boundary = structured.params.boundary = declared || _stripBoundaryControls(this._generateBoundary());
declared || _stripBoundaryControls(this._generateBoundary());
} }
else { else {
this.boundary = false; this.boundary = false;
@@ -1312,13 +1307,19 @@ class MimeNode {
return _stripBoundaryControls(this.rootNode.boundaryPrefix + '-' + this.rootNode.baseBoundary) + '-Part_' + this._nodeId; return _stripBoundaryControls(this.rootNode.boundaryPrefix + '-' + this.rootNode.baseBoundary) + '-Part_' + this._nodeId;
} }
/** /**
* Encodes a header value for use in the generated rfc2822 email. * Encodes a header value the way this node encodes it in the generated message:
* address headers are punycoded and mime encoded, message id headers get their
* angle brackets, a Date object is formatted and any other value is mime encoded
* when it holds non-ascii or control characters (using the node's `textEncoding`).
* *
* @param key Header key * The result is a single unfolded line without the key. Fold it before writing it
* into a message, `key + ': ' + value` folded at 76 characters is what the node does.
*
* @param key Header key, case insensitive
* @param value Header value * @param value Header value
* @internal * @returns Encoded header value
*/ */
_encodeHeaderValue(key, value) { encodeHeaderValue(key, value) {
key = this._normalizeHeaderKey(key); key = this._normalizeHeaderKey(key);
switch (key) { switch (key) {
// Structured headers // Structured headers
@@ -1383,6 +1384,27 @@ class MimeNode {
return this._encodeHeaderText(value); return this._encodeHeaderText(value);
} }
} }
/**
* Encodes a header value without a node at hand. Same as the instance method of a
* node created with the given options.
*
* @param key Header key, case insensitive
* @param value Header value
* @param [options] Encoding options
* @param [options.textEncoding] either 'Q' (the default) or 'B'
* @returns Encoded header value
*/
static encodeHeaderValue(key, value, options) {
return new MimeNode(false, { textEncoding: options?.textEncoding }).encodeHeaderValue(key, value);
}
/**
* Alias of encodeHeaderValue, kept for callers that used the method before it was public
*
* @internal
*/
_encodeHeaderValue(key, value) {
return this.encodeHeaderValue(key, value);
}
/** /**
* Rebuilds address object using punycode and other adjustments * Rebuilds address object using punycode and other adjustments
* *
+1 -1
View File
@@ -1,3 +1,3 @@
export declare const name = "nodemailer"; export declare const name = "nodemailer";
export declare const version = "10.1.6"; export declare const version = "10.2.0";
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.6'; export const version = '10.2.0';
export const homepage = 'https://nodemailer.com/'; export const homepage = 'https://nodemailer.com/';
+3 -12
View File
@@ -45,15 +45,9 @@ function encodeBytes(buffer, next, binary, previous) {
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const ord = buffer[i]; const ord = buffer[i];
const following = i + 1 < len ? buffer[i + 1] : next; const following = i + 1 < len ? buffer[i + 1] : next;
const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) ? (ord === 0x0d ? following === 0x0a : (i > 0 ? buffer[i - 1] : previous) === 0x0d) : true;
? ord === 0x0d
? following === 0x0a
: (i > 0 ? buffer[i - 1] : previous) === 0x0d
: true;
// if the char is in allowed range, then keep as is, unless it is a WS in the end of a line // if the char is in allowed range, then keep as is, unless it is a WS in the end of a line
if (QP_LITERAL[ord] && if (QP_LITERAL[ord] && lineBreakByte && !(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) {
lineBreakByte &&
!(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) {
output[pos++] = ord; output[pos++] = ord;
continue; continue;
} }
@@ -114,10 +108,7 @@ export function wrap(str, lineLength) {
line = line.substr(0, line.length - match[0].length); line = line.substr(0, line.length - match[0].length);
} }
// ensure that utf-8 sequences are not split // ensure that utf-8 sequences are not split
while (line.length > 3 && while (line.length > 3 && line.length < len - pos && !line.match(/^(?:=[\da-f]{2}){1,4}$/i) && (match = line.match(/[=][\da-f]{2}$/gi))) {
line.length < len - pos &&
!line.match(/^(?:=[\da-f]{2}){1,4}$/i) &&
(match = line.match(/[=][\da-f]{2}$/gi))) {
code = parseInt(match[0].substr(1, 2), 16); code = parseInt(match[0].substr(1, 2), 16);
if (code < 128) { if (code < 128) {
break; break;
+1 -3
View File
@@ -71,9 +71,7 @@ class SendmailTransport {
return done(err); return done(err);
} }
// force -i to keep single dots // force -i to keep single dots
const args = this.args const args = this.args ? ['-i'].concat(this.args).concat(envelope.to) : ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to);
? ['-i'].concat(this.args).concat(envelope.to)
: ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to);
const callback = (err) => { const callback = (err) => {
if (returned) { if (returned) {
// ignore any additional responses, already done // ignore any additional responses, already done
+2 -6
View File
@@ -217,9 +217,7 @@ export const resolveHostname = (options, callback) => {
return respond(err); return respond(err);
} }
// Get all supported addresses from dns.lookup // Get all supported addresses from dns.lookup
const supportedAddresses = addresses const supportedAddresses = addresses ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) : [];
? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address)
: [];
if (addresses && addresses.length && !supportedAddresses.length) { if (addresses && addresses.length && !supportedAddresses.length) {
// there are addresses but none can be used // there are addresses but none can be used
console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`); console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`);
@@ -549,9 +547,7 @@ export const assign = function (...args) {
if (isProtoKey(key)) { if (isProtoKey(key)) {
return; return;
} }
if (['tls', 'auth'].includes(key) && if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') {
source[key] &&
typeof source[key] === 'object') {
// tls and auth are special keys that need to be enumerated separately // tls and auth are special keys that need to be enumerated separately
// other objects are passed as is. Enumerating is a copy of user supplied // other objects are passed as is. Enumerating is a copy of user supplied
// keys just like the loop above, so it gets the same treatment // keys just like the loop above, so it gets the same treatment
+1 -1
View File
@@ -9,7 +9,7 @@ import * as errors from '../errors.js';
// never sends \r\n\r\n cannot grow memory unboundedly before the socket times out. // never sends \r\n\r\n cannot grow memory unboundedly before the socket times out.
const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
// URL hostnames keep the brackets around an IPv6 literal, socket options and net.isIPv6 take it without // URL hostnames keep the brackets around an IPv6 literal, socket options and net.isIPv6 take it without
const unbracket = (host) => typeof host === 'string' && host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; const unbracket = (host) => (typeof host === 'string' && host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host);
function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) { function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) {
if (typeof tlsOptions === 'function') { if (typeof tlsOptions === 'function') {
callback = tlsOptions; callback = tlsOptions;
+6
View File
@@ -245,6 +245,12 @@ export interface SMTPConnectionSendInfo extends SMTPConnectionEnvelopeInfo {
messageSize: number; messageSize: number;
/** Final server response for the message */ /** Final server response for the message */
response: string; response: string;
/**
* Message-ID the server gave the message, with angle brackets, when its final reply names
* one in a known format (Microsoft 365 and Outlook.com, Amazon SES). Such a server replaces
* the Message-ID header, so the delivered copy carries this id instead of messageId
*/
serverMessageId?: string | undefined;
} }
/** /**
* Callback for send(), receives the result once the server accepted the message. The error * Callback for send(), receives the result once the server accepted the message. The error
+16 -13
View File
@@ -7,7 +7,8 @@ import crypto from 'node:crypto';
import DataStream from './data-stream.js'; import DataStream from './data-stream.js';
import { PassThrough } from 'node:stream'; import { PassThrough } from 'node:stream';
import * as shared from '../shared/index.js'; import * as shared from '../shared/index.js';
import { ERR_ACCESS_DENIED, isTransientError } from '../errors.js'; import { parseServerMessageId } from './server-message-id.js';
import { ERR_ACCESS_DENIED, isPermanentResponseCode, isTransientError } from '../errors.js';
// default timeout values in ms // default timeout values in ms
const CONNECTION_TIMEOUT = 2 * 60 * 1000; // how much to wait for the connection to be established const CONNECTION_TIMEOUT = 2 * 60 * 1000; // how much to wait for the connection to be established
const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity before disconnecting the client const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity before disconnecting the client
@@ -361,9 +362,7 @@ class SMTPConnection extends EventEmitter {
if (this._destroyed || this._closing) { if (this._destroyed || this._closing) {
return; return;
} }
const connectFn = secure const connectFn = secure ? tls.connect : net.connect;
? tls.connect
: net.connect;
try { try {
this._socket = connectFn(opts, () => this._onConnect()); this._socket = connectFn(opts, () => this._onConnect());
this._setupConnectionHandlers(); this._setupConnectionHandlers();
@@ -701,6 +700,16 @@ class SMTPConnection extends EventEmitter {
result.messageTime = Date.now() - envelopeTime; result.messageTime = Date.now() - envelopeTime;
result.messageSize = stream.outByteCount; result.messageSize = stream.outByteCount;
result.response = str; result.response = str;
const serverMessageId = parseServerMessageId(result.response, this.options.host);
if (serverMessageId) {
result.serverMessageId = serverMessageId;
}
// an LMTP server answers the message once per recipient, so recipients can be
// refused after the envelope info was built without any rejected errors on it
const rejectedErrors = this._envelope?.rejectedErrors;
if (!result.rejectedErrors && rejectedErrors && rejectedErrors.length) {
result.rejectedErrors = rejectedErrors;
}
return callback(null, result); return callback(null, result);
}); });
if (typeof message.pipe === 'function') { if (typeof message.pipe === 'function') {
@@ -928,6 +937,7 @@ class SMTPConnection extends EventEmitter {
const responseCode = (typeof response === 'string' && Number((response.match(/^\d+/) || [])[0])) || false; const responseCode = (typeof response === 'string' && Number((response.match(/^\d+/) || [])[0])) || false;
if (responseCode) { if (responseCode) {
err.responseCode = responseCode; err.responseCode = responseCode;
err.permanent = isPermanentResponseCode(responseCode);
} }
if (command) { if (command) {
err.command = command; err.command = command;
@@ -970,12 +980,7 @@ class SMTPConnection extends EventEmitter {
this.close(); this.close();
return; return;
} }
if (!failureResponse && if (!failureResponse && this.stage === 'connected' && !this._responseActions.length && !this._pendingSend && !this._destroyed && !this._closing) {
this.stage === 'connected' &&
!this._responseActions.length &&
!this._pendingSend &&
!this._destroyed &&
!this._closing) {
// nothing was waiting for the server, so this is the server ending an idle session // nothing was waiting for the server, so this is the server ending an idle session
// (usually after its idle timeout), not a failure // (usually after its idle timeout), not a failure
this.logger.info({ this.logger.info({
@@ -1196,9 +1201,7 @@ class SMTPConnection extends EventEmitter {
const args = []; const args = [];
let useSmtpUtf8 = false; let useSmtpUtf8 = false;
this._envelope = (envelope || {}); this._envelope = (envelope || {});
this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '') this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '').toString().trim();
.toString()
.trim();
this._envelope.to = [] this._envelope.to = []
.concat(this._envelope.to || []) .concat(this._envelope.to || [])
.map(to => ((to && to.address) || to || '').toString().trim()); .map(to => ((to && to.address) || to || '').toString().trim());
@@ -0,0 +1,14 @@
/**
* Reads the Message-ID a receiving server gave the message out of its final 250 reply. Some
* servers replace the Message-ID header of a submitted message with one of their own, and
* the copy the recipients and the sent folder get carries that one instead of the id the
* message was built with. Only replies in a format known to carry such an id are read.
*/
/**
* Reads the server assigned Message-ID out of the final reply to a message
*
* @param response Final server reply for the message
* @param [host] Hostname the connection was made to, SES replies are only read for an SES host
* @returns The Message-ID with its angle brackets, or undefined when the reply names none
*/
export declare function parseServerMessageId(response: string | undefined, host?: string): string | undefined;
+50
View File
@@ -0,0 +1,50 @@
/**
* Reads the Message-ID a receiving server gave the message out of its final 250 reply. Some
* servers replace the Message-ID header of a submitted message with one of their own, and
* the copy the recipients and the sent folder get carries that one instead of the id the
* message was built with. Only replies in a format known to carry such an id are read.
*/
// Microsoft 365 and Outlook.com: "250 2.0.0 OK <id@host.prod.outlook.com> [Hostname=...]"
const OUTLOOK_REPLY = /^250 2\.0\.0 OK (<[^<>\s]+\.prod\.outlook\.com>)/i;
// Amazon SES: "250 Ok 0100018b5c3c0a4f-...-000000", the local part of the Message-ID it sets
const SES_REPLY = /^250 Ok ([0-9a-f]+(?:-[0-9a-f]+)+)$/i;
// an AWS region name such as us-east-1 or ap-southeast-2
const AWS_REGION = /^[a-z]{2}(?:-[a-z]+)+-\d+$/;
/**
* Finds the AWS region an SES SMTP endpoint is in from its hostname, such as
* email-smtp.eu-west-1.amazonaws.com or a VPC endpoint name under it
*
* @param host SMTP hostname
* @returns Region name, or undefined for a host outside amazonaws.com and awsapps.com
*/
function sesRegion(host) {
const labels = host.toLowerCase().replace(/\.$/, '').split('.');
const domain = labels.slice(-2).join('.');
if (domain !== 'amazonaws.com' && domain !== 'awsapps.com') {
return undefined;
}
return labels.slice(0, -2).find(label => AWS_REGION.test(label));
}
/**
* Reads the server assigned Message-ID out of the final reply to a message
*
* @param response Final server reply for the message
* @param [host] Hostname the connection was made to, SES replies are only read for an SES host
* @returns The Message-ID with its angle brackets, or undefined when the reply names none
*/
export function parseServerMessageId(response, host) {
const reply = (response || '').toString().trim();
const outlook = reply.match(OUTLOOK_REPLY);
if (outlook) {
return outlook[1];
}
const ses = reply.match(SES_REPLY);
if (ses && host) {
const region = sesRegion(host);
if (region) {
// the SES Message-ID domain is email.amazonses.com in us-east-1 and the region elsewhere
return '<' + ses[1].toLowerCase() + '@' + (region === 'us-east-1' ? 'email' : region) + '.amazonses.com>';
}
}
return undefined;
}
+11
View File
@@ -110,6 +110,17 @@ declare class SMTPPool extends EventEmitter {
* Returns true if there are free slots in the queue * Returns true if there are free slots in the queue
*/ */
isIdle(): boolean; isIdle(): boolean;
/**
* Counts the messages the pool has not finished with yet: the ones waiting in the queue
* plus the ones a connection is transmitting right now. isIdle() only tells whether the
* pool can take another message, it is already true while a message is still being sent,
* so use this to check that nothing would be lost by closing the pool. A message that is
* re-queued after its connection dropped is counted until its callback runs, also while it waits to be put back on the queue, and
* it is no longer counted by the time its callback runs.
*
* @returns Number of queued and in-flight messages
*/
pendingCount(): number;
/** /**
* Verifies SMTP configuration * Verifies SMTP configuration
* *
+25 -1
View File
@@ -62,6 +62,7 @@ class SMTPPool extends EventEmitter {
this._queue = []; this._queue = [];
this._connections = []; this._connections = [];
this._connectionCounter = 0; this._connectionCounter = 0;
this._pending = 0;
this.idling = true; this.idling = true;
setImmediate(() => { setImmediate(() => {
if (this.idling) { if (this.idling) {
@@ -94,10 +95,20 @@ class SMTPPool extends EventEmitter {
setImmediate(() => callback(err)); setImmediate(() => callback(err));
return false; return false;
} }
// counted until the callback runs, whichever path runs it: delivered, failed, requeued
// until it gives up, or drained by close()
let counted = true;
this._pending++;
this._queue.push({ this._queue.push({
mail, mail,
requeueAttempts: 0, requeueAttempts: 0,
callback callback: (err, info) => {
if (counted) {
counted = false;
this._pending--;
}
callback(err, info);
}
}); });
if (this.idling && this._queue.length >= this.options.maxConnections) { if (this.idling && this._queue.length >= this.options.maxConnections) {
this.idling = false; this.idling = false;
@@ -441,6 +452,19 @@ class SMTPPool extends EventEmitter {
isIdle() { isIdle() {
return this.idling; return this.idling;
} }
/**
* Counts the messages the pool has not finished with yet: the ones waiting in the queue
* plus the ones a connection is transmitting right now. isIdle() only tells whether the
* pool can take another message, it is already true while a message is still being sent,
* so use this to check that nothing would be lost by closing the pool. A message that is
* re-queued after its connection dropped is counted until its callback runs, also while it waits to be put back on the queue, and
* it is no longer counted by the time its callback runs.
*
* @returns Number of queued and in-flight messages
*/
pendingCount() {
return this._pending;
}
verify(callback) { verify(callback) {
let promise; let promise;
if (!callback) { if (!callback) {
+2 -5
View File
@@ -19,8 +19,7 @@ export default class PoolResource extends EventEmitter {
switch ((this.options.auth.type || '').toString().toUpperCase()) { switch ((this.options.auth.type || '').toString().toUpperCase()) {
case 'OAUTH2': { case 'OAUTH2': {
const oauth2 = new XOAuth2(this.options.auth, this.logger); const oauth2 = new XOAuth2(this.options.auth, this.logger);
oauth2.provisionCallback = oauth2.provisionCallback = (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
(this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
this.auth = { this.auth = {
type: 'OAUTH2', type: 'OAUTH2',
user: this.options.auth.user, user: this.options.auth.user,
@@ -208,9 +207,7 @@ export default class PoolResource extends EventEmitter {
this.connection.close(); this.connection.close();
return; return;
} }
if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && err.responseCode !== 421 && !this.connection._destroyed) {
err.responseCode !== 421 &&
!this.connection._destroyed) {
// The server refused this message, the connection itself is fine. Reset the // The server refused this message, the connection itself is fine. Reset the
// session and keep using it instead of opening a new one // session and keep using it instead of opening a new one
this.connection.reset(resetErr => { this.connection.reset(resetErr => {
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodemailer", "name": "nodemailer",
"version": "10.1.6", "version": "10.2.0",
"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",
@@ -128,6 +128,8 @@
"test": "node --import tsx --test --test-concurrency=1 $(find test \\( -name '*-test.ts' -o -name '*.test.ts' \\))", "test": "node --import tsx --test --test-concurrency=1 $(find test \\( -name '*-test.ts' -o -name '*.test.ts' \\))",
"pretest:coverage": "npm run build", "pretest:coverage": "npm run build",
"test:coverage": "c8 node --import tsx --test --test-concurrency=1 $(find test \\( -name '*-test.ts' -o -name '*.test.ts' \\))", "test:coverage": "c8 node --import tsx --test --test-concurrency=1 $(find test \\( -name '*-test.ts' -o -name '*.test.ts' \\))",
"pretest:cjs": "npm run build",
"test:cjs": "NODE_OPTIONS=--no-experimental-require-module node test/package/cjs-smoke.cjs",
"pretest:bun": "npm run build", "pretest:bun": "npm run build",
"test:bun": "bun test --timeout 60000 $(find ./test \\( -name '*-test.ts' -o -name '*.test.ts' \\))", "test:bun": "bun test --timeout 60000 $(find ./test \\( -name '*-test.ts' -o -name '*.test.ts' \\))",
"format": "prettier --write \"**/*.{js,cjs,ts,json,md}\"", "format": "prettier --write \"**/*.{js,cjs,ts,json,md}\"",
@@ -155,7 +157,7 @@
"@types/node": "20.19.43", "@types/node": "20.19.43",
"bunyan": "1.8.15", "bunyan": "1.8.15",
"c8": "12.0.0", "c8": "12.0.0",
"eslint": "10.12.0", "eslint": "10.13.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"globals": "17.13.0", "globals": "17.13.0",
"libbase64": "1.3.2", "libbase64": "1.3.2",