15 Commits
v8 ... v15

Author SHA1 Message Date
Dawid Dziurla
4758fd2f0e node_modules: update (#276)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
2026-03-14 19:11:45 +01:00
dependabot[bot]
512815fe9c build(deps): bump undici from 6.23.0 to 6.24.1 (#275)
Bumps [undici](https://github.com/nodejs/undici) from 6.23.0 to 6.24.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.24.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-14 19:10:39 +01:00
Fritz Elfert
4ca48c76b4 Fix 234 (#271)
* Fix envelope handling

* Updated documentation

* Update main.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Implemented review suggestions

* Implemented review suggestions

* Implemented review suggestions

* Use nodemailer's addressparser instead of regular expressions

* Updated README regarding address formats

* Updated README regarding address formats

* Update README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Use addressparser regardless of envelopeX ist set or not.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-13 10:51:21 +01:00
Fritz Elfert
e9d44227e5 Upgrade to node24 (#273) 2026-03-11 09:04:18 +01:00
Dawid Dziurla
949ec3d78a node_modules: update (#270)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
2026-03-10 10:09:25 +01:00
dependabot[bot]
f0e039bce4 build(deps): bump nodemailer from 8.0.1 to 8.0.2 (#269)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.1...v8.0.2)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 10:08:28 +01:00
Fritz Elfert
41125078ce Misc. fixes (#267)
* Misc. fixes

- Do not expose 'username' to a potential recipient.
- Make 'from' mandatory, because there is no fallback anymore.
- Validate 'from' to make shure, it contains an email address.
- Avoid namespace pollution: Use let instead of var.

* Suggested changes from codereview
2026-03-10 10:08:10 +01:00
dependabot[bot]
e035119249 build(deps): bump minimatch from 3.1.3 to 3.1.5 (#265)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.3 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.3...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 13:42:35 +01:00
Spiros Kalogeropoulos
ba302ba66e Add support for custom email headers via JSON input (#264)
* Adding custom header with JSON support

* Adding headers in test workflow

---------

Co-authored-by: W506810_wexinc <spiros.kalogeropoulos@wexinc.com>
2026-02-27 13:38:27 +01:00
Dawid Dziurla
7c60f8f904 node_modules: update (#263)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
2026-02-25 14:17:23 +01:00
dependabot[bot]
09ee758a9b build(deps): bump minimatch from 3.1.2 to 3.1.3 (#262)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.3.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.3)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 14:16:23 +01:00
Dawid Dziurla
5335a581b9 node_modules: update (#261)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
2026-02-09 10:59:27 +01:00
dependabot[bot]
63e792e90a build(deps): bump nodemailer from 8.0.0 to 8.0.1 (#260)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.0...v8.0.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 10:57:57 +01:00
Dawid Dziurla
62a2d05b79 node_modules: update (#259)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
2026-02-05 08:49:11 +01:00
dependabot[bot]
a4eb4faebc build(deps): bump nodemailer from 7.0.13 to 8.0.0 (#258)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 7.0.13 to 8.0.0.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v7.0.13...v8.0.0)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 08:47:46 +01:00
37 changed files with 833 additions and 319 deletions

View File

@@ -46,6 +46,7 @@ jobs:
convert_markdown: true convert_markdown: true
attachments: package.json,action.yml attachments: package.json,action.yml
priority: high priority: high
headers: '{"X-My-Test-Header": "Passed"}'
- name: Get mail - name: Get mail
run: | run: |

View File

@@ -27,51 +27,86 @@ Some features:
# * smtp://user:password@server:port # * smtp://user:password@server:port
# * smtp+starttls://user:password@server:port # * smtp+starttls://user:password@server:port
connection_url: ${{secrets.MAIL_CONNECTION}} connection_url: ${{secrets.MAIL_CONNECTION}}
# Required mail server address if not connection_url: # Required mail server address if not connection_url:
server_address: smtp.gmail.com server_address: smtp.gmail.com
# Server port, default 25: # Server port, default 25:
server_port: 465 server_port: 465
# Optional whether this connection use TLS (default is true if server_port is 465) # Optional whether this connection use TLS (default is true if server_port is 465)
secure: true secure: true
# Optional (recommended) mail server username: # Optional (recommended) mail server username:
username: ${{secrets.MAIL_USERNAME}} username: ${{secrets.MAIL_USERNAME}}
# Optional (recommended) mail server password: # Optional (recommended) mail server password:
password: ${{secrets.MAIL_PASSWORD}} password: ${{secrets.MAIL_PASSWORD}}
# Required mail subject: # Required mail subject:
subject: Github Actions job result subject: Github Actions job result
# Required recipients' addresses:
to: obiwan@example.com,yoda@example.com # Optional recipients. Separate multiple addresses by a comma (possibly surrounded by whitespace):
# Required sender full name (address can be skipped): to: obiwan@example.com, yoda@example.com
from: Luke Skywalker # <user@example.com>
# Required sender (supported formats: see "Supported address formats" below)
from: 'Luke Skywalker <user@example.com>'
# Optional plain body: # Optional plain body:
body: Build job of ${{github.repository}} completed successfully! body: Build job of ${{github.repository}} completed successfully!
# Optional HTML body read from file: # Optional HTML body read from file:
html_body: file://README.html html_body: file://README.html
# Optional carbon copy recipients:
cc: kyloren@example.com,leia@example.com # Optional carbon copy recipients. Separate multiple addresses by a comma (possibly surrounded by whitespace):
# Optional blind carbon copy recipients: cc: 'kyloren@example.com, "Her Majesty, Princess Leia" <leia@example.com>'
bcc: r2d2@example.com,hansolo@example.com
# Optional blind carbon copy recipients. Separate multiple addresses by a comma (possibly surrounded by whitespace):
bcc: r2d2@example.com, hansolo@example.com
# Optional recipient of the email response: # Optional recipient of the email response:
reply_to: luke@example.com reply_to: luke@example.com
# Optional Message ID this message is replying to: # Optional Message ID this message is replying to:
in_reply_to: <random-luke@example.com> in_reply_to: '<3cc627c8-6181-453b-d90b-04aae9e23b21@github.com>'
# Optional unsigned/invalid certificates allowance: # Optional unsigned/invalid certificates allowance:
ignore_cert: true ignore_cert: true
# Optional converting Markdown to HTML (set content_type to text/html too): # Optional converting Markdown to HTML (set content_type to text/html too):
convert_markdown: true convert_markdown: true
# Optional attachments: # Optional attachments:
attachments: attachments.zip,git.diff,./dist/static/*.js attachments: attachments.zip,git.diff,./dist/static/*.js
# Optional priority: 'high', 'normal' (default) or 'low' # Optional priority: 'high', 'normal' (default) or 'low'
priority: low priority: low
# Optional custom headers:
headers: '{"X-Priority": "3 (Normal)", "X-My-Header": "MyValue"}'
# Optional nodemailerlog: true/false # Optional nodemailerlog: true/false
nodemailerlog: false nodemailerlog: false
# Optional nodemailerdebug: true/false if true lognodem will also be set true
# Optional nodemailerdebug: true/false if true nodemailerlog will also be set true
nodemailerdebug: false nodemailerdebug: false
# Optional custom SMTP MAIL FROM address (overrides username): # Optional custom SMTP MAIL FROM address (overrides username):
envelope_from: mailer@example.com envelope_from: mailer@example.com
# Optional custom SMTP RCPT TO addresses (overrides to, cc, bcc):
envelope_to: mailer@example.com,admin@example.com # Optional custom SMTP RCPT TO addresses (overrides to, cc, bcc). Separate multiple addresses by a comma (possibly surrounded by whitespace):
envelope_to: mailer@example.com, admin@example.com
``` ```
### Remark for `envelope_from` and `envelope_to`
[nodemailer](https://nodemailer.com/) (the node module that does the actual sending) requires that if the optional custom envelope is used, **both** its attributes `from` and `to` must be set. To facilitate setting only one of `envelope_from` or `envelope_to`, this action sets the other one from the regular message fields in the following way:
* If only `envelope_from` is set, `envelope_to` will be set to the concatenation of `to`, `cc` and `bcc` (with duplicates removed).
* If only `envelope_to` is set, `envelope_from` will be set to the address specified in `from`.
### Supported address formats
This action now uses nodemailer's addressparser. The supported address formats are described [here](https://nodemailer.com/message/addresses).
Mail addresses can contain YAML special characters like '<' and '>'. To avoid YAML parsing issues, addresses that contain such characters should be enclosed in single quotes.
## Troubleshooting ## Troubleshooting

View File

@@ -25,7 +25,7 @@ inputs:
description: Recipients mail addresses (separated with comma) description: Recipients mail addresses (separated with comma)
required: false required: false
from: from:
description: Full name of mail sender (might be with an email address specified in <>) description: 'Either a plain email address, or full name of the sender, followed by whitespace, followed by email address enclosed in <>'
required: true required: true
body: body:
description: Body of mail message (might be a filename prefixed with file:// to read from) description: Body of mail message (might be a filename prefixed with file:// to read from)
@@ -57,6 +57,9 @@ inputs:
priority: priority:
description: Set Priority level for the mail message to 'high', 'normal' (default) or 'low' description: Set Priority level for the mail message to 'high', 'normal' (default) or 'low'
required: false required: false
headers:
description: 'Custom headers for the mail message as a JSON string (e.g., {"X-Custom-Header": "value"})'
required: false
nodemailerlog: nodemailerlog:
description: Log option for nodemailer description: Log option for nodemailer
required: false required: false
@@ -70,5 +73,5 @@ inputs:
description: Custom envelope recipient addresses for SMTP RCPT TO command (separated with comma) description: Custom envelope recipient addresses for SMTP RCPT TO command (separated with comma)
required: false required: false
runs: runs:
using: node20 using: node24
main: main.js main: main.js

73
main.js
View File

@@ -1,4 +1,5 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import addressparser from "nodemailer/lib/addressparser/index.js";
import * as core from "@actions/core"; import * as core from "@actions/core";
import * as glob from "@actions/glob"; import * as glob from "@actions/glob";
import fs from "node:fs"; import fs from "node:fs";
@@ -23,14 +24,6 @@ function getText(textOrFile, convertMarkdown) {
return text; return text;
} }
function getFrom(from, username) {
if (from.match(/.+ <.+@.+>/)) {
return from;
}
return `"${from}" <${username}>`;
}
async function getAttachments(attachments) { async function getAttachments(attachments) {
const globber = await glob.create(attachments.split(",").join("\n")); const globber = await glob.create(attachments.split(",").join("\n"));
const files = await globber.glob(); const files = await globber.glob();
@@ -47,6 +40,39 @@ function sleep(ms) {
}); });
} }
/**
* Prepare an envelope object for nodemailer.
*
* If only one of envelopeFrom or envelopeTo is set, make sure that both
* are set in the returned object. Furthermore, make sure that the attribute 'to'
* is an array of email addresses, not a comma-separated string.
*/
function setupEnvelope(envelopeFrom, envelopeTo, from, to, cc, bcc) {
if (envelopeFrom || envelopeTo) {
// Take address in from, if envelopeFrom is not set.
envelopeFrom = envelopeFrom ? addressparser(envelopeFrom) : addressparser(from);
if (envelopeFrom.length != 1 || envelopeFrom[0].address == '') {
throw new Error("'envelopeFrom' address is invalid");
}
if (envelopeTo) {
envelopeTo = addressparser(envelopeTo);
} else {
// Take addresses in to, cc and bcc. Deduplication is handled by nodemailer.
for (const src of [to, cc, bcc]) {
if (src) {
let parsed = addressparser(src);
envelopeTo = envelopeTo ? envelopeTo.concat(parsed) : parsed;
}
}
}
return {
from: envelopeFrom,
to: envelopeTo,
};
}
return undefined;
}
async function main() { async function main() {
try { try {
let serverAddress = core.getInput("server_address"); let serverAddress = core.getInput("server_address");
@@ -115,6 +141,14 @@ async function main() {
required: false, required: false,
}); });
const envelopeTo = core.getInput("envelope_to", { required: false }); const envelopeTo = core.getInput("envelope_to", { required: false });
const headers = core.getInput("headers", { required: false });
// Basic check for an email sender address
// Either: "Plain Simple Name <user@doma.in>" or just "user@doma.in" (without the <>)
let parsed = addressparser(from);
if (parsed.length != 1 || parsed[0].address == '') {
throw new Error("'from' address is invalid");
}
// if neither to, cc or bcc is provided, throw error // if neither to, cc or bcc is provided, throw error
if (!to && !cc && !bcc) { if (!to && !cc && !bcc) {
@@ -149,11 +183,8 @@ async function main() {
proxy: process.env.HTTP_PROXY, proxy: process.env.HTTP_PROXY,
}); });
var i = 1; const messageOptions = {
while (true) { from: from,
try {
const info = await transport.sendMail({
from: getFrom(from, username),
to: to, to: to,
subject: getText(subject, false), subject: getText(subject, false),
cc: cc ? cc : undefined, cc: cc ? cc : undefined,
@@ -166,17 +197,17 @@ async function main() {
? getText(htmlBody, convertMarkdown) ? getText(htmlBody, convertMarkdown)
: undefined, : undefined,
priority: priority ? priority : undefined, priority: priority ? priority : undefined,
headers: headers ? JSON.parse(headers) : undefined,
attachments: attachments attachments: attachments
? await getAttachments(attachments) ? await getAttachments(attachments)
: undefined, : undefined,
envelope: envelope: setupEnvelope(envelopeFrom, envelopeTo, from, to, cc, bcc),
envelopeFrom || envelopeTo };
? {
from: envelopeFrom ? envelopeFrom : undefined, let i = 1;
to: envelopeTo ? envelopeTo : undefined, while (true) {
} try {
: undefined, const info = await transport.sendMail(messageOptions);
});
break; break;
} catch (error) { } catch (error) {
if (!error.message.includes("Try again later,")) { if (!error.message.includes("Try again later,")) {

18
node_modules/.package-lock.json generated vendored
View File

@@ -185,9 +185,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@@ -197,9 +197,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "7.0.13", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -319,9 +319,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.23.0", "version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"

37
node_modules/minimatch/README.md generated vendored
View File

@@ -10,6 +10,43 @@ This is the matching library used internally by npm.
It works by converting glob expressions into JavaScript `RegExp` It works by converting glob expressions into JavaScript `RegExp`
objects. objects.
## Important Security Consideration!
> [!WARNING]
> This library uses JavaScript regular expressions. Please read
> the following warning carefully, and be thoughtful about what
> you provide to this library in production systems.
_Any_ library in JavaScript that deals with matching string
patterns using regular expressions will be subject to
[ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
if the pattern is generated using untrusted input.
Efforts have been made to mitigate risk as much as is feasible in
such a library, providing maximum recursion depths and so forth,
but these measures can only ultimately protect against accidents,
not malice. A dedicated attacker can _always_ find patterns that
cannot be defended against by a bash-compatible glob pattern
matching system that uses JavaScript regular expressions.
To be extremely clear:
> [!WARNING]
> **If you create a system where you take user input, and use
> that input as the source of a Regular Expression pattern, in
> this or any extant glob matcher in JavaScript, you will be
> pwned.**
A future version of this library _may_ use a different matching
algorithm which does not exhibit backtracking problems. If and
when that happens, it will likely be a sweeping change, and those
improvements will **not** be backported to legacy versions.
In the near term, it is not reasonable to continue to play
whack-a-mole with security advisories, and so any future ReDoS
reports will be considered "working as intended", and resolved
entirely by this warning.
## Usage ## Usage
```javascript ```javascript

260
node_modules/minimatch/minimatch.js generated vendored
View File

@@ -142,6 +142,8 @@ function Minimatch (pattern, options) {
} }
this.options = options this.options = options
this.maxGlobstarRecursion = options.maxGlobstarRecursion !== undefined
? options.maxGlobstarRecursion : 200
this.set = [] this.set = []
this.pattern = pattern this.pattern = pattern
this.regexp = null this.regexp = null
@@ -390,6 +392,9 @@ function parse (pattern, isSub) {
continue continue
} }
// coalesce consecutive non-globstar * characters
if (c === '*' && stateChar === '*') continue
// if we already have a stateChar, then it means // if we already have a stateChar, then it means
// that there was something like ** or +? in there. // that there was something like ** or +? in there.
// Handle the stateChar, then proceed with this one. // Handle the stateChar, then proceed with this one.
@@ -784,19 +789,163 @@ Minimatch.prototype.match = function match (f, partial) {
// out of pattern, then that's fine, as long as all // out of pattern, then that's fine, as long as all
// the parts match. // the parts match.
Minimatch.prototype.matchOne = function (file, pattern, partial) { Minimatch.prototype.matchOne = function (file, pattern, partial) {
var options = this.options if (pattern.indexOf(GLOBSTAR) !== -1) {
return this._matchGlobstar(file, pattern, partial, 0, 0)
}
return this._matchOne(file, pattern, partial, 0, 0)
}
this.debug('matchOne', Minimatch.prototype._matchGlobstar = function (file, pattern, partial, fileIndex, patternIndex) {
{ 'this': this, file: file, pattern: pattern }) var i
this.debug('matchOne', file.length, pattern.length) // find first globstar from patternIndex
var firstgs = -1
for (i = patternIndex; i < pattern.length; i++) {
if (pattern[i] === GLOBSTAR) { firstgs = i; break }
}
for (var fi = 0, // find last globstar
pi = 0, var lastgs = -1
fl = file.length, for (i = pattern.length - 1; i >= 0; i--) {
pl = pattern.length if (pattern[i] === GLOBSTAR) { lastgs = i; break }
}
var head = pattern.slice(patternIndex, firstgs)
var body = partial ? pattern.slice(firstgs + 1) : pattern.slice(firstgs + 1, lastgs)
var tail = partial ? [] : pattern.slice(lastgs + 1)
// check the head
if (head.length) {
var fileHead = file.slice(fileIndex, fileIndex + head.length)
if (!this._matchOne(fileHead, head, partial, 0, 0)) {
return false
}
fileIndex += head.length
}
// check the tail
var fileTailMatch = 0
if (tail.length) {
if (tail.length + fileIndex > file.length) return false
var tailStart = file.length - tail.length
if (this._matchOne(file, tail, partial, tailStart, 0)) {
fileTailMatch = tail.length
} else {
// affordance for stuff like a/**/* matching a/b/
if (file[file.length - 1] !== '' ||
fileIndex + tail.length === file.length) {
return false
}
tailStart--
if (!this._matchOne(file, tail, partial, tailStart, 0)) {
return false
}
fileTailMatch = tail.length + 1
}
}
// if body is empty (single ** between head and tail)
if (!body.length) {
var sawSome = !!fileTailMatch
for (i = fileIndex; i < file.length - fileTailMatch; i++) {
var f = String(file[i])
sawSome = true
if (f === '.' || f === '..' ||
(!this.options.dot && f.charAt(0) === '.')) {
return false
}
}
return partial || sawSome
}
// split body into segments at each GLOBSTAR
var bodySegments = [[[], 0]]
var currentBody = bodySegments[0]
var nonGsParts = 0
var nonGsPartsSums = [0]
for (var bi = 0; bi < body.length; bi++) {
var b = body[bi]
if (b === GLOBSTAR) {
nonGsPartsSums.push(nonGsParts)
currentBody = [[], 0]
bodySegments.push(currentBody)
} else {
currentBody[0].push(b)
nonGsParts++
}
}
var idx = bodySegments.length - 1
var fileLength = file.length - fileTailMatch
for (var si = 0; si < bodySegments.length; si++) {
bodySegments[si][1] = fileLength -
(nonGsPartsSums[idx--] + bodySegments[si][0].length)
}
return !!this._matchGlobStarBodySections(
file, bodySegments, fileIndex, 0, partial, 0, !!fileTailMatch
)
}
// return false for "nope, not matching"
// return null for "not matching, cannot keep trying"
Minimatch.prototype._matchGlobStarBodySections = function (
file, bodySegments, fileIndex, bodyIndex, partial, globStarDepth, sawTail
) {
var bs = bodySegments[bodyIndex]
if (!bs) {
// just make sure there are no bad dots
for (var i = fileIndex; i < file.length; i++) {
sawTail = true
var f = file[i]
if (f === '.' || f === '..' ||
(!this.options.dot && f.charAt(0) === '.')) {
return false
}
}
return sawTail
}
var body = bs[0]
var after = bs[1]
while (fileIndex <= after) {
var m = this._matchOne(
file.slice(0, fileIndex + body.length),
body,
partial,
fileIndex,
0
)
// if limit exceeded, no match. intentional false negative,
// acceptable break in correctness for security.
if (m && globStarDepth < this.maxGlobstarRecursion) {
var sub = this._matchGlobStarBodySections(
file, bodySegments,
fileIndex + body.length, bodyIndex + 1,
partial, globStarDepth + 1, sawTail
)
if (sub !== false) {
return sub
}
}
var f = file[fileIndex]
if (f === '.' || f === '..' ||
(!this.options.dot && f.charAt(0) === '.')) {
return false
}
fileIndex++
}
return partial || null
}
Minimatch.prototype._matchOne = function (file, pattern, partial, fileIndex, patternIndex) {
var fi, pi, fl, pl
for (
fi = fileIndex, pi = patternIndex, fl = file.length, pl = pattern.length
; (fi < fl) && (pi < pl) ; (fi < fl) && (pi < pl)
; fi++, pi++) { ; fi++, pi++
) {
this.debug('matchOne loop') this.debug('matchOne loop')
var p = pattern[pi] var p = pattern[pi]
var f = file[fi] var f = file[fi]
@@ -806,87 +955,7 @@ Minimatch.prototype.matchOne = function (file, pattern, partial) {
// should be impossible. // should be impossible.
// some invalid regexp stuff in the set. // some invalid regexp stuff in the set.
/* istanbul ignore if */ /* istanbul ignore if */
if (p === false) return false if (p === false || p === GLOBSTAR) return false
if (p === GLOBSTAR) {
this.debug('GLOBSTAR', [pattern, p, f])
// "**"
// a/**/b/**/c would match the following:
// a/b/x/y/z/c
// a/x/y/z/b/c
// a/b/x/b/x/c
// a/b/c
// To do this, take the rest of the pattern after
// the **, and see if it would match the file remainder.
// If so, return success.
// If not, the ** "swallows" a segment, and try again.
// This is recursively awful.
//
// a/**/b/**/c matching a/b/x/y/z/c
// - a matches a
// - doublestar
// - matchOne(b/x/y/z/c, b/**/c)
// - b matches b
// - doublestar
// - matchOne(x/y/z/c, c) -> no
// - matchOne(y/z/c, c) -> no
// - matchOne(z/c, c) -> no
// - matchOne(c, c) yes, hit
var fr = fi
var pr = pi + 1
if (pr === pl) {
this.debug('** at the end')
// a ** at the end will just swallow the rest.
// We have found a match.
// however, it will not swallow /.x, unless
// options.dot is set.
// . and .. are *never* matched by **, for explosively
// exponential reasons.
for (; fi < fl; fi++) {
if (file[fi] === '.' || file[fi] === '..' ||
(!options.dot && file[fi].charAt(0) === '.')) return false
}
return true
}
// ok, let's see if we can swallow whatever we can.
while (fr < fl) {
var swallowee = file[fr]
this.debug('\nglobstar while', file, fr, pattern, pr, swallowee)
// XXX remove this slice. Just pass the start index.
if (this.matchOne(file.slice(fr), pattern.slice(pr), partial)) {
this.debug('globstar found match!', fr, fl, swallowee)
// found a match.
return true
} else {
// can't swallow "." or ".." ever.
// can only swallow ".foo" when explicitly asked.
if (swallowee === '.' || swallowee === '..' ||
(!options.dot && swallowee.charAt(0) === '.')) {
this.debug('dot detected!', file, fr, pattern, pr)
break
}
// ** swallows a segment, and continue.
this.debug('globstar swallow a segment, and continue')
fr++
}
}
// no match was found.
// However, in partial mode, we can't say this is necessarily over.
// If there's more *pattern* left, then
/* istanbul ignore if */
if (partial) {
// ran out of file
this.debug('\n>>> no match, partial?', file, fr, pattern, pr)
if (fr === fl) return true
}
return false
}
// something other than ** // something other than **
// non-magic patterns just have to match exactly // non-magic patterns just have to match exactly
@@ -903,17 +972,6 @@ Minimatch.prototype.matchOne = function (file, pattern, partial) {
if (!hit) return false if (!hit) return false
} }
// Note: ending in / means that we'll get a final ""
// at the end of the pattern. This can only match a
// corresponding "" at the end of the file.
// If the file ends in /, then it can only match a
// a pattern that ends in /, unless the pattern just
// doesn't have any more for it. But, a/b/ should *not*
// match "a/b/*", even though "" matches against the
// [^/]*? pattern, except in partial mode, where it might
// simply not be reached yet.
// However, a/b/ should still satisfy a/*
// now either we fell off the end of the pattern, or we're done. // now either we fell off the end of the pattern, or we're done.
if (fi === fl && pi === pl) { if (fi === fl && pi === pl) {
// ran out of pattern and filename at the same time. // ran out of pattern and filename at the same time.

View File

@@ -2,9 +2,9 @@
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)", "author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)",
"name": "minimatch", "name": "minimatch",
"description": "a glob matcher in javascript", "description": "a glob matcher in javascript",
"version": "3.1.2", "version": "3.1.5",
"publishConfig": { "publishConfig": {
"tag": "v3-legacy" "tag": "legacy-v3"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

30
node_modules/nodemailer/CHANGELOG.md generated vendored
View File

@@ -1,5 +1,35 @@
# CHANGELOG # CHANGELOG
## [8.0.2](https://github.com/nodemailer/nodemailer/compare/v8.0.1...v8.0.2) (2026-03-09)
### Bug Fixes
* merge fragmented display names with unquoted commas in addressparser ([fe27f7f](https://github.com/nodemailer/nodemailer/commit/fe27f7fd57f7587d897274438da2f628ad0ad7d9))
## [8.0.1](https://github.com/nodemailer/nodemailer/compare/v8.0.0...v8.0.1) (2026-02-07)
### Bug Fixes
* absorb TLS errors during socket teardown ([7f8dde4](https://github.com/nodemailer/nodemailer/commit/7f8dde41438c66b8311e888fa5f8c518fcaba6f1))
* absorb TLS errors during socket teardown ([381f628](https://github.com/nodemailer/nodemailer/commit/381f628d55e62bb3131bd2a452fa1ce00bc48aea))
* Add Gmail Workspace service configuration ([#1787](https://github.com/nodemailer/nodemailer/issues/1787)) ([dc97ede](https://github.com/nodemailer/nodemailer/commit/dc97ede417b3030b311771541b1f17f5ca76bcbf))
## [8.0.0](https://github.com/nodemailer/nodemailer/compare/v7.0.13...v8.0.0) (2026-02-04)
### ⚠ BREAKING CHANGES
* Error code 'NoAuth' renamed to 'ENOAUTH'
### Bug Fixes
* add connection fallback to alternative DNS addresses ([e726d6f](https://github.com/nodemailer/nodemailer/commit/e726d6f44aa7ca14e943d4303243cb5494b09c75))
* centralize and standardize error codes ([45062ce](https://github.com/nodemailer/nodemailer/commit/45062ce7a4705f3e63c5d9e606547f4d99fd29b5))
* harden DNS fallback against race conditions and cleanup issues ([4fa3c63](https://github.com/nodemailer/nodemailer/commit/4fa3c63a1f36aefdbaea7f57a133adc458413a47))
* improve socket cleanup to prevent potential memory leaks ([6069fdc](https://github.com/nodemailer/nodemailer/commit/6069fdcff68a3eef9a9bb16b2bf5ddb924c02091))
## [7.0.13](https://github.com/nodemailer/nodemailer/compare/v7.0.12...v7.0.13) (2026-01-27) ## [7.0.13](https://github.com/nodemailer/nodemailer/compare/v7.0.12...v7.0.13) (2026-01-27)

View File

@@ -361,6 +361,20 @@ function addressparser(str, options) {
} }
}); });
// Merge fragments from unquoted display names containing commas/semicolons.
// When "Joe Foo, PhD <joe@example.com>" is split on the comma, it produces
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
// Detect this pattern and recombine: a name-only entry followed by an entry
// that has both a name and an address (from angle-bracket notation).
for (let i = parsedAddresses.length - 2; i >= 0; i--) {
let current = parsedAddresses[i];
let next = parsedAddresses[i + 1];
if (current.address === '' && current.name && !current.group && next.address && next.name && !next.group) {
next.name = current.name + ', ' + next.name;
parsedAddresses.splice(i, 1);
}
}
if (options.flatten) { if (options.flatten) {
let addresses = []; let addresses = [];
let walkAddressList = list => { let walkAddressList = list => {

61
node_modules/nodemailer/lib/errors.js generated vendored Normal file
View File

@@ -0,0 +1,61 @@
'use strict';
/**
* Nodemailer Error Codes
*
* Centralized error code definitions for consistent error handling.
*
* Usage:
* const errors = require('./errors');
* let err = new Error('Connection closed');
* err.code = errors.ECONNECTION;
*/
/**
* Error code descriptions for documentation and debugging
*/
const ERROR_CODES = {
// Connection errors
ECONNECTION: 'Connection closed unexpectedly',
ETIMEDOUT: 'Connection or operation timed out',
ESOCKET: 'Socket-level error',
EDNS: 'DNS resolution failed',
// TLS/Security errors
ETLS: 'TLS handshake or STARTTLS failed',
EREQUIRETLS: 'REQUIRETLS not supported by server (RFC 8689)',
// Protocol errors
EPROTOCOL: 'Invalid SMTP server response',
EENVELOPE: 'Invalid mail envelope (sender or recipients)',
EMESSAGE: 'Message delivery error',
ESTREAM: 'Stream processing error',
// Authentication errors
EAUTH: 'Authentication failed',
ENOAUTH: 'Authentication credentials not provided',
EOAUTH2: 'OAuth2 token generation or refresh error',
// Resource errors
EMAXLIMIT: 'Pool resource limit reached (max messages per connection)',
// Transport-specific errors
ESENDMAIL: 'Sendmail command error',
ESES: 'AWS SES transport error',
// Configuration and access errors
ECONFIG: 'Invalid configuration',
EPROXY: 'Proxy connection error',
EFILEACCESS: 'File access rejected (disableFileAccess is set)',
EURLACCESS: 'URL access rejected (disableUrlAccess is set)',
EFETCH: 'HTTP fetch error'
};
// Export error codes as string constants and the full definitions object
module.exports = Object.keys(ERROR_CODES).reduce(
(exports, code) => {
exports[code] = code;
return exports;
},
{ ERROR_CODES }
);

View File

@@ -8,6 +8,7 @@ const PassThrough = require('stream').PassThrough;
const Cookies = require('./cookies'); const Cookies = require('./cookies');
const packageData = require('../../package.json'); const packageData = require('../../package.json');
const net = require('net'); const net = require('net');
const errors = require('../errors');
const MAX_REDIRECTS = 5; const MAX_REDIRECTS = 5;
@@ -76,7 +77,7 @@ function nmfetch(url, options) {
return; return;
} }
finished = true; finished = true;
err.type = 'FETCH'; err.code = errors.EFETCH;
err.sourceUrl = url; err.sourceUrl = url;
fetchRes.emit('error', err); fetchRes.emit('error', err);
}); });
@@ -99,7 +100,7 @@ function nmfetch(url, options) {
return; return;
} }
finished = true; finished = true;
E.type = 'FETCH'; E.code = errors.EFETCH;
E.sourceUrl = url; E.sourceUrl = url;
fetchRes.emit('error', E); fetchRes.emit('error', E);
return; return;
@@ -147,7 +148,7 @@ function nmfetch(url, options) {
} catch (E) { } catch (E) {
finished = true; finished = true;
setImmediate(() => { setImmediate(() => {
E.type = 'FETCH'; E.code = errors.EFETCH;
E.sourceUrl = url; E.sourceUrl = url;
fetchRes.emit('error', E); fetchRes.emit('error', E);
}); });
@@ -162,7 +163,7 @@ function nmfetch(url, options) {
finished = true; finished = true;
req.abort(); req.abort();
let err = new Error('Request Timeout'); let err = new Error('Request Timeout');
err.type = 'FETCH'; err.code = errors.EFETCH;
err.sourceUrl = url; err.sourceUrl = url;
fetchRes.emit('error', err); fetchRes.emit('error', err);
}); });
@@ -173,7 +174,7 @@ function nmfetch(url, options) {
return; return;
} }
finished = true; finished = true;
err.type = 'FETCH'; err.code = errors.EFETCH;
err.sourceUrl = url; err.sourceUrl = url;
fetchRes.emit('error', err); fetchRes.emit('error', err);
}); });
@@ -204,7 +205,7 @@ function nmfetch(url, options) {
if (options.redirects > options.maxRedirects) { if (options.redirects > options.maxRedirects) {
finished = true; finished = true;
let err = new Error('Maximum redirect count exceeded'); let err = new Error('Maximum redirect count exceeded');
err.type = 'FETCH'; err.code = errors.EFETCH;
err.sourceUrl = url; err.sourceUrl = url;
fetchRes.emit('error', err); fetchRes.emit('error', err);
req.abort(); req.abort();
@@ -222,7 +223,7 @@ function nmfetch(url, options) {
if (res.statusCode >= 300 && !options.allowErrorResponse) { if (res.statusCode >= 300 && !options.allowErrorResponse) {
finished = true; finished = true;
let err = new Error('Invalid status code ' + res.statusCode); let err = new Error('Invalid status code ' + res.statusCode);
err.type = 'FETCH'; err.code = errors.EFETCH;
err.sourceUrl = url; err.sourceUrl = url;
fetchRes.emit('error', err); fetchRes.emit('error', err);
req.abort(); req.abort();
@@ -234,7 +235,7 @@ function nmfetch(url, options) {
return; return;
} }
finished = true; finished = true;
err.type = 'FETCH'; err.code = errors.EFETCH;
err.sourceUrl = url; err.sourceUrl = url;
fetchRes.emit('error', err); fetchRes.emit('error', err);
req.abort(); req.abort();
@@ -247,7 +248,7 @@ function nmfetch(url, options) {
return; return;
} }
finished = true; finished = true;
err.type = 'FETCH'; err.code = errors.EFETCH;
err.sourceUrl = url; err.sourceUrl = url;
fetchRes.emit('error', err); fetchRes.emit('error', err);
req.abort(); req.abort();
@@ -267,7 +268,7 @@ function nmfetch(url, options) {
} }
} catch (err) { } catch (err) {
finished = true; finished = true;
err.type = 'FETCH'; err.code = errors.EFETCH;
err.sourceUrl = url; err.sourceUrl = url;
fetchRes.emit('error', err); fetchRes.emit('error', err);
return; return;

View File

@@ -6,6 +6,7 @@ const mimeTypes = require('../mime-funcs/mime-types');
const MailComposer = require('../mail-composer'); const MailComposer = require('../mail-composer');
const DKIM = require('../dkim'); const DKIM = require('../dkim');
const httpProxyClient = require('../smtp-connection/http-proxy-client'); const httpProxyClient = require('../smtp-connection/http-proxy-client');
const errors = require('../errors');
const util = require('util'); const util = require('util');
const urllib = require('url'); const urllib = require('url');
const packageData = require('../../package.json'); const packageData = require('../../package.json');
@@ -337,7 +338,9 @@ class Mail extends EventEmitter {
case 'socks4': case 'socks4':
case 'socks4a': { case 'socks4a': {
if (!this.meta.has('proxy_socks_module')) { if (!this.meta.has('proxy_socks_module')) {
return callback(new Error('Socks module not loaded')); let err = new Error('Socks module not loaded');
err.code = errors.EPROXY;
return callback(err);
} }
let connect = ipaddress => { let connect = ipaddress => {
let proxyV2 = !!this.meta.get('proxy_socks_module').SocksClient; let proxyV2 = !!this.meta.get('proxy_socks_module').SocksClient;
@@ -394,7 +397,9 @@ class Mail extends EventEmitter {
}); });
} }
} }
callback(new Error('Unknown proxy configuration')); let err = new Error('Unknown proxy configuration');
err.code = errors.EPROXY;
callback(err);
}; };
} }

View File

@@ -13,6 +13,7 @@ const qp = require('../qp');
const base64 = require('../base64'); const base64 = require('../base64');
const addressparser = require('../addressparser'); const addressparser = require('../addressparser');
const nmfetch = require('../fetch'); const nmfetch = require('../fetch');
const errors = require('../errors');
const LastNewline = require('./last-newline'); const LastNewline = require('./last-newline');
const LeWindows = require('./le-windows'); const LeWindows = require('./le-windows');
@@ -979,7 +980,11 @@ class MimeNode {
} else if (content && typeof content.path === 'string' && !content.href) { } else if (content && typeof content.path === 'string' && !content.href) {
if (this.disableFileAccess) { if (this.disableFileAccess) {
contentStream = new PassThrough(); contentStream = new PassThrough();
setImmediate(() => contentStream.emit('error', new Error('File access rejected for ' + content.path))); setImmediate(() => {
let err = new Error('File access rejected for ' + content.path);
err.code = errors.EFILEACCESS;
contentStream.emit('error', err);
});
return contentStream; return contentStream;
} }
// read file // read file
@@ -987,7 +992,11 @@ class MimeNode {
} else if (content && typeof content.href === 'string') { } else if (content && typeof content.href === 'string') {
if (this.disableUrlAccess) { if (this.disableUrlAccess) {
contentStream = new PassThrough(); contentStream = new PassThrough();
setImmediate(() => contentStream.emit('error', new Error('Url access rejected for ' + content.href))); setImmediate(() => {
let err = new Error('Url access rejected for ' + content.href);
err.code = errors.EURLACCESS;
contentStream.emit('error', err);
});
return contentStream; return contentStream;
} }
// fetch URL // fetch URL

View File

@@ -8,6 +8,7 @@ const SendmailTransport = require('./sendmail-transport');
const StreamTransport = require('./stream-transport'); const StreamTransport = require('./stream-transport');
const JSONTransport = require('./json-transport'); const JSONTransport = require('./json-transport');
const SESTransport = require('./ses-transport'); const SESTransport = require('./ses-transport');
const errors = require('./errors');
const nmfetch = require('./fetch'); const nmfetch = require('./fetch');
const packageData = require('../package.json'); const packageData = require('../package.json');
@@ -49,7 +50,7 @@ module.exports.createTransport = function (transporter, defaults) {
let error = new Error( let error = new Error(
'Using legacy SES configuration, expecting @aws-sdk/client-sesv2, see https://nodemailer.com/transports/ses/' 'Using legacy SES configuration, expecting @aws-sdk/client-sesv2, see https://nodemailer.com/transports/ses/'
); );
error.code = 'LegacyConfig'; error.code = errors.ECONFIG;
throw error; throw error;
} }
transporter = new SESTransport(options); transporter = new SESTransport(options);

View File

@@ -3,6 +3,7 @@
const spawn = require('child_process').spawn; const spawn = require('child_process').spawn;
const packageData = require('../../package.json'); const packageData = require('../../package.json');
const shared = require('../shared'); const shared = require('../shared');
const errors = require('../errors');
/** /**
* Generates a Transport object for Sendmail * Generates a Transport object for Sendmail
@@ -72,7 +73,9 @@ class SendmailTransport {
.concat(envelope.to || []) .concat(envelope.to || [])
.some(addr => /^-/.test(addr)); .some(addr => /^-/.test(addr));
if (hasInvalidAddresses) { if (hasInvalidAddresses) {
return done(new Error('Can not send mail. Invalid envelope addresses.')); let err = new Error('Can not send mail. Invalid envelope addresses.');
err.code = errors.ESENDMAIL;
return done(err);
} }
if (this.args) { if (this.args) {
@@ -141,6 +144,7 @@ class SendmailTransport {
} else { } else {
err = new Error('Sendmail exited with code ' + code); err = new Error('Sendmail exited with code ' + code);
} }
err.code = errors.ESENDMAIL;
this.logger.error( this.logger.error(
{ {
@@ -202,7 +206,9 @@ class SendmailTransport {
sourceStream.pipe(sendmail.stdin); sourceStream.pipe(sendmail.stdin);
} else { } else {
return callback(new Error('sendmail was not found')); let err = new Error('sendmail was not found');
err.code = errors.ESENDMAIL;
return callback(err);
} }
} }
} }

View File

@@ -82,15 +82,22 @@ const formatDNSValue = (value, extra) => {
return Object.assign({}, extra || {}); return Object.assign({}, extra || {});
} }
let addresses = value.addresses || [];
// Select a random address from available addresses, or null if none
let host = null;
if (addresses.length === 1) {
host = addresses[0];
} else if (addresses.length > 1) {
host = addresses[Math.floor(Math.random() * addresses.length)];
}
return Object.assign( return Object.assign(
{ {
servername: value.servername, servername: value.servername,
host: host,
!value.addresses || !value.addresses.length // Include all addresses for connection fallback support
? null _addresses: addresses
: value.addresses.length === 1
? value.addresses[0]
: value.addresses[Math.floor(Math.random() * value.addresses.length)]
}, },
extra || {} extra || {}
); );
@@ -151,66 +158,32 @@ module.exports.resolveHostname = (options, callback) => {
} }
} }
// Resolve both IPv4 and IPv6 addresses for fallback support
let ipv4Addresses = [];
let ipv6Addresses = [];
let ipv4Error = null;
let ipv6Error = null;
resolver(4, options.host, options, (err, addresses) => { resolver(4, options.host, options, (err, addresses) => {
if (err) { if (err) {
if (cached) { ipv4Error = err;
dnsCache.set(options.host, { } else {
value: cached.value, ipv4Addresses = addresses || [];
expires: Date.now() + (options.dnsTtl || DNS_TTL)
});
return callback(
null,
formatDNSValue(cached.value, {
cached: true,
error: err
})
);
}
return callback(err);
}
if (addresses && addresses.length) {
let value = {
addresses,
servername: options.servername || options.host
};
dnsCache.set(options.host, {
value,
expires: Date.now() + (options.dnsTtl || DNS_TTL)
});
return callback(
null,
formatDNSValue(value, {
cached: false
})
);
} }
resolver(6, options.host, options, (err, addresses) => { resolver(6, options.host, options, (err, addresses) => {
if (err) { if (err) {
if (cached) { ipv6Error = err;
dnsCache.set(options.host, { } else {
value: cached.value, ipv6Addresses = addresses || [];
expires: Date.now() + (options.dnsTtl || DNS_TTL)
});
return callback(
null,
formatDNSValue(cached.value, {
cached: true,
error: err
})
);
}
return callback(err);
} }
if (addresses && addresses.length) { // Combine addresses: IPv4 first, then IPv6
let allAddresses = ipv4Addresses.concat(ipv6Addresses);
if (allAddresses.length) {
let value = { let value = {
addresses, addresses: allAddresses,
servername: options.servername || options.host servername: options.servername || options.host
}; };
@@ -227,6 +200,25 @@ module.exports.resolveHostname = (options, callback) => {
); );
} }
// No addresses from resolve4/resolve6, try dns.lookup as fallback
if (ipv4Error && ipv6Error) {
// Both resolvers had errors
if (cached) {
dnsCache.set(options.host, {
value: cached.value,
expires: Date.now() + (options.dnsTtl || DNS_TTL)
});
return callback(
null,
formatDNSValue(cached.value, {
cached: true,
error: ipv4Error
})
);
}
}
try { try {
dns.lookup(options.host, { all: true }, (err, addresses) => { dns.lookup(options.host, { all: true }, (err, addresses) => {
if (err) { if (err) {
@@ -247,19 +239,17 @@ module.exports.resolveHostname = (options, callback) => {
return callback(err); return callback(err);
} }
let address = addresses // Get all supported addresses from dns.lookup
? addresses let supportedAddresses = addresses
.filter(addr => isFamilySupported(addr.family)) ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address)
.map(addr => addr.address) : [];
.shift()
: false;
if (addresses && addresses.length && !address) { if (addresses && addresses.length && !supportedAddresses.length) {
// there are addresses but none can be used // there are addresses but none can be used
console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`); console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`);
} }
if (!address && cached) { if (!supportedAddresses.length && cached) {
// nothing was found, fallback to cached value // nothing was found, fallback to cached value
return callback( return callback(
null, null,
@@ -270,7 +260,7 @@ module.exports.resolveHostname = (options, callback) => {
} }
let value = { let value = {
addresses: address ? [address] : [options.host], addresses: supportedAddresses.length ? supportedAddresses : [options.host],
servername: options.servername || options.host servername: options.servername || options.host
}; };
@@ -286,7 +276,7 @@ module.exports.resolveHostname = (options, callback) => {
}) })
); );
}); });
} catch (_err) { } catch (lookupErr) {
if (cached) { if (cached) {
dnsCache.set(options.host, { dnsCache.set(options.host, {
value: cached.value, value: cached.value,
@@ -297,11 +287,11 @@ module.exports.resolveHostname = (options, callback) => {
null, null,
formatDNSValue(cached.value, { formatDNSValue(cached.value, {
cached: true, cached: true,
error: err error: lookupErr
}) })
); );
} }
return callback(err); return callback(ipv4Error || ipv6Error || lookupErr);
} }
}); });
}); });

View File

@@ -7,6 +7,7 @@
const net = require('net'); const net = require('net');
const tls = require('tls'); const tls = require('tls');
const urllib = require('url'); const urllib = require('url');
const errors = require('../errors');
/** /**
* Establishes proxied connection to destinationPort * Establishes proxied connection to destinationPort
@@ -121,7 +122,9 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
} catch (_E) { } catch (_E) {
// ignore // ignore
} }
return callback(new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || ''))); let err = new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || ''));
err.code = errors.EPROXY;
return callback(err);
} }
socket.removeListener('error', tempSocketErr); socket.removeListener('error', tempSocketErr);

View File

@@ -15,6 +15,7 @@ const CONNECTION_TIMEOUT = 2 * 60 * 1000; // how much to wait for the connection
const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity before disconnecting the client const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity before disconnecting the client
const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved
const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname
const TEARDOWN_NOOP = () => {}; // reusable no-op handler for absorbing errors during socket teardown
/** /**
* Generates a SMTP connection object * Generates a SMTP connection object
@@ -197,6 +198,17 @@ class SMTPConnection extends EventEmitter {
this._onSocketClose = () => this._onClose(); this._onSocketClose = () => this._onClose();
this._onSocketEnd = () => this._onEnd(); this._onSocketEnd = () => this._onEnd();
this._onSocketTimeout = () => this._onTimeout(); this._onSocketTimeout = () => this._onTimeout();
/**
* Connection-phase error handler (supports fallback to alternative addresses)
*/
this._onConnectionSocketError = err => this._onConnectionError(err, 'ESOCKET');
/**
* Connection attempt counter for fallback race condition protection
* @private
*/
this._connectionAttemptId = 0;
} }
/** /**
@@ -232,18 +244,10 @@ class SMTPConnection extends EventEmitter {
opts.localAddress = this.options.localAddress; opts.localAddress = this.options.localAddress;
} }
let setupConnectionHandlers = () => {
this._connectionTimeout = setTimeout(() => {
this._onError('Connection timeout', 'ETIMEDOUT', false, 'CONN');
}, this.options.connectionTimeout || CONNECTION_TIMEOUT);
this._socket.on('error', this._onSocketError);
};
if (this.options.connection) { if (this.options.connection) {
// connection is already opened // connection is already opened
this._socket = this.options.connection; this._socket = this.options.connection;
setupConnectionHandlers(); this._setupConnectionHandlers();
if (this.secureConnection && !this.alreadySecured) { if (this.secureConnection && !this.alreadySecured) {
setImmediate(() => setImmediate(() =>
@@ -288,7 +292,7 @@ class SMTPConnection extends EventEmitter {
this._socket.setKeepAlive(true); this._socket.setKeepAlive(true);
this._onConnect(); this._onConnect();
}); });
setupConnectionHandlers(); this._setupConnectionHandlers();
} catch (E) { } catch (E) {
return setImmediate(() => this._onError(E, 'ECONNECTION', false, 'CONN')); return setImmediate(() => this._onError(E, 'ECONNECTION', false, 'CONN'));
} }
@@ -327,15 +331,12 @@ class SMTPConnection extends EventEmitter {
opts[key] = resolved[key]; opts[key] = resolved[key];
} }
}); });
try {
this._socket = tls.connect(opts, () => { // Store fallback addresses for retry on connection failure
this._socket.setKeepAlive(true); this._fallbackAddresses = (resolved._addresses || []).filter(addr => addr !== opts.host);
this._onConnect(); this._connectOpts = Object.assign({}, opts);
});
setupConnectionHandlers(); this._connectToHost(opts, true);
} catch (E) {
return setImmediate(() => this._onError(E, 'ECONNECTION', false, 'CONN'));
}
}); });
} else { } else {
// connect using plaintext // connect using plaintext
@@ -360,17 +361,99 @@ class SMTPConnection extends EventEmitter {
opts[key] = resolved[key]; opts[key] = resolved[key];
} }
}); });
// Store fallback addresses for retry on connection failure
this._fallbackAddresses = (resolved._addresses || []).filter(addr => addr !== opts.host);
this._connectOpts = Object.assign({}, opts);
this._connectToHost(opts, false);
});
}
}
/**
* Attempts to connect to the specified host address
*
* @param {Object} opts Connection options
* @param {Boolean} secure Whether to use TLS
*/
_connectToHost(opts, secure) {
this._connectionAttemptId++;
const currentAttemptId = this._connectionAttemptId;
let connectFn = secure ? tls.connect : net.connect;
try { try {
this._socket = net.connect(opts, () => { this._socket = connectFn(opts, () => {
// Ignore callback if this is a stale connection attempt
if (this._connectionAttemptId !== currentAttemptId) {
return;
}
this._socket.setKeepAlive(true); this._socket.setKeepAlive(true);
this._onConnect(); this._onConnect();
}); });
setupConnectionHandlers(); this._setupConnectionHandlers();
} catch (E) { } catch (E) {
return setImmediate(() => this._onError(E, 'ECONNECTION', false, 'CONN')); return setImmediate(() => this._onError(E, 'ECONNECTION', false, 'CONN'));
} }
});
} }
/**
* Sets up connection timeout and error handlers
*/
_setupConnectionHandlers() {
this._connectionTimeout = setTimeout(() => {
this._onConnectionError('Connection timeout', 'ETIMEDOUT');
}, this.options.connectionTimeout || CONNECTION_TIMEOUT);
this._socket.on('error', this._onConnectionSocketError);
}
/**
* Handles connection errors with fallback to alternative addresses
*
* @param {Error|String} err Error object or message
* @param {String} code Error code
*/
_onConnectionError(err, code) {
clearTimeout(this._connectionTimeout);
// Check if we have fallback addresses to try
let canFallback = this._fallbackAddresses && this._fallbackAddresses.length && this.stage === 'init' && !this._destroyed;
if (!canFallback) {
// No more fallback addresses, report the error
this._onError(err, code, false, 'CONN');
return;
}
let nextHost = this._fallbackAddresses.shift();
this.logger.info(
{
tnx: 'network',
failedHost: this._connectOpts.host,
nextHost,
error: err.message || err
},
'Connection to %s failed, trying %s',
this._connectOpts.host,
nextHost
);
// Clean up current socket
if (this._socket) {
try {
this._socket.removeListener('error', this._onConnectionSocketError);
this._socket.destroy();
} catch (_E) {
// ignore
}
this._socket = null;
}
// Update host and retry
this._connectOpts.host = nextHost;
this._connectToHost(this._connectOpts, this.secureConnection);
} }
/** /**
@@ -414,6 +497,18 @@ class SMTPConnection extends EventEmitter {
if (socket && !socket.destroyed) { if (socket && !socket.destroyed) {
try { try {
// Clear socket timeout to prevent timer leaks
socket.setTimeout(0);
// Remove all listeners to allow proper garbage collection
socket.removeListener('data', this._onSocketData);
socket.removeListener('timeout', this._onSocketTimeout);
socket.removeListener('close', this._onSocketClose);
socket.removeListener('end', this._onSocketEnd);
socket.removeListener('error', this._onSocketError);
socket.removeListener('error', this._onConnectionSocketError);
// Absorb errors that may fire during socket teardown (e.g. server
// sending cleartext after TLS shutdown triggers ERR_SSL_BAD_RECORD_TYPE)
socket.on('error', TEARDOWN_NOOP);
socket[closeMethod](); socket[closeMethod]();
} catch (_E) { } catch (_E) {
// just ignore // just ignore
@@ -715,7 +810,10 @@ class SMTPConnection extends EventEmitter {
this._socket.removeListener('timeout', this._onSocketTimeout); this._socket.removeListener('timeout', this._onSocketTimeout);
this._socket.removeListener('close', this._onSocketClose); this._socket.removeListener('close', this._onSocketClose);
this._socket.removeListener('end', this._onSocketEnd); this._socket.removeListener('end', this._onSocketEnd);
// Switch from connection-phase error handler to normal error handler
this._socket.removeListener('error', this._onConnectionSocketError);
this._socket.on('error', this._onSocketError);
this._socket.on('data', this._onSocketData); this._socket.on('data', this._onSocketData);
this._socket.once('close', this._onSocketClose); this._socket.once('close', this._onSocketClose);
this._socket.once('end', this._onSocketEnd); this._socket.once('end', this._onSocketEnd);
@@ -941,8 +1039,10 @@ class SMTPConnection extends EventEmitter {
this.upgrading = false; this.upgrading = false;
this._socket.on('data', this._onSocketData); 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('close', this._onSocketClose);
socketPlain.removeListener('end', this._onSocketEnd); socketPlain.removeListener('end', this._onSocketEnd);
socketPlain.removeListener('error', this._onSocketError);
return callback(null, true); return callback(null, true);
}); });

View File

@@ -5,6 +5,7 @@ const PoolResource = require('./pool-resource');
const SMTPConnection = require('../smtp-connection'); const SMTPConnection = require('../smtp-connection');
const wellKnown = require('../well-known'); const wellKnown = require('../well-known');
const shared = require('../shared'); const shared = require('../shared');
const errors = require('../errors');
const packageData = require('../../package.json'); const packageData = require('../../package.json');
/** /**
@@ -633,7 +634,7 @@ class SMTPPool extends EventEmitter {
}); });
} else if (!auth && connection.allowsAuth && options.forceAuth) { } else if (!auth && connection.allowsAuth && options.forceAuth) {
let err = new Error('Authentication info was not provided'); let err = new Error('Authentication info was not provided');
err.code = 'NoAuth'; err.code = errors.ENOAUTH;
returned = true; returned = true;
connection.close(); connection.close();

View File

@@ -3,6 +3,7 @@
const SMTPConnection = require('../smtp-connection'); const SMTPConnection = require('../smtp-connection');
const assign = require('../shared').assign; const assign = require('../shared').assign;
const XOAuth2 = require('../xoauth2'); const XOAuth2 = require('../xoauth2');
const errors = require('../errors');
const EventEmitter = require('events'); const EventEmitter = require('events');
/** /**
@@ -121,7 +122,7 @@ class PoolResource extends EventEmitter {
let err = new Error('Unexpected socket close'); let err = new Error('Unexpected socket close');
if (this.connection && this.connection._socket && this.connection._socket.upgrading) { if (this.connection && this.connection._socket && this.connection._socket.upgrading) {
// starttls connection errors // starttls connection errors
err.code = 'ETLS'; err.code = errors.ETLS;
} }
callback(err); callback(err);
}, 1000); }, 1000);
@@ -226,7 +227,7 @@ class PoolResource extends EventEmitter {
let err; let err;
if (this.messages >= this.options.maxMessages) { if (this.messages >= this.options.maxMessages) {
err = new Error('Resource exhausted'); err = new Error('Resource exhausted');
err.code = 'EMAXLIMIT'; err.code = errors.EMAXLIMIT;
this.connection.close(); this.connection.close();
this.emit('error', err); this.emit('error', err);
} else { } else {

View File

@@ -5,6 +5,7 @@ const SMTPConnection = require('../smtp-connection');
const wellKnown = require('../well-known'); const wellKnown = require('../well-known');
const shared = require('../shared'); const shared = require('../shared');
const XOAuth2 = require('../xoauth2'); const XOAuth2 = require('../xoauth2');
const errors = require('../errors');
const packageData = require('../../package.json'); const packageData = require('../../package.json');
/** /**
@@ -190,7 +191,7 @@ class SMTPTransport extends EventEmitter {
let err = new Error('Unexpected socket close'); let err = new Error('Unexpected socket close');
if (connection && connection._socket && connection._socket.upgrading) { if (connection && connection._socket && connection._socket.upgrading) {
// starttls connection errors // starttls connection errors
err.code = 'ETLS'; err.code = errors.ETLS;
} }
callback(err); callback(err);
}, 1000); }, 1000);
@@ -392,7 +393,7 @@ class SMTPTransport extends EventEmitter {
}); });
} else if (!authData && connection.allowsAuth && options.forceAuth) { } else if (!authData && connection.allowsAuth && options.forceAuth) {
let err = new Error('Authentication info was not provided'); let err = new Error('Authentication info was not provided');
err.code = 'NoAuth'; err.code = errors.ENOAUTH;
returned = true; returned = true;
connection.close(); connection.close();

View File

@@ -147,6 +147,14 @@
"secure": true "secure": true
}, },
"GmailWorkspace": {
"description": "Gmail Workspace",
"aliases": ["Google Workspace Mail"],
"host": "smtp-relay.gmail.com",
"port": 465,
"secure": true
},
"GMX": { "GMX": {
"description": "GMX Mail", "description": "GMX Mail",
"domains": ["gmx.com", "gmx.net", "gmx.de"], "domains": ["gmx.com", "gmx.net", "gmx.de"],

View File

@@ -4,6 +4,7 @@ const Stream = require('stream').Stream;
const nmfetch = require('../fetch'); const nmfetch = require('../fetch');
const crypto = require('crypto'); const crypto = require('crypto');
const shared = require('../shared'); const shared = require('../shared');
const errors = require('../errors');
/** /**
* XOAUTH2 access_token generator for Gmail. * XOAUTH2 access_token generator for Gmail.
@@ -41,7 +42,9 @@ class XOAuth2 extends Stream {
if (options && options.serviceClient) { if (options && options.serviceClient) {
if (!options.privateKey || !options.user) { if (!options.privateKey || !options.user) {
setImmediate(() => this.emit('error', new Error('Options "privateKey" and "user" are required for service account!'))); let err = new Error('Options "privateKey" and "user" are required for service account!');
err.code = errors.EOAUTH2;
setImmediate(() => this.emit('error', err));
return; return;
} }
@@ -120,7 +123,9 @@ class XOAuth2 extends Stream {
'Cannot renew access token for %s: No refresh mechanism available', 'Cannot renew access token for %s: No refresh mechanism available',
this.options.user this.options.user
); );
return callback(new Error("Can't create new access token for user")); let err = new Error("Can't create new access token for user");
err.code = errors.EOAUTH2;
return callback(err);
} }
// If renewal already in progress, queue this request instead of starting another // If renewal already in progress, queue this request instead of starting another
@@ -218,7 +223,9 @@ class XOAuth2 extends Stream {
try { try {
token = this.jwtSignRS256(tokenData); token = this.jwtSignRS256(tokenData);
} catch (_err) { } catch (_err) {
return callback(new Error("Can't generate token. Check your auth options")); let err = new Error("Can't generate token. Check your auth options");
err.code = errors.EOAUTH2;
return callback(err);
} }
urlOptions = { urlOptions = {
@@ -232,7 +239,9 @@ class XOAuth2 extends Stream {
}; };
} else { } else {
if (!this.options.refreshToken) { if (!this.options.refreshToken) {
return callback(new Error("Can't create new access token for user")); let err = new Error("Can't create new access token for user");
err.code = errors.EOAUTH2;
return callback(err);
} }
// web app - https://developers.google.com/identity/protocols/OAuth2WebServer // web app - https://developers.google.com/identity/protocols/OAuth2WebServer
@@ -289,7 +298,9 @@ class XOAuth2 extends Stream {
'Response: %s', 'Response: %s',
(body || '').toString() (body || '').toString()
); );
return callback(new Error('Invalid authentication response')); let err = new Error('Invalid authentication response');
err.code = errors.EOAUTH2;
return callback(err);
} }
let logData = {}; let logData = {};
@@ -320,7 +331,9 @@ class XOAuth2 extends Stream {
if (data.error_uri) { if (data.error_uri) {
errorMessage += ' (' + data.error_uri + ')'; errorMessage += ' (' + data.error_uri + ')';
} }
return callback(new Error(errorMessage)); let err = new Error(errorMessage);
err.code = errors.EOAUTH2;
return callback(err);
} }
if (data.access_token) { if (data.access_token) {
@@ -328,7 +341,9 @@ class XOAuth2 extends Stream {
return callback(null, this.accessToken); return callback(null, this.accessToken);
} }
return callback(new Error('No access token')); let err = new Error('No access token');
err.code = errors.EOAUTH2;
return callback(err);
}); });
} }

12
node_modules/nodemailer/package.json generated vendored
View File

@@ -1,6 +1,6 @@
{ {
"name": "nodemailer", "name": "nodemailer",
"version": "7.0.13", "version": "8.0.2",
"description": "Easy as cake e-mail sending from your Node.js applications", "description": "Easy as cake e-mail sending from your Node.js applications",
"main": "lib/nodemailer.js", "main": "lib/nodemailer.js",
"scripts": { "scripts": {
@@ -26,12 +26,12 @@
}, },
"homepage": "https://nodemailer.com/", "homepage": "https://nodemailer.com/",
"devDependencies": { "devDependencies": {
"@aws-sdk/client-sesv2": "3.975.0", "@aws-sdk/client-sesv2": "3.1004.0",
"bunyan": "1.8.15", "bunyan": "1.8.15",
"c8": "10.1.3", "c8": "11.0.0",
"eslint": "9.39.2", "eslint": "10.0.3",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"globals": "17.1.0", "globals": "17.4.0",
"libbase64": "1.3.0", "libbase64": "1.3.0",
"libmime": "5.3.7", "libmime": "5.3.7",
"libqp": "2.1.1", "libqp": "2.1.1",
@@ -39,7 +39,7 @@
"prettier": "3.8.1", "prettier": "3.8.1",
"proxy": "1.0.2", "proxy": "1.0.2",
"proxy-test-server": "1.0.0", "proxy-test-server": "1.0.0",
"smtp-server": "3.18.0" "smtp-server": "3.18.1"
}, },
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"

View File

@@ -27,6 +27,7 @@ import { errors } from 'undici'
| `InformationalError` | `UND_ERR_INFO` | expected error with reason | | `InformationalError` | `UND_ERR_INFO` | expected error with reason |
| `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed | | `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed |
| `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed | | `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed |
| `MessageSizeExceededError` | `UND_ERR_WS_MESSAGE_SIZE_EXCEEDED` | WebSocket decompressed message exceeded the maximum allowed size |
### `SocketError` ### `SocketError`

View File

@@ -13,6 +13,14 @@ Arguments:
* **url** `URL | string` - The url's protocol *must* be `ws` or `wss`. * **url** `URL | string` - The url's protocol *must* be `ws` or `wss`.
* **protocol** `string | string[] | WebSocketInit` (optional) - Subprotocol(s) to request the server use, or a [`Dispatcher`](./Dispatcher.md). * **protocol** `string | string[] | WebSocketInit` (optional) - Subprotocol(s) to request the server use, or a [`Dispatcher`](./Dispatcher.md).
### WebSocketInit
When passing an object as the second argument, the following options are available:
* **protocols** `string | string[]` (optional) - Subprotocol(s) to request the server use.
* **dispatcher** `Dispatcher` (optional) - A custom [`Dispatcher`](/docs/docs/api/Dispatcher.md) to use for the connection.
* **headers** `HeadersInit` (optional) - Custom headers to include in the WebSocket handshake request.
### Example: ### Example:
This example will not work in browsers or other platforms that don't allow passing an object. This example will not work in browsers or other platforms that don't allow passing an object.

View File

@@ -379,6 +379,24 @@ class SecureProxyConnectionError extends UndiciError {
[kSecureProxyConnectionError] = true [kSecureProxyConnectionError] = true
} }
const kMessageSizeExceededError = Symbol.for('undici.error.UND_ERR_WS_MESSAGE_SIZE_EXCEEDED')
class MessageSizeExceededError extends UndiciError {
constructor (message) {
super(message)
this.name = 'MessageSizeExceededError'
this.message = message || 'Max decompressed message size exceeded'
this.code = 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED'
}
static [Symbol.hasInstance] (instance) {
return instance && instance[kMessageSizeExceededError] === true
}
get [kMessageSizeExceededError] () {
return true
}
}
module.exports = { module.exports = {
AbortError, AbortError,
HTTPParserError, HTTPParserError,
@@ -402,5 +420,6 @@ module.exports = {
ResponseExceededMaxSizeError, ResponseExceededMaxSizeError,
RequestRetryError, RequestRetryError,
ResponseError, ResponseError,
SecureProxyConnectionError SecureProxyConnectionError,
MessageSizeExceededError
} }

View File

@@ -66,6 +66,10 @@ class Request {
throw new InvalidArgumentError('upgrade must be a string') throw new InvalidArgumentError('upgrade must be a string')
} }
if (upgrade && !isValidHeaderValue(upgrade)) {
throw new InvalidArgumentError('invalid upgrade header')
}
if (headersTimeout != null && (!Number.isFinite(headersTimeout) || headersTimeout < 0)) { if (headersTimeout != null && (!Number.isFinite(headersTimeout) || headersTimeout < 0)) {
throw new InvalidArgumentError('invalid headersTimeout') throw new InvalidArgumentError('invalid headersTimeout')
} }
@@ -360,13 +364,19 @@ function processHeader (request, key, val) {
val = `${val}` val = `${val}`
} }
if (request.host === null && headerName === 'host') { if (headerName === 'host') {
if (request.host !== null) {
throw new InvalidArgumentError('duplicate host header')
}
if (typeof val !== 'string') { if (typeof val !== 'string') {
throw new InvalidArgumentError('invalid host header') throw new InvalidArgumentError('invalid host header')
} }
// Consumed by Client // Consumed by Client
request.host = val request.host = val
} else if (request.contentLength === null && headerName === 'content-length') { } else if (headerName === 'content-length') {
if (request.contentLength !== null) {
throw new InvalidArgumentError('duplicate content-length header')
}
request.contentLength = parseInt(val, 10) request.contentLength = parseInt(val, 10)
if (!Number.isFinite(request.contentLength)) { if (!Number.isFinite(request.contentLength)) {
throw new InvalidArgumentError('invalid content-length header') throw new InvalidArgumentError('invalid content-length header')

View File

@@ -2,17 +2,30 @@
const { createInflateRaw, Z_DEFAULT_WINDOWBITS } = require('node:zlib') const { createInflateRaw, Z_DEFAULT_WINDOWBITS } = require('node:zlib')
const { isValidClientWindowBits } = require('./util') const { isValidClientWindowBits } = require('./util')
const { MessageSizeExceededError } = require('../../core/errors')
const tail = Buffer.from([0x00, 0x00, 0xff, 0xff]) const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
const kBuffer = Symbol('kBuffer') const kBuffer = Symbol('kBuffer')
const kLength = Symbol('kLength') const kLength = Symbol('kLength')
// Default maximum decompressed message size: 4 MB
const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
class PerMessageDeflate { class PerMessageDeflate {
/** @type {import('node:zlib').InflateRaw} */ /** @type {import('node:zlib').InflateRaw} */
#inflate #inflate
#options = {} #options = {}
/** @type {boolean} */
#aborted = false
/** @type {Function|null} */
#currentCallback = null
/**
* @param {Map<string, string>} extensions
*/
constructor (extensions) { constructor (extensions) {
this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover') this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits') this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
@@ -24,6 +37,11 @@ class PerMessageDeflate {
// payload of the message. // payload of the message.
// 2. Decompress the resulting data using DEFLATE. // 2. Decompress the resulting data using DEFLATE.
if (this.#aborted) {
callback(new MessageSizeExceededError())
return
}
if (!this.#inflate) { if (!this.#inflate) {
let windowBits = Z_DEFAULT_WINDOWBITS let windowBits = Z_DEFAULT_WINDOWBITS
@@ -36,13 +54,37 @@ class PerMessageDeflate {
windowBits = Number.parseInt(this.#options.serverMaxWindowBits) windowBits = Number.parseInt(this.#options.serverMaxWindowBits)
} }
try {
this.#inflate = createInflateRaw({ windowBits }) this.#inflate = createInflateRaw({ windowBits })
} catch (err) {
callback(err)
return
}
this.#inflate[kBuffer] = [] this.#inflate[kBuffer] = []
this.#inflate[kLength] = 0 this.#inflate[kLength] = 0
this.#inflate.on('data', (data) => { this.#inflate.on('data', (data) => {
this.#inflate[kBuffer].push(data) if (this.#aborted) {
return
}
this.#inflate[kLength] += data.length this.#inflate[kLength] += data.length
if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) {
this.#aborted = true
this.#inflate.removeAllListeners()
this.#inflate.destroy()
this.#inflate = null
if (this.#currentCallback) {
const cb = this.#currentCallback
this.#currentCallback = null
cb(new MessageSizeExceededError())
}
return
}
this.#inflate[kBuffer].push(data)
}) })
this.#inflate.on('error', (err) => { this.#inflate.on('error', (err) => {
@@ -51,16 +93,22 @@ class PerMessageDeflate {
}) })
} }
this.#currentCallback = callback
this.#inflate.write(chunk) this.#inflate.write(chunk)
if (fin) { if (fin) {
this.#inflate.write(tail) this.#inflate.write(tail)
} }
this.#inflate.flush(() => { this.#inflate.flush(() => {
if (this.#aborted || !this.#inflate) {
return
}
const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]) const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength])
this.#inflate[kBuffer].length = 0 this.#inflate[kBuffer].length = 0
this.#inflate[kLength] = 0 this.#inflate[kLength] = 0
this.#currentCallback = null
callback(null, full) callback(null, full)
}) })

View File

@@ -37,6 +37,10 @@ class ByteParser extends Writable {
/** @type {Map<string, PerMessageDeflate>} */ /** @type {Map<string, PerMessageDeflate>} */
#extensions #extensions
/**
* @param {import('./websocket').WebSocket} ws
* @param {Map<string, string>|null} extensions
*/
constructor (ws, extensions) { constructor (ws, extensions) {
super() super()
@@ -179,6 +183,7 @@ class ByteParser extends Writable {
const buffer = this.consume(8) const buffer = this.consume(8)
const upper = buffer.readUInt32BE(0) const upper = buffer.readUInt32BE(0)
const lower = buffer.readUInt32BE(4)
// 2^31 is the maximum bytes an arraybuffer can contain // 2^31 is the maximum bytes an arraybuffer can contain
// on 32-bit systems. Although, on 64-bit systems, this is // on 32-bit systems. Although, on 64-bit systems, this is
@@ -186,14 +191,12 @@ class ByteParser extends Writable {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_array_length // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_array_length
// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;drc=1946212ac0100668f14eb9e2843bdd846e510a1e;bpv=1;bpt=1;l=1275 // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;drc=1946212ac0100668f14eb9e2843bdd846e510a1e;bpv=1;bpt=1;l=1275
// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array-buffer.h;l=34;drc=1946212ac0100668f14eb9e2843bdd846e510a1e // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array-buffer.h;l=34;drc=1946212ac0100668f14eb9e2843bdd846e510a1e
if (upper > 2 ** 31 - 1) { if (upper !== 0 || lower > 2 ** 31 - 1) {
failWebsocketConnection(this.ws, 'Received payload length > 2^31 bytes.') failWebsocketConnection(this.ws, 'Received payload length > 2^31 bytes.')
return return
} }
const lower = buffer.readUInt32BE(4) this.#info.payloadLength = lower
this.#info.payloadLength = (upper << 8) + lower
this.#state = parserStates.READ_DATA this.#state = parserStates.READ_DATA
} else if (this.#state === parserStates.READ_DATA) { } else if (this.#state === parserStates.READ_DATA) {
if (this.#byteOffset < this.#info.payloadLength) { if (this.#byteOffset < this.#info.payloadLength) {
@@ -223,7 +226,7 @@ class ByteParser extends Writable {
} else { } else {
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => { this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
if (error) { if (error) {
closeWebSocketConnection(this.ws, 1007, error.message, error.message.length) failWebsocketConnection(this.ws, error.message)
return return
} }

View File

@@ -266,6 +266,12 @@ function parseExtensions (extensions) {
* @param {string} value * @param {string} value
*/ */
function isValidClientWindowBits (value) { function isValidClientWindowBits (value) {
// Must have at least one character
if (value.length === 0) {
return false
}
// Check all characters are ASCII digits
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
const byte = value.charCodeAt(i) const byte = value.charCodeAt(i)
@@ -274,7 +280,9 @@ function isValidClientWindowBits (value) {
} }
} }
return true // Check numeric range: zlib requires windowBits in range 8-15
const num = Number.parseInt(value, 10)
return num >= 8 && num <= 15
} }
// https://nodejs.org/api/intl.html#detecting-internationalization-support // https://nodejs.org/api/intl.html#detecting-internationalization-support

View File

@@ -431,7 +431,7 @@ class WebSocket extends EventTarget {
* @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol
*/ */
#onConnectionEstablished (response, parsedExtensions) { #onConnectionEstablished (response, parsedExtensions) {
// processResponse is called when the "responses header list has been received and initialized." // processResponse is called when the "response's header list has been received and initialized."
// once this happens, the connection is open // once this happens, the connection is open
this[kResponse] = response this[kResponse] = response

4
node_modules/undici/package.json generated vendored
View File

@@ -1,6 +1,6 @@
{ {
"name": "undici", "name": "undici",
"version": "6.23.0", "version": "6.24.1",
"description": "An HTTP/1.1 client, written from scratch for Node.js", "description": "An HTTP/1.1 client, written from scratch for Node.js",
"homepage": "https://undici.nodejs.org", "homepage": "https://undici.nodejs.org",
"bugs": { "bugs": {
@@ -107,6 +107,7 @@
"devDependencies": { "devDependencies": {
"@fastify/busboy": "2.1.1", "@fastify/busboy": "2.1.1",
"@matteo.collina/tspl": "^0.1.1", "@matteo.collina/tspl": "^0.1.1",
"@metcoder95/https-pem": "^1.0.0",
"@sinonjs/fake-timers": "^11.1.0", "@sinonjs/fake-timers": "^11.1.0",
"@types/node": "~18.19.50", "@types/node": "~18.19.50",
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
@@ -117,7 +118,6 @@
"fast-check": "^3.17.1", "fast-check": "^3.17.1",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"formdata-node": "^6.0.3", "formdata-node": "^6.0.3",
"https-pem": "^3.0.0",
"husky": "^9.0.7", "husky": "^9.0.7",
"jest": "^29.0.2", "jest": "^29.0.2",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",

View File

@@ -146,4 +146,10 @@ declare namespace Errors {
name: 'SecureProxyConnectionError'; name: 'SecureProxyConnectionError';
code: 'UND_ERR_PRX_TLS'; code: 'UND_ERR_PRX_TLS';
} }
/** WebSocket decompressed message exceeded maximum size. */
export class MessageSizeExceededError extends UndiciError {
name: 'MessageSizeExceededError'
code: 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED'
}
} }

20
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"dependencies": { "dependencies": {
"@actions/core": "^3.0.0", "@actions/core": "^3.0.0",
"@actions/glob": "^0.6.1", "@actions/glob": "^0.6.1",
"nodemailer": "^7.0.13", "nodemailer": "^8.0.2",
"showdown": "^1.9.1" "showdown": "^1.9.1"
} }
}, },
@@ -194,9 +194,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@@ -206,9 +206,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "7.0.13", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -328,9 +328,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.23.0", "version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"

View File

@@ -5,7 +5,7 @@
"dependencies": { "dependencies": {
"@actions/core": "^3.0.0", "@actions/core": "^3.0.0",
"@actions/glob": "^0.6.1", "@actions/glob": "^0.6.1",
"nodemailer": "^7.0.13", "nodemailer": "^8.0.2",
"showdown": "^1.9.1" "showdown": "^1.9.1"
} }
} }