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