diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 2cb30877..0d3dc170 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -94,9 +94,9 @@ } }, "node_modules/nodemailer": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.6.tgz", - "integrity": "sha512-8j/UEmA4AXr51URr3Ob0h+RCBtr0NKJ76Y2P9wJT6Eu3dRyruj2ULh2e6aiKrGg8nlUj1oX6N5h8dHA82EouaQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.0.tgz", + "integrity": "sha512-/XyGw6kX2stMdKGUJdAQmYAeY1FscWfdRydfd2NtK3A3KDfUUXCqZzExSNAt2xhh90X2qLCR+o2Gd48YPJcJwA==", "license": "MIT-0", "engines": { "node": ">=20.0.0" diff --git a/node_modules/nodemailer/CHANGELOG.md b/node_modules/nodemailer/CHANGELOG.md index 438ccd60..dfda201d 100644 --- a/node_modules/nodemailer/CHANGELOG.md +++ b/node_modules/nodemailer/CHANGELOG.md @@ -1,5 +1,28 @@ # 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) diff --git a/node_modules/nodemailer/dist/cjs/addressparser/index.js b/node_modules/nodemailer/dist/cjs/addressparser/index.js index 78aa99e5..e78a59eb 100644 --- a/node_modules/nodemailer/dist/cjs/addressparser/index.js +++ b/node_modules/nodemailer/dist/cjs/addressparser/index.js @@ -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 // its own, with the quoting put back on, and takes its turn like any other const local = data.quotedLocalAt[i]; - const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) - ? _quoteLocalPart(data.text[i]) - : ''; + const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) ? _quoteLocalPart(data.text[i]) : ''; if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) { data.address = [quotedLocal || candidates[i]]; data.text.splice(i, 1); @@ -1020,8 +1018,7 @@ class Tokenizer { return; } if (this.node < 0) { - this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | - (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i); + this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i); } if (chr === '\n') { // Convert newlines to spaces. Carriage return is ignored as \r and \n usually diff --git a/node_modules/nodemailer/dist/cjs/dkim/index.js b/node_modules/nodemailer/dist/cjs/dkim/index.js index 4d00b6e0..3d23312a 100644 --- a/node_modules/nodemailer/dist/cjs/dkim/index.js +++ b/node_modules/nodemailer/dist/cjs/dkim/index.js @@ -25,9 +25,7 @@ class DKIMSigner { this.chunks = []; this.chunklen = 0; this.readPos = 0; - this.cachePath = this.cacheDir - ? node_path_1.default.join(this.cacheDir, 'message.' + Date.now() + '-' + node_crypto_1.default.randomBytes(14).toString('hex')) - : false; + this.cachePath = this.cacheDir ? node_path_1.default.join(this.cacheDir, 'message.' + Date.now() + '-' + node_crypto_1.default.randomBytes(14).toString('hex')) : false; this.cache = false; this.headers = false; this.bodyHash = false; diff --git a/node_modules/nodemailer/dist/cjs/errors.d.ts b/node_modules/nodemailer/dist/cjs/errors.d.ts index bb7ec1f9..80c285ed 100644 --- a/node_modules/nodemailer/dist/cjs/errors.d.ts +++ b/node_modules/nodemailer/dist/cjs/errors.d.ts @@ -68,6 +68,18 @@ export declare const isTransientError: (err: { code?: string | undefined; responseCode?: number | undefined; }) => 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 * 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; /** Numeric SMTP response code */ 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 */ sourceUrl?: string | undefined; /** Recipient address the error applies to */ diff --git a/node_modules/nodemailer/dist/cjs/errors.js b/node_modules/nodemailer/dist/cjs/errors.js index b7cc8b72..dc098f23 100644 --- a/node_modules/nodemailer/dist/cjs/errors.js +++ b/node_modules/nodemailer/dist/cjs/errors.js @@ -10,7 +10,7 @@ * err.code = errors.ECONNECTION; */ 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 */ @@ -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; 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 * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, diff --git a/node_modules/nodemailer/dist/cjs/fetch/cookies.js b/node_modules/nodemailer/dist/cjs/fetch/cookies.js index f4cc82e4..1374deac 100644 --- a/node_modules/nodemailer/dist/cjs/fetch/cookies.js +++ b/node_modules/nodemailer/dist/cjs/fetch/cookies.js @@ -198,16 +198,14 @@ class Cookies { // check if hostname matches // .foo.com also matches subdomains, foo.com does not if (urlparts.hostname !== cookie.domain && - (cookie.domain.charAt(0) !== '.' || - ('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) { + (cookie.domain.charAt(0) !== '.' || ('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) { return false; } // 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 const pathname = urlparts.pathname || '/'; const cookiePath = cookie.path; - const pathMatches = pathname === cookiePath || - (pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/')); + const pathMatches = pathname === cookiePath || (pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/')); if (!pathMatches) { return false; } diff --git a/node_modules/nodemailer/dist/cjs/fetch/index.js b/node_modules/nodemailer/dist/cjs/fetch/index.js index 9107f514..e661c25c 100644 --- a/node_modules/nodemailer/dist/cjs/fetch/index.js +++ b/node_modules/nodemailer/dist/cjs/fetch/index.js @@ -262,11 +262,7 @@ function nmfetch(url, options) { } }); } - if (parsed.protocol === 'https:' && - parsed.hostname && - parsed.hostname !== reqOptions.host && - !node_net_1.default.isIP(parsed.hostname) && - !reqOptions.servername) { + if (parsed.protocol === 'https:' && parsed.hostname && parsed.hostname !== reqOptions.host && !node_net_1.default.isIP(parsed.hostname) && !reqOptions.servername) { reqOptions.servername = parsed.hostname; } try { @@ -369,9 +365,7 @@ function nmfetch(url, options) { // downgrade would also put them on the wire in cleartext). Strip sensitive // request headers so an attacker who controls the redirect target cannot // harvest them. - const crossOrigin = redirectParsed.protocol !== parsed.protocol || - redirectParsed.hostname !== parsed.hostname || - fetchPort(redirectParsed) !== fetchPort(parsed); + const crossOrigin = redirectParsed.protocol !== parsed.protocol || redirectParsed.hostname !== parsed.hostname || fetchPort(redirectParsed) !== fetchPort(parsed); if (options.headers && crossOrigin) { const sensitive = ['authorization', 'cookie', 'proxy-authorization']; Object.keys(options.headers).forEach(key => { diff --git a/node_modules/nodemailer/dist/cjs/mail-composer/index.js b/node_modules/nodemailer/dist/cjs/mail-composer/index.js index 437c8ebf..91a3c99a 100644 --- a/node_modules/nodemailer/dist/cjs/mail-composer/index.js +++ b/node_modules/nodemailer/dist/cjs/mail-composer/index.js @@ -54,6 +54,40 @@ function isContentObject(value) { const content = value; 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 { constructor(mail) { this.mail = mail || {}; @@ -136,8 +170,11 @@ class MailComposer { contentTransferEncoding = attachment.contentTransferEncoding; } else if (isMessageNode) { - // the content might include non-ASCII bytes but at this point we do not know it yet - contentTransferEncoding = '8bit'; + // a message/* part can not be encoded (RFC 2046 5.2.1), so the label has to describe + // 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 { contentTransferEncoding = 'base64'; // the default @@ -152,9 +189,7 @@ class MailComposer { } 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 - data.filename = - (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || - 'attachment-' + (i + 1); + data.filename = (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1); if (data.filename.indexOf('.') < 0) { data.filename += '.' + mimeFuncs.detectExtension(data.contentType); } @@ -315,8 +350,7 @@ class MailComposer { if (this.mail.icalEvent) { eventObject = Object.assign({}, this._getIcalEvent()); eventObject.filename = false; - eventObject.contentType = - 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase(); + eventObject.contentType = 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase(); if (!eventObject.headers) { eventObject.headers = {}; } @@ -344,8 +378,7 @@ class MailComposer { alternative = this._processDataUrl(alternative); } const data = { - contentType: alternative.contentType || - mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'), + contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'), contentTransferEncoding: alternative.contentTransferEncoding }; if (alternative.filename) { diff --git a/node_modules/nodemailer/dist/cjs/mailer/mail-message.js b/node_modules/nodemailer/dist/cjs/mailer/mail-message.js index 29b206d4..c0a18a59 100644 --- a/node_modules/nodemailer/dist/cjs/mailer/mail-message.js +++ b/node_modules/nodemailer/dist/cjs/mailer/mail-message.js @@ -109,8 +109,7 @@ class MailMessage { if (!attachment.filename) { // a backslash separates as well, so a Windows path does not put the sender's directories in the headers attachment.filename = - (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || - 'attachment-' + (i + 1); + (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1); if (attachment.filename.indexOf('.') < 0) { attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); } @@ -245,7 +244,7 @@ class MailMessage { value = (value && value.value) || value; if (value) { 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; } @@ -257,10 +256,10 @@ class MailMessage { }); } if (data.references) { - data.normalizedHeaders.references = this.message._encodeHeaderValue('references', data.references); + data.normalizedHeaders.references = this.message.encodeHeaderValue('references', data.references); } 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); }); @@ -329,7 +328,7 @@ class MailMessage { comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment); // List-ID expects a bare domain-like identifier, so strip the // scheme prefix that _formatListUrl adds or passes through - return ((value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<')); + return (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<'); } // List-*: (comment) // the ctext specials go out as quoted-pairs, otherwise a ")" closes the diff --git a/node_modules/nodemailer/dist/cjs/mime-funcs/index.js b/node_modules/nodemailer/dist/cjs/mime-funcs/index.js index d706ba32..895b0efe 100644 --- a/node_modules/nodemailer/dist/cjs/mime-funcs/index.js +++ b/node_modules/nodemailer/dist/cjs/mime-funcs/index.js @@ -70,6 +70,12 @@ function isPlainText(value, isParam) { const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/; 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 * 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]; // a parameter name is a token too and it is emitted without any quoting around it 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 => { if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') { paramsArray.push(encodedParam.key + '=' + encodedParam.value); @@ -286,7 +292,7 @@ function buildHeaderParam(key, data, maxLength) { let i, len; maxLength = maxLength || 50; // process ascii only text - if (isPlainText(data, true)) { + if (isPlainParamValue(data)) { // check if conversion is even needed if (encodedStr.length <= maxLength) { return [ diff --git a/node_modules/nodemailer/dist/cjs/mime-funcs/mime-types.js b/node_modules/nodemailer/dist/cjs/mime-funcs/mime-types.js index 10dd6c62..e51bf19f 100644 --- a/node_modules/nodemailer/dist/cjs/mime-funcs/mime-types.js +++ b/node_modules/nodemailer/dist/cjs/mime-funcs/mime-types.js @@ -1104,10 +1104,7 @@ const extensions = new Map([ ['bdm', 'application/vnd.syncml.dm+wbxml'], ['bed', 'application/vnd.realvnc.bed'], ['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'], ['bmi', 'application/vnd.bmi'], ['bmp', ['image/bmp', 'image/x-windows-bmp']], @@ -1152,10 +1149,7 @@ const extensions = new Map([ ['cii', 'application/vnd.anser-web-certificate-issue-initiation'], ['cil', 'application/vnd.ms-artgalry'], ['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'], ['clkp', 'application/vnd.crick.clicker.palette'], ['clkt', 'application/vnd.crick.clicker.template'], @@ -1760,10 +1754,7 @@ const extensions = new Map([ ['sbml', 'application/sbml+xml'], ['sc', 'application/vnd.ibm.secure-container'], ['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'], ['scs', 'application/scvp-cv-response'], ['sct', 'text/scriptlet'], diff --git a/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts b/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts index 4e2b4131..ec97e9ce 100644 --- a/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts @@ -58,6 +58,13 @@ export interface MimeNodePreparedHeaderValue { * 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; +/** + * 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 * setHeader and addHeader accept @@ -319,6 +326,31 @@ declare class MimeNode { * @param raw Raw MIME contents */ 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 @@ -327,7 +359,8 @@ declare namespace MimeNode { type Options = MimeNodeOptions; type Addresses = MimeNodeAddresses; type Envelope = MimeNodeEnvelope; + type HeaderEncodingOptions = MimeNodeHeaderEncodingOptions; } /** 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; diff --git a/node_modules/nodemailer/dist/cjs/mime-node/index.js b/node_modules/nodemailer/dist/cjs/mime-node/index.js index 3d0d5d0a..ae18d9d1 100644 --- a/node_modules/nodemailer/dist/cjs/mime-node/index.js +++ b/node_modules/nodemailer/dist/cjs/mime-node/index.js @@ -155,9 +155,7 @@ function exceedsDomainLimits(domain) { */ function normalizeDomain(domain, toUnicode) { // domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6 - if (typeof node_url_1.default.domainToASCII === 'function' && - typeof node_url_1.default.domainToUnicode === 'function' && - !URL_PARSER_UNSAFE.test(domain)) { + if (typeof node_url_1.default.domainToASCII === 'function' && 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: // 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 @@ -670,9 +668,7 @@ class MimeNode { // 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, ''); this._handleContentType(structured); - if (structured.value.match(/^text\/plain\b/) && - typeof this.content === 'string' && - /[\u0080-\uFFFF]/.test(this.content)) { + if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) { structured.params.charset = 'utf-8'; } if (this.filename) { @@ -713,7 +709,7 @@ class MimeNode { } break; } - value = this._encodeHeaderValue(key, value); + value = this.encodeHeaderValue(key, value); // skip empty lines if (!(value || '').toString().trim()) { return; @@ -1331,8 +1327,7 @@ class MimeNode { // 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. const declared = _stripBoundaryControls(structured.params.boundary || this.boundary || ''); - this.boundary = structured.params.boundary = - declared || _stripBoundaryControls(this._generateBoundary()); + this.boundary = structured.params.boundary = declared || _stripBoundaryControls(this._generateBoundary()); } else { this.boundary = false; @@ -1350,13 +1345,19 @@ class MimeNode { 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 - * @internal + * @returns Encoded header value */ - _encodeHeaderValue(key, value) { + encodeHeaderValue(key, value) { key = this._normalizeHeaderKey(key); switch (key) { // Structured headers @@ -1421,6 +1422,27 @@ class MimeNode { 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 * diff --git a/node_modules/nodemailer/dist/cjs/package-info.d.ts b/node_modules/nodemailer/dist/cjs/package-info.d.ts index b6096e51..c3dde549 100644 --- a/node_modules/nodemailer/dist/cjs/package-info.d.ts +++ b/node_modules/nodemailer/dist/cjs/package-info.d.ts @@ -1,3 +1,3 @@ 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/"; diff --git a/node_modules/nodemailer/dist/cjs/package-info.js b/node_modules/nodemailer/dist/cjs/package-info.js index 62350541..0ed8d1c7 100644 --- a/node_modules/nodemailer/dist/cjs/package-info.js +++ b/node_modules/nodemailer/dist/cjs/package-info.js @@ -3,5 +3,5 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.homepage = exports.version = exports.name = void 0; exports.name = 'nodemailer'; -exports.version = '10.1.6'; +exports.version = '10.2.0'; exports.homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/cjs/qp/index.js b/node_modules/nodemailer/dist/cjs/qp/index.js index 304b42cc..f501b5b7 100644 --- a/node_modules/nodemailer/dist/cjs/qp/index.js +++ b/node_modules/nodemailer/dist/cjs/qp/index.js @@ -50,15 +50,9 @@ function encodeBytes(buffer, next, binary, previous) { for (let i = 0; i < len; i++) { const ord = buffer[i]; const following = i + 1 < len ? buffer[i + 1] : next; - const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) - ? ord === 0x0d - ? following === 0x0a - : (i > 0 ? buffer[i - 1] : previous) === 0x0d - : true; + const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) ? (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 (QP_LITERAL[ord] && - lineBreakByte && - !(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) { + if (QP_LITERAL[ord] && lineBreakByte && !(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) { output[pos++] = ord; continue; } @@ -119,10 +113,7 @@ function wrap(str, lineLength) { line = line.substr(0, line.length - match[0].length); } // ensure that utf-8 sequences are not split - while (line.length > 3 && - line.length < len - pos && - !line.match(/^(?:=[\da-f]{2}){1,4}$/i) && - (match = line.match(/[=][\da-f]{2}$/gi))) { + while (line.length > 3 && 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); if (code < 128) { break; diff --git a/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js b/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js index 9073dee8..16d236a4 100644 --- a/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js +++ b/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js @@ -109,9 +109,7 @@ class SendmailTransport { return done(err); } // force -i to keep single dots - const args = this.args - ? ['-i'].concat(this.args).concat(envelope.to) - : ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to); + const args = this.args ? ['-i'].concat(this.args).concat(envelope.to) : ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to); const callback = (err) => { if (returned) { // ignore any additional responses, already done diff --git a/node_modules/nodemailer/dist/cjs/shared/index.js b/node_modules/nodemailer/dist/cjs/shared/index.js index b125d639..81b6752f 100644 --- a/node_modules/nodemailer/dist/cjs/shared/index.js +++ b/node_modules/nodemailer/dist/cjs/shared/index.js @@ -263,9 +263,7 @@ const resolveHostname = (options, callback) => { return respond(err); } // Get all supported addresses from dns.lookup - const supportedAddresses = addresses - ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) - : []; + const supportedAddresses = addresses ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) : []; if (addresses && addresses.length && !supportedAddresses.length) { // there are addresses but none can be used 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)) { return; } - if (['tls', 'auth'].includes(key) && - source[key] && - typeof source[key] === 'object') { + if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') { // 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 // keys just like the loop above, so it gets the same treatment diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js b/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js index 3ad79769..ae839538 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js @@ -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. const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; // 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) { if (typeof tlsOptions === 'function') { callback = tlsOptions; diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts b/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts index da692c86..5bdbd7ff 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts @@ -245,6 +245,12 @@ export interface SMTPConnectionSendInfo extends SMTPConnectionEnvelopeInfo { messageSize: number; /** Final server response for the message */ 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 diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js index 81a61498..875c2431 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js @@ -45,6 +45,7 @@ const node_crypto_1 = __importDefault(require("node:crypto")); const data_stream_js_1 = __importDefault(require("./data-stream.js")); const node_stream_1 = require("node:stream"); 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"); // default timeout values in ms 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) { return; } - const connectFn = secure - ? node_tls_1.default.connect - : node_net_1.default.connect; + const connectFn = secure ? node_tls_1.default.connect : node_net_1.default.connect; try { this._socket = connectFn(opts, () => this._onConnect()); this._setupConnectionHandlers(); @@ -739,6 +738,16 @@ class SMTPConnection extends node_events_1.EventEmitter { result.messageTime = Date.now() - envelopeTime; result.messageSize = stream.outByteCount; 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); }); 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; if (responseCode) { err.responseCode = responseCode; + err.permanent = (0, errors_js_1.isPermanentResponseCode)(responseCode); } if (command) { err.command = command; @@ -1008,12 +1018,7 @@ class SMTPConnection extends node_events_1.EventEmitter { this.close(); return; } - if (!failureResponse && - this.stage === 'connected' && - !this._responseActions.length && - !this._pendingSend && - !this._destroyed && - !this._closing) { + if (!failureResponse && 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 // (usually after its idle timeout), not a failure this.logger.info({ @@ -1234,9 +1239,7 @@ class SMTPConnection extends node_events_1.EventEmitter { const args = []; let useSmtpUtf8 = false; this._envelope = (envelope || {}); - this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '') - .toString() - .trim(); + this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '').toString().trim(); this._envelope.to = [] .concat(this._envelope.to || []) .map(to => ((to && to.address) || to || '').toString().trim()); diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/server-message-id.d.ts b/node_modules/nodemailer/dist/cjs/smtp-connection/server-message-id.d.ts new file mode 100644 index 00000000..cdbc6d0c --- /dev/null +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/server-message-id.d.ts @@ -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; diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/server-message-id.js b/node_modules/nodemailer/dist/cjs/smtp-connection/server-message-id.js new file mode 100644 index 00000000..e19c78df --- /dev/null +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/server-message-id.js @@ -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 [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; +} diff --git a/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts b/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts index 7c28f12b..45ccae35 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts @@ -110,6 +110,17 @@ declare class SMTPPool extends EventEmitter { * Returns true if there are free slots in the queue */ 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 * diff --git a/node_modules/nodemailer/dist/cjs/smtp-pool/index.js b/node_modules/nodemailer/dist/cjs/smtp-pool/index.js index a947a198..796d2246 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/index.js @@ -100,6 +100,7 @@ class SMTPPool extends node_events_1.EventEmitter { this._queue = []; this._connections = []; this._connectionCounter = 0; + this._pending = 0; this.idling = true; setImmediate(() => { if (this.idling) { @@ -132,10 +133,20 @@ class SMTPPool extends node_events_1.EventEmitter { setImmediate(() => callback(err)); 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({ mail, requeueAttempts: 0, - callback + callback: (err, info) => { + if (counted) { + counted = false; + this._pending--; + } + callback(err, info); + } }); if (this.idling && this._queue.length >= this.options.maxConnections) { this.idling = false; @@ -479,6 +490,19 @@ class SMTPPool extends node_events_1.EventEmitter { isIdle() { 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) { let promise; if (!callback) { diff --git a/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js b/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js index 87ca3b4f..20cef6e8 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js @@ -57,8 +57,7 @@ class PoolResource extends node_events_1.EventEmitter { switch ((this.options.auth.type || '').toString().toUpperCase()) { case 'OAUTH2': { const oauth2 = new index_js_3.default(this.options.auth, this.logger); - oauth2.provisionCallback = - (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback; + oauth2.provisionCallback = (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback; this.auth = { type: 'OAUTH2', user: this.options.auth.user, @@ -246,9 +245,7 @@ class PoolResource extends node_events_1.EventEmitter { this.connection.close(); return; } - if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && - err.responseCode !== 421 && - !this.connection._destroyed) { + if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && err.responseCode !== 421 && !this.connection._destroyed) { // The server refused this message, the connection itself is fine. Reset the // session and keep using it instead of opening a new one this.connection.reset(resetErr => { diff --git a/node_modules/nodemailer/dist/esm/addressparser/index.js b/node_modules/nodemailer/dist/esm/addressparser/index.js index ec5dad1d..60010b63 100644 --- a/node_modules/nodemailer/dist/esm/addressparser/index.js +++ b/node_modules/nodemailer/dist/esm/addressparser/index.js @@ -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 // its own, with the quoting put back on, and takes its turn like any other const local = data.quotedLocalAt[i]; - const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) - ? _quoteLocalPart(data.text[i]) - : ''; + const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) ? _quoteLocalPart(data.text[i]) : ''; if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) { data.address = [quotedLocal || candidates[i]]; data.text.splice(i, 1); @@ -1018,8 +1016,7 @@ class Tokenizer { return; } if (this.node < 0) { - this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | - (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i); + this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i); } if (chr === '\n') { // Convert newlines to spaces. Carriage return is ignored as \r and \n usually diff --git a/node_modules/nodemailer/dist/esm/dkim/index.js b/node_modules/nodemailer/dist/esm/dkim/index.js index 1dad2388..456f2af1 100644 --- a/node_modules/nodemailer/dist/esm/dkim/index.js +++ b/node_modules/nodemailer/dist/esm/dkim/index.js @@ -20,9 +20,7 @@ class DKIMSigner { this.chunks = []; this.chunklen = 0; this.readPos = 0; - this.cachePath = this.cacheDir - ? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex')) - : false; + this.cachePath = this.cacheDir ? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex')) : false; this.cache = false; this.headers = false; this.bodyHash = false; diff --git a/node_modules/nodemailer/dist/esm/errors.d.ts b/node_modules/nodemailer/dist/esm/errors.d.ts index bb7ec1f9..80c285ed 100644 --- a/node_modules/nodemailer/dist/esm/errors.d.ts +++ b/node_modules/nodemailer/dist/esm/errors.d.ts @@ -68,6 +68,18 @@ export declare const isTransientError: (err: { code?: string | undefined; responseCode?: number | undefined; }) => 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 * 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; /** Numeric SMTP response code */ 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 */ sourceUrl?: string | undefined; /** Recipient address the error applies to */ diff --git a/node_modules/nodemailer/dist/esm/errors.js b/node_modules/nodemailer/dist/esm/errors.js index 4eee7a31..8d02ea7c 100644 --- a/node_modules/nodemailer/dist/esm/errors.js +++ b/node_modules/nodemailer/dist/esm/errors.js @@ -69,6 +69,18 @@ export const EFETCH = 'EFETCH'; * 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; +/** + * 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 * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, diff --git a/node_modules/nodemailer/dist/esm/fetch/cookies.js b/node_modules/nodemailer/dist/esm/fetch/cookies.js index 8a66c215..db764c83 100644 --- a/node_modules/nodemailer/dist/esm/fetch/cookies.js +++ b/node_modules/nodemailer/dist/esm/fetch/cookies.js @@ -160,16 +160,14 @@ class Cookies { // check if hostname matches // .foo.com also matches subdomains, foo.com does not if (urlparts.hostname !== cookie.domain && - (cookie.domain.charAt(0) !== '.' || - ('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) { + (cookie.domain.charAt(0) !== '.' || ('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) { return false; } // 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 const pathname = urlparts.pathname || '/'; const cookiePath = cookie.path; - const pathMatches = pathname === cookiePath || - (pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/')); + const pathMatches = pathname === cookiePath || (pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/')); if (!pathMatches) { return false; } diff --git a/node_modules/nodemailer/dist/esm/fetch/index.js b/node_modules/nodemailer/dist/esm/fetch/index.js index 46e61c73..a786547a 100644 --- a/node_modules/nodemailer/dist/esm/fetch/index.js +++ b/node_modules/nodemailer/dist/esm/fetch/index.js @@ -224,11 +224,7 @@ function nmfetch(url, options) { } }); } - if (parsed.protocol === 'https:' && - parsed.hostname && - parsed.hostname !== reqOptions.host && - !net.isIP(parsed.hostname) && - !reqOptions.servername) { + if (parsed.protocol === 'https:' && parsed.hostname && parsed.hostname !== reqOptions.host && !net.isIP(parsed.hostname) && !reqOptions.servername) { reqOptions.servername = parsed.hostname; } try { @@ -331,9 +327,7 @@ function nmfetch(url, options) { // downgrade would also put them on the wire in cleartext). Strip sensitive // request headers so an attacker who controls the redirect target cannot // harvest them. - const crossOrigin = redirectParsed.protocol !== parsed.protocol || - redirectParsed.hostname !== parsed.hostname || - fetchPort(redirectParsed) !== fetchPort(parsed); + const crossOrigin = redirectParsed.protocol !== parsed.protocol || redirectParsed.hostname !== parsed.hostname || fetchPort(redirectParsed) !== fetchPort(parsed); if (options.headers && crossOrigin) { const sensitive = ['authorization', 'cookie', 'proxy-authorization']; Object.keys(options.headers).forEach(key => { diff --git a/node_modules/nodemailer/dist/esm/mail-composer/index.js b/node_modules/nodemailer/dist/esm/mail-composer/index.js index bc85f063..a0144e0f 100644 --- a/node_modules/nodemailer/dist/esm/mail-composer/index.js +++ b/node_modules/nodemailer/dist/esm/mail-composer/index.js @@ -16,6 +16,40 @@ function isContentObject(value) { const content = value; 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 { constructor(mail) { this.mail = mail || {}; @@ -98,8 +132,11 @@ class MailComposer { contentTransferEncoding = attachment.contentTransferEncoding; } else if (isMessageNode) { - // the content might include non-ASCII bytes but at this point we do not know it yet - contentTransferEncoding = '8bit'; + // a message/* part can not be encoded (RFC 2046 5.2.1), so the label has to describe + // 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 { contentTransferEncoding = 'base64'; // the default @@ -114,9 +151,7 @@ class MailComposer { } 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 - data.filename = - (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || - 'attachment-' + (i + 1); + data.filename = (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1); if (data.filename.indexOf('.') < 0) { data.filename += '.' + mimeFuncs.detectExtension(data.contentType); } @@ -277,8 +312,7 @@ class MailComposer { if (this.mail.icalEvent) { eventObject = Object.assign({}, this._getIcalEvent()); eventObject.filename = false; - eventObject.contentType = - 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase(); + eventObject.contentType = 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase(); if (!eventObject.headers) { eventObject.headers = {}; } @@ -306,8 +340,7 @@ class MailComposer { alternative = this._processDataUrl(alternative); } const data = { - contentType: alternative.contentType || - mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'), + contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'), contentTransferEncoding: alternative.contentTransferEncoding }; if (alternative.filename) { diff --git a/node_modules/nodemailer/dist/esm/mailer/mail-message.js b/node_modules/nodemailer/dist/esm/mailer/mail-message.js index 835b8527..7e836057 100644 --- a/node_modules/nodemailer/dist/esm/mailer/mail-message.js +++ b/node_modules/nodemailer/dist/esm/mailer/mail-message.js @@ -71,8 +71,7 @@ export default class MailMessage { if (!attachment.filename) { // a backslash separates as well, so a Windows path does not put the sender's directories in the headers attachment.filename = - (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || - 'attachment-' + (i + 1); + (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1); if (attachment.filename.indexOf('.') < 0) { attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); } @@ -207,7 +206,7 @@ export default class MailMessage { value = (value && value.value) || value; if (value) { 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; } @@ -219,10 +218,10 @@ export default class MailMessage { }); } if (data.references) { - data.normalizedHeaders.references = this.message._encodeHeaderValue('references', data.references); + data.normalizedHeaders.references = this.message.encodeHeaderValue('references', data.references); } 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); }); @@ -291,7 +290,7 @@ export default class MailMessage { comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment); // List-ID expects a bare domain-like identifier, so strip the // scheme prefix that _formatListUrl adds or passes through - return ((value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<')); + return (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<'); } // List-*: (comment) // the ctext specials go out as quoted-pairs, otherwise a ")" closes the diff --git a/node_modules/nodemailer/dist/esm/mime-funcs/index.js b/node_modules/nodemailer/dist/esm/mime-funcs/index.js index 8d68686b..8d4b7565 100644 --- a/node_modules/nodemailer/dist/esm/mime-funcs/index.js +++ b/node_modules/nodemailer/dist/esm/mime-funcs/index.js @@ -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]/; 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 * 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]; // a parameter name is a token too and it is emitted without any quoting around it 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 => { if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') { paramsArray.push(encodedParam.key + '=' + encodedParam.value); @@ -237,7 +243,7 @@ export function buildHeaderParam(key, data, maxLength) { let i, len; maxLength = maxLength || 50; // process ascii only text - if (isPlainText(data, true)) { + if (isPlainParamValue(data)) { // check if conversion is even needed if (encodedStr.length <= maxLength) { return [ diff --git a/node_modules/nodemailer/dist/esm/mime-funcs/mime-types.js b/node_modules/nodemailer/dist/esm/mime-funcs/mime-types.js index 794086ff..72f9d09c 100644 --- a/node_modules/nodemailer/dist/esm/mime-funcs/mime-types.js +++ b/node_modules/nodemailer/dist/esm/mime-funcs/mime-types.js @@ -1097,10 +1097,7 @@ const extensions = new Map([ ['bdm', 'application/vnd.syncml.dm+wbxml'], ['bed', 'application/vnd.realvnc.bed'], ['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'], ['bmi', 'application/vnd.bmi'], ['bmp', ['image/bmp', 'image/x-windows-bmp']], @@ -1145,10 +1142,7 @@ const extensions = new Map([ ['cii', 'application/vnd.anser-web-certificate-issue-initiation'], ['cil', 'application/vnd.ms-artgalry'], ['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'], ['clkp', 'application/vnd.crick.clicker.palette'], ['clkt', 'application/vnd.crick.clicker.template'], @@ -1753,10 +1747,7 @@ const extensions = new Map([ ['sbml', 'application/sbml+xml'], ['sc', 'application/vnd.ibm.secure-container'], ['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'], ['scs', 'application/scvp-cv-response'], ['sct', 'text/scriptlet'], diff --git a/node_modules/nodemailer/dist/esm/mime-node/index.d.ts b/node_modules/nodemailer/dist/esm/mime-node/index.d.ts index 4e2b4131..ec97e9ce 100644 --- a/node_modules/nodemailer/dist/esm/mime-node/index.d.ts +++ b/node_modules/nodemailer/dist/esm/mime-node/index.d.ts @@ -58,6 +58,13 @@ export interface MimeNodePreparedHeaderValue { * 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; +/** + * 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 * setHeader and addHeader accept @@ -319,6 +326,31 @@ declare class MimeNode { * @param raw Raw MIME contents */ 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 @@ -327,7 +359,8 @@ declare namespace MimeNode { type Options = MimeNodeOptions; type Addresses = MimeNodeAddresses; type Envelope = MimeNodeEnvelope; + type HeaderEncodingOptions = MimeNodeHeaderEncodingOptions; } /** 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; diff --git a/node_modules/nodemailer/dist/esm/mime-node/index.js b/node_modules/nodemailer/dist/esm/mime-node/index.js index a5e15d90..7af23e80 100644 --- a/node_modules/nodemailer/dist/esm/mime-node/index.js +++ b/node_modules/nodemailer/dist/esm/mime-node/index.js @@ -117,9 +117,7 @@ function exceedsDomainLimits(domain) { */ function normalizeDomain(domain, toUnicode) { // domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6 - if (typeof urlModule.domainToASCII === 'function' && - typeof urlModule.domainToUnicode === 'function' && - !URL_PARSER_UNSAFE.test(domain)) { + if (typeof urlModule.domainToASCII === 'function' && 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: // 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 @@ -632,9 +630,7 @@ class MimeNode { // 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, ''); this._handleContentType(structured); - if (structured.value.match(/^text\/plain\b/) && - typeof this.content === 'string' && - /[\u0080-\uFFFF]/.test(this.content)) { + if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) { structured.params.charset = 'utf-8'; } if (this.filename) { @@ -675,7 +671,7 @@ class MimeNode { } break; } - value = this._encodeHeaderValue(key, value); + value = this.encodeHeaderValue(key, value); // skip empty lines if (!(value || '').toString().trim()) { return; @@ -1293,8 +1289,7 @@ class MimeNode { // 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. const declared = _stripBoundaryControls(structured.params.boundary || this.boundary || ''); - this.boundary = structured.params.boundary = - declared || _stripBoundaryControls(this._generateBoundary()); + this.boundary = structured.params.boundary = declared || _stripBoundaryControls(this._generateBoundary()); } else { this.boundary = false; @@ -1312,13 +1307,19 @@ class MimeNode { 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 - * @internal + * @returns Encoded header value */ - _encodeHeaderValue(key, value) { + encodeHeaderValue(key, value) { key = this._normalizeHeaderKey(key); switch (key) { // Structured headers @@ -1383,6 +1384,27 @@ class MimeNode { 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 * diff --git a/node_modules/nodemailer/dist/esm/package-info.d.ts b/node_modules/nodemailer/dist/esm/package-info.d.ts index b6096e51..c3dde549 100644 --- a/node_modules/nodemailer/dist/esm/package-info.d.ts +++ b/node_modules/nodemailer/dist/esm/package-info.d.ts @@ -1,3 +1,3 @@ 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/"; diff --git a/node_modules/nodemailer/dist/esm/package-info.js b/node_modules/nodemailer/dist/esm/package-info.js index 4a89c7a0..1c9f1333 100644 --- a/node_modules/nodemailer/dist/esm/package-info.js +++ b/node_modules/nodemailer/dist/esm/package-info.js @@ -1,4 +1,4 @@ // Generated by scripts/build.js from package.json. Do not edit by hand. export const name = 'nodemailer'; -export const version = '10.1.6'; +export const version = '10.2.0'; export const homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/esm/qp/index.js b/node_modules/nodemailer/dist/esm/qp/index.js index e062f8c1..ce80d962 100644 --- a/node_modules/nodemailer/dist/esm/qp/index.js +++ b/node_modules/nodemailer/dist/esm/qp/index.js @@ -45,15 +45,9 @@ function encodeBytes(buffer, next, binary, previous) { for (let i = 0; i < len; i++) { const ord = buffer[i]; const following = i + 1 < len ? buffer[i + 1] : next; - const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) - ? ord === 0x0d - ? following === 0x0a - : (i > 0 ? buffer[i - 1] : previous) === 0x0d - : true; + const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) ? (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 (QP_LITERAL[ord] && - lineBreakByte && - !(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) { + if (QP_LITERAL[ord] && lineBreakByte && !(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) { output[pos++] = ord; continue; } @@ -114,10 +108,7 @@ export function wrap(str, lineLength) { line = line.substr(0, line.length - match[0].length); } // ensure that utf-8 sequences are not split - while (line.length > 3 && - line.length < len - pos && - !line.match(/^(?:=[\da-f]{2}){1,4}$/i) && - (match = line.match(/[=][\da-f]{2}$/gi))) { + while (line.length > 3 && 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); if (code < 128) { break; diff --git a/node_modules/nodemailer/dist/esm/sendmail-transport/index.js b/node_modules/nodemailer/dist/esm/sendmail-transport/index.js index 61c77593..ef987745 100644 --- a/node_modules/nodemailer/dist/esm/sendmail-transport/index.js +++ b/node_modules/nodemailer/dist/esm/sendmail-transport/index.js @@ -71,9 +71,7 @@ class SendmailTransport { return done(err); } // force -i to keep single dots - const args = this.args - ? ['-i'].concat(this.args).concat(envelope.to) - : ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to); + const args = this.args ? ['-i'].concat(this.args).concat(envelope.to) : ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to); const callback = (err) => { if (returned) { // ignore any additional responses, already done diff --git a/node_modules/nodemailer/dist/esm/shared/index.js b/node_modules/nodemailer/dist/esm/shared/index.js index 507dfaee..5a9b3af8 100644 --- a/node_modules/nodemailer/dist/esm/shared/index.js +++ b/node_modules/nodemailer/dist/esm/shared/index.js @@ -217,9 +217,7 @@ export const resolveHostname = (options, callback) => { return respond(err); } // Get all supported addresses from dns.lookup - const supportedAddresses = addresses - ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) - : []; + const supportedAddresses = addresses ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) : []; if (addresses && addresses.length && !supportedAddresses.length) { // there are addresses but none can be used 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)) { return; } - if (['tls', 'auth'].includes(key) && - source[key] && - typeof source[key] === 'object') { + if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') { // 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 // keys just like the loop above, so it gets the same treatment diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js b/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js index 750f7946..025ff3ac 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js +++ b/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js @@ -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. const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; // 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) { if (typeof tlsOptions === 'function') { callback = tlsOptions; diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts b/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts index da692c86..5bdbd7ff 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts @@ -245,6 +245,12 @@ export interface SMTPConnectionSendInfo extends SMTPConnectionEnvelopeInfo { messageSize: number; /** Final server response for the message */ 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 diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/index.js b/node_modules/nodemailer/dist/esm/smtp-connection/index.js index bf372a4c..cbaa2e47 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.js @@ -7,7 +7,8 @@ import crypto from 'node:crypto'; import DataStream from './data-stream.js'; import { PassThrough } from 'node:stream'; 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 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 @@ -361,9 +362,7 @@ class SMTPConnection extends EventEmitter { if (this._destroyed || this._closing) { return; } - const connectFn = secure - ? tls.connect - : net.connect; + const connectFn = secure ? tls.connect : net.connect; try { this._socket = connectFn(opts, () => this._onConnect()); this._setupConnectionHandlers(); @@ -701,6 +700,16 @@ class SMTPConnection extends EventEmitter { result.messageTime = Date.now() - envelopeTime; result.messageSize = stream.outByteCount; 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); }); if (typeof message.pipe === 'function') { @@ -928,6 +937,7 @@ class SMTPConnection extends EventEmitter { const responseCode = (typeof response === 'string' && Number((response.match(/^\d+/) || [])[0])) || false; if (responseCode) { err.responseCode = responseCode; + err.permanent = isPermanentResponseCode(responseCode); } if (command) { err.command = command; @@ -970,12 +980,7 @@ class SMTPConnection extends EventEmitter { this.close(); return; } - if (!failureResponse && - this.stage === 'connected' && - !this._responseActions.length && - !this._pendingSend && - !this._destroyed && - !this._closing) { + if (!failureResponse && 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 // (usually after its idle timeout), not a failure this.logger.info({ @@ -1196,9 +1201,7 @@ class SMTPConnection extends EventEmitter { const args = []; let useSmtpUtf8 = false; this._envelope = (envelope || {}); - this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '') - .toString() - .trim(); + this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '').toString().trim(); this._envelope.to = [] .concat(this._envelope.to || []) .map(to => ((to && to.address) || to || '').toString().trim()); diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/server-message-id.d.ts b/node_modules/nodemailer/dist/esm/smtp-connection/server-message-id.d.ts new file mode 100644 index 00000000..cdbc6d0c --- /dev/null +++ b/node_modules/nodemailer/dist/esm/smtp-connection/server-message-id.d.ts @@ -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; diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/server-message-id.js b/node_modules/nodemailer/dist/esm/smtp-connection/server-message-id.js new file mode 100644 index 00000000..9fca31db --- /dev/null +++ b/node_modules/nodemailer/dist/esm/smtp-connection/server-message-id.js @@ -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 [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; +} diff --git a/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts b/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts index 7c28f12b..45ccae35 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts @@ -110,6 +110,17 @@ declare class SMTPPool extends EventEmitter { * Returns true if there are free slots in the queue */ 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 * diff --git a/node_modules/nodemailer/dist/esm/smtp-pool/index.js b/node_modules/nodemailer/dist/esm/smtp-pool/index.js index 1e3ea174..3a9f97e6 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-pool/index.js @@ -62,6 +62,7 @@ class SMTPPool extends EventEmitter { this._queue = []; this._connections = []; this._connectionCounter = 0; + this._pending = 0; this.idling = true; setImmediate(() => { if (this.idling) { @@ -94,10 +95,20 @@ class SMTPPool extends EventEmitter { setImmediate(() => callback(err)); 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({ mail, requeueAttempts: 0, - callback + callback: (err, info) => { + if (counted) { + counted = false; + this._pending--; + } + callback(err, info); + } }); if (this.idling && this._queue.length >= this.options.maxConnections) { this.idling = false; @@ -441,6 +452,19 @@ class SMTPPool extends EventEmitter { isIdle() { 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) { let promise; if (!callback) { diff --git a/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js b/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js index 5ca031ea..7127b699 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js +++ b/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js @@ -19,8 +19,7 @@ export default class PoolResource extends EventEmitter { switch ((this.options.auth.type || '').toString().toUpperCase()) { case 'OAUTH2': { const oauth2 = new XOAuth2(this.options.auth, this.logger); - oauth2.provisionCallback = - (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback; + oauth2.provisionCallback = (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback; this.auth = { type: 'OAUTH2', user: this.options.auth.user, @@ -208,9 +207,7 @@ export default class PoolResource extends EventEmitter { this.connection.close(); return; } - if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && - err.responseCode !== 421 && - !this.connection._destroyed) { + if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && err.responseCode !== 421 && !this.connection._destroyed) { // The server refused this message, the connection itself is fine. Reset the // session and keep using it instead of opening a new one this.connection.reset(resetErr => { diff --git a/node_modules/nodemailer/package.json b/node_modules/nodemailer/package.json index ab7ed6b5..0f75f887 100644 --- a/node_modules/nodemailer/package.json +++ b/node_modules/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "nodemailer", - "version": "10.1.6", + "version": "10.2.0", "description": "Easy as cake e-mail sending from your Node.js applications", "type": "module", "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' \\))", "pretest:coverage": "npm run build", "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", "test:bun": "bun test --timeout 60000 $(find ./test \\( -name '*-test.ts' -o -name '*.test.ts' \\))", "format": "prettier --write \"**/*.{js,cjs,ts,json,md}\"", @@ -155,7 +157,7 @@ "@types/node": "20.19.43", "bunyan": "1.8.15", "c8": "12.0.0", - "eslint": "10.12.0", + "eslint": "10.13.0", "eslint-config-prettier": "10.1.8", "globals": "17.13.0", "libbase64": "1.3.2",