mirror of
https://github.com/dawidd6/action-send-mail.git
synced 2026-09-17 09:06:48 +07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67ce3558d6 | |||
| 4a3d0f9ca3 | |||
| 364e471101 | |||
| 420aeba895 |
+4
-4
@@ -94,12 +94,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
|
||||
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.8.tgz",
|
||||
"integrity": "sha512-uCZ0AtFDI46sHwJ6mhwNuIoA/KiTC0ZCzX7g01941+H9VMIXEiZjtgjQkzoBRTeQba0cIrKfwk31q99PfcIMRw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/showdown": {
|
||||
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
*.js text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.html text eol=lf
|
||||
*.htm text eol=lf
|
||||
*.ics -text
|
||||
*.bin -text
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
upgrade: true,
|
||||
reject: [
|
||||
// API changes break existing tests
|
||||
'proxy'
|
||||
]
|
||||
};
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
node_modules
|
||||
coverage
|
||||
*.min.js
|
||||
dist
|
||||
build
|
||||
.nyc_output
|
||||
package-lock.json
|
||||
CHANGELOG.md
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
printWidth: 160,
|
||||
tabWidth: 4,
|
||||
singleQuote: true,
|
||||
endOfLine: 'lf',
|
||||
trailingComma: 'none',
|
||||
arrowParens: 'avoid'
|
||||
};
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "node",
|
||||
"package-name": "nodemailer",
|
||||
"pull-request-title-pattern": "chore${scope}: release ${version} [skip-ci]"
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -1,5 +1,97 @@
|
||||
# CHANGELOG
|
||||
|
||||
## [10.0.8](https://github.com/nodemailer/nodemailer/compare/v10.0.7...v10.0.8) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **mime-node:** clean the boundary where it is written, not only where it is built ([e14278d](https://github.com/nodemailer/nodemailer/commit/e14278d2dae9427280c79450605a27b1c5d2c355))
|
||||
* **mime-node:** drop every control character from multipart boundary material ([a82a355](https://github.com/nodemailer/nodemailer/commit/a82a35554848a2e07485ff81f80aefe2736e5a04))
|
||||
|
||||
## [10.0.7](https://github.com/nodemailer/nodemailer/compare/v10.0.6...v10.0.7) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **mime-funcs:** do not double encode Buffer input when chunking base64 mime words ([#1865](https://github.com/nodemailer/nodemailer/issues/1865)) ([4327a59](https://github.com/nodemailer/nodemailer/commit/4327a59939748a7f9394b0a029495fd476f68f30))
|
||||
* **mime-node:** keep a boundary that is only line breaks from stripping to empty ([ec46800](https://github.com/nodemailer/nodemailer/commit/ec46800cdcab734d9aa79e1278e819962b3b8f09))
|
||||
* **mime-node:** strip line breaks from multipart boundary material ([#1867](https://github.com/nodemailer/nodemailer/issues/1867)) ([03c1a5c](https://github.com/nodemailer/nodemailer/commit/03c1a5c48b6828d9392983d8718fdb1fd583a06c))
|
||||
* **smtp-pool:** release rate-limited connections on close ([#1866](https://github.com/nodemailer/nodemailer/issues/1866)) ([7f5c7a4](https://github.com/nodemailer/nodemailer/commit/7f5c7a46b61da9f5c5feaffd921c55b9d5892ee5))
|
||||
|
||||
## [10.0.6](https://github.com/nodemailer/nodemailer/compare/v10.0.5...v10.0.6) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **addressparser:** scan free text for an address in linear time ([437d7fc](https://github.com/nodemailer/nodemailer/commit/437d7fc47403df176bc39271641541b7a9bce102))
|
||||
|
||||
## [10.0.5](https://github.com/nodemailer/nodemailer/compare/v10.0.4...v10.0.5) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **addressparser:** parse comment-joined addresses in linear time ([c07f175](https://github.com/nodemailer/nodemailer/commit/c07f17518d25aca8ab2ad66968dcbca538c24b89))
|
||||
|
||||
## [10.0.4](https://github.com/nodemailer/nodemailer/compare/v10.0.3...v10.0.4) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **fetch:** scope a cookie without a Path to the RFC 6265 default path ([2f907cb](https://github.com/nodemailer/nodemailer/commit/2f907cb71396860d0b2534dc72c3d252ef16339d))
|
||||
* **fetch:** send cookies set with Path back to the exact path ([#1861](https://github.com/nodemailer/nodemailer/issues/1861)) ([d557113](https://github.com/nodemailer/nodemailer/commit/d5571139750de47abbac73a3b07ae90ff39a7bdd))
|
||||
* **mail-composer:** keep httpHeaders and tls for href alternatives and icalEvent ([#1862](https://github.com/nodemailer/nodemailer/issues/1862)) ([7f502be](https://github.com/nodemailer/nodemailer/commit/7f502bee4a6b0386099c66c0a35d92850ec462f7))
|
||||
* resolve well-known services by their primary domains ([#1859](https://github.com/nodemailer/nodemailer/issues/1859)) ([085f525](https://github.com/nodemailer/nodemailer/commit/085f525e6b292f0ca32ab1b20d4a4ee82fc40e2d))
|
||||
* **ses-transport:** throw a configuration error when the SES client is missing ([#1863](https://github.com/nodemailer/nodemailer/issues/1863)) ([4d9c4c9](https://github.com/nodemailer/nodemailer/commit/4d9c4c966aaca61ff6d7649a1ac0aa0ceac6ef1e))
|
||||
|
||||
## [10.0.3](https://github.com/nodemailer/nodemailer/compare/v10.0.2...v10.0.3) (2026-09-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **fetch:** honor the cookie Domain attribute without accepting public suffixes ([1608391](https://github.com/nodemailer/nodemailer/commit/1608391ff4ccd5422a88e4cf760b26068aad6d39)), closes [#1856](https://github.com/nodemailer/nodemailer/issues/1856)
|
||||
|
||||
## [10.0.2](https://github.com/nodemailer/nodemailer/compare/v10.0.1...v10.0.2) (2026-09-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **mime-node:** flatten nested recipient arrays without recursion ([ebe0849](https://github.com/nodemailer/nodemailer/commit/ebe084940aef88278afc6016b78c6d1c3821bb66))
|
||||
* **shared:** keep the TLS server name out of the DNS cache ([a6512db](https://github.com/nodemailer/nodemailer/commit/a6512dbcb3c6e7f2f70d3acccc5752defe3c61fe))
|
||||
|
||||
## [10.0.1](https://github.com/nodemailer/nodemailer/compare/v10.0.0...v10.0.1) (2026-09-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **types:** accept an explicit undefined for optional properties ([209719d](https://github.com/nodemailer/nodemailer/commit/209719dd6a192656a6b18de265a3b4ac387208ff)), closes [#1853](https://github.com/nodemailer/nodemailer/issues/1853)
|
||||
* **types:** drop the internal members from the published declarations ([81e64ea](https://github.com/nodemailer/nodemailer/commit/81e64eaee70aabf867afef88d3733556c9fcc1cc))
|
||||
|
||||
## [10.0.0](https://github.com/nodemailer/nodemailer/compare/v9.1.1...v10.0.0) (2026-09-03)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* Node.js 20 or newer is required. The Node.js 6 syntax compatibility check and the .npmignore file are gone.
|
||||
|
||||
### Features
|
||||
|
||||
* keep the @types/nodemailer type layout working ([1cc5356](https://github.com/nodemailer/nodemailer/commit/1cc535627e1bb672214b09fa4cc503567275fd7f))
|
||||
* migrate to TypeScript with ES module and CommonJS builds ([f7cbf83](https://github.com/nodemailer/nodemailer/commit/f7cbf8384e0ea6055efcc514afb8bc070ac3a2b1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply the other keys of a configuration object next to its url ([29610f9](https://github.com/nodemailer/nodemailer/commit/29610f9fe04dbd8af04c4a3362f3b68d293592da))
|
||||
* **dkim:** canonicalize raw messages the way verifiers do ([2c84b11](https://github.com/nodemailer/nodemailer/commit/2c84b117d4d138751e9ffa8607f3796771bb5e66))
|
||||
* keep a transporter assignable to the plain Transporter type ([8bf55fb](https://github.com/nodemailer/nodemailer/commit/8bf55fb177cf155b24d6bb86a7cd86670146d551))
|
||||
* **shared:** keep a colon in the user name of a connection or proxy url ([6acf4b6](https://github.com/nodemailer/nodemailer/commit/6acf4b67d919971f516f58940113c9b8598b2254))
|
||||
* **shared:** refuse URL hosts the legacy parser would truncate ([17a5068](https://github.com/nodemailer/nodemailer/commit/17a5068f06e62fc04e1d5545ed70d4248bef258e))
|
||||
* **shared:** resolve hostnames when the runtime has no interface table ([8b03240](https://github.com/nodemailer/nodemailer/commit/8b032407135ea05ae377e623a7021705a4a139d0))
|
||||
* **smtp-connection:** clear the timers of a connection dropped before the greeting ([01dcaa0](https://github.com/nodemailer/nodemailer/commit/01dcaa05b212aafc33747041027fb8c66497e0eb))
|
||||
* **smtp-connection:** keep an incomplete server reply out of lastServerResponse ([1a6e427](https://github.com/nodemailer/nodemailer/commit/1a6e4271adb808509adf11832ad1adfdfc05c31b))
|
||||
* **smtp-pool:** free the pool slot when the proxy socket can not be opened ([204a344](https://github.com/nodemailer/nodemailer/commit/204a344f7e4068ff41385195ee394a223ebadd84))
|
||||
* **well-known:** keep nodemailer/lib/well-known/services.json available ([367730c](https://github.com/nodemailer/nodemailer/commit/367730cce85bc78c7804405c2cc7d89d93aafdd1))
|
||||
|
||||
## [9.1.1](https://github.com/nodemailer/nodemailer/compare/v9.1.0...v9.1.1) (2026-09-01)
|
||||
|
||||
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
# Nodemailer
|
||||
|
||||
E-mail sending library for Node.js. Zero runtime dependencies. Entry point is `lib/nodemailer.js`, which exposes `createTransport(transporter, defaults)` and routes to one of the bundled transports based on the options object.
|
||||
|
||||
## Layout
|
||||
|
||||
- `lib/nodemailer.js` — public entry, transport dispatch (`createTransport`).
|
||||
- `lib/mailer/` — `Mail` class: the user-facing transport wrapper that normalizes messages, runs the DKIM signer, and hands off to the underlying transport's `.send()`.
|
||||
- `lib/mail-composer/` + `lib/mime-node/` — message → MIME tree → raw RFC822 stream.
|
||||
- `lib/smtp-connection/` — low-level SMTP/LMTP/ESMTP client. Hot path; security-sensitive. Used by `smtp-transport` and `smtp-pool`.
|
||||
- `lib/smtp-transport/` — single-shot SMTP transport.
|
||||
- `lib/smtp-pool/` — pooled SMTP transport with rate limiting.
|
||||
- `lib/sendmail-transport/`, `lib/ses-transport/`, `lib/stream-transport/`, `lib/json-transport/` — alternate transports.
|
||||
- `lib/dkim/`, `lib/addressparser/`, `lib/mime-funcs/`, `lib/base64/`, `lib/qp/`, `lib/punycode/`, `lib/well-known/`, `lib/xoauth2/`, `lib/fetch/`, `lib/shared/` — supporting modules.
|
||||
- `test/` — mirrors `lib/` structure. Most suites spin up real `smtp-server` instances on ephemeral ports; raw `net` servers are used when byte-exact reply control is needed (e.g. injecting non-ASCII or invalid UTF-8).
|
||||
|
||||
Each transport must implement `name`, `version`, and `send(mail, callback)`. `Mail` discovers them via duck typing.
|
||||
|
||||
## Engine target
|
||||
|
||||
`engines.node = ">=6.0.0"`. The library is shipped as ES2017 script-mode CommonJS — no `import`, no top-level `await`, no optional chaining, no nullish coalescing, no class fields. ESLint enforces `ecmaVersion: 2017` and `sourceType: 'script'`. There is a Node 6 syntax-compat check (`npm run test:syntax`, runs `test/syntax-compat.js` inside `node:6-alpine`) that must keep passing — do not introduce syntax that breaks it. `'use strict';` directive at the top of every file.
|
||||
|
||||
## Conventions
|
||||
|
||||
- CommonJS only: `const x = require('...')`, `module.exports = ...`.
|
||||
- Callback-first style throughout the public API. Many internals are still callback-based — match the style of the file you are editing rather than introducing promises mid-module.
|
||||
- Prettier handles formatting; ESLint handles correctness. Run `npm run format` and `npm run lint` before sending changes. The lint config disables Prettier-overlapping rules.
|
||||
- Snake_case is not used; camelCase for variables and methods, PascalCase for classes.
|
||||
- Prefer small, surgical diffs. The codebase is mature and load-bearing — avoid drive-by refactors, comment churn, or "improvements" outside the scope of the change.
|
||||
- Every change to security-sensitive code (anything in `lib/smtp-connection/`, address parsing, header generation, DKIM) needs tests that exercise the failure mode, not just the happy path.
|
||||
|
||||
## Testing
|
||||
|
||||
- `npm test` — full suite via `node --test` (~150s, 480+ tests, runs serially).
|
||||
- `npm run test:coverage` — same suite under `c8`.
|
||||
- `npm run test:syntax` — Node 6 syntax compatibility check in Docker.
|
||||
- `npm run lint` / `npm run lint:fix`.
|
||||
- `npm run format` / `npm run format:check`.
|
||||
|
||||
Always run `npm test` and `npm run lint` before considering a change done. Tests are required to pass on every commit because release-please cuts releases directly from `master`.
|
||||
|
||||
## Releases
|
||||
|
||||
Releases, version numbers, the `version` field in `package.json`, git tags, `CHANGELOG.md` entries, and npm publication are all managed automatically by the release-please GitHub Action (`.github/workflows/release.yaml`, configured by `.release-please-config.json`). **Never edit any of these manually and never propose manual edits to them.**
|
||||
|
||||
Release-please derives the next version and changelog from Conventional Commit messages on `master`, opens a release PR (`chore: release X.Y.Z [skip-ci]`), and publishes to npm with provenance when that PR is merged. The only thing that should land on `master` between releases is normal commits with Conventional Commit prefixes — release-please takes care of the rest.
|
||||
|
||||
Conventional Commit prefixes used in this repo: `fix:`, `feat:`, `chore:`, `docs:`, `refactor:`, `test:`. Use `fix:` for anything users would benefit from seeing in the changelog, including security fixes (reference the GHSA in the body).
|
||||
|
||||
## 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.
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at info@nodemailer.com. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
+7
-3
@@ -10,15 +10,19 @@ See [nodemailer.com](https://nodemailer.com/) for documentation and terms.
|
||||
|
||||
> Nodemailer is developed by the team behind **[EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs)**, a self-hosted email API that turns any Gmail, Microsoft 365, or IMAP account into a REST endpoint, with managed OAuth2, webhooks for incoming mail, and built-in [sending](https://emailengine.app/sending-emails?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs). If you would rather call an HTTP API than maintain IMAP and SMTP connections yourself, that is what it is for.
|
||||
|
||||
## Supported runtimes
|
||||
|
||||
Nodemailer targets Node.js 20 and later. The ES module build also runs on [Bun](https://bun.sh/) (tested against the latest release) and on [Cloudflare Workers](https://developers.cloudflare.com/workers/) with the `nodejs_compat` compatibility flag. On Workers only the SMTP based transports apply, `sendmail` needs a child process, and the runtime does not allow turning certificate validation off, so `tls.rejectUnauthorized: false` fails there with an error.
|
||||
|
||||
## Having an issue?
|
||||
|
||||
#### First review the docs
|
||||
|
||||
Documentation for Nodemailer can be found at [nodemailer.com](https://nodemailer.com/about/).
|
||||
|
||||
#### Nodemailer throws a SyntaxError for "..."
|
||||
#### Nodemailer fails to load or throws a SyntaxError
|
||||
|
||||
You are using an older Node.js version than v6.0. Upgrade Node.js to get support for the spread operator. Nodemailer supports all Node.js versions starting from Node.js@v6.0.0.
|
||||
Nodemailer 10 and later require Node.js 20 or newer. The package ships both ES module and CommonJS builds, so `import nodemailer from 'nodemailer'` and `const nodemailer = require('nodemailer')` both work. If you need to run on an older Node.js version, stay on the 9.x line.
|
||||
|
||||
#### I'm having issues with Gmail
|
||||
|
||||
@@ -68,7 +72,7 @@ let configOptions = {
|
||||
|
||||
#### I have an issue with TypeScript types
|
||||
|
||||
Nodemailer has official support for Node.js only. For anything related to TypeScript, you need to directly contact the authors of the [type definitions](https://www.npmjs.com/package/@types/nodemailer).
|
||||
Nodemailer 10 and later are written in TypeScript and ship their own type definitions, so `@types/nodemailer` is no longer needed and should be removed from your project to avoid conflicting declarations. The type names follow the layout of the old definitions, so references such as `Mail.Options`, `SMTPTransport.Options` or `Transporter<SMTPTransport.SentMessageInfo>` keep compiling, and the most used types (`SendMailOptions`, `Transporter`, `SentMessageInfo`, `Attachment`, `Address`) are exported from the package root. For older Nodemailer versions, the community maintained [type definitions](https://www.npmjs.com/package/@types/nodemailer) still apply.
|
||||
|
||||
#### I have a different problem
|
||||
|
||||
|
||||
+7
-7
@@ -6,13 +6,13 @@ 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.
|
||||
backport patches to older majors, so upgrading to the current release line is
|
||||
the supported way to receive security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 9.x | :white_check_mark: |
|
||||
| < 9.0 | :x: |
|
||||
| 10.x | :white_check_mark: |
|
||||
| < 10.0 | :x: |
|
||||
|
||||
If you are on an older major, please upgrade. See the migration notes at
|
||||
<https://nodemailer.com/> before updating.
|
||||
@@ -41,7 +41,7 @@ When reporting, please include as much of the following as you can:
|
||||
- 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.
|
||||
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.
|
||||
|
||||
@@ -49,12 +49,12 @@ Security Advisory, and reporters who wish to be named are credited.
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Options for addressparser
|
||||
*/
|
||||
export interface AddressParserOptions {
|
||||
/** Flatten groups into a single list of mailboxes */
|
||||
flatten?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* A single mailbox. Either value may be an empty string when the input did not carry it
|
||||
*/
|
||||
export interface MailboxAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
group?: undefined;
|
||||
}
|
||||
/**
|
||||
* An address group. RFC 5322 does not allow nested groups, so any nesting is flattened
|
||||
* into `group`
|
||||
*/
|
||||
export interface GroupAddress {
|
||||
name: string;
|
||||
group: Address[];
|
||||
address?: undefined;
|
||||
}
|
||||
/**
|
||||
* A parsed address entry, either a mailbox or a group
|
||||
*/
|
||||
export type Address = MailboxAddress | GroupAddress;
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address field
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* 'Name <address@domain>'
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: 'Name', address: 'address@domain'}]
|
||||
*
|
||||
* @param str Address field
|
||||
* @param options Optional options object
|
||||
* @param options._depth Internal recursion depth counter (do not set manually)
|
||||
* @return An array of address objects
|
||||
*/
|
||||
export default function addressparser(str?: string | null, options?: AddressParserOptions): Address[];
|
||||
+626
@@ -0,0 +1,626 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.default = addressparser;
|
||||
/**
|
||||
* Restores the quoting of a local part that was read out of a quoted string.
|
||||
*
|
||||
* RFC 5321 allows '@' inside a quoted local part, so handing '"user@evil.com"@good.com'
|
||||
* on as the bare 'user@evil.com@good.com' leaves it to the consumer which '@' splits the
|
||||
* domain off. Getting that wrong is a misrouting vector, so the quotes go back on. The
|
||||
* same holds for the other specials: a ',' or a ';' that loses its quotes reads as a
|
||||
* recipient separator once the consumer puts the address back into a header.
|
||||
*
|
||||
* This module has no dependencies so that it can ship on its own, which is why the two
|
||||
* grammar tests below are spelled out here instead of shared with src/mime-node. Keeping
|
||||
* only what is ambiguous quoted is deliberate, mime-node applies the stricter RFC 5321
|
||||
* dot-atom rule on top of this when it emits an address.
|
||||
*
|
||||
* @param address Address with an unquoted local part
|
||||
* @return Address with the local part as a quoted-string
|
||||
*/
|
||||
function _quoteLocalPart(address) {
|
||||
const lastAt = address.lastIndexOf('@');
|
||||
if (lastAt < 0) {
|
||||
// no domain to split off, nothing can be misrouted
|
||||
return address;
|
||||
}
|
||||
const user = address.substr(0, lastAt);
|
||||
if (/^[^\s"(),:;<>@[\\\]]+$/.test(user) || /^"(?:[^"\\]|\\[\s\S])*"$/.test(user)) {
|
||||
// a local part that carries no special reads the same with or without the quotes,
|
||||
// and one that is already a complete quoted-string needs nothing either
|
||||
return address;
|
||||
}
|
||||
return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
|
||||
}
|
||||
/**
|
||||
* Reached for every parsed address, so it is built once rather than per call.
|
||||
*/
|
||||
const HAS_WHITESPACE = /\s/;
|
||||
/**
|
||||
* An addr-spec that carries its whitespace legally, inside a quoted local part. The
|
||||
* optional tail is the malformed shape: a real mailbox with wreckage trailing it.
|
||||
*/
|
||||
const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
||||
/**
|
||||
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
||||
*/
|
||||
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
||||
/**
|
||||
* The looser reading applied once the strict one finds nothing, which tolerates the
|
||||
* further '@' that a domain should not have but malformed headers carry anyway.
|
||||
*/
|
||||
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
||||
/**
|
||||
* An addr-spec sitting inside free text, together with the whitespace around it. Sticky
|
||||
* on purpose: it is run at the one offset _looseAddressStart picks rather than being let
|
||||
* loose to search, see there.
|
||||
*/
|
||||
const LOOSE_TEXT_ADDR = /\s*\b[^@\s]+@[^\s]+\b\s*/y;
|
||||
/**
|
||||
* The characters JS `\s` matches, which the scan below has to agree with to land on the
|
||||
* same match the pattern would.
|
||||
*/
|
||||
function _isSpaceCode(code) {
|
||||
return (code === 0x20 ||
|
||||
(code >= 0x09 && code <= 0x0d) ||
|
||||
code === 0xa0 ||
|
||||
code === 0x1680 ||
|
||||
(code >= 0x2000 && code <= 0x200a) ||
|
||||
code === 0x2028 ||
|
||||
code === 0x2029 ||
|
||||
code === 0x202f ||
|
||||
code === 0x205f ||
|
||||
code === 0x3000 ||
|
||||
code === 0xfeff);
|
||||
}
|
||||
/**
|
||||
* The characters JS `\w` matches without the unicode flag, the set the `\b` in
|
||||
* LOOSE_TEXT_ADDR is read against. charCodeAt off either end of the string gives NaN,
|
||||
* which compares false throughout, so out of range reads as the non-word the pattern
|
||||
* treats them as.
|
||||
*/
|
||||
function _isWordCode(code) {
|
||||
return (code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || code === 0x5f;
|
||||
}
|
||||
/**
|
||||
* Whether `\b` holds at an offset
|
||||
*/
|
||||
function _isBoundary(text, at) {
|
||||
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
|
||||
}
|
||||
/**
|
||||
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
||||
*
|
||||
* Letting the pattern search for itself is quadratic: '[^@\s]+' is retried from every
|
||||
* offset and rescans the run to the next '@' each time, so 140KB of header holding no
|
||||
* usable '@' blocks the event loop for about ten seconds (GHSA-v53p-9fqp-m79j). The search is also unnecessary.
|
||||
* '[^@\s]+' crosses neither whitespace nor a '@', so a match can only begin at the head of
|
||||
* a whitespace delimited run or just past a '@' inside one, and '[^\s]+\b' gives characters
|
||||
* back until it lands on a boundary, so the only end it can take in that run is the last
|
||||
* boundary in it. Both are found in one pass, and the pattern is then run at that single
|
||||
* offset.
|
||||
*
|
||||
* @param text Free text to look in
|
||||
* @return Offset to match at, or -1
|
||||
*/
|
||||
function _looseAddressStart(text) {
|
||||
const len = text.length;
|
||||
let pos = 0;
|
||||
while (pos < len) {
|
||||
while (pos < len && _isSpaceCode(text.charCodeAt(pos))) {
|
||||
pos++;
|
||||
}
|
||||
if (pos >= len) {
|
||||
break;
|
||||
}
|
||||
const runStart = pos;
|
||||
let runEnd = pos;
|
||||
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
|
||||
runEnd++;
|
||||
}
|
||||
let at = text.indexOf('@', runStart);
|
||||
if (at >= 0 && at < runEnd) {
|
||||
let lastBoundary = -1;
|
||||
for (let k = runEnd; k > runStart; k--) {
|
||||
if (_isBoundary(text, k)) {
|
||||
lastBoundary = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let atomStart = runStart;
|
||||
while (lastBoundary >= 0 && at >= 0 && at < runEnd) {
|
||||
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
|
||||
// and the boundary that ends the match has to sit past both
|
||||
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
|
||||
for (let start = atomStart; start < at; start++) {
|
||||
if (_isBoundary(text, start)) {
|
||||
if (start > runStart) {
|
||||
return start;
|
||||
}
|
||||
// the leading '\s*' is greedy, so a match that begins at the run
|
||||
// takes the whitespace in front of it along
|
||||
let padded = runStart;
|
||||
while (padded > 0 && _isSpaceCode(text.charCodeAt(padded - 1))) {
|
||||
padded--;
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
}
|
||||
}
|
||||
atomStart = at + 1;
|
||||
at = text.indexOf('@', atomStart);
|
||||
}
|
||||
}
|
||||
pos = runEnd;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
/**
|
||||
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
|
||||
*
|
||||
* A malformed header can put more than a mailbox between the angle brackets, most often
|
||||
* because the generator wrote the recipient twice: '<user@example.com user@example.com>'
|
||||
* or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
|
||||
* never be a mailbox anyone could deliver to, and passing it on as the address loses the
|
||||
* recipient that is sitting right there in the header.
|
||||
*
|
||||
* The run that still reads as an addr-spec is kept and whatever is left over becomes
|
||||
* display text rather than being dropped. Candidates are read strictly first and then
|
||||
* under the looser grammar, the same two tiers the unquoted-text branch below applies to
|
||||
* the same problem, so that '<a@b@c.com junk>' and a bare 'a@b@c.com junk' agree on the
|
||||
* recipient. When several runs qualify the first wins, which is what that branch's looser
|
||||
* tier does within a token.
|
||||
*
|
||||
* A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
|
||||
* '<"user name"@example.com>' is well formed and means exactly what it says.
|
||||
*
|
||||
* @param data Collected address parts, mutated in place
|
||||
*/
|
||||
function _recoverAddrSpec(data) {
|
||||
if (!HAS_WHITESPACE.test(data.address)) {
|
||||
return;
|
||||
}
|
||||
let address;
|
||||
let rest;
|
||||
const quoted = data.address.match(QUOTED_LOCAL_ADDR);
|
||||
if (quoted) {
|
||||
if (!quoted[2]) {
|
||||
// the whitespace sits inside the quoted local part, this is a well formed mailbox
|
||||
return;
|
||||
}
|
||||
// a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
|
||||
// than splitting into the quotes
|
||||
address = quoted[1];
|
||||
rest = [quoted[2]];
|
||||
}
|
||||
else {
|
||||
if (data.address.indexOf('"') >= 0) {
|
||||
// Splitting on whitespace loses track of where the quoted string starts and ends,
|
||||
// and this module does not take addresses out of quoted strings: the run picked out
|
||||
// of '<junk "user@evil.com b"@good.com>' would be an address from the domain the
|
||||
// quotes were hiding. Every well formed shape was already handled above, so what is
|
||||
// left is wreckage either way and the original is the honest answer
|
||||
return;
|
||||
}
|
||||
const parts = data.address.split(/\s+/);
|
||||
let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
|
||||
if (addrIndex < 0) {
|
||||
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
||||
}
|
||||
if (addrIndex < 0) {
|
||||
// nothing in there reads as an address, there is no better answer than the original
|
||||
return;
|
||||
}
|
||||
address = parts.splice(addrIndex, 1)[0];
|
||||
rest = parts;
|
||||
}
|
||||
data.address = address;
|
||||
data.text = [data.text]
|
||||
.concat(rest)
|
||||
.filter(part => part)
|
||||
.join(' ');
|
||||
}
|
||||
/**
|
||||
* Converts tokens for a single address into an address object
|
||||
*
|
||||
* @param tokens Tokens object
|
||||
* @param depth Current recursion depth for nested group protection
|
||||
* @return Address object
|
||||
*/
|
||||
function _handleAddress(tokens, depth) {
|
||||
let isGroup = false;
|
||||
let state = 'text';
|
||||
const addresses = [];
|
||||
const data = {
|
||||
address: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: [],
|
||||
textWasQuoted: []
|
||||
};
|
||||
let insideQuotes = false;
|
||||
// Last character of the run each state is currently accumulating. Reading it back off
|
||||
// the accumulator with slice(-1) makes the engine flatten the whole growing string on
|
||||
// every token, which is quadratic over an address built from many comment-joined atoms
|
||||
// (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the
|
||||
// character is carried along instead of re-read.
|
||||
const lastChars = { address: '', comment: '', group: '', text: '' };
|
||||
// Filter out <addresses>, (comments) and regular text
|
||||
for (let i = 0, len = tokens.length; i < len; i++) {
|
||||
const token = tokens[i];
|
||||
const prevToken = i ? tokens[i - 1] : null;
|
||||
if (token.type === 'operator') {
|
||||
switch (token.value) {
|
||||
case '<':
|
||||
state = 'address';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case '(':
|
||||
state = 'comment';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case ':':
|
||||
state = 'group';
|
||||
isGroup = true;
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case '"':
|
||||
insideQuotes = !insideQuotes;
|
||||
state = 'text';
|
||||
break;
|
||||
default:
|
||||
state = 'text';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (token.value) {
|
||||
if (state === 'address') {
|
||||
// Handle unquoted name that includes a "<".
|
||||
// Apple Mail truncates everything between an unexpected < and an address.
|
||||
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
||||
}
|
||||
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
|
||||
// of the '@', but it cannot join two atoms into one: gluing across it would read
|
||||
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
|
||||
// deliver to a domain the sender never named.
|
||||
const parts = data[state];
|
||||
const joins = prevToken &&
|
||||
prevToken.noBreak &&
|
||||
parts.length &&
|
||||
(prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
|
||||
if (joins) {
|
||||
data[state][data[state].length - 1] += token.value;
|
||||
if (token.value) {
|
||||
lastChars[state] = token.value.charAt(token.value.length - 1);
|
||||
}
|
||||
if (state === 'text' && insideQuotes) {
|
||||
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
||||
}
|
||||
}
|
||||
else {
|
||||
data[state].push(token.value);
|
||||
lastChars[state] = token.value.charAt(token.value.length - 1);
|
||||
if (state === 'text') {
|
||||
data.textWasQuoted.push(insideQuotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If there is no text but a comment, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
if (isGroup) {
|
||||
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||
data.text = data.text.join(' ');
|
||||
// Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
|
||||
let groupMembers = [];
|
||||
if (data.group.length) {
|
||||
const parsedGroup = addressparser(data.group.join(','), { _depth: depth + 1 });
|
||||
parsedGroup.forEach(member => {
|
||||
if (member.group) {
|
||||
groupMembers = groupMembers.concat(member.group);
|
||||
}
|
||||
else {
|
||||
groupMembers.push(member);
|
||||
}
|
||||
});
|
||||
}
|
||||
addresses.push({
|
||||
name: data.text || '',
|
||||
group: groupMembers
|
||||
});
|
||||
}
|
||||
else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.address.length && data.text.length) {
|
||||
for (let i = data.text.length - 1; i >= 0; i--) {
|
||||
// Security: Do not extract email addresses from quoted strings.
|
||||
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
||||
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
||||
if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
|
||||
data.address = data.text.splice(i, 1);
|
||||
data.textWasQuoted.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Try a looser regex match if strict match found nothing
|
||||
if (!data.address.length) {
|
||||
let extracted = false;
|
||||
for (let i = data.text.length - 1; i >= 0; i--) {
|
||||
// Security: Do not extract email addresses from quoted strings
|
||||
if (!data.textWasQuoted[i]) {
|
||||
const part = data.text[i];
|
||||
let remainder = part;
|
||||
const at = _looseAddressStart(part);
|
||||
if (at >= 0) {
|
||||
LOOSE_TEXT_ADDR.lastIndex = at;
|
||||
const match = LOOSE_TEXT_ADDR.exec(part);
|
||||
if (match) {
|
||||
data.address = [match[0].trim()];
|
||||
extracted = true;
|
||||
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
||||
}
|
||||
}
|
||||
data.text[i] = remainder.trim();
|
||||
if (extracted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If there's still no text but a comment exists, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
// Keep only the first address occurrence, push others to regular text
|
||||
if (data.address.length > 1) {
|
||||
data.text = data.text.concat(data.address.splice(1));
|
||||
}
|
||||
// An address is only taken from unquoted text, so anything left in the text at this
|
||||
// point that still has to serve as the address carries its quoting in this flag
|
||||
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
|
||||
// Join values with spaces
|
||||
data.text = data.text.join(' ');
|
||||
data.address = data.address.join(' ');
|
||||
_recoverAddrSpec(data);
|
||||
const address = {
|
||||
address: data.address || data.text || '',
|
||||
name: data.text || data.address || ''
|
||||
};
|
||||
if (address.address === address.name) {
|
||||
if (/@/.test(address.address || '')) {
|
||||
address.name = '';
|
||||
}
|
||||
else {
|
||||
address.address = '';
|
||||
}
|
||||
}
|
||||
if (addressFromQuotedText && address.address) {
|
||||
address.address = _quoteLocalPart(address.address);
|
||||
}
|
||||
addresses.push(address);
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
/**
|
||||
* Creates a Tokenizer object for tokenizing address field strings
|
||||
*
|
||||
* @constructor
|
||||
* @param str Address field string
|
||||
*/
|
||||
class Tokenizer {
|
||||
constructor(str) {
|
||||
this.str = (str || '').toString();
|
||||
this.operatorCurrent = '';
|
||||
this.operatorExpecting = '';
|
||||
this.node = null;
|
||||
this.escaped = false;
|
||||
this.inDomainLiteral = false;
|
||||
this.list = [];
|
||||
/**
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
*/
|
||||
this.operators = {
|
||||
'"': '"',
|
||||
'(': ')',
|
||||
'<': '>',
|
||||
',': '',
|
||||
':': ';',
|
||||
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
||||
// than for terminating a group, but they are also not valid for any
|
||||
// other use in this context. Given that some mail clients have
|
||||
// historically allowed the semicolon as a delimiter equivalent to the
|
||||
// comma in their UI, it makes sense to treat them the same as a comma
|
||||
// when used outside of a group.
|
||||
';': ''
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Tokenizes the original input string
|
||||
*
|
||||
* @return An array of operator|text tokens
|
||||
*/
|
||||
tokenize() {
|
||||
const list = [];
|
||||
for (let i = 0, len = this.str.length; i < len; i++) {
|
||||
const chr = this.str.charAt(i);
|
||||
const nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
|
||||
this.checkChar(chr, nextChr);
|
||||
}
|
||||
this.list.forEach(node => {
|
||||
node.value = (node.value || '').toString().trim();
|
||||
if (node.value) {
|
||||
list.push(node);
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
/**
|
||||
* Checks if a character is an operator or text and acts accordingly
|
||||
*
|
||||
* @param chr Character from the address field
|
||||
*/
|
||||
checkChar(chr, nextChr) {
|
||||
// Track RFC 5322 domain-literals ("[" *dtext "]"). Operator characters such
|
||||
// as the ":" of an IPv6 address-literal (user@[IPv6:2001:db8::1]) are dtext
|
||||
// and must not be treated as the group delimiter while inside the brackets.
|
||||
// Quoted strings and comments are handled separately via operatorExpecting,
|
||||
// so only enter this state when no operator is open. The list separators ","
|
||||
// and ";" are the exception: they always end the literal (and split the
|
||||
// address list) so that an unclosed "[" cannot swallow later recipients.
|
||||
if (!this.escaped && !this.operatorExpecting) {
|
||||
if (!this.inDomainLiteral && chr === '[') {
|
||||
this.inDomainLiteral = true;
|
||||
}
|
||||
else if (this.inDomainLiteral && (chr === ']' || chr === ',' || chr === ';')) {
|
||||
this.inDomainLiteral = false;
|
||||
}
|
||||
}
|
||||
if (this.escaped) {
|
||||
// ignore next condition blocks
|
||||
}
|
||||
else if (chr === this.operatorExpecting) {
|
||||
this.node = {
|
||||
type: 'operator',
|
||||
value: chr
|
||||
};
|
||||
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
|
||||
this.node.noBreak = true;
|
||||
}
|
||||
this.list.push(this.node);
|
||||
this.node = null;
|
||||
this.operatorExpecting = '';
|
||||
this.escaped = false;
|
||||
return;
|
||||
}
|
||||
else if (!this.operatorExpecting && !this.inDomainLiteral && chr in this.operators) {
|
||||
this.node = {
|
||||
type: 'operator',
|
||||
value: chr
|
||||
};
|
||||
this.list.push(this.node);
|
||||
this.node = null;
|
||||
this.operatorExpecting = this.operators[chr];
|
||||
this.escaped = false;
|
||||
return;
|
||||
}
|
||||
else if (['"', "'"].includes(this.operatorExpecting) && chr === '\\') {
|
||||
this.escaped = true;
|
||||
return;
|
||||
}
|
||||
if (!this.node) {
|
||||
this.node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
};
|
||||
this.list.push(this.node);
|
||||
}
|
||||
if (chr === '\n') {
|
||||
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually
|
||||
// go together anyway and there already is a WS for \n. Lone \r means something is fishy.
|
||||
chr = ' ';
|
||||
}
|
||||
if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
|
||||
// skip command bytes
|
||||
this.node.value += chr;
|
||||
}
|
||||
this.escaped = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Maximum recursion depth for parsing nested groups.
|
||||
* RFC 5322 doesn't allow nested groups, so this is a safeguard against
|
||||
* malicious input that could cause stack overflow.
|
||||
*/
|
||||
const MAX_NESTED_GROUP_DEPTH = 50;
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address field
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* 'Name <address@domain>'
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: 'Name', address: 'address@domain'}]
|
||||
*
|
||||
* @param str Address field
|
||||
* @param options Optional options object
|
||||
* @param options._depth Internal recursion depth counter (do not set manually)
|
||||
* @return An array of address objects
|
||||
*/
|
||||
function addressparser(str, options) {
|
||||
options = options || {};
|
||||
const depth = options._depth || 0;
|
||||
// Prevent stack overflow from deeply nested groups (DoS protection)
|
||||
if (depth > MAX_NESTED_GROUP_DEPTH) {
|
||||
return [];
|
||||
}
|
||||
const tokenizer = new Tokenizer(str);
|
||||
const tokens = tokenizer.tokenize();
|
||||
const addresses = [];
|
||||
let address = [];
|
||||
let parsedAddresses = [];
|
||||
tokens.forEach(token => {
|
||||
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
|
||||
if (address.length) {
|
||||
addresses.push(address);
|
||||
}
|
||||
address = [];
|
||||
}
|
||||
else {
|
||||
address.push(token);
|
||||
}
|
||||
});
|
||||
if (address.length) {
|
||||
addresses.push(address);
|
||||
}
|
||||
addresses.forEach(addr => {
|
||||
const handled = _handleAddress(addr, depth);
|
||||
// Appended in place. Rebuilding the accumulator with concat() would copy every
|
||||
// entry collected so far on each address, making a flat list cost O(n^2).
|
||||
for (let i = 0; i < handled.length; i++) {
|
||||
parsedAddresses.push(handled[i]);
|
||||
}
|
||||
});
|
||||
// Merge fragments produced when unquoted display names contain commas.
|
||||
// "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
||||
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
|
||||
// Recombine: a name-only entry followed by an entry with both name and address.
|
||||
// Walked back to front so that a run of fragments folds into one entry in a single
|
||||
// pass. Splicing each fragment out of the list instead would cost O(n^2).
|
||||
const mergedAddresses = [];
|
||||
for (let i = parsedAddresses.length - 1; i >= 0; i--) {
|
||||
const current = parsedAddresses[i];
|
||||
const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
|
||||
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
||||
next.name = current.name + ', ' + next.name;
|
||||
}
|
||||
else {
|
||||
mergedAddresses.push(current);
|
||||
}
|
||||
}
|
||||
mergedAddresses.reverse();
|
||||
parsedAddresses = mergedAddresses;
|
||||
if (options.flatten) {
|
||||
const flatAddresses = [];
|
||||
const walkAddressList = (list) => {
|
||||
list.forEach(entry => {
|
||||
if (entry.group) {
|
||||
return walkAddressList(entry.group);
|
||||
}
|
||||
flatAddresses.push(entry);
|
||||
});
|
||||
};
|
||||
walkAddressList(parsedAddresses);
|
||||
return flatAddresses;
|
||||
}
|
||||
return parsedAddresses;
|
||||
}
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { Transform } from 'node:stream';
|
||||
/**
|
||||
* Encodes a Buffer into a base64 encoded string
|
||||
*
|
||||
* @param buffer Buffer to convert
|
||||
* @returns base64 encoded string
|
||||
*/
|
||||
export declare function encode(buffer: Buffer | string): string;
|
||||
/**
|
||||
* Adds soft line breaks to a base64 string
|
||||
*
|
||||
* @param str base64 encoded string that might need line wrapping
|
||||
* @param [lineLength=76] Maximum allowed length for a line
|
||||
* @returns Soft-wrapped base64 encoded string
|
||||
*/
|
||||
export declare function wrap(str: string, lineLength?: number | false): string;
|
||||
/**
|
||||
* Options for the base64 encoder stream
|
||||
*/
|
||||
export interface EncoderOptions {
|
||||
/** Maximum length for lines, set to false to disable wrapping */
|
||||
lineLength?: number | false | undefined;
|
||||
}
|
||||
/**
|
||||
* Creates a transform stream for encoding data to base64 encoding
|
||||
*
|
||||
* @constructor
|
||||
* @param options Stream options
|
||||
* @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
|
||||
*/
|
||||
export declare class Encoder extends Transform {
|
||||
options: EncoderOptions;
|
||||
inputBytes: number;
|
||||
outputBytes: number;
|
||||
constructor(options?: EncoderOptions);
|
||||
}
|
||||
Generated
Vendored
+33
-53
@@ -1,36 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
const { Transform } = require('stream');
|
||||
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Encoder = void 0;
|
||||
exports.encode = encode;
|
||||
exports.wrap = wrap;
|
||||
const node_stream_1 = require("node:stream");
|
||||
/**
|
||||
* Encodes a Buffer into a base64 encoded string
|
||||
*
|
||||
* @param {Buffer} buffer Buffer to convert
|
||||
* @returns {String} base64 encoded string
|
||||
* @param buffer Buffer to convert
|
||||
* @returns base64 encoded string
|
||||
*/
|
||||
function encode(buffer) {
|
||||
if (typeof buffer === 'string') {
|
||||
buffer = Buffer.from(buffer, 'utf-8');
|
||||
}
|
||||
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds soft line breaks to a base64 string
|
||||
*
|
||||
* @param {String} str base64 encoded string that might need line wrapping
|
||||
* @param {Number} [lineLength=76] Maximum allowed length for a line
|
||||
* @returns {String} Soft-wrapped base64 encoded string
|
||||
* @param str base64 encoded string that might need line wrapping
|
||||
* @param [lineLength=76] Maximum allowed length for a line
|
||||
* @returns Soft-wrapped base64 encoded string
|
||||
*/
|
||||
function wrap(str, lineLength) {
|
||||
str = (str || '').toString();
|
||||
lineLength = lineLength || 76;
|
||||
|
||||
if (str.length <= lineLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
let pos = 0;
|
||||
const chunkLength = lineLength * 1024;
|
||||
@@ -40,87 +38,74 @@ function wrap(str, lineLength) {
|
||||
result.push(wrappedLines);
|
||||
pos += chunkLength;
|
||||
}
|
||||
|
||||
return result.join('\r\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a transform stream for encoding data to base64 encoding
|
||||
*
|
||||
* @constructor
|
||||
* @param {Object} options Stream options
|
||||
* @param {Number} [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
|
||||
* @param options Stream options
|
||||
* @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
|
||||
*/
|
||||
class Encoder extends Transform {
|
||||
class Encoder extends node_stream_1.Transform {
|
||||
constructor(options) {
|
||||
super();
|
||||
this.options = options || {};
|
||||
|
||||
if (this.options.lineLength !== false) {
|
||||
this.options.lineLength = this.options.lineLength || 76;
|
||||
}
|
||||
|
||||
this._curLine = '';
|
||||
this._remainingBytes = false;
|
||||
|
||||
this.inputBytes = 0;
|
||||
this.outputBytes = 0;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_transform(chunk, encoding, done) {
|
||||
if (encoding !== 'buffer') {
|
||||
chunk = Buffer.from(chunk, encoding);
|
||||
let buf = encoding !== 'buffer' ? Buffer.from(chunk, encoding) : chunk;
|
||||
if (!buf || !buf.length) {
|
||||
setImmediate(done);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chunk || !chunk.length) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
this.inputBytes += chunk.length;
|
||||
|
||||
this.inputBytes += buf.length;
|
||||
if (this._remainingBytes && this._remainingBytes.length) {
|
||||
chunk = Buffer.concat([this._remainingBytes, chunk], this._remainingBytes.length + chunk.length);
|
||||
buf = Buffer.concat([this._remainingBytes, buf], this._remainingBytes.length + buf.length);
|
||||
this._remainingBytes = false;
|
||||
}
|
||||
|
||||
if (chunk.length % 3) {
|
||||
this._remainingBytes = chunk.slice(chunk.length - (chunk.length % 3));
|
||||
chunk = chunk.slice(0, chunk.length - (chunk.length % 3));
|
||||
} else {
|
||||
if (buf.length % 3) {
|
||||
this._remainingBytes = buf.slice(buf.length - (buf.length % 3));
|
||||
buf = buf.slice(0, buf.length - (buf.length % 3));
|
||||
}
|
||||
else {
|
||||
this._remainingBytes = false;
|
||||
}
|
||||
|
||||
let b64 = this._curLine + encode(chunk);
|
||||
|
||||
let b64 = this._curLine + encode(buf);
|
||||
if (this.options.lineLength) {
|
||||
b64 = wrap(b64, this.options.lineLength);
|
||||
|
||||
// remove last line as it is still most probably incomplete
|
||||
const lastLF = b64.lastIndexOf('\n');
|
||||
if (lastLF < 0) {
|
||||
this._curLine = b64;
|
||||
b64 = '';
|
||||
} else if (lastLF === b64.length - 1) {
|
||||
}
|
||||
else if (lastLF === b64.length - 1) {
|
||||
this._curLine = '';
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this._curLine = b64.substring(lastLF + 1);
|
||||
b64 = b64.substring(0, lastLF + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (b64) {
|
||||
this.outputBytes += b64.length;
|
||||
this.push(Buffer.from(b64, 'ascii'));
|
||||
}
|
||||
|
||||
setImmediate(done);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_flush(done) {
|
||||
if (this._remainingBytes && this._remainingBytes.length) {
|
||||
this._curLine += encode(this._remainingBytes);
|
||||
}
|
||||
|
||||
if (this._curLine) {
|
||||
this._curLine = wrap(this._curLine, this.options.lineLength);
|
||||
this.outputBytes += this._curLine.length;
|
||||
@@ -130,9 +115,4 @@ class Encoder extends Transform {
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encode,
|
||||
wrap,
|
||||
Encoder
|
||||
};
|
||||
exports.Encoder = Encoder;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { type DKIMKey, type DKIMPrivateKey, type DKIMSignOptions } from './sign.js';
|
||||
import { PassThrough, type Readable } from 'node:stream';
|
||||
/**
|
||||
* A single DKIM signing key
|
||||
*/
|
||||
export type { DKIMKey, DKIMPrivateKey, DKIMSignOptions };
|
||||
/**
|
||||
* Options for the DKIM signer
|
||||
*/
|
||||
export interface DKIMOptions extends DKIMSignOptions {
|
||||
/** One or more signing keys, used instead of the domainName, keySelector and privateKey options */
|
||||
keys?: DKIMKey | DKIMKey[] | undefined;
|
||||
/** Directory for buffering large message bodies to disk, no buffering when not set */
|
||||
cacheDir?: string | false | undefined;
|
||||
/** Body size in bytes from which the body is buffered to cacheDir, defaults to 10 MB */
|
||||
cacheTreshold?: number | undefined;
|
||||
/** Hash algorithm for the body hash and the signature, defaults to sha256 */
|
||||
hashAlgo?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* The signed message as returned by DKIM#sign
|
||||
*/
|
||||
export interface DKIMSignedStream extends PassThrough {
|
||||
/** true if the message body was buffered to cacheDir while signing */
|
||||
usingCache: boolean;
|
||||
}
|
||||
declare class DKIM {
|
||||
options: DKIMOptions;
|
||||
keys: DKIMKey[];
|
||||
constructor(options: DKIMOptions);
|
||||
sign(input: Readable | Buffer | string, extraOptions?: DKIMOptions): DKIMSignedStream;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `DKIM.Options` style references keep working
|
||||
*/
|
||||
declare namespace DKIM {
|
||||
type Options = DKIMOptions;
|
||||
type SingleKeyOptions = Omit<DKIMOptions, 'keys'>;
|
||||
}
|
||||
export default DKIM;
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
"use strict";
|
||||
// FIXME:
|
||||
// replace this Transform mess with a method that pipes input argument to output argument
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const message_parser_js_1 = __importDefault(require("./message-parser.js"));
|
||||
const relaxed_body_js_1 = __importDefault(require("./relaxed-body.js"));
|
||||
const sign_js_1 = __importDefault(require("./sign.js"));
|
||||
const node_stream_1 = require("node:stream");
|
||||
const node_fs_1 = __importDefault(require("node:fs"));
|
||||
const node_path_1 = __importDefault(require("node:path"));
|
||||
const node_crypto_1 = __importDefault(require("node:crypto"));
|
||||
const objects_js_1 = require("../shared/objects.js");
|
||||
const DKIM_ALGO = 'sha256';
|
||||
const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // buffer messages larger than this to disk
|
||||
class DKIMSigner {
|
||||
constructor(options, keys, input, output) {
|
||||
this.options = options || {};
|
||||
this.keys = keys;
|
||||
this.cacheTreshold = Number(this.options.cacheTreshold) || MAX_MESSAGE_SIZE;
|
||||
this.hashAlgo = this.options.hashAlgo || DKIM_ALGO;
|
||||
this.cacheDir = this.options.cacheDir || false;
|
||||
this.chunks = [];
|
||||
this.chunklen = 0;
|
||||
this.readPos = 0;
|
||||
this.cachePath = this.cacheDir
|
||||
? node_path_1.default.join(this.cacheDir, 'message.' + Date.now() + '-' + node_crypto_1.default.randomBytes(14).toString('hex'))
|
||||
: false;
|
||||
this.cache = false;
|
||||
this.headers = false;
|
||||
this.bodyHash = false;
|
||||
this.parser = false;
|
||||
this.relaxedBody = false;
|
||||
this.input = input;
|
||||
this.output = output;
|
||||
this.output.usingCache = false;
|
||||
this.hasErrored = false;
|
||||
this.input.on('error', err => {
|
||||
this.hasErrored = true;
|
||||
this.cleanup();
|
||||
output.emit('error', err);
|
||||
});
|
||||
}
|
||||
cleanup() {
|
||||
if (!this.cache || !this.cachePath) {
|
||||
return;
|
||||
}
|
||||
node_fs_1.default.unlink(this.cachePath, () => false);
|
||||
}
|
||||
createReadCache() {
|
||||
// pipe remainings to cache file
|
||||
this.cache = node_fs_1.default.createReadStream(this.cachePath);
|
||||
this.cache.once('error', err => {
|
||||
this.cleanup();
|
||||
this.output.emit('error', err);
|
||||
});
|
||||
this.cache.once('close', () => {
|
||||
this.cleanup();
|
||||
});
|
||||
this.cache.pipe(this.output);
|
||||
}
|
||||
sendNextChunk() {
|
||||
if (this.hasErrored) {
|
||||
return;
|
||||
}
|
||||
if (this.readPos >= this.chunks.length) {
|
||||
if (!this.cache) {
|
||||
this.output.end();
|
||||
return;
|
||||
}
|
||||
return this.createReadCache();
|
||||
}
|
||||
const chunk = this.chunks[this.readPos++];
|
||||
if (this.output.write(chunk) === false) {
|
||||
this.output.once('drain', () => {
|
||||
this.sendNextChunk();
|
||||
});
|
||||
return;
|
||||
}
|
||||
setImmediate(() => this.sendNextChunk());
|
||||
}
|
||||
sendSignedOutput() {
|
||||
let keyPos = 0;
|
||||
const signNextKey = () => {
|
||||
if (keyPos >= this.keys.length) {
|
||||
this.output.write(this.parser.rawHeaders);
|
||||
setImmediate(() => this.sendNextChunk());
|
||||
return;
|
||||
}
|
||||
const key = this.keys[keyPos++];
|
||||
const dkimField = (0, sign_js_1.default)(this.headers, this.hashAlgo, this.bodyHash, {
|
||||
domainName: key.domainName,
|
||||
keySelector: key.keySelector,
|
||||
privateKey: key.privateKey,
|
||||
headerFieldNames: this.options.headerFieldNames,
|
||||
skipFields: this.options.skipFields
|
||||
});
|
||||
if (dkimField) {
|
||||
this.output.write(Buffer.from(dkimField + '\r\n'));
|
||||
}
|
||||
setImmediate(signNextKey);
|
||||
};
|
||||
if (this.bodyHash && this.headers) {
|
||||
return signNextKey();
|
||||
}
|
||||
this.output.write(this.parser.rawHeaders);
|
||||
this.sendNextChunk();
|
||||
}
|
||||
createWriteCache() {
|
||||
this.output.usingCache = true;
|
||||
// pipe remainings to cache file
|
||||
this.cache = node_fs_1.default.createWriteStream(this.cachePath);
|
||||
this.cache.once('error', err => {
|
||||
this.cleanup();
|
||||
// drain input
|
||||
this.relaxedBody.unpipe(this.cache);
|
||||
this.relaxedBody.on('readable', () => {
|
||||
while (this.relaxedBody.read() !== null) {
|
||||
// do nothing
|
||||
}
|
||||
});
|
||||
this.hasErrored = true;
|
||||
// emit error
|
||||
this.output.emit('error', err);
|
||||
});
|
||||
this.cache.once('close', () => {
|
||||
this.sendSignedOutput();
|
||||
});
|
||||
this.relaxedBody.removeAllListeners('readable');
|
||||
this.relaxedBody.pipe(this.cache);
|
||||
}
|
||||
signStream() {
|
||||
this.parser = new message_parser_js_1.default();
|
||||
this.relaxedBody = new relaxed_body_js_1.default({
|
||||
hashAlgo: this.hashAlgo
|
||||
});
|
||||
this.parser.on('headers', value => {
|
||||
this.headers = value;
|
||||
});
|
||||
this.relaxedBody.on('hash', value => {
|
||||
this.bodyHash = value;
|
||||
});
|
||||
this.relaxedBody.on('readable', () => {
|
||||
let chunk;
|
||||
if (this.cache) {
|
||||
return;
|
||||
}
|
||||
while ((chunk = this.relaxedBody.read()) !== null) {
|
||||
this.chunks.push(chunk);
|
||||
this.chunklen += chunk.length;
|
||||
if (this.chunklen >= this.cacheTreshold && this.cachePath) {
|
||||
return this.createWriteCache();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.relaxedBody.on('end', () => {
|
||||
if (this.cache) {
|
||||
return;
|
||||
}
|
||||
this.sendSignedOutput();
|
||||
});
|
||||
this.parser.pipe(this.relaxedBody);
|
||||
setImmediate(() => this.input.pipe(this.parser));
|
||||
}
|
||||
}
|
||||
class DKIM {
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
this.keys = [].concat(this.options.keys || {
|
||||
domainName: options.domainName,
|
||||
keySelector: options.keySelector,
|
||||
privateKey: options.privateKey
|
||||
});
|
||||
}
|
||||
sign(input, extraOptions) {
|
||||
const output = new node_stream_1.PassThrough();
|
||||
let inputStream = input;
|
||||
let writeValue = false;
|
||||
if (Buffer.isBuffer(input)) {
|
||||
writeValue = input;
|
||||
inputStream = new node_stream_1.PassThrough();
|
||||
}
|
||||
else if (typeof input === 'string') {
|
||||
writeValue = Buffer.from(input);
|
||||
inputStream = new node_stream_1.PassThrough();
|
||||
}
|
||||
let options = this.options;
|
||||
if (extraOptions && Object.keys(extraOptions).length) {
|
||||
// extraOptions is mail.data._dkim, caller supplied message data. An own
|
||||
// "__proto__" key there would let every option this signer reads and the
|
||||
// transport did not set, such as skipFields, answer from the caller
|
||||
options = (0, objects_js_1.copyOwnKeys)({}, extraOptions);
|
||||
(0, objects_js_1.copyOwnKeys)(options, this.options);
|
||||
}
|
||||
const signer = new DKIMSigner(options, this.keys, inputStream, output);
|
||||
setImmediate(() => {
|
||||
signer.signStream();
|
||||
if (writeValue) {
|
||||
setImmediate(() => {
|
||||
inputStream.end(writeValue);
|
||||
});
|
||||
}
|
||||
});
|
||||
return output;
|
||||
}
|
||||
}
|
||||
exports.default = DKIM;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Transform, type TransformOptions } from 'node:stream';
|
||||
/**
|
||||
* A header line as emitted with the 'headers' event
|
||||
*/
|
||||
export interface MessageParserHeaderLine {
|
||||
/** Lowercase header field name */
|
||||
key: string;
|
||||
/** Full header line, folded continuation lines included, one character per byte ('binary' encoding) */
|
||||
line: string;
|
||||
}
|
||||
/**
|
||||
* MessageParser instance is a transform stream that separates message headers
|
||||
* from the rest of the body. Headers are emitted with the 'headers' event. Message
|
||||
* body is passed on as the resulting stream.
|
||||
*/
|
||||
export default class MessageParser extends Transform {
|
||||
lastBytes: Buffer;
|
||||
headersParsed: boolean;
|
||||
headerBytes: number;
|
||||
headerChunks: Buffer[] | null;
|
||||
rawHeaders: Buffer | false;
|
||||
bodySize: number;
|
||||
constructor(options?: TransformOptions);
|
||||
/**
|
||||
* Keeps count of the last 4 bytes in order to detect line breaks on chunk boundaries
|
||||
*
|
||||
* @param data Next data chunk from the stream
|
||||
*/
|
||||
updateLastBytes(data: Buffer): void;
|
||||
/**
|
||||
* Finds and removes message headers from the remaining body. We want to keep
|
||||
* headers separated until final delivery to be able to modify these
|
||||
*
|
||||
* @param data Next chunk of data
|
||||
* @return Returns true if headers are already found or false otherwise
|
||||
*/
|
||||
checkHeaders(data: Buffer): boolean;
|
||||
parseHeaders(): MessageParserHeaderLine[];
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_stream_1 = require("node:stream");
|
||||
/**
|
||||
* MessageParser instance is a transform stream that separates message headers
|
||||
* from the rest of the body. Headers are emitted with the 'headers' event. Message
|
||||
* body is passed on as the resulting stream.
|
||||
*/
|
||||
class MessageParser extends node_stream_1.Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.lastBytes = Buffer.alloc(4);
|
||||
this.headersParsed = false;
|
||||
this.headerBytes = 0;
|
||||
this.headerChunks = [];
|
||||
this.rawHeaders = false;
|
||||
this.bodySize = 0;
|
||||
}
|
||||
/**
|
||||
* Keeps count of the last 4 bytes in order to detect line breaks on chunk boundaries
|
||||
*
|
||||
* @param data Next data chunk from the stream
|
||||
*/
|
||||
updateLastBytes(data) {
|
||||
const lblen = this.lastBytes.length;
|
||||
const nblen = Math.min(data.length, lblen);
|
||||
// shift existing bytes
|
||||
for (let i = 0, len = lblen - nblen; i < len; i++) {
|
||||
this.lastBytes[i] = this.lastBytes[i + nblen];
|
||||
}
|
||||
// add new bytes
|
||||
for (let i = 1; i <= nblen; i++) {
|
||||
this.lastBytes[lblen - i] = data[data.length - i];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Finds and removes message headers from the remaining body. We want to keep
|
||||
* headers separated until final delivery to be able to modify these
|
||||
*
|
||||
* @param data Next chunk of data
|
||||
* @return Returns true if headers are already found or false otherwise
|
||||
*/
|
||||
checkHeaders(data) {
|
||||
if (this.headersParsed) {
|
||||
return true;
|
||||
}
|
||||
const lblen = this.lastBytes.length;
|
||||
let headerPos = 0;
|
||||
for (let i = 0, len = this.lastBytes.length + data.length; i < len; i++) {
|
||||
let chr;
|
||||
if (i < lblen) {
|
||||
chr = this.lastBytes[i];
|
||||
}
|
||||
else {
|
||||
chr = data[i - lblen];
|
||||
}
|
||||
if (chr === 0x0a && i) {
|
||||
const pr1 = i - 1 < lblen ? this.lastBytes[i - 1] : data[i - 1 - lblen];
|
||||
const pr2 = i > 1 ? (i - 2 < lblen ? this.lastBytes[i - 2] : data[i - 2 - lblen]) : false;
|
||||
if (pr1 === 0x0a) {
|
||||
this.headersParsed = true;
|
||||
headerPos = i - lblen + 1;
|
||||
this.headerBytes += headerPos;
|
||||
break;
|
||||
}
|
||||
else if (pr1 === 0x0d && pr2 === 0x0a) {
|
||||
this.headersParsed = true;
|
||||
headerPos = i - lblen + 1;
|
||||
this.headerBytes += headerPos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.headersParsed) {
|
||||
this.headerChunks.push(data.slice(0, headerPos));
|
||||
this.rawHeaders = Buffer.concat(this.headerChunks, this.headerBytes);
|
||||
this.headerChunks = null;
|
||||
this.emit('headers', this.parseHeaders());
|
||||
if (data.length > headerPos) {
|
||||
const chunk = data.slice(headerPos);
|
||||
this.bodySize += chunk.length;
|
||||
// this would be the first chunk of data sent downstream
|
||||
setImmediate(() => this.push(chunk));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.headerBytes += data.length;
|
||||
this.headerChunks.push(data);
|
||||
// store last 4 bytes to catch header break
|
||||
this.updateLastBytes(data);
|
||||
return false;
|
||||
}
|
||||
/** @internal */
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (!chunk || !chunk.length) {
|
||||
return callback();
|
||||
}
|
||||
if (typeof chunk === 'string') {
|
||||
chunk = Buffer.from(chunk, encoding);
|
||||
}
|
||||
let headersFound;
|
||||
try {
|
||||
headersFound = this.checkHeaders(chunk);
|
||||
}
|
||||
catch (E) {
|
||||
return callback(E);
|
||||
}
|
||||
if (headersFound) {
|
||||
this.bodySize += chunk.length;
|
||||
this.push(chunk);
|
||||
}
|
||||
setImmediate(callback);
|
||||
}
|
||||
/** @internal */
|
||||
_flush(callback) {
|
||||
if (this.headerChunks) {
|
||||
// no empty line was seen, so the message consists of headers only
|
||||
this.rawHeaders = Buffer.concat(this.headerChunks, this.headerBytes);
|
||||
this.headerChunks = null;
|
||||
this.emit('headers', this.parseHeaders());
|
||||
}
|
||||
callback();
|
||||
}
|
||||
parseHeaders() {
|
||||
// the header bytes are kept as they are, one character per byte, so the
|
||||
// signature covers exactly the bytes the receiving side canonicalizes
|
||||
// Only SP and HTAB fold a line, and only they are trimmed from the field name, the
|
||||
// same whitespace the relaxed canonicalization in sign.ts works with
|
||||
const lines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/);
|
||||
for (let i = lines.length - 1; i > 0; i--) {
|
||||
if (/^[ \t]/.test(lines[i])) {
|
||||
lines[i - 1] += '\n' + lines[i];
|
||||
lines.splice(i, 1);
|
||||
}
|
||||
}
|
||||
return lines
|
||||
.filter(line => /[^ \t\r]/.test(line))
|
||||
.map(line => ({
|
||||
key: line
|
||||
.substr(0, line.indexOf(':'))
|
||||
.replace(/^[ \t]+|[ \t]+$/g, '')
|
||||
.toLowerCase(),
|
||||
line
|
||||
}));
|
||||
}
|
||||
}
|
||||
exports.default = MessageParser;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Transform } from 'node:stream';
|
||||
import crypto from 'node:crypto';
|
||||
/**
|
||||
* Options for the relaxed body hash stream
|
||||
*/
|
||||
export interface RelaxedBodyOptions {
|
||||
/** Hash algorithm for the body hash, defaults to sha256 */
|
||||
hashAlgo?: string | undefined;
|
||||
/** Collect the canonicalized body and emit it with the 'hash' event */
|
||||
debug?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* Passes the message body through unchanged and hashes its relaxed
|
||||
* canonicalization (RFC 6376 section 3.4.4) on the side: whitespace at the end
|
||||
* of a line is dropped, runs of whitespace within a line become a single space,
|
||||
* every line ends with CRLF, empty lines at the end of the body are ignored and
|
||||
* a non-empty body always ends with CRLF. Bytes are canonicalized as they arrive,
|
||||
* so a line of any length costs constant memory.
|
||||
*/
|
||||
export default class RelaxedBody extends Transform {
|
||||
bodyHash: crypto.Hash;
|
||||
/** Bytes of the original body seen so far */
|
||||
byteLength: number;
|
||||
debug: boolean | undefined;
|
||||
constructor(options?: RelaxedBodyOptions);
|
||||
updateHash(chunk: Buffer, final?: boolean): void;
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
"use strict";
|
||||
// streams through a message body and calculates relaxed body hash
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_stream_1 = require("node:stream");
|
||||
const node_crypto_1 = __importDefault(require("node:crypto"));
|
||||
const CHAR_CR = 0x0d;
|
||||
const CHAR_LF = 0x0a;
|
||||
const CHAR_SPACE = 0x20;
|
||||
const CHAR_TAB = 0x09;
|
||||
const CRLF = Buffer.from('\r\n');
|
||||
// a run of empty lines is hashed from this buffer in slices
|
||||
const EMPTY_LINES = Buffer.alloc(4096, CRLF);
|
||||
/**
|
||||
* Passes the message body through unchanged and hashes its relaxed
|
||||
* canonicalization (RFC 6376 section 3.4.4) on the side: whitespace at the end
|
||||
* of a line is dropped, runs of whitespace within a line become a single space,
|
||||
* every line ends with CRLF, empty lines at the end of the body are ignored and
|
||||
* a non-empty body always ends with CRLF. Bytes are canonicalized as they arrive,
|
||||
* so a line of any length costs constant memory.
|
||||
*/
|
||||
class RelaxedBody extends node_stream_1.Transform {
|
||||
constructor(options) {
|
||||
super();
|
||||
options = options || {};
|
||||
this.bodyHash = node_crypto_1.default.createHash(options.hashAlgo || 'sha256');
|
||||
this.byteLength = 0;
|
||||
this.debug = options.debug;
|
||||
this._debugBody = options.debug ? [] : false;
|
||||
this._lineHasContent = false;
|
||||
this._pendingWsp = false;
|
||||
this._pendingCr = false;
|
||||
this._pendingEmptyLines = 0;
|
||||
}
|
||||
/** @internal */
|
||||
_hashCanonical(data) {
|
||||
if (!data.length) {
|
||||
return;
|
||||
}
|
||||
this.bodyHash.update(data);
|
||||
if (this._debugBody) {
|
||||
this._debugBody.push(Buffer.from(data));
|
||||
}
|
||||
}
|
||||
/** @internal */
|
||||
_hashEmptyLines() {
|
||||
while (this._pendingEmptyLines > 0) {
|
||||
const count = Math.min(this._pendingEmptyLines, EMPTY_LINES.length / 2);
|
||||
this._hashCanonical(EMPTY_LINES.subarray(0, count * 2));
|
||||
this._pendingEmptyLines -= count;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Writes a content byte, with the space a pending run of whitespace collapses to,
|
||||
* into the output buffer and returns the new write position. Kept a method rather
|
||||
* than a closure so the write position stays a plain local in the byte loop
|
||||
* @internal
|
||||
*/
|
||||
_emitContent(out, outPos, c) {
|
||||
if (!this._lineHasContent) {
|
||||
if (this._pendingEmptyLines) {
|
||||
// the first content byte of a line is where the empty lines before it
|
||||
// become part of the body, so hash what is in the buffer before them
|
||||
this._hashCanonical(out.subarray(0, outPos));
|
||||
outPos = 0;
|
||||
this._hashEmptyLines();
|
||||
}
|
||||
this._lineHasContent = true;
|
||||
}
|
||||
if (this._pendingWsp) {
|
||||
out[outPos++] = CHAR_SPACE;
|
||||
this._pendingWsp = false;
|
||||
}
|
||||
out[outPos++] = c;
|
||||
return outPos;
|
||||
}
|
||||
updateHash(chunk, final) {
|
||||
// every byte contributes itself at most once, plus a CR for a bare LF
|
||||
// and, once per chunk, a pending space and CR carried over from before
|
||||
const out = Buffer.allocUnsafe(chunk.length * 2 + 2);
|
||||
let outPos = 0;
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const c = chunk[i];
|
||||
if (c === CHAR_LF) {
|
||||
// end of line, a CR right before it and any trailing whitespace are dropped
|
||||
if (this._lineHasContent) {
|
||||
out[outPos++] = CHAR_CR;
|
||||
out[outPos++] = CHAR_LF;
|
||||
this._lineHasContent = false;
|
||||
}
|
||||
else {
|
||||
this._pendingEmptyLines++;
|
||||
}
|
||||
this._pendingWsp = false;
|
||||
this._pendingCr = false;
|
||||
continue;
|
||||
}
|
||||
if (this._pendingCr) {
|
||||
// not followed by LF, so the CR is content
|
||||
outPos = this._emitContent(out, outPos, CHAR_CR);
|
||||
this._pendingCr = false;
|
||||
}
|
||||
if (c === CHAR_CR) {
|
||||
this._pendingCr = true;
|
||||
}
|
||||
else if (c === CHAR_SPACE || c === CHAR_TAB) {
|
||||
this._pendingWsp = true;
|
||||
}
|
||||
else {
|
||||
outPos = this._emitContent(out, outPos, c);
|
||||
}
|
||||
}
|
||||
if (final && this._pendingCr) {
|
||||
// a CR at the very end of the body is content
|
||||
outPos = this._emitContent(out, outPos, CHAR_CR);
|
||||
this._pendingCr = false;
|
||||
}
|
||||
this._hashCanonical(out.subarray(0, outPos));
|
||||
}
|
||||
/** @internal */
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (!chunk || !chunk.length) {
|
||||
return callback();
|
||||
}
|
||||
if (typeof chunk === 'string') {
|
||||
chunk = Buffer.from(chunk, encoding);
|
||||
}
|
||||
this.updateHash(chunk);
|
||||
this.byteLength += chunk.length;
|
||||
this.push(chunk);
|
||||
callback();
|
||||
}
|
||||
/** @internal */
|
||||
_flush(callback) {
|
||||
this.updateHash(Buffer.alloc(0), true);
|
||||
if (this._lineHasContent) {
|
||||
// the body does not end with a line break, add one
|
||||
this._hashCanonical(CRLF);
|
||||
}
|
||||
this.emit('hash', this.bodyHash.digest('base64'), this.debug ? Buffer.concat(this._debugBody) : false);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
exports.default = RelaxedBody;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { MessageParserHeaderLine } from './message-parser.js';
|
||||
/**
|
||||
* Private key accepted by crypto.Sign#sign: a PEM string, a Buffer, a KeyObject or an
|
||||
* object with the key and its passphrase
|
||||
*/
|
||||
export type DKIMPrivateKey = crypto.KeyLike | crypto.SignKeyObjectInput | crypto.SignPrivateKeyInput;
|
||||
/**
|
||||
* Options for the DKIM signature header generator
|
||||
*/
|
||||
export interface DKIMKey {
|
||||
/** Domain name to be signed for */
|
||||
domainName?: string | undefined;
|
||||
/** DKIM key selector to use */
|
||||
keySelector?: string | undefined;
|
||||
/** DKIM private key to use */
|
||||
privateKey?: DKIMPrivateKey | undefined;
|
||||
}
|
||||
export interface DKIMSignOptions extends DKIMKey {
|
||||
/** Colon separated list of header field names to sign, defaults to the RFC4871 list */
|
||||
headerFieldNames?: string | undefined;
|
||||
/** Colon separated list of header field names to leave out of the signature */
|
||||
skipFields?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* Canonicalized headers and the list of field names that went into them
|
||||
*/
|
||||
export interface DKIMRelaxedHeaders {
|
||||
/** Relaxed header lines, each terminated with CRLF, one character per byte ('binary' encoding) */
|
||||
headers: string;
|
||||
/** Colon separated list of the field names that were included */
|
||||
fieldNames: string;
|
||||
}
|
||||
/**
|
||||
* Returns DKIM signature header line
|
||||
*
|
||||
* @param headers Parsed headers object from MessageParser
|
||||
* @param bodyHash Base64 encoded hash of the message
|
||||
* @param options DKIM options
|
||||
* @param options.domainName Domain name to be signed for
|
||||
* @param options.keySelector DKIM key selector to use
|
||||
* @param options.privateKey DKIM private key to use
|
||||
* @return Complete header line
|
||||
*/
|
||||
declare function sign(headers: MessageParserHeaderLine[], hashAlgo: string, bodyHash: string, options?: DKIMSignOptions): string | false;
|
||||
declare namespace sign {
|
||||
var relaxedHeaders: (headers: MessageParserHeaderLine[], fieldNames?: string, skipFields?: string) => DKIMRelaxedHeaders;
|
||||
}
|
||||
export default sign;
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const punycode = __importStar(require("../punycode/index.js"));
|
||||
const mimeFuncs = __importStar(require("../mime-funcs/index.js"));
|
||||
const node_crypto_1 = __importDefault(require("node:crypto"));
|
||||
/**
|
||||
* Returns DKIM signature header line
|
||||
*
|
||||
* @param headers Parsed headers object from MessageParser
|
||||
* @param bodyHash Base64 encoded hash of the message
|
||||
* @param options DKIM options
|
||||
* @param options.domainName Domain name to be signed for
|
||||
* @param options.keySelector DKIM key selector to use
|
||||
* @param options.privateKey DKIM private key to use
|
||||
* @return Complete header line
|
||||
*/
|
||||
function sign(headers, hashAlgo, bodyHash, options) {
|
||||
options = options || {};
|
||||
// all listed fields from RFC4871 #5.5
|
||||
const defaultFieldNames = 'From:Sender:Reply-To:Subject:Date:Message-ID:To:' +
|
||||
'Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:' +
|
||||
'Content-Description:Resent-Date:Resent-From:Resent-Sender:' +
|
||||
'Resent-To:Resent-Cc:Resent-Message-ID:In-Reply-To:References:' +
|
||||
'List-Id:List-Help:List-Unsubscribe:List-Subscribe:List-Post:' +
|
||||
'List-Owner:List-Archive';
|
||||
const fieldNames = options.headerFieldNames || defaultFieldNames;
|
||||
const canonicalizedHeaderData = relaxedHeaders(headers, fieldNames, options.skipFields);
|
||||
const dkimHeader = generateDKIMHeader(options.domainName, options.keySelector, canonicalizedHeaderData.fieldNames, hashAlgo, bodyHash);
|
||||
canonicalizedHeaderData.headers += 'dkim-signature:' + relaxedHeaderLine(dkimHeader);
|
||||
const signer = node_crypto_1.default.createSign(('rsa-' + hashAlgo).toUpperCase());
|
||||
// the header lines are 'binary' strings, so this reproduces the original header bytes
|
||||
signer.update(canonicalizedHeaderData.headers, 'latin1');
|
||||
let signature;
|
||||
try {
|
||||
signature = signer.sign(options.privateKey, 'base64');
|
||||
}
|
||||
catch (_E) {
|
||||
return false;
|
||||
}
|
||||
return dkimHeader + signature.replace(/(^.{73}|.{75}(?!\r?\n|\r))/g, '$&\r\n ').trim();
|
||||
}
|
||||
sign.relaxedHeaders = relaxedHeaders;
|
||||
exports.default = sign;
|
||||
function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) {
|
||||
// the caller supplied tag values are interpolated straight into the tag list, and none of
|
||||
// them has any way to carry a control char, DEL, or one of the delimiters that would close
|
||||
// the value and open a tag of its own
|
||||
const cleanTagValue = (value) => (value || '').toString().replace(/[\x00-\x1f\x7f;=]/g, '');
|
||||
const dkim = [
|
||||
'v=1',
|
||||
'a=rsa-' + hashAlgo,
|
||||
'c=relaxed/relaxed',
|
||||
'd=' + punycode.toASCII(cleanTagValue(domainName)),
|
||||
'q=dns/txt',
|
||||
's=' + cleanTagValue(keySelector),
|
||||
'bh=' + bodyHash,
|
||||
'h=' + cleanTagValue(fieldNames)
|
||||
].join('; ');
|
||||
return mimeFuncs.foldLines('DKIM-Signature: ' + dkim, 76) + ';\r\n b=';
|
||||
}
|
||||
function relaxedHeaders(headers, fieldNames, skipFields) {
|
||||
const includedFields = new Set();
|
||||
const skip = new Set();
|
||||
const headerFields = new Map();
|
||||
(skipFields || '')
|
||||
.toLowerCase()
|
||||
.split(':')
|
||||
.forEach(field => {
|
||||
skip.add(field.trim());
|
||||
});
|
||||
(fieldNames || '')
|
||||
.toLowerCase()
|
||||
.split(':')
|
||||
.filter(field => !skip.has(field.trim()))
|
||||
.forEach(field => {
|
||||
includedFields.add(field.trim());
|
||||
});
|
||||
for (let i = headers.length - 1; i >= 0; i--) {
|
||||
const line = headers[i];
|
||||
// only include the first value from bottom to top
|
||||
if (includedFields.has(line.key) && !headerFields.has(line.key)) {
|
||||
headerFields.set(line.key, relaxedHeaderLine(line.line));
|
||||
}
|
||||
}
|
||||
const headersList = [];
|
||||
const fields = [];
|
||||
includedFields.forEach(field => {
|
||||
if (headerFields.has(field)) {
|
||||
fields.push(field);
|
||||
headersList.push(field + ':' + headerFields.get(field));
|
||||
}
|
||||
});
|
||||
return {
|
||||
headers: headersList.join('\r\n') + '\r\n',
|
||||
fieldNames: fields.join(':')
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Relaxed canonicalization of a header field value (RFC 6376 section 3.4.2): unfold, turn
|
||||
* every run of SP and HTAB into a single SP and drop the whitespace next to the colon and
|
||||
* at the end. Only SP and HTAB count as whitespace, so bytes that decode to other space
|
||||
* characters, such as a non-breaking space in a UTF-8 header, stay as they are
|
||||
*/
|
||||
function relaxedHeaderLine(line) {
|
||||
return line
|
||||
.substr(line.indexOf(':') + 1)
|
||||
.replace(/\r?\n/g, '')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/^ | $/g, '');
|
||||
}
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Nodemailer Error Codes
|
||||
*
|
||||
* Centralized error code definitions for consistent error handling.
|
||||
*
|
||||
* Usage:
|
||||
* import * as errors from './errors.js';
|
||||
* const err: NodemailerError = new Error('Connection closed');
|
||||
* err.code = errors.ECONNECTION;
|
||||
*/
|
||||
/**
|
||||
* Error code descriptions for documentation and debugging
|
||||
*/
|
||||
export declare const ERROR_CODES: {
|
||||
readonly ECONNECTION: "Connection closed unexpectedly";
|
||||
readonly ETIMEDOUT: "Connection or operation timed out";
|
||||
readonly ESOCKET: "Socket-level error";
|
||||
readonly EDNS: "DNS resolution failed";
|
||||
readonly ETLS: "TLS handshake or STARTTLS failed";
|
||||
readonly EREQUIRETLS: "REQUIRETLS not supported by server (RFC 8689)";
|
||||
readonly EPROTOCOL: "Invalid SMTP server response";
|
||||
readonly EENVELOPE: "Invalid mail envelope (sender or recipients)";
|
||||
readonly EMESSAGE: "Message delivery error";
|
||||
readonly ESTREAM: "Stream processing error";
|
||||
readonly EAUTH: "Authentication failed";
|
||||
readonly ENOAUTH: "Authentication credentials not provided";
|
||||
readonly EOAUTH2: "OAuth2 token generation or refresh error";
|
||||
readonly EMAXLIMIT: "Pool resource limit reached (max messages per connection)";
|
||||
readonly EMAXRECIPIENTS: "Recipient count exceeds maxRecipients";
|
||||
readonly ESENDMAIL: "Sendmail command error";
|
||||
readonly ESES: "AWS SES transport error";
|
||||
readonly ECONFIG: "Invalid configuration";
|
||||
readonly EPROXY: "Proxy connection error";
|
||||
readonly EFILEACCESS: "File access rejected (disableFileAccess is set)";
|
||||
readonly EURLACCESS: "URL access rejected (disableUrlAccess is set)";
|
||||
readonly EFETCH: "HTTP fetch error";
|
||||
};
|
||||
/**
|
||||
* Union of all known Nodemailer error codes
|
||||
*/
|
||||
export type ErrorCode = keyof typeof ERROR_CODES;
|
||||
export declare const ECONNECTION = "ECONNECTION";
|
||||
export declare const ETIMEDOUT = "ETIMEDOUT";
|
||||
export declare const ESOCKET = "ESOCKET";
|
||||
export declare const EDNS = "EDNS";
|
||||
export declare const ETLS = "ETLS";
|
||||
export declare const EREQUIRETLS = "EREQUIRETLS";
|
||||
export declare const EPROTOCOL = "EPROTOCOL";
|
||||
export declare const EENVELOPE = "EENVELOPE";
|
||||
export declare const EMESSAGE = "EMESSAGE";
|
||||
export declare const ESTREAM = "ESTREAM";
|
||||
export declare const EAUTH = "EAUTH";
|
||||
export declare const ENOAUTH = "ENOAUTH";
|
||||
export declare const EOAUTH2 = "EOAUTH2";
|
||||
export declare const EMAXLIMIT = "EMAXLIMIT";
|
||||
export declare const EMAXRECIPIENTS = "EMAXRECIPIENTS";
|
||||
export declare const ESENDMAIL = "ESENDMAIL";
|
||||
export declare const ESES = "ESES";
|
||||
export declare const ECONFIG = "ECONFIG";
|
||||
export declare const EPROXY = "EPROXY";
|
||||
export declare const EFILEACCESS = "EFILEACCESS";
|
||||
export declare const EURLACCESS = "EURLACCESS";
|
||||
export declare const EFETCH = "EFETCH";
|
||||
/**
|
||||
* An Error together with the properties Nodemailer attaches to the errors it
|
||||
* hands to callers. Every property is optional, the set that is present
|
||||
* depends on where the error originated.
|
||||
*/
|
||||
export interface NodemailerError extends Error {
|
||||
/** Nodemailer error code, see ERROR_CODES */
|
||||
code?: string | undefined;
|
||||
/** SMTP command that was in flight when the server replied with an error */
|
||||
command?: string | undefined;
|
||||
/** Raw SMTP server response */
|
||||
response?: string | undefined;
|
||||
/** Numeric SMTP response code */
|
||||
responseCode?: number | undefined;
|
||||
/** URL of the resource that could not be fetched */
|
||||
sourceUrl?: string | undefined;
|
||||
/** Recipient address the error applies to */
|
||||
recipient?: string | undefined;
|
||||
/** Recipient addresses rejected by the server */
|
||||
rejected?: string[] | undefined;
|
||||
/** Per-recipient errors for the rejected addresses */
|
||||
rejectedErrors?: NodemailerError[] | undefined;
|
||||
}
|
||||
/**
|
||||
* Node style callback: called with an error, or with null and the result
|
||||
*/
|
||||
export type Callback<T> = (err: NodemailerError | null, result: T) => void;
|
||||
/**
|
||||
* Callback as the library calls it on its error paths: with an error alone, or with null and
|
||||
* the result. The public signatures use Callback, internally the error paths cast to this
|
||||
*/
|
||||
export type ResultCallback<T> = (err: NodemailerError | null, result?: T) => void;
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Nodemailer Error Codes
|
||||
*
|
||||
* Centralized error code definitions for consistent error handling.
|
||||
*
|
||||
* Usage:
|
||||
* import * as errors from './errors.js';
|
||||
* const err: NodemailerError = new Error('Connection closed');
|
||||
* err.code = errors.ECONNECTION;
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.EFETCH = exports.EURLACCESS = exports.EFILEACCESS = exports.EPROXY = exports.ECONFIG = exports.ESES = exports.ESENDMAIL = exports.EMAXRECIPIENTS = exports.EMAXLIMIT = exports.EOAUTH2 = exports.ENOAUTH = exports.EAUTH = exports.ESTREAM = exports.EMESSAGE = exports.EENVELOPE = exports.EPROTOCOL = exports.EREQUIRETLS = exports.ETLS = exports.EDNS = exports.ESOCKET = exports.ETIMEDOUT = exports.ECONNECTION = exports.ERROR_CODES = void 0;
|
||||
/**
|
||||
* Error code descriptions for documentation and debugging
|
||||
*/
|
||||
exports.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)',
|
||||
EMAXRECIPIENTS: 'Recipient count exceeds maxRecipients',
|
||||
// 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'
|
||||
};
|
||||
// Error codes as string constants
|
||||
exports.ECONNECTION = 'ECONNECTION';
|
||||
exports.ETIMEDOUT = 'ETIMEDOUT';
|
||||
exports.ESOCKET = 'ESOCKET';
|
||||
exports.EDNS = 'EDNS';
|
||||
exports.ETLS = 'ETLS';
|
||||
exports.EREQUIRETLS = 'EREQUIRETLS';
|
||||
exports.EPROTOCOL = 'EPROTOCOL';
|
||||
exports.EENVELOPE = 'EENVELOPE';
|
||||
exports.EMESSAGE = 'EMESSAGE';
|
||||
exports.ESTREAM = 'ESTREAM';
|
||||
exports.EAUTH = 'EAUTH';
|
||||
exports.ENOAUTH = 'ENOAUTH';
|
||||
exports.EOAUTH2 = 'EOAUTH2';
|
||||
exports.EMAXLIMIT = 'EMAXLIMIT';
|
||||
exports.EMAXRECIPIENTS = 'EMAXRECIPIENTS';
|
||||
exports.ESENDMAIL = 'ESENDMAIL';
|
||||
exports.ESES = 'ESES';
|
||||
exports.ECONFIG = 'ECONFIG';
|
||||
exports.EPROXY = 'EPROXY';
|
||||
exports.EFILEACCESS = 'EFILEACCESS';
|
||||
exports.EURLACCESS = 'EURLACCESS';
|
||||
exports.EFETCH = 'EFETCH';
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Options for the Cookies jar
|
||||
*/
|
||||
export interface CookiesOptions {
|
||||
/** Lifetime in seconds for cookies that do not set their own expiration (default 1800) */
|
||||
sessionTimeout?: number | string | undefined;
|
||||
}
|
||||
/**
|
||||
* A cookie as parsed from a 'Set-Cookie:' header and kept in the jar
|
||||
*/
|
||||
export interface Cookie {
|
||||
name?: string | undefined;
|
||||
value?: string | undefined;
|
||||
domain?: string | undefined;
|
||||
path?: string | undefined;
|
||||
expires?: Date | undefined;
|
||||
secure?: boolean | undefined;
|
||||
httponly?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* Creates a biskviit cookie jar for managing cookie values in memory
|
||||
*
|
||||
* @constructor
|
||||
* @param [options] Optional options object
|
||||
*/
|
||||
export default class Cookies {
|
||||
options: CookiesOptions;
|
||||
cookies: Cookie[];
|
||||
constructor(options?: CookiesOptions);
|
||||
/**
|
||||
* Stores a cookie string to the cookie storage
|
||||
*
|
||||
* @param cookieStr Value from the 'Set-Cookie:' header
|
||||
* @param url Current URL
|
||||
*/
|
||||
set(cookieStr: string, url?: string): boolean;
|
||||
/**
|
||||
* Returns cookie string for the 'Cookie:' header.
|
||||
*
|
||||
* @param url URL to check for
|
||||
* @returns Cookie header or empty string if no matches were found
|
||||
*/
|
||||
get(url?: string): string;
|
||||
/**
|
||||
* Lists all valied cookie objects for the specified URL
|
||||
*
|
||||
* @param url URL to check for
|
||||
* @returns An array of cookie objects
|
||||
*/
|
||||
list(url?: string): Cookie[];
|
||||
/**
|
||||
* Parses cookie string from the 'Set-Cookie:' header
|
||||
*
|
||||
* @param cookieStr String from the 'Set-Cookie:' header
|
||||
* @returns Cookie object
|
||||
*/
|
||||
parse(cookieStr?: string): Cookie;
|
||||
/**
|
||||
* Checks if a cookie object is valid for a specified URL
|
||||
*
|
||||
* @param cookie Cookie object
|
||||
* @param url URL to check for
|
||||
* @returns true if cookie is valid for specifiec URL
|
||||
*/
|
||||
match(cookie: Cookie, url?: string): boolean;
|
||||
/**
|
||||
* Adds (or updates/removes if needed) a cookie object to the cookie storage
|
||||
*
|
||||
* @param cookie Cookie value to be stored
|
||||
*/
|
||||
add(cookie: Cookie): boolean;
|
||||
/**
|
||||
* Checks if two cookie objects are the same
|
||||
*
|
||||
* @param a Cookie to check against
|
||||
* @param b Cookie to check against
|
||||
* @returns True, if the cookies are the same
|
||||
*/
|
||||
compare(a: Cookie, b: Cookie): boolean;
|
||||
/**
|
||||
* Checks if a cookie is expired
|
||||
*
|
||||
* @param cookie Cookie object to check against
|
||||
* @returns True, if the cookie is expired
|
||||
*/
|
||||
isExpired(cookie: Cookie): boolean;
|
||||
/**
|
||||
* Returns the default path for an URL path argument, the default-path of
|
||||
* RFC 6265 section 5.1.4. A cookie that carries no Path attribute is scoped
|
||||
* to the directory of the URL it was set from
|
||||
*
|
||||
* @param pathname
|
||||
* @returns Default path
|
||||
*/
|
||||
getPath(pathname?: string | null): string;
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
"use strict";
|
||||
// module to handle cookies
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_net_1 = __importDefault(require("node:net"));
|
||||
const urllib = __importStar(require("../shared/url.js"));
|
||||
const SESSION_TIMEOUT = 1800; // 30 min
|
||||
/**
|
||||
* Creates a biskviit cookie jar for managing cookie values in memory
|
||||
*
|
||||
* @constructor
|
||||
* @param [options] Optional options object
|
||||
*/
|
||||
class Cookies {
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
this.cookies = [];
|
||||
}
|
||||
/**
|
||||
* Stores a cookie string to the cookie storage
|
||||
*
|
||||
* @param cookieStr Value from the 'Set-Cookie:' header
|
||||
* @param url Current URL
|
||||
*/
|
||||
set(cookieStr, url) {
|
||||
const urlparts = urllib.parse(url || '');
|
||||
const cookie = this.parse(cookieStr);
|
||||
let domain;
|
||||
if (cookie.domain) {
|
||||
domain = cookie.domain.replace(/^\./, '');
|
||||
// do not allow cross origin cookies. There is no public suffix list here, so a
|
||||
// multi-label suffix like 'co.uk' can not be told apart from a registrable domain
|
||||
if (
|
||||
// can't be valid if the requested domain is shorter than current hostname
|
||||
urlparts.hostname.length < domain.length ||
|
||||
// a top level domain is not a valid scope, 'Domain=com' would otherwise be
|
||||
// sent to every .com host. A trailing dot does not make 'com.' any better
|
||||
domain.indexOf('.') < 0 ||
|
||||
domain.endsWith('.') ||
|
||||
// an IP address has no subdomains, so cookies set on it stay host-only
|
||||
node_net_1.default.isIP(urlparts.hostname) ||
|
||||
// prefix domains with dot to be sure that partial matches are not used
|
||||
!('.' + urlparts.hostname).endsWith('.' + domain)) {
|
||||
cookie.domain = urlparts.hostname;
|
||||
}
|
||||
}
|
||||
else {
|
||||
cookie.domain = urlparts.hostname;
|
||||
}
|
||||
if (!cookie.path) {
|
||||
cookie.path = this.getPath(urlparts.pathname);
|
||||
}
|
||||
// if no expire date, then use sessionTimeout value
|
||||
if (!cookie.expires) {
|
||||
cookie.expires = new Date(Date.now() + (Number(this.options.sessionTimeout || SESSION_TIMEOUT) || SESSION_TIMEOUT) * 1000);
|
||||
}
|
||||
return this.add(cookie);
|
||||
}
|
||||
/**
|
||||
* Returns cookie string for the 'Cookie:' header.
|
||||
*
|
||||
* @param url URL to check for
|
||||
* @returns Cookie header or empty string if no matches were found
|
||||
*/
|
||||
get(url) {
|
||||
return this.list(url)
|
||||
.map(cookie => cookie.name + '=' + cookie.value)
|
||||
.join('; ');
|
||||
}
|
||||
/**
|
||||
* Lists all valied cookie objects for the specified URL
|
||||
*
|
||||
* @param url URL to check for
|
||||
* @returns An array of cookie objects
|
||||
*/
|
||||
list(url) {
|
||||
const result = [];
|
||||
for (let i = this.cookies.length - 1; i >= 0; i--) {
|
||||
const cookie = this.cookies[i];
|
||||
if (this.isExpired(cookie)) {
|
||||
this.cookies.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
if (this.match(cookie, url)) {
|
||||
result.unshift(cookie);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Parses cookie string from the 'Set-Cookie:' header
|
||||
*
|
||||
* @param cookieStr String from the 'Set-Cookie:' header
|
||||
* @returns Cookie object
|
||||
*/
|
||||
parse(cookieStr) {
|
||||
const cookie = {};
|
||||
(cookieStr || '')
|
||||
.toString()
|
||||
.split(';')
|
||||
.forEach(cookiePart => {
|
||||
const valueParts = cookiePart.split('=');
|
||||
const key = valueParts.shift().trim().toLowerCase();
|
||||
let value = valueParts.join('=').trim();
|
||||
let domain;
|
||||
if (!key) {
|
||||
// skip empty parts
|
||||
return;
|
||||
}
|
||||
switch (key) {
|
||||
case 'expires': {
|
||||
const expires = new Date(value);
|
||||
// ignore date if can not parse it
|
||||
if (expires.toString() !== 'Invalid Date') {
|
||||
cookie.expires = expires;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'path':
|
||||
cookie.path = value;
|
||||
break;
|
||||
case 'domain':
|
||||
domain = value.toLowerCase();
|
||||
if (domain.length && domain.charAt(0) !== '.') {
|
||||
domain = '.' + domain; // ensure preceeding dot for user set domains
|
||||
}
|
||||
cookie.domain = domain;
|
||||
break;
|
||||
case 'max-age':
|
||||
cookie.expires = new Date(Date.now() + (Number(value) || 0) * 1000);
|
||||
break;
|
||||
case 'secure':
|
||||
cookie.secure = true;
|
||||
break;
|
||||
case 'httponly':
|
||||
cookie.httponly = true;
|
||||
break;
|
||||
default:
|
||||
if (!cookie.name) {
|
||||
cookie.name = key;
|
||||
cookie.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return cookie;
|
||||
}
|
||||
/**
|
||||
* Checks if a cookie object is valid for a specified URL
|
||||
*
|
||||
* @param cookie Cookie object
|
||||
* @param url URL to check for
|
||||
* @returns true if cookie is valid for specifiec URL
|
||||
*/
|
||||
match(cookie, url) {
|
||||
const urlparts = urllib.parse(url || '');
|
||||
// check if hostname matches
|
||||
// .foo.com also matches subdomains, foo.com does not
|
||||
if (urlparts.hostname !== cookie.domain &&
|
||||
(cookie.domain.charAt(0) !== '.' ||
|
||||
('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) {
|
||||
return false;
|
||||
}
|
||||
// check if the request path path-matches the cookie path (RFC 6265 section 5.1.4):
|
||||
// identical paths match, otherwise the cookie path must be a directory prefix
|
||||
const pathname = urlparts.pathname || '/';
|
||||
const cookiePath = cookie.path;
|
||||
const pathMatches = pathname === cookiePath ||
|
||||
(pathname.startsWith(cookiePath) && (cookiePath.endsWith('/') || pathname.charAt(cookiePath.length) === '/'));
|
||||
if (!pathMatches) {
|
||||
return false;
|
||||
}
|
||||
// check secure argument
|
||||
if (cookie.secure && urlparts.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Adds (or updates/removes if needed) a cookie object to the cookie storage
|
||||
*
|
||||
* @param cookie Cookie value to be stored
|
||||
*/
|
||||
add(cookie) {
|
||||
// nothing to do here
|
||||
if (!cookie || !cookie.name) {
|
||||
return false;
|
||||
}
|
||||
// overwrite if has same params
|
||||
for (let i = 0, len = this.cookies.length; i < len; i++) {
|
||||
if (this.compare(this.cookies[i], cookie)) {
|
||||
// check if the cookie needs to be removed instead
|
||||
if (this.isExpired(cookie)) {
|
||||
this.cookies.splice(i, 1); // remove expired/unset cookie
|
||||
return false;
|
||||
}
|
||||
this.cookies[i] = cookie;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// add as new if not already expired
|
||||
if (!this.isExpired(cookie)) {
|
||||
this.cookies.push(cookie);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Checks if two cookie objects are the same
|
||||
*
|
||||
* @param a Cookie to check against
|
||||
* @param b Cookie to check against
|
||||
* @returns True, if the cookies are the same
|
||||
*/
|
||||
compare(a, b) {
|
||||
return a.name === b.name && a.path === b.path && a.domain === b.domain && a.secure === b.secure && a.httponly === b.httponly;
|
||||
}
|
||||
/**
|
||||
* Checks if a cookie is expired
|
||||
*
|
||||
* @param cookie Cookie object to check against
|
||||
* @returns True, if the cookie is expired
|
||||
*/
|
||||
isExpired(cookie) {
|
||||
return (cookie.expires && cookie.expires < new Date()) || !cookie.value;
|
||||
}
|
||||
/**
|
||||
* Returns the default path for an URL path argument, the default-path of
|
||||
* RFC 6265 section 5.1.4. A cookie that carries no Path attribute is scoped
|
||||
* to the directory of the URL it was set from
|
||||
*
|
||||
* @param pathname
|
||||
* @returns Default path
|
||||
*/
|
||||
getPath(pathname) {
|
||||
const pathParts = (pathname || '/').split('/');
|
||||
pathParts.pop(); // remove filename part
|
||||
const path = pathParts.join('/').trim();
|
||||
// a path that holds no more than one '/' is scoped to the root path, and so
|
||||
// is one that does not start with '/' at all
|
||||
if (path.charAt(0) !== '/') {
|
||||
return '/';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
exports.default = Cookies;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import http from 'node:http';
|
||||
import { PassThrough, type Readable } from 'node:stream';
|
||||
import Cookies from './cookies.js';
|
||||
/**
|
||||
* Options for nmfetch
|
||||
*/
|
||||
export interface FetchOptions {
|
||||
/** HTTP method, defaults to GET, or to POST when a body is given */
|
||||
method?: string | undefined;
|
||||
/** Request headers, keys are lowercased before use */
|
||||
headers?: http.OutgoingHttpHeaders | undefined;
|
||||
/** Overrides the default User-Agent header */
|
||||
userAgent?: string | undefined;
|
||||
/** Cookie string(s) to seed the cookie jar with for this URL */
|
||||
cookie?: string | string[] | false | undefined;
|
||||
/** Cookie jar shared across redirects, created when missing */
|
||||
cookies?: Cookies | undefined;
|
||||
/** Request body: a readable stream, a Buffer, a form object or a string */
|
||||
body?: Readable | Buffer | {
|
||||
[key: string]: any;
|
||||
} | string | false | undefined;
|
||||
/** Content-Type header for the body, false leaves it out for a stream body */
|
||||
contentType?: string | false | undefined;
|
||||
/** TLS settings, only the keys listed in TLS_OPTION_KEYS are used */
|
||||
tls?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
/** Request timeout in milliseconds */
|
||||
timeout?: number | undefined;
|
||||
/** Maximum number of redirects to follow (default 5) */
|
||||
maxRedirects?: number | undefined;
|
||||
/** Resolve responses with a status code of 300 or above instead of emitting an error */
|
||||
allowErrorResponse?: boolean | undefined;
|
||||
/** Redirects followed so far, set by nmfetch itself */
|
||||
redirects?: number | undefined;
|
||||
/** Response stream shared across redirects, set by nmfetch itself */
|
||||
fetchRes?: FetchResponse | undefined;
|
||||
}
|
||||
/**
|
||||
* The stream nmfetch returns. The response body is piped into it, the status code and
|
||||
* headers of the final response are attached once they arrive
|
||||
*/
|
||||
export interface FetchResponse extends PassThrough {
|
||||
statusCode?: number | undefined;
|
||||
headers?: http.IncomingHttpHeaders | undefined;
|
||||
}
|
||||
declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
|
||||
declare namespace nmfetch {
|
||||
var Cookies: typeof import("./cookies.js").default;
|
||||
}
|
||||
export default nmfetch;
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_http_1 = __importDefault(require("node:http"));
|
||||
const node_https_1 = __importDefault(require("node:https"));
|
||||
const urllib = __importStar(require("../shared/url.js"));
|
||||
const node_zlib_1 = __importDefault(require("node:zlib"));
|
||||
const node_stream_1 = require("node:stream");
|
||||
const cookies_js_1 = __importDefault(require("./cookies.js"));
|
||||
const packageData = __importStar(require("../package-info.js"));
|
||||
const node_net_1 = __importDefault(require("node:net"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
const objects_js_1 = require("../shared/objects.js");
|
||||
const MAX_REDIRECTS = 5;
|
||||
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
|
||||
// from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
|
||||
// or lookup would otherwise repoint the request at a destination that never went through
|
||||
// the URL checks below.
|
||||
//
|
||||
// The source of truth is the tls.connect() option list in the Node docs. A key missing
|
||||
// here is dropped silently, so extend this list rather than working around it.
|
||||
const TLS_OPTION_KEYS = [
|
||||
'ALPNProtocols',
|
||||
'ca',
|
||||
'cert',
|
||||
'checkServerIdentity',
|
||||
'ciphers',
|
||||
'crl',
|
||||
'dhparam',
|
||||
'ecdhCurve',
|
||||
'honorCipherOrder',
|
||||
'key',
|
||||
'maxVersion',
|
||||
'minVersion',
|
||||
'passphrase',
|
||||
'pfx',
|
||||
'rejectUnauthorized',
|
||||
'secureContext',
|
||||
'secureOptions',
|
||||
'secureProtocol',
|
||||
'servername',
|
||||
'sessionIdContext',
|
||||
'sigalgs'
|
||||
];
|
||||
/**
|
||||
* Resolves a URL only if it is one this module is willing to request.
|
||||
*
|
||||
* urllib.parse throws for a host that contains forbidden bytes, and it is called for
|
||||
* every URL that reaches nmfetch, including ones that arrive from a message attachment
|
||||
* or from a redirect Location header. An uncaught throw here takes the process down,
|
||||
* so a URL that does not parse is reported the same way as one with a scheme we refuse.
|
||||
*
|
||||
* @param url URL to parse
|
||||
* @returns Parsed URL, or false if it is not a usable http(s) URL
|
||||
*/
|
||||
function parseFetchUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = urllib.parse(url);
|
||||
}
|
||||
catch (_err) {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
function nmfetch(url, options) {
|
||||
options = options || {};
|
||||
options.fetchRes = options.fetchRes || new node_stream_1.PassThrough();
|
||||
options.cookies = options.cookies || new cookies_js_1.default();
|
||||
options.redirects = options.redirects || 0;
|
||||
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
|
||||
const fetchRes = options.fetchRes;
|
||||
const parsed = parseFetchUrl(url);
|
||||
if (!parsed) {
|
||||
// Only http(s) URLs can be fetched. Any other scheme (file:, gopher:, a
|
||||
// protocol-relative redirect target etc.) would otherwise be silently served over
|
||||
// plain HTTP, possibly against an unintended host. Bail out before the cookie jar
|
||||
// is touched so a refused URL can not seed it, and release a caller supplied body:
|
||||
// this is the one exit that runs before the error handler below is attached to it,
|
||||
// so an error on that stream would have nowhere to go and the fd or socket behind
|
||||
// it would never be released.
|
||||
if (options.body && typeof options.body.destroy === 'function') {
|
||||
options.body.on('error', () => false);
|
||||
options.body.destroy();
|
||||
}
|
||||
setImmediate(() => {
|
||||
const err = new Error('Unsupported protocol for URL ' + url);
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
});
|
||||
return fetchRes;
|
||||
}
|
||||
if (options.cookie) {
|
||||
[].concat(options.cookie || []).forEach(cookie => {
|
||||
options.cookies.set(cookie, url);
|
||||
});
|
||||
options.cookie = false;
|
||||
}
|
||||
let method = (options.method || '').toString().trim().toUpperCase() || 'GET';
|
||||
let finished = false;
|
||||
let cookies;
|
||||
let body;
|
||||
const handler = parsed.protocol === 'https:' ? node_https_1.default : node_http_1.default;
|
||||
const headers = {
|
||||
'accept-encoding': 'gzip,deflate',
|
||||
'user-agent': 'nodemailer/' + packageData.version
|
||||
};
|
||||
Object.keys(options.headers || {}).forEach(key => {
|
||||
// options.headers is the caller's httpHeaders, straight off an attachment
|
||||
if ((0, objects_js_1.isProtoKey)(key.toLowerCase().trim())) {
|
||||
return;
|
||||
}
|
||||
headers[key.toLowerCase().trim()] = options.headers[key];
|
||||
});
|
||||
if (options.userAgent) {
|
||||
headers['user-agent'] = options.userAgent;
|
||||
}
|
||||
if (parsed.auth) {
|
||||
headers.Authorization = 'Basic ' + Buffer.from(parsed.auth).toString('base64');
|
||||
}
|
||||
if ((cookies = options.cookies.get(url))) {
|
||||
headers.cookie = cookies;
|
||||
}
|
||||
if (options.body) {
|
||||
if (options.contentType !== false) {
|
||||
headers['Content-Type'] = options.contentType || 'application/x-www-form-urlencoded';
|
||||
}
|
||||
if (typeof options.body.pipe === 'function') {
|
||||
// it's a stream
|
||||
headers['Transfer-Encoding'] = 'chunked';
|
||||
body = options.body;
|
||||
body.on('error', (err) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
});
|
||||
}
|
||||
else {
|
||||
if (options.body instanceof Buffer) {
|
||||
body = options.body;
|
||||
}
|
||||
else if (typeof options.body === 'object') {
|
||||
try {
|
||||
// encodeURIComponent can fail on invalid input (partial emoji etc.)
|
||||
body = Buffer.from(Object.keys(options.body)
|
||||
.map(key => {
|
||||
const value = options.body[key].toString().trim();
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(value);
|
||||
})
|
||||
.join('&'));
|
||||
}
|
||||
catch (E) {
|
||||
if (finished) {
|
||||
return undefined;
|
||||
}
|
||||
finished = true;
|
||||
E.code = errors.EFETCH;
|
||||
E.sourceUrl = url;
|
||||
fetchRes.emit('error', E);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
else {
|
||||
body = Buffer.from(options.body.toString().trim());
|
||||
}
|
||||
headers['Content-Type'] = options.contentType || 'application/x-www-form-urlencoded';
|
||||
headers['Content-Length'] = body.length;
|
||||
}
|
||||
// if method is not provided, use POST instead of GET
|
||||
method = (options.method || '').toString().trim().toUpperCase() || 'POST';
|
||||
}
|
||||
let req;
|
||||
const reqOptions = {
|
||||
method,
|
||||
host: parsed.hostname,
|
||||
path: parsed.path,
|
||||
port: parsed.port ? parsed.port : parsed.protocol === 'https:' ? 443 : 80,
|
||||
headers,
|
||||
// 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
|
||||
};
|
||||
if (options.tls) {
|
||||
// see TLS_OPTION_KEYS
|
||||
Object.keys(options.tls).forEach(key => {
|
||||
if (TLS_OPTION_KEYS.includes(key)) {
|
||||
reqOptions[key] = options.tls[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
if (parsed.protocol === 'https:' &&
|
||||
parsed.hostname &&
|
||||
parsed.hostname !== reqOptions.host &&
|
||||
!node_net_1.default.isIP(parsed.hostname) &&
|
||||
!reqOptions.servername) {
|
||||
reqOptions.servername = parsed.hostname;
|
||||
}
|
||||
try {
|
||||
req = handler.request(reqOptions);
|
||||
}
|
||||
catch (E) {
|
||||
finished = true;
|
||||
setImmediate(() => {
|
||||
E.code = errors.EFETCH;
|
||||
E.sourceUrl = url;
|
||||
fetchRes.emit('error', E);
|
||||
});
|
||||
return fetchRes;
|
||||
}
|
||||
if (options.timeout) {
|
||||
req.setTimeout(options.timeout, () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
req.abort();
|
||||
const err = new Error('Request Timeout');
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
});
|
||||
}
|
||||
req.on('error', (err) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
});
|
||||
req.on('response', res => {
|
||||
let inflate;
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
switch (res.headers['content-encoding']) {
|
||||
case 'gzip':
|
||||
case 'deflate':
|
||||
inflate = node_zlib_1.default.createUnzip();
|
||||
break;
|
||||
}
|
||||
if (res.headers['set-cookie']) {
|
||||
[].concat(res.headers['set-cookie'] || []).forEach(cookie => {
|
||||
options.cookies.set(cookie, url);
|
||||
});
|
||||
}
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
// redirect
|
||||
options.redirects++;
|
||||
if (options.redirects > options.maxRedirects) {
|
||||
finished = true;
|
||||
const err = new Error('Maximum redirect count exceeded');
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
req.abort();
|
||||
return;
|
||||
}
|
||||
// redirect does not include POST body
|
||||
options.method = 'GET';
|
||||
options.body = false;
|
||||
let redirectUrl;
|
||||
try {
|
||||
redirectUrl = urllib.resolve(url, res.headers.location);
|
||||
}
|
||||
catch (_err) {
|
||||
// the legacy resolver throws on a Location the WHATWG parser also refused,
|
||||
// so fall through to the check below with what the server actually sent
|
||||
redirectUrl = res.headers.location;
|
||||
}
|
||||
const redirectParsed = parseFetchUrl(redirectUrl);
|
||||
if (!redirectParsed) {
|
||||
// Refuse the redirect target here rather than leaving it to the recursive
|
||||
// call: that call gets its own `finished` flag and no handle on this
|
||||
// request, so this one would stay open and could emit a second error on
|
||||
// the shared fetchRes once it times out. Callers listen with req.once().
|
||||
finished = true;
|
||||
const err = new Error('Unsupported protocol for URL ' + redirectUrl);
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = redirectUrl;
|
||||
fetchRes.emit('error', err);
|
||||
req.abort();
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
fetchRes.headers = res.headers;
|
||||
if (res.statusCode >= 300 && !options.allowErrorResponse) {
|
||||
finished = true;
|
||||
const err = new Error('Invalid status code ' + res.statusCode);
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
req.abort();
|
||||
return;
|
||||
}
|
||||
res.on('error', (err) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
req.abort();
|
||||
});
|
||||
if (inflate) {
|
||||
res.pipe(inflate).pipe(fetchRes);
|
||||
inflate.on('error', (err) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
req.abort();
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.pipe(fetchRes);
|
||||
}
|
||||
});
|
||||
setImmediate(() => {
|
||||
if (body) {
|
||||
try {
|
||||
if (typeof body.pipe === 'function') {
|
||||
return body.pipe(req);
|
||||
}
|
||||
req.write(body);
|
||||
}
|
||||
catch (err) {
|
||||
finished = true;
|
||||
err.code = errors.EFETCH;
|
||||
err.sourceUrl = url;
|
||||
fetchRes.emit('error', err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
return fetchRes;
|
||||
}
|
||||
nmfetch.Cookies = cookies_js_1.default;
|
||||
exports.default = nmfetch;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import type { Logger } from '../shared/index.js';
|
||||
import type { MimeNodeEnvelope } from '../mime-node/index.js';
|
||||
import type { default as MailMessage, MailMessageData } from '../mailer/mail-message.js';
|
||||
import type { default as Mail, SentMessageInfo, SendMailOptions, TransportOptions } from '../mailer/index.js';
|
||||
/**
|
||||
* Options for the JSON transport
|
||||
*/
|
||||
export interface JSONTransportOptions extends TransportOptions {
|
||||
/** Selects this transport in createTransport */
|
||||
jsonTransport?: boolean | undefined;
|
||||
/** If true, the message is returned as an object instead of a JSON string */
|
||||
skipEncoding?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* The value the JSON transport hands to the send callback
|
||||
*/
|
||||
export interface JSONSentMessageInfo extends SentMessageInfo {
|
||||
/** The envelope the message was generated with */
|
||||
envelope: MimeNodeEnvelope;
|
||||
/** Message-ID value of the message */
|
||||
messageId: string;
|
||||
/** The normalized message as a JSON string, or as the object itself when skipEncoding is set */
|
||||
message: string | MailMessageData;
|
||||
}
|
||||
/**
|
||||
* Generates a Transport object to generate JSON output
|
||||
*
|
||||
* @constructor
|
||||
* @param optional config parameter
|
||||
*/
|
||||
declare class JSONTransport {
|
||||
mailer: Mail<JSONSentMessageInfo>;
|
||||
options: JSONTransportOptions;
|
||||
name: string;
|
||||
version: string;
|
||||
logger: Logger;
|
||||
constructor(options?: JSONTransportOptions);
|
||||
/**
|
||||
* <p>Compiles a mailcomposer message and forwards it to handler that sends it.</p>
|
||||
*
|
||||
* @param mail MailComposer object
|
||||
* @param done Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail: MailMessage<JSONSentMessageInfo>, done: (err: Error | null, info?: JSONSentMessageInfo) => void): void;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `JSONTransport.Options` style references keep working
|
||||
*/
|
||||
declare namespace JSONTransport {
|
||||
type Options = JSONTransportOptions;
|
||||
type MailOptions = SendMailOptions;
|
||||
type SentMessageInfo = JSONSentMessageInfo;
|
||||
}
|
||||
export default JSONTransport;
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const packageData = __importStar(require("../package-info.js"));
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
/**
|
||||
* Generates a Transport object to generate JSON output
|
||||
*
|
||||
* @constructor
|
||||
* @param optional config parameter
|
||||
*/
|
||||
class JSONTransport {
|
||||
constructor(options) {
|
||||
options = options || {};
|
||||
this.options = options;
|
||||
this.name = 'JSONTransport';
|
||||
this.version = packageData.version;
|
||||
this.logger = shared.getLogger(this.options, {
|
||||
component: this.options.component || 'json-transport'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* <p>Compiles a mailcomposer message and forwards it to handler that sends it.</p>
|
||||
*
|
||||
* @param mail MailComposer object
|
||||
* @param done Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail, done) {
|
||||
// Sendmail strips this header line by itself. send() runs after the message was
|
||||
// compiled, so mail.message is set
|
||||
mail.message.keepBcc = true;
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
if (recipients.length > 3) {
|
||||
recipients.push('...and ' + recipients.splice(2).length + ' more');
|
||||
}
|
||||
this.logger.info({
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Composing JSON structure of %s to <%s>', messageId, recipients.join(', '));
|
||||
setImmediate(() => {
|
||||
mail.normalize((err, data) => {
|
||||
if (err) {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Failed building JSON structure for %s. %s', messageId, err.message);
|
||||
return done(err);
|
||||
}
|
||||
delete data.envelope;
|
||||
delete data.normalizedHeaders;
|
||||
return done(null, {
|
||||
envelope,
|
||||
messageId,
|
||||
message: this.options.skipEncoding ? data : JSON.stringify(data)
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
exports.default = JSONTransport;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import MimeNode, { type MimeNodeAddressInput, type MimeNodeContent, type MimeNodeEnvelopeInput, type MimeNodeHeaders, type MimeNodeOptions } from '../mime-node/index.js';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type { OutgoingHttpHeaders } from 'node:http';
|
||||
/**
|
||||
* A content element, the shape of the text, html, watchHtml, amp and alternatives values.
|
||||
* The content is either given in `content`, read from `path` or `href`, or `raw` supplies
|
||||
* a pregenerated MIME part
|
||||
*/
|
||||
export interface MailComposerAlternative {
|
||||
/** The content itself: a string, a Buffer, a readable stream or a content descriptor */
|
||||
content?: MimeNodeContent | undefined;
|
||||
/** File path, data URI or http(s) URL to read the content from */
|
||||
path?: string | false | undefined;
|
||||
/** URL or data URI to fetch the content from */
|
||||
href?: string | false | undefined;
|
||||
/** Request headers for a URL fetch */
|
||||
httpHeaders?: OutgoingHttpHeaders | undefined;
|
||||
/** TLS settings for a URL fetch, see nmfetch */
|
||||
tls?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
/** Encoding of a string `content`, decoded into a Buffer unless it is utf8 or ascii */
|
||||
encoding?: string | undefined;
|
||||
/** Pregenerated MIME part, used as is instead of building the node */
|
||||
raw?: MimeNodeContent | undefined;
|
||||
/** Content type, detected from the filename, path or URL when not set */
|
||||
contentType?: string | undefined;
|
||||
/** Content-Transfer-Encoding for the node, false leaves the choice to the node */
|
||||
contentTransferEncoding?: string | false | undefined;
|
||||
/** Filename for the node, false suppresses the generated one */
|
||||
filename?: string | false | undefined;
|
||||
/** Additional headers for the node */
|
||||
headers?: MimeNodeHeaders | undefined;
|
||||
}
|
||||
/**
|
||||
* An attachment, the shape of the attachments entries
|
||||
*/
|
||||
export interface MailComposerAttachment extends MailComposerAlternative {
|
||||
/** Content-Disposition for the node, 'attachment' by default and 'inline' for a message node or an image with a cid */
|
||||
contentDisposition?: string | undefined;
|
||||
/** Content-Id for an embedded image, moves the attachment into the multipart/related node beside the html */
|
||||
cid?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* The icalEvent value as an object
|
||||
*/
|
||||
export interface MailComposerIcalEvent extends MailComposerAlternative {
|
||||
/** iCalendar method, PUBLISH by default */
|
||||
method?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* The attachments as sorted by getAttachments
|
||||
*/
|
||||
export interface MailComposerAttachments {
|
||||
/** Attachments for the multipart/mixed node, the icalEvent attachment included */
|
||||
attached: MailComposerAttachment[];
|
||||
/** Attachments with a cid, embedded in the multipart/related node beside the html */
|
||||
related: MailComposerAttachment[];
|
||||
}
|
||||
/**
|
||||
* A List-* header entry, a bare url or an url with a comment
|
||||
*/
|
||||
export type MailComposerListHeaderEntry = string | {
|
||||
url: string;
|
||||
comment?: string | undefined;
|
||||
};
|
||||
/**
|
||||
* The list value: List-* headers keyed by the part after "List-" (help, unsubscribe,
|
||||
* subscribe, post, owner, archive, id). An array emits one header per entry, an entry that
|
||||
* is itself an array joins its values into one header
|
||||
*/
|
||||
export interface MailComposerListHeaders {
|
||||
[key: string]: MailComposerListHeaderEntry | (MailComposerListHeaderEntry | MailComposerListHeaderEntry[])[];
|
||||
}
|
||||
/**
|
||||
* Mail options, the message data MailComposer builds the MIME tree from. The address
|
||||
* fields, subject, messageId, date, inReplyTo and references become headers of the root
|
||||
* node. Some fields are read by the mailer that hands the data to MailComposer rather than
|
||||
* by MailComposer itself, these are marked as such
|
||||
*/
|
||||
export interface MailComposerOptions {
|
||||
from?: MimeNodeAddressInput | undefined;
|
||||
sender?: MimeNodeAddressInput | undefined;
|
||||
to?: MimeNodeAddressInput | undefined;
|
||||
cc?: MimeNodeAddressInput | undefined;
|
||||
bcc?: MimeNodeAddressInput | undefined;
|
||||
replyTo?: MimeNodeAddressInput | undefined;
|
||||
inReplyTo?: string | undefined;
|
||||
references?: string | string[] | undefined;
|
||||
subject?: string | undefined;
|
||||
/** Message-ID header value, generated when missing */
|
||||
messageId?: string | undefined;
|
||||
/** Date header value, the current time when missing */
|
||||
date?: Date | string | undefined;
|
||||
/** Plaintext version of the message */
|
||||
text?: string | Buffer | Readable | MailComposerAlternative | undefined;
|
||||
/** HTML version of the message */
|
||||
html?: string | Buffer | Readable | MailComposerAlternative | undefined;
|
||||
/** Apple Watch specific HTML version of the message */
|
||||
watchHtml?: string | Buffer | Readable | MailComposerAlternative | undefined;
|
||||
/** AMP4EMAIL version of the message */
|
||||
amp?: string | Buffer | Readable | MailComposerAlternative | undefined;
|
||||
/** iCalendar event, included both as a text/calendar alternative and as an application/ics attachment */
|
||||
icalEvent?: string | Buffer | Readable | MailComposerIcalEvent | undefined;
|
||||
attachments?: MailComposerAttachment[] | undefined;
|
||||
/** Further alternatives for the multipart/alternative node, after text, watchHtml, amp, html and the calendar event */
|
||||
alternatives?: MailComposerAlternative[] | undefined;
|
||||
/** Custom headers for the root node, the standard headers above override them */
|
||||
headers?: MimeNodeHeaders | undefined;
|
||||
/** List-* headers, read by the mailer */
|
||||
list?: MailComposerListHeaders | undefined;
|
||||
/** SMTP envelope to use instead of the one generated from the headers */
|
||||
envelope?: MimeNodeEnvelopeInput | undefined;
|
||||
/** Content-Transfer-Encoding to force for the text/* nodes that do not set their own */
|
||||
encoding?: string | undefined;
|
||||
/** Header string encoding, 'Q' (the default) or 'B', 'quoted-printable' and 'base64' are accepted as well */
|
||||
textEncoding?: string | undefined;
|
||||
/** Pregenerated rfc822 message, used as is instead of building one */
|
||||
raw?: MimeNodeContent | undefined;
|
||||
/** Reject content that points to a URL */
|
||||
disableUrlAccess?: boolean | undefined;
|
||||
/** Reject content that points to a file path */
|
||||
disableFileAccess?: boolean | undefined;
|
||||
/** Convert data: images in the html into embedded attachments, read by the mailer */
|
||||
attachDataUrls?: boolean | undefined;
|
||||
/** Prefix for the generated multipart boundaries */
|
||||
boundaryPrefix?: string | undefined;
|
||||
/** Shared part of the unique multipart boundary */
|
||||
baseBoundary?: string | undefined;
|
||||
/** 'win' for CRLF and 'linux' for LF line breaks in the generated message, kept as is when not set */
|
||||
newline?: string | undefined;
|
||||
/** Keep the Bcc header in the generated message, listed for completeness, the transports set it on the message directly */
|
||||
keepBcc?: boolean | undefined;
|
||||
/** Method to normalize header keys for custom caseing */
|
||||
normalizeHeaderKey?: MimeNodeOptions['normalizeHeaderKey'] | undefined;
|
||||
/** 'high', 'normal' or 'low', sets the priority headers, read by the mailer */
|
||||
priority?: string | undefined;
|
||||
/** X-Mailer header value, false leaves the header out, read by the mailer */
|
||||
xMailer?: string | false | undefined;
|
||||
}
|
||||
declare class MailComposer {
|
||||
mail: MailComposerOptions;
|
||||
message: MimeNode | false;
|
||||
constructor(mail?: MailComposerOptions);
|
||||
/**
|
||||
* Builds MimeNode instance
|
||||
*/
|
||||
compile(): MimeNode;
|
||||
/**
|
||||
* List all attachments. Resulting attachment objects can be used as input for MimeNode nodes
|
||||
*
|
||||
* @param findRelated If true separate related attachments from attached ones
|
||||
* @returns An object of arrays (`related` and `attached`)
|
||||
*/
|
||||
getAttachments(findRelated?: boolean): MailComposerAttachments;
|
||||
/**
|
||||
* List alternatives. Resulting objects can be used as input for MimeNode nodes
|
||||
*
|
||||
* @returns An array of alternative elements. Includes the `text` and `html` values as well
|
||||
*/
|
||||
getAlternatives(): MailComposerAlternative[];
|
||||
}
|
||||
/**
|
||||
* Type alias in the layout of the other modules (@types/nodemailer had no namespace for MailComposer)
|
||||
*/
|
||||
declare namespace MailComposer {
|
||||
type Options = MailComposerOptions;
|
||||
}
|
||||
export default MailComposer;
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
"use strict";
|
||||
/* eslint no-undefined: 0 */
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const index_js_1 = __importDefault(require("../mime-node/index.js"));
|
||||
const mimeFuncs = __importStar(require("../mime-funcs/index.js"));
|
||||
const index_js_2 = require("../shared/index.js");
|
||||
/**
|
||||
* Creates the object for composing a MimeNode instance out from the mail options
|
||||
*
|
||||
* @constructor
|
||||
* @param mail Mail options
|
||||
*/
|
||||
/**
|
||||
* Tells whether a content value is a content descriptor object (something to load or to
|
||||
* use as is) rather than the content itself
|
||||
*/
|
||||
function isContentObject(value) {
|
||||
const content = value;
|
||||
return typeof value === 'object' && !!(content.content || content.path || content.href || content.raw);
|
||||
}
|
||||
class MailComposer {
|
||||
constructor(mail) {
|
||||
this.mail = mail || {};
|
||||
this.message = false;
|
||||
}
|
||||
/**
|
||||
* Builds MimeNode instance
|
||||
*/
|
||||
compile() {
|
||||
this._alternatives = this.getAlternatives();
|
||||
this._htmlNode = this._alternatives.filter(alternative => /^text\/html\b/i.test(alternative.contentType)).pop();
|
||||
this._attachments = this.getAttachments(!!this._htmlNode);
|
||||
this._useRelated = !!(this._htmlNode && this._attachments.related.length);
|
||||
this._useAlternative = this._alternatives.length > 1;
|
||||
this._useMixed = this._attachments.attached.length > 1 || (this._alternatives.length && this._attachments.attached.length === 1);
|
||||
// Compose MIME tree
|
||||
if (this.mail.raw) {
|
||||
this.message = new index_js_1.default('message/rfc822', {
|
||||
newline: this.mail.newline,
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess
|
||||
}).setRaw(this.mail.raw);
|
||||
}
|
||||
else if (this._useMixed) {
|
||||
this.message = this._createMixed();
|
||||
}
|
||||
else if (this._useAlternative) {
|
||||
this.message = this._createAlternative();
|
||||
}
|
||||
else if (this._useRelated) {
|
||||
this.message = this._createRelated();
|
||||
}
|
||||
else {
|
||||
this.message = this._createContentNode(false, []
|
||||
.concat(this._alternatives || [])
|
||||
.concat(this._attachments.attached || [])
|
||||
.shift() || {
|
||||
contentType: 'text/plain',
|
||||
content: ''
|
||||
});
|
||||
}
|
||||
// Add custom headers
|
||||
if (this.mail.headers) {
|
||||
this.message.addHeader(this.mail.headers);
|
||||
}
|
||||
// Add headers to the root node, always overrides custom headers
|
||||
['from', 'sender', 'to', 'cc', 'bcc', 'reply-to', 'in-reply-to', 'references', 'subject', 'message-id', 'date'].forEach(header => {
|
||||
const key = header.replace(/-(\w)/g, (o, c) => c.toUpperCase());
|
||||
if (this.mail[key]) {
|
||||
this.message.setHeader(header, this.mail[key]);
|
||||
}
|
||||
});
|
||||
// Sets custom envelope
|
||||
if (this.mail.envelope) {
|
||||
this.message.setEnvelope(this.mail.envelope);
|
||||
}
|
||||
// ensure Message-Id value
|
||||
this.message.messageId();
|
||||
return this.message;
|
||||
}
|
||||
/**
|
||||
* List all attachments. Resulting attachment objects can be used as input for MimeNode nodes
|
||||
*
|
||||
* @param findRelated If true separate related attachments from attached ones
|
||||
* @returns An object of arrays (`related` and `attached`)
|
||||
*/
|
||||
getAttachments(findRelated) {
|
||||
let eventObject;
|
||||
const attachments = [].concat(this.mail.attachments || []).map((attachment, i) => {
|
||||
if (/^data:/i.test((attachment.path || attachment.href))) {
|
||||
attachment = this._processDataUrl(attachment);
|
||||
}
|
||||
const contentType = attachment.contentType || mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin');
|
||||
const isImage = /^image\//i.test(contentType);
|
||||
const isMessageNode = /^message\//i.test(contentType);
|
||||
const contentDisposition = attachment.contentDisposition || (isMessageNode || (isImage && attachment.cid) ? 'inline' : 'attachment');
|
||||
let contentTransferEncoding;
|
||||
if ('contentTransferEncoding' in attachment) {
|
||||
// also contains `false`, to set
|
||||
contentTransferEncoding = attachment.contentTransferEncoding;
|
||||
}
|
||||
else if (isMessageNode) {
|
||||
// the content might include non-ASCII bytes but at this point we do not know it yet
|
||||
contentTransferEncoding = '8bit';
|
||||
}
|
||||
else {
|
||||
contentTransferEncoding = 'base64'; // the default
|
||||
}
|
||||
const data = {
|
||||
contentType,
|
||||
contentDisposition,
|
||||
contentTransferEncoding
|
||||
};
|
||||
if (attachment.filename) {
|
||||
data.filename = attachment.filename;
|
||||
}
|
||||
else if (!isMessageNode && attachment.filename !== false) {
|
||||
data.filename =
|
||||
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1);
|
||||
if (data.filename.indexOf('.') < 0) {
|
||||
data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
|
||||
}
|
||||
}
|
||||
if (/^https?:\/\//i.test(attachment.path)) {
|
||||
attachment.href = attachment.path;
|
||||
attachment.path = undefined;
|
||||
}
|
||||
if (attachment.cid) {
|
||||
data.cid = attachment.cid;
|
||||
}
|
||||
if (attachment.raw) {
|
||||
data.raw = attachment.raw;
|
||||
}
|
||||
else if (attachment.path) {
|
||||
data.content = {
|
||||
path: attachment.path
|
||||
};
|
||||
}
|
||||
else if (attachment.href) {
|
||||
data.content = {
|
||||
href: attachment.href,
|
||||
httpHeaders: attachment.httpHeaders,
|
||||
tls: attachment.tls
|
||||
};
|
||||
}
|
||||
else {
|
||||
data.content = attachment.content || '';
|
||||
}
|
||||
if (attachment.encoding) {
|
||||
data.encoding = attachment.encoding;
|
||||
}
|
||||
if (attachment.headers) {
|
||||
data.headers = attachment.headers;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
if (this.mail.icalEvent) {
|
||||
eventObject = Object.assign({}, this._getIcalEvent());
|
||||
eventObject.contentType = 'application/ics';
|
||||
if (!eventObject.headers) {
|
||||
eventObject.headers = {};
|
||||
}
|
||||
eventObject.filename = eventObject.filename || 'invite.ics';
|
||||
eventObject.headers['Content-Disposition'] = 'attachment';
|
||||
eventObject.headers['Content-Transfer-Encoding'] = 'base64';
|
||||
}
|
||||
if (!findRelated) {
|
||||
return {
|
||||
attached: attachments.concat(eventObject || []),
|
||||
related: []
|
||||
};
|
||||
}
|
||||
return {
|
||||
attached: attachments.filter(attachment => !attachment.cid).concat(eventObject || []),
|
||||
related: attachments.filter(attachment => !!attachment.cid)
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 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 Normalized icalEvent data
|
||||
* @internal
|
||||
*/
|
||||
_getIcalEvent() {
|
||||
if (!this._icalEvent) {
|
||||
let icalEvent;
|
||||
if (isContentObject(this.mail.icalEvent)) {
|
||||
// an own "__proto__" key would make the copy inherit path/href from caller
|
||||
// data, and the mapping below then replaces the content the caller did set
|
||||
icalEvent = (0, index_js_2.copyOwnKeys)({}, 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,
|
||||
tls: icalEvent.tls
|
||||
};
|
||||
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
|
||||
*
|
||||
* @returns An array of alternative elements. Includes the `text` and `html` values as well
|
||||
*/
|
||||
getAlternatives() {
|
||||
const alternatives = [];
|
||||
let text, html, watchHtml, amp, eventObject;
|
||||
if (this.mail.text) {
|
||||
if (isContentObject(this.mail.text)) {
|
||||
text = this.mail.text;
|
||||
}
|
||||
else {
|
||||
text = {
|
||||
content: this.mail.text
|
||||
};
|
||||
}
|
||||
text.contentType = 'text/plain; charset=utf-8';
|
||||
}
|
||||
if (this.mail.watchHtml) {
|
||||
if (isContentObject(this.mail.watchHtml)) {
|
||||
watchHtml = this.mail.watchHtml;
|
||||
}
|
||||
else {
|
||||
watchHtml = {
|
||||
content: this.mail.watchHtml
|
||||
};
|
||||
}
|
||||
watchHtml.contentType = 'text/watch-html; charset=utf-8';
|
||||
}
|
||||
if (this.mail.amp) {
|
||||
if (isContentObject(this.mail.amp)) {
|
||||
amp = this.mail.amp;
|
||||
}
|
||||
else {
|
||||
amp = {
|
||||
content: this.mail.amp
|
||||
};
|
||||
}
|
||||
amp.contentType = 'text/x-amp-html; charset=utf-8';
|
||||
}
|
||||
// NB! when including attachments with a calendar alternative you might end up in a blank screen on some clients
|
||||
if (this.mail.icalEvent) {
|
||||
eventObject = Object.assign({}, this._getIcalEvent());
|
||||
eventObject.filename = false;
|
||||
eventObject.contentType =
|
||||
'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
|
||||
if (!eventObject.headers) {
|
||||
eventObject.headers = {};
|
||||
}
|
||||
}
|
||||
if (this.mail.html) {
|
||||
if (isContentObject(this.mail.html)) {
|
||||
html = this.mail.html;
|
||||
}
|
||||
else {
|
||||
html = {
|
||||
content: this.mail.html
|
||||
};
|
||||
}
|
||||
html.contentType = 'text/html; charset=utf-8';
|
||||
}
|
||||
[]
|
||||
.concat(text || [])
|
||||
.concat(watchHtml || [])
|
||||
.concat(amp || [])
|
||||
.concat(html || [])
|
||||
.concat(eventObject || [])
|
||||
.concat(this.mail.alternatives || [])
|
||||
.forEach(alternative => {
|
||||
if (/^data:/i.test((alternative.path || alternative.href))) {
|
||||
alternative = this._processDataUrl(alternative);
|
||||
}
|
||||
const data = {
|
||||
contentType: alternative.contentType ||
|
||||
mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
|
||||
contentTransferEncoding: alternative.contentTransferEncoding
|
||||
};
|
||||
if (alternative.filename) {
|
||||
data.filename = alternative.filename;
|
||||
}
|
||||
if (/^https?:\/\//i.test(alternative.path)) {
|
||||
alternative.href = alternative.path;
|
||||
alternative.path = undefined;
|
||||
}
|
||||
if (alternative.raw) {
|
||||
data.raw = alternative.raw;
|
||||
}
|
||||
else if (alternative.path) {
|
||||
data.content = {
|
||||
path: alternative.path
|
||||
};
|
||||
}
|
||||
else if (alternative.href) {
|
||||
data.content = {
|
||||
href: alternative.href,
|
||||
httpHeaders: alternative.httpHeaders,
|
||||
tls: alternative.tls
|
||||
};
|
||||
}
|
||||
else {
|
||||
data.content = alternative.content || '';
|
||||
}
|
||||
if (alternative.encoding) {
|
||||
data.encoding = alternative.encoding;
|
||||
}
|
||||
if (alternative.headers) {
|
||||
data.headers = alternative.headers;
|
||||
}
|
||||
alternatives.push(data);
|
||||
});
|
||||
return alternatives;
|
||||
}
|
||||
/**
|
||||
* Builds multipart/mixed node. It should always contain different type of elements on the same level
|
||||
* eg. text + attachments
|
||||
*
|
||||
* @param parentNode Parent for this note. If it does not exist, a root node is created
|
||||
* @returns MimeNode node element
|
||||
* @internal
|
||||
*/
|
||||
_createMixed(parentNode) {
|
||||
const node = parentNode
|
||||
? parentNode.createChild('multipart/mixed', {
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess,
|
||||
normalizeHeaderKey: this.mail.normalizeHeaderKey,
|
||||
newline: this.mail.newline
|
||||
})
|
||||
: new index_js_1.default('multipart/mixed', {
|
||||
baseBoundary: this.mail.baseBoundary,
|
||||
textEncoding: this.mail.textEncoding,
|
||||
boundaryPrefix: this.mail.boundaryPrefix,
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess,
|
||||
normalizeHeaderKey: this.mail.normalizeHeaderKey,
|
||||
newline: this.mail.newline
|
||||
});
|
||||
if (this._useAlternative) {
|
||||
this._createAlternative(node);
|
||||
}
|
||||
else if (this._useRelated) {
|
||||
this._createRelated(node);
|
||||
}
|
||||
[]
|
||||
.concat((!this._useAlternative && this._alternatives) || [])
|
||||
.concat(this._attachments.attached || [])
|
||||
.forEach(element => {
|
||||
// if the element is a html node from related subpart then ignore it
|
||||
if (!this._useRelated || element !== this._htmlNode) {
|
||||
this._createContentNode(node, element);
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
/**
|
||||
* Builds multipart/alternative node. It should always contain same type of elements on the same level
|
||||
* eg. text + html view of the same data
|
||||
*
|
||||
* @param parentNode Parent for this note. If it does not exist, a root node is created
|
||||
* @returns MimeNode node element
|
||||
* @internal
|
||||
*/
|
||||
_createAlternative(parentNode) {
|
||||
const node = parentNode
|
||||
? parentNode.createChild('multipart/alternative', {
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess,
|
||||
normalizeHeaderKey: this.mail.normalizeHeaderKey,
|
||||
newline: this.mail.newline
|
||||
})
|
||||
: new index_js_1.default('multipart/alternative', {
|
||||
baseBoundary: this.mail.baseBoundary,
|
||||
textEncoding: this.mail.textEncoding,
|
||||
boundaryPrefix: this.mail.boundaryPrefix,
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess,
|
||||
normalizeHeaderKey: this.mail.normalizeHeaderKey,
|
||||
newline: this.mail.newline
|
||||
});
|
||||
this._alternatives.forEach(alternative => {
|
||||
if (this._useRelated && this._htmlNode === alternative) {
|
||||
this._createRelated(node);
|
||||
}
|
||||
else {
|
||||
this._createContentNode(node, alternative);
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
/**
|
||||
* Builds multipart/related node. It should always contain html node with related attachments
|
||||
*
|
||||
* @param parentNode Parent for this note. If it does not exist, a root node is created
|
||||
* @returns MimeNode node element
|
||||
* @internal
|
||||
*/
|
||||
_createRelated(parentNode) {
|
||||
const node = parentNode
|
||||
? parentNode.createChild('multipart/related; type="text/html"', {
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess,
|
||||
normalizeHeaderKey: this.mail.normalizeHeaderKey,
|
||||
newline: this.mail.newline
|
||||
})
|
||||
: new index_js_1.default('multipart/related; type="text/html"', {
|
||||
baseBoundary: this.mail.baseBoundary,
|
||||
textEncoding: this.mail.textEncoding,
|
||||
boundaryPrefix: this.mail.boundaryPrefix,
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess,
|
||||
normalizeHeaderKey: this.mail.normalizeHeaderKey,
|
||||
newline: this.mail.newline
|
||||
});
|
||||
this._createContentNode(node, this._htmlNode);
|
||||
this._attachments.related.forEach(alternative => this._createContentNode(node, alternative));
|
||||
return node;
|
||||
}
|
||||
/**
|
||||
* Creates a regular node with contents
|
||||
*
|
||||
* @param parentNode Parent for this note. If it does not exist, a root node is created
|
||||
* @param element Node data
|
||||
* @returns MimeNode node element
|
||||
* @internal
|
||||
*/
|
||||
_createContentNode(parentNode, element) {
|
||||
element = element || {};
|
||||
element.content = element.content || '';
|
||||
const encoding = (element.encoding || 'utf8')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.replace(/[-_\s]/g, '');
|
||||
const node = parentNode
|
||||
? parentNode.createChild(element.contentType, {
|
||||
filename: element.filename,
|
||||
textEncoding: this.mail.textEncoding,
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess,
|
||||
normalizeHeaderKey: this.mail.normalizeHeaderKey,
|
||||
newline: this.mail.newline
|
||||
})
|
||||
: new index_js_1.default(element.contentType, {
|
||||
filename: element.filename,
|
||||
baseBoundary: this.mail.baseBoundary,
|
||||
textEncoding: this.mail.textEncoding,
|
||||
boundaryPrefix: this.mail.boundaryPrefix,
|
||||
disableUrlAccess: this.mail.disableUrlAccess,
|
||||
disableFileAccess: this.mail.disableFileAccess,
|
||||
normalizeHeaderKey: this.mail.normalizeHeaderKey,
|
||||
newline: this.mail.newline
|
||||
});
|
||||
// add custom headers
|
||||
if (element.headers) {
|
||||
node.addHeader(element.headers);
|
||||
}
|
||||
if (element.cid) {
|
||||
node.setHeader('Content-Id', '<' + element.cid.replace(/[<>]/g, '') + '>');
|
||||
}
|
||||
if (element.contentTransferEncoding) {
|
||||
node.setHeader('Content-Transfer-Encoding', element.contentTransferEncoding);
|
||||
}
|
||||
else if (this.mail.encoding && /^text\//i.test(element.contentType)) {
|
||||
node.setHeader('Content-Transfer-Encoding', this.mail.encoding);
|
||||
}
|
||||
if (!/^text\//i.test(element.contentType) || element.contentDisposition) {
|
||||
node.setHeader('Content-Disposition', element.contentDisposition || (element.cid && /^image\//i.test(element.contentType) ? 'inline' : 'attachment'));
|
||||
}
|
||||
if (typeof element.content === 'string' && !['utf8', 'usascii', 'ascii'].includes(encoding)) {
|
||||
element.content = Buffer.from(element.content, encoding);
|
||||
}
|
||||
// prefer pregenerated raw content
|
||||
if (element.raw) {
|
||||
node.setRaw(element.raw);
|
||||
}
|
||||
else {
|
||||
node.setContent(element.content);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
/**
|
||||
* Parses data uri and converts it to a Buffer
|
||||
*
|
||||
* @param element Content element
|
||||
* @return Parsed element
|
||||
* @internal
|
||||
*/
|
||||
_processDataUrl(element) {
|
||||
const dataUrl = element.path || element.href;
|
||||
// Early validation to prevent ReDoS
|
||||
if (!dataUrl || typeof dataUrl !== 'string') {
|
||||
return element;
|
||||
}
|
||||
if (!dataUrl.startsWith('data:')) {
|
||||
return element;
|
||||
}
|
||||
if (dataUrl.length > 52428800) {
|
||||
// 52428800 chars = 50MB limit for data URL string (~37.5MB decoded image)
|
||||
// Extract content type before rejecting to preserve MIME type
|
||||
let detectedType = 'application/octet-stream';
|
||||
const commaPos = dataUrl.indexOf(',');
|
||||
if (commaPos > 0 && commaPos < 200) {
|
||||
// Parse header safely with size limit
|
||||
const header = dataUrl.substring(5, commaPos); // skip 'data:'
|
||||
const parts = header.split(';');
|
||||
if (parts[0] && parts[0].includes('/')) {
|
||||
detectedType = parts[0].trim();
|
||||
}
|
||||
}
|
||||
// Return empty content for excessively long data URLs
|
||||
return Object.assign((0, index_js_2.copyOwnKeys)({}, element), {
|
||||
path: false,
|
||||
href: false,
|
||||
content: Buffer.alloc(0),
|
||||
contentType: element.contentType || detectedType
|
||||
});
|
||||
}
|
||||
let parsedDataUri;
|
||||
try {
|
||||
parsedDataUri = (0, index_js_2.parseDataURI)(dataUrl);
|
||||
}
|
||||
catch (_err) {
|
||||
return element;
|
||||
}
|
||||
if (!parsedDataUri) {
|
||||
return element;
|
||||
}
|
||||
element.content = parsedDataUri.data;
|
||||
element.contentType = element.contentType || parsedDataUri.contentType;
|
||||
if ('path' in element) {
|
||||
element.path = false;
|
||||
}
|
||||
if ('href' in element) {
|
||||
element.href = false;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
}
|
||||
exports.default = MailComposer;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import * as shared from '../shared/index.js';
|
||||
import DKIM, { type DKIMOptions } from '../dkim/index.js';
|
||||
import MailMessage, { type MailDefaults, type SendMailOptions } from './mail-message.js';
|
||||
import net from 'node:net';
|
||||
import type { ConnectionOptions } from 'node:tls';
|
||||
import type { MailComposerAlternative, MailComposerAttachment, MailComposerIcalEvent, MailComposerListHeaderEntry, MailComposerListHeaders } from '../mail-composer/index.js';
|
||||
import type { NodemailerError, ResultCallback } from '../errors.js';
|
||||
import type { ParsedUrl } from '../shared/url.js';
|
||||
import type { MimeNodeAddress, MimeNodeEnvelope, MimeNodeEnvelopeInput, MimeNodeHeaders, MimeNodeOptions } from '../mime-node/index.js';
|
||||
import type { XOAuth2ProvisionCallback } from '../xoauth2/index.js';
|
||||
export type { SendMailOptions, MailDefaults, MailMessageData, MailMessageDataCallback, MailMessageContentCallback, MailMessageListHeader, MailMessageListHeaderValue } from './mail-message.js';
|
||||
export type { default as MailMessage } from './mail-message.js';
|
||||
/**
|
||||
* The base shape of the object a transport hands back for a sent message. Every bundled
|
||||
* transport sets the envelope and the Message-ID, the rest depends on the transport.
|
||||
*
|
||||
* The index signature keeps a transport specific field readable through this type, and it
|
||||
* is also what a result type has to inherit to stay assignable to it, so the result type
|
||||
* of a transport outside this package has to extend this interface rather than restate it
|
||||
*/
|
||||
export interface SentMessageInfo {
|
||||
/** The envelope the message was sent with */
|
||||
envelope: MimeNodeEnvelope;
|
||||
/** Message-ID of the sent message */
|
||||
messageId: string;
|
||||
/** Recipient addresses the transport accepted */
|
||||
accepted?: string[] | undefined;
|
||||
/** Recipient addresses the transport rejected */
|
||||
rejected?: string[] | undefined;
|
||||
/** Recipient addresses left pending, LMTP reports these */
|
||||
pending?: string[] | undefined;
|
||||
/** Last response from the server */
|
||||
response?: string | undefined;
|
||||
/** The generated message, for the transports that hand it back instead of sending it */
|
||||
message?: unknown;
|
||||
/** Transport specific fields */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* Callback for sendMail, receives the transport result once the transport has taken the
|
||||
* message
|
||||
*/
|
||||
export type SendMailCallback<T = SentMessageInfo> = (err: NodemailerError | null, info: T) => void;
|
||||
/**
|
||||
* Callback for verify(), success is true once the transport accepted the configuration
|
||||
*/
|
||||
export type VerifyCallback = (err: NodemailerError | null, success?: true) => void;
|
||||
/**
|
||||
* Callback a plugin calls once it is done, an error aborts the send
|
||||
*/
|
||||
export type PluginCallback = (err?: NodemailerError | null) => void;
|
||||
/**
|
||||
* A plugin registered with use(): receives the message and a callback to call once done
|
||||
*/
|
||||
export type PluginFunction<T = SentMessageInfo> = (mail: MailMessage<T>, callback: PluginCallback) => void;
|
||||
/**
|
||||
* Connection options a getSocket handler receives: the options of the transport asking for
|
||||
* the socket, with the host and port to connect to
|
||||
*/
|
||||
export interface GetSocketOptions {
|
||||
host?: string | undefined;
|
||||
port?: number | string | undefined;
|
||||
[key: string]: any;
|
||||
}
|
||||
/**
|
||||
* The result of a getSocket handler, the socket to use for the connection
|
||||
*/
|
||||
export interface SocketOptions {
|
||||
/** An established socket, the proxied connection */
|
||||
connection?: net.Socket | undefined;
|
||||
}
|
||||
/**
|
||||
* Receives the socket options from a getSocket handler, or the error that prevented the
|
||||
* connection
|
||||
*/
|
||||
export type GetSocketCallback = (err: NodemailerError | null, socketOptions?: SocketOptions) => void;
|
||||
/**
|
||||
* A socket handler. Mail sets one on the transport as getSocket when a proxy is configured,
|
||||
* the SMTP transports call it to get a proxied socket instead of connecting directly
|
||||
*/
|
||||
export type GetSocketHandler = (options: GetSocketOptions, callback: GetSocketCallback) => void;
|
||||
/**
|
||||
* A custom proxy handler, registered with set('proxy_handler_' + protocol, handler) for the
|
||||
* protocol of the proxy url
|
||||
*/
|
||||
export type ProxyHandler = (proxy: ParsedUrl, options: GetSocketOptions, callback: GetSocketCallback) => void;
|
||||
/**
|
||||
* Well known keys of the meta store, see set() and get(): the OAuth2 token provisioning
|
||||
* callback the SMTP transports use, the socks module for socks proxies, and a custom proxy
|
||||
* handler per proxy protocol
|
||||
*/
|
||||
export interface MailMeta {
|
||||
/** Called by the SMTP transports when a new OAuth2 access token is needed */
|
||||
oauth2_provision_cb: XOAuth2ProvisionCallback;
|
||||
/** The socks module, v1 or v2, used to connect through a socks proxy */
|
||||
proxy_socks_module: any;
|
||||
[key: `proxy_handler_${string}`]: ProxyHandler;
|
||||
[key: string]: any;
|
||||
}
|
||||
/**
|
||||
* A transport as consumed by Mail: any object with a name, a version and a send method
|
||||
* works, the rest is optional. Mail forwards its close, isIdle and verify calls to the
|
||||
* methods of the same name as they are, so their arguments are up to the transport
|
||||
*/
|
||||
export interface Transport<T = SentMessageInfo> {
|
||||
/** Transport name, used for logging */
|
||||
name: string;
|
||||
/** Transport version, used for logging */
|
||||
version: string;
|
||||
/** Hands a message to the transport, the callback receives the transport result */
|
||||
send(mail: MailMessage<T>, callback: ResultCallback<T>): void;
|
||||
/** Checks the configuration, the SMTP transports connect and authenticate for it */
|
||||
verify?(...args: any[]): any;
|
||||
/** Closes the transport */
|
||||
close?(...args: any[]): any;
|
||||
/** Tells whether the transport can take a message right away */
|
||||
isIdle?(...args: any[]): any;
|
||||
/** Registers an event listener, the transport may emit 'log', 'error', 'idle' and 'clear' */
|
||||
on?(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
/** The Mail object the transport belongs to, set by Mail */
|
||||
mailer?: Mail<T> | undefined;
|
||||
/** Socket handler for a proxied connection, set by Mail when a proxy is configured */
|
||||
getSocket?: GetSocketHandler | undefined;
|
||||
}
|
||||
/**
|
||||
* Transport configuration as read by Mail itself. The transport reads its own options from
|
||||
* the same object, see the transport for those
|
||||
*/
|
||||
export interface TransportOptions {
|
||||
/** Bunyan compatible logger, true for the default console logger, false or unset for no logging */
|
||||
logger?: shared.ExternalLogger | boolean | undefined;
|
||||
/** Component name for the log lines, defaults to 'mail' */
|
||||
component?: string | undefined;
|
||||
/** DKIM signing options, every message is signed with these unless it carries its own */
|
||||
dkim?: DKIMOptions | undefined;
|
||||
/** Proxy url. http(s) proxies work as is, socks proxies need the socks module set with set('proxy_socks_module', socks) */
|
||||
proxy?: string | undefined;
|
||||
/** TLS options, rejectUnauthorized applies to an https proxy as well */
|
||||
tls?: ConnectionOptions | undefined;
|
||||
/** Reject content that points to a file path, forced onto every message */
|
||||
disableFileAccess?: boolean | undefined;
|
||||
/** Reject content that points to a URL, forced onto every message */
|
||||
disableUrlAccess?: boolean | undefined;
|
||||
/** Method to normalize header keys for custom caseing, forced onto every message */
|
||||
normalizeHeaderKey?: MimeNodeOptions['normalizeHeaderKey'] | undefined;
|
||||
/** Recipients allowed on one message, forced onto every message, 0 disables the limit, defaults to 100000 */
|
||||
maxRecipients?: number | undefined;
|
||||
/** Convert data: images in the html into embedded attachments */
|
||||
attachDataUrls?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* The transporter object createTransport returns, a Mail instance wrapping a transport
|
||||
*/
|
||||
export type Transporter<T = SentMessageInfo> = Mail<T>;
|
||||
/**
|
||||
* Creates an object for exposing the Mail API
|
||||
*
|
||||
* @constructor
|
||||
* @param transporter Transport object instance to pass the mails to
|
||||
*/
|
||||
declare class Mail<out T = SentMessageInfo> extends EventEmitter {
|
||||
options: TransportOptions;
|
||||
/** Message defaults given to createTransport, kept public because the DefinitelyTyped typings declared it */
|
||||
_defaults: MailDefaults;
|
||||
meta: Map<string, any>;
|
||||
dkim: DKIM | false;
|
||||
transporter: Transport<T>;
|
||||
logger: shared.Logger;
|
||||
/** Closes the transport, the pooled SMTP transport closes its connections */
|
||||
close: () => void;
|
||||
/** Tells whether the transport can take a message right away */
|
||||
isIdle: () => boolean;
|
||||
/** Checks the configuration, the SMTP transports connect and authenticate for it */
|
||||
verify: {
|
||||
(callback: VerifyCallback): void;
|
||||
(): Promise<true>;
|
||||
};
|
||||
/** Socket handler for a proxied connection, set by setupProxy and handed to the transport on the next send */
|
||||
getSocket?: GetSocketHandler | false | undefined;
|
||||
constructor(transporter: Transport<T>, options?: TransportOptions, defaults?: MailDefaults);
|
||||
use(step: string, plugin: PluginFunction<T>): this;
|
||||
/**
|
||||
* Sends an email using the preselected transport object
|
||||
*
|
||||
* @param data E-data description
|
||||
* @param callback Callback to run once the sending succeeded or failed
|
||||
*/
|
||||
sendMail(data: SendMailOptions): Promise<T>;
|
||||
sendMail(data: SendMailOptions, callback: SendMailCallback<T>): void;
|
||||
getVersionString(): string;
|
||||
/**
|
||||
* Sets up proxy handler for a Nodemailer object
|
||||
*
|
||||
* @param proxyUrl Proxy configuration url
|
||||
*/
|
||||
setupProxy(proxyUrl: string): void;
|
||||
set<K extends keyof MailMeta & string>(key: K, value: MailMeta[K]): Map<string, any>;
|
||||
get<K extends keyof MailMeta & string>(key: K): MailMeta[K] | undefined;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `Mail.Options` style references keep working
|
||||
*/
|
||||
type MailPluginFunction<T> = PluginFunction<T>;
|
||||
declare namespace Mail {
|
||||
type Options = SendMailOptions;
|
||||
type Address = MimeNodeAddress;
|
||||
type Attachment = MailComposerAttachment;
|
||||
type AttachmentLike = MailComposerAlternative;
|
||||
type AmpAttachment = MailComposerAlternative;
|
||||
type IcalAttachment = MailComposerIcalEvent;
|
||||
type Headers = MimeNodeHeaders;
|
||||
type ListHeader = MailComposerListHeaderEntry;
|
||||
type ListHeaders = MailComposerListHeaders;
|
||||
type Envelope = MimeNodeEnvelopeInput;
|
||||
type TextEncoding = NonNullable<SendMailOptions['textEncoding']>;
|
||||
type PluginFunction<T = SentMessageInfo> = MailPluginFunction<T>;
|
||||
}
|
||||
export default Mail;
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_events_1 = require("node:events");
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
const mimeTypes = __importStar(require("../mime-funcs/mime-types.js"));
|
||||
const index_js_1 = __importDefault(require("../mail-composer/index.js"));
|
||||
const index_js_2 = __importDefault(require("../dkim/index.js"));
|
||||
const http_proxy_client_js_1 = __importDefault(require("../smtp-connection/http-proxy-client.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
const node_util_1 = __importDefault(require("node:util"));
|
||||
const urllib = __importStar(require("../shared/url.js"));
|
||||
const packageData = __importStar(require("../package-info.js"));
|
||||
const mail_message_js_1 = __importDefault(require("./mail-message.js"));
|
||||
const node_net_1 = __importDefault(require("node:net"));
|
||||
const node_dns_1 = __importDefault(require("node:dns"));
|
||||
const node_crypto_1 = __importDefault(require("node:crypto"));
|
||||
/**
|
||||
* Recipients allowed on one message unless the caller sets its own maxRecipients. A backstop
|
||||
* against a runaway or hostile recipient list rather than a delivery policy: RFC 5321 only
|
||||
* asks a server to accept 100, so a real send is bounded far below this.
|
||||
*/
|
||||
const DEFAULT_MAX_RECIPIENTS = 100000;
|
||||
/**
|
||||
* Creates an object for exposing the Mail API
|
||||
*
|
||||
* @constructor
|
||||
* @param transporter Transport object instance to pass the mails to
|
||||
*/
|
||||
class Mail extends node_events_1.EventEmitter {
|
||||
constructor(transporter, options, defaults) {
|
||||
super();
|
||||
this.options = options || {};
|
||||
this._defaults = defaults || {};
|
||||
this._defaultPlugins = {
|
||||
compile: [(...args) => this._convertDataImages(...args)],
|
||||
stream: []
|
||||
};
|
||||
this._userPlugins = {
|
||||
compile: [],
|
||||
stream: []
|
||||
};
|
||||
this.meta = new Map();
|
||||
this.dkim = this.options.dkim ? new index_js_2.default(this.options.dkim) : false;
|
||||
this.transporter = transporter;
|
||||
this.transporter.mailer = this;
|
||||
this.logger = shared.getLogger(this.options, {
|
||||
component: this.options.component || 'mail'
|
||||
});
|
||||
this.logger.debug({
|
||||
tnx: 'create'
|
||||
}, 'Creating transport: %s', this.getVersionString());
|
||||
// setup emit handlers for the transporter
|
||||
if (typeof this.transporter.on === 'function') {
|
||||
// deprecated log interface
|
||||
this.transporter.on('log', log => {
|
||||
this.logger.debug({
|
||||
tnx: 'transport'
|
||||
}, '%s: %s', log.type, log.message);
|
||||
});
|
||||
// transporter errors
|
||||
this.transporter.on('error', err => {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'transport'
|
||||
}, 'Transport Error: %s', err.message);
|
||||
this.emit('error', err);
|
||||
});
|
||||
// indicates if the sender has became idle
|
||||
this.transporter.on('idle', (...args) => {
|
||||
this.emit('idle', ...args);
|
||||
});
|
||||
// indicates if the sender has became idle and all connections are terminated
|
||||
this.transporter.on('clear', (...args) => {
|
||||
this.emit('clear', ...args);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Optional methods passed to the underlying transport object
|
||||
*/
|
||||
['close', 'isIdle', 'verify'].forEach(method => {
|
||||
this[method] = (...args) => {
|
||||
if (typeof this.transporter[method] === 'function') {
|
||||
if (method === 'verify' && typeof this.getSocket === 'function') {
|
||||
this.transporter.getSocket = this.getSocket;
|
||||
this.getSocket = false;
|
||||
}
|
||||
return this.transporter[method](...args);
|
||||
}
|
||||
this.logger.warn({
|
||||
tnx: 'transport',
|
||||
methodName: method
|
||||
}, 'Non existing method %s called for transport', method);
|
||||
return false;
|
||||
};
|
||||
});
|
||||
// setup proxy handling
|
||||
if (this.options.proxy && typeof this.options.proxy === 'string') {
|
||||
this.setupProxy(this.options.proxy);
|
||||
}
|
||||
}
|
||||
use(step, plugin) {
|
||||
step = (step || '').toString();
|
||||
if (!this._userPlugins.hasOwnProperty(step)) {
|
||||
this._userPlugins[step] = [plugin];
|
||||
}
|
||||
else {
|
||||
this._userPlugins[step].push(plugin);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
sendMail(data, callback = null) {
|
||||
let promise;
|
||||
if (!callback) {
|
||||
promise = new Promise((resolve, reject) => {
|
||||
callback = shared.callbackPromise(resolve, reject);
|
||||
});
|
||||
}
|
||||
const done = callback;
|
||||
if (typeof this.getSocket === 'function') {
|
||||
this.transporter.getSocket = this.getSocket;
|
||||
this.getSocket = false;
|
||||
}
|
||||
const mail = new mail_message_js_1.default(this, data);
|
||||
this.logger.debug({
|
||||
tnx: 'transport',
|
||||
name: this.transporter.name,
|
||||
version: this.transporter.version,
|
||||
action: 'send'
|
||||
}, 'Sending mail using %s/%s', this.transporter.name, this.transporter.version);
|
||||
this._processPlugins('compile', mail, err => {
|
||||
if (err) {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'plugin',
|
||||
action: 'compile'
|
||||
}, 'PluginCompile Error: %s', err.message);
|
||||
return done(err);
|
||||
}
|
||||
let recipientCount;
|
||||
try {
|
||||
mail.message = new index_js_1.default(mail.data).compile();
|
||||
mail.setMailerHeader();
|
||||
mail.setPriorityHeaders();
|
||||
mail.setListHeaders();
|
||||
recipientCount = mail.message.getEnvelope().to.length;
|
||||
}
|
||||
catch (err) {
|
||||
// message data can throw while it is compiled, the error belongs to the callback
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'transport',
|
||||
action: 'send'
|
||||
}, 'Compile Error: %s', err.message);
|
||||
return done(err);
|
||||
}
|
||||
const maxRecipients = mail.data.maxRecipients === undefined ? DEFAULT_MAX_RECIPIENTS : mail.data.maxRecipients;
|
||||
if (maxRecipients && recipientCount > maxRecipients) {
|
||||
const err = new Error(`Message has ${recipientCount} recipients, which is over the ${maxRecipients} allowed by maxRecipients`);
|
||||
err.code = errors.EMAXRECIPIENTS;
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'transport',
|
||||
action: 'send'
|
||||
}, 'Send Error: %s', err.message);
|
||||
return done(err);
|
||||
}
|
||||
this._processPlugins('stream', mail, err => {
|
||||
if (err) {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'plugin',
|
||||
action: 'stream'
|
||||
}, 'PluginStream Error: %s', err.message);
|
||||
return done(err);
|
||||
}
|
||||
if (mail.data.dkim || this.dkim) {
|
||||
mail.message.processFunc(input => {
|
||||
const dkim = mail.data.dkim ? new index_js_2.default(mail.data.dkim) : this.dkim;
|
||||
this.logger.debug({
|
||||
tnx: 'DKIM',
|
||||
messageId: mail.message.messageId(),
|
||||
dkimDomains: dkim.keys.map(key => key.keySelector + '.' + key.domainName).join(', ')
|
||||
}, 'Signing outgoing message with %s keys', dkim.keys.length);
|
||||
return dkim.sign(input, mail.data._dkim);
|
||||
});
|
||||
}
|
||||
this.transporter.send(mail, (...args) => {
|
||||
if (args[0]) {
|
||||
this.logger.error({
|
||||
err: args[0],
|
||||
tnx: 'transport',
|
||||
action: 'send'
|
||||
}, 'Send Error: %s', args[0].message);
|
||||
}
|
||||
done(...args);
|
||||
});
|
||||
});
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
getVersionString() {
|
||||
return node_util_1.default.format('%s (%s; +%s; %s/%s)', packageData.name, packageData.version, packageData.homepage, this.transporter.name, this.transporter.version);
|
||||
}
|
||||
/** @internal */
|
||||
_processPlugins(step, mail, callback) {
|
||||
step = (step || '').toString();
|
||||
if (!this._userPlugins.hasOwnProperty(step)) {
|
||||
return callback();
|
||||
}
|
||||
const userPlugins = this._userPlugins[step] || [];
|
||||
const defaultPlugins = this._defaultPlugins[step] || [];
|
||||
if (userPlugins.length) {
|
||||
this.logger.debug({
|
||||
tnx: 'transaction',
|
||||
pluginCount: userPlugins.length,
|
||||
step
|
||||
}, 'Using %s plugins for %s', userPlugins.length, step);
|
||||
}
|
||||
if (userPlugins.length + defaultPlugins.length === 0) {
|
||||
return callback();
|
||||
}
|
||||
let pos = 0;
|
||||
let block = 'default';
|
||||
const processPlugins = () => {
|
||||
let curplugins = block === 'default' ? defaultPlugins : userPlugins;
|
||||
if (pos >= curplugins.length) {
|
||||
if (block === 'default' && userPlugins.length) {
|
||||
block = 'user';
|
||||
pos = 0;
|
||||
curplugins = userPlugins;
|
||||
}
|
||||
else {
|
||||
return callback();
|
||||
}
|
||||
}
|
||||
const plugin = curplugins[pos++];
|
||||
plugin(mail, err => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
processPlugins();
|
||||
});
|
||||
};
|
||||
processPlugins();
|
||||
}
|
||||
/**
|
||||
* Sets up proxy handler for a Nodemailer object
|
||||
*
|
||||
* @param proxyUrl Proxy configuration url
|
||||
*/
|
||||
setupProxy(proxyUrl) {
|
||||
const proxy = urllib.parse(proxyUrl);
|
||||
// setup socket handler for the mailer object
|
||||
this.getSocket = (options, callback) => {
|
||||
const protocol = proxy.protocol.replace(/:$/, '').toLowerCase();
|
||||
if (this.meta.has('proxy_handler_' + protocol)) {
|
||||
return this.meta.get('proxy_handler_' + protocol)(proxy, options, callback);
|
||||
}
|
||||
switch (protocol) {
|
||||
// Connect using a HTTP CONNECT method
|
||||
case 'http':
|
||||
case 'https':
|
||||
(0, http_proxy_client_js_1.default)(proxy.href, options.port, options.host, this.options.tls || {}, (err, socket) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, {
|
||||
connection: socket
|
||||
});
|
||||
});
|
||||
return;
|
||||
case 'socks':
|
||||
case 'socks5':
|
||||
case 'socks4':
|
||||
case 'socks4a': {
|
||||
if (!this.meta.has('proxy_socks_module')) {
|
||||
let err = new Error('Socks module not loaded');
|
||||
err.code = errors.EPROXY;
|
||||
return callback(err);
|
||||
}
|
||||
const connect = (ipaddress) => {
|
||||
const proxyV2 = !!this.meta.get('proxy_socks_module').SocksClient;
|
||||
const socksClient = proxyV2 ? this.meta.get('proxy_socks_module').SocksClient : this.meta.get('proxy_socks_module');
|
||||
const proxyType = Number(proxy.protocol.replace(/\D/g, '')) || 5;
|
||||
const connectionOpts = {
|
||||
proxy: {
|
||||
ipaddress,
|
||||
port: Number(proxy.port),
|
||||
type: proxyType
|
||||
},
|
||||
[proxyV2 ? 'destination' : 'target']: {
|
||||
host: options.host,
|
||||
port: options.port
|
||||
},
|
||||
command: 'connect'
|
||||
};
|
||||
if (proxy.username || proxy.password) {
|
||||
const username = proxy.username || '';
|
||||
const password = proxy.password || '';
|
||||
if (proxyV2) {
|
||||
connectionOpts.proxy.userId = username;
|
||||
connectionOpts.proxy.password = password;
|
||||
}
|
||||
else if (proxyType === 4) {
|
||||
connectionOpts.userid = username;
|
||||
}
|
||||
else {
|
||||
connectionOpts.authentication = {
|
||||
username,
|
||||
password
|
||||
};
|
||||
}
|
||||
}
|
||||
socksClient.createConnection(connectionOpts, (err, info) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, {
|
||||
connection: info.socket || info
|
||||
});
|
||||
});
|
||||
};
|
||||
if (node_net_1.default.isIP(proxy.hostname)) {
|
||||
return connect(proxy.hostname);
|
||||
}
|
||||
return node_dns_1.default.resolve(proxy.hostname, (err, address) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
connect(Array.isArray(address) ? address[0] : address);
|
||||
});
|
||||
}
|
||||
}
|
||||
let err = new Error('Unknown proxy configuration');
|
||||
err.code = errors.EPROXY;
|
||||
callback(err);
|
||||
};
|
||||
}
|
||||
/** @internal */
|
||||
_convertDataImages(mail, callback) {
|
||||
if ((!this.options.attachDataUrls && !mail.data.attachDataUrls) || !mail.data.html) {
|
||||
return callback();
|
||||
}
|
||||
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 = node_crypto_1.default.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) {
|
||||
return this.meta.set(key, value);
|
||||
}
|
||||
get(key) {
|
||||
return this.meta.get(key);
|
||||
}
|
||||
}
|
||||
exports.default = Mail;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import MimeNode, { type MimeNodePreparedHeaderValue } from '../mime-node/index.js';
|
||||
import type { MailComposerOptions } from '../mail-composer/index.js';
|
||||
import type { DKIMOptions } from '../dkim/index.js';
|
||||
import type { SMTPEnvelopeDsn } from '../smtp-connection/index.js';
|
||||
import type { SMTPTransportAuthOptions } from '../smtp-transport/index.js';
|
||||
import type { NodemailerError } from '../errors.js';
|
||||
import type { ResolveContentOptions } from '../shared/index.js';
|
||||
import type Mail from './index.js';
|
||||
import type { SentMessageInfo } from './index.js';
|
||||
/**
|
||||
* The message data accepted by sendMail. MailComposerOptions describes the fields the MIME
|
||||
* tree is built from, the fields below are the ones the mailer reads on top of those
|
||||
*/
|
||||
export interface SendMailOptions extends MailComposerOptions {
|
||||
/** DKIM signing options for this message, used instead of the ones of the transporter */
|
||||
dkim?: DKIMOptions | undefined;
|
||||
/** Recipients allowed on this message, 0 disables the limit, defaults to 100000 */
|
||||
maxRecipients?: number | undefined;
|
||||
/** SMTP transports: DSN parameters for the envelope, sent when the server supports the DSN extension */
|
||||
dsn?: SMTPEnvelopeDsn | undefined;
|
||||
/** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */
|
||||
requireTLSExtensionEnabled?: boolean | undefined;
|
||||
/** SMTP transports: per-message authentication settings, used instead of the transport level auth */
|
||||
auth?: SMTPTransportAuthOptions | undefined;
|
||||
/** SES transport: extra SendEmailCommand parameters merged into the API call */
|
||||
ses?: {
|
||||
[key: string]: unknown;
|
||||
} | undefined;
|
||||
}
|
||||
/**
|
||||
* Default message fields, the third argument of createTransport. Applied to every message
|
||||
* for the fields the message does not set itself, the headers are merged one by one
|
||||
*/
|
||||
export type MailDefaults = SendMailOptions;
|
||||
/**
|
||||
* The message data as held by a MailMessage: the options the caller passed to sendMail with
|
||||
* the transporter defaults applied. resolveAll rewrites the content and address fields in
|
||||
* place and normalize adds the envelope, the Message-ID and the normalized headers
|
||||
*/
|
||||
export interface MailMessageData extends SendMailOptions {
|
||||
/** Header values flattened to strings and keyed by lowercase header name, set by normalize */
|
||||
normalizedHeaders?: {
|
||||
[key: string]: string;
|
||||
} | undefined;
|
||||
}
|
||||
/**
|
||||
* Callback for resolveAll and normalize, receives the message data with every content value
|
||||
* resolved
|
||||
*/
|
||||
export type MailMessageDataCallback = (err: NodemailerError | null, data: MailMessageData) => void;
|
||||
/**
|
||||
* Callback for resolveContent, receives the resolved content value
|
||||
*/
|
||||
export type MailMessageContentCallback = (err: NodemailerError | null, value?: any) => void;
|
||||
/**
|
||||
* A single prepared List-* header value, emitted as is
|
||||
*/
|
||||
export interface MailMessageListHeaderValue extends MimeNodePreparedHeaderValue {
|
||||
prepared: boolean;
|
||||
foldLines: boolean;
|
||||
value: string;
|
||||
}
|
||||
/**
|
||||
* A List-* header as built from the list value, one prepared value per list entry
|
||||
*/
|
||||
export interface MailMessageListHeader {
|
||||
/** Header key, 'list-' followed by the lowercase list key */
|
||||
key: string;
|
||||
value: MailMessageListHeaderValue[];
|
||||
}
|
||||
export default class MailMessage<T = SentMessageInfo> {
|
||||
mailer: Mail<T>;
|
||||
data: MailMessageData;
|
||||
message: MimeNode | null;
|
||||
constructor(mailer: Mail<T>, data?: SendMailOptions);
|
||||
resolveContent(data: {
|
||||
[key: string]: any;
|
||||
}, key: string | number, callback: MailMessageContentCallback): void;
|
||||
resolveContent(data: {
|
||||
[key: string]: any;
|
||||
}, key: string | number, options: ResolveContentOptions | false | undefined, callback: MailMessageContentCallback): void;
|
||||
resolveContent(data: {
|
||||
[key: string]: any;
|
||||
}, key: string | number, options?: ResolveContentOptions | false): Promise<any>;
|
||||
resolveAll(callback: MailMessageDataCallback): void;
|
||||
normalize(callback: MailMessageDataCallback): void;
|
||||
setMailerHeader(): void;
|
||||
setPriorityHeaders(): void;
|
||||
setListHeaders(): void;
|
||||
}
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
const index_js_1 = __importDefault(require("../mime-node/index.js"));
|
||||
const mimeFuncs = __importStar(require("../mime-funcs/index.js"));
|
||||
// Only an own key counts as already set. `key in obj` also matches every member of
|
||||
// Object.prototype, which silently drops a transporter default legitimately named
|
||||
// toString or constructor.
|
||||
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
||||
class MailMessage {
|
||||
constructor(mailer, data) {
|
||||
this.mailer = mailer;
|
||||
this.data = {};
|
||||
this.message = null;
|
||||
data = data || {};
|
||||
const options = mailer.options || {};
|
||||
const defaults = mailer._defaults || {};
|
||||
shared.copyOwnKeys(this.data, data);
|
||||
this.data.headers = this.data.headers || {};
|
||||
// Apply defaults. `_defaults` is caller supplied too, it is the second argument of
|
||||
// createTransport, so it needs the same treatment as `data` above
|
||||
shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key));
|
||||
// headers is a special case. Allow setting individual default headers
|
||||
shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
|
||||
// force specific keys from transporter options
|
||||
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => {
|
||||
if (key in options) {
|
||||
this.data[key] = options[key];
|
||||
}
|
||||
});
|
||||
// The access flags are a sandbox rather than a message field, so `defaults` counts as
|
||||
// transporter configuration for them. For a transporter plugin it is the only channel
|
||||
// there is, createTransport leaves `options` undefined for one, and the defaults copy
|
||||
// above yields to anything the message already set, which let message data switch the
|
||||
// sandbox back off. Closing is one way here, same as in resolveContent below: either
|
||||
// side may switch a flag on, neither can switch off what the other closed.
|
||||
['disableFileAccess', 'disableUrlAccess'].forEach(key => {
|
||||
if (!(key in options) && hasOwn(defaults, key)) {
|
||||
this.data[key] = this.data[key] || defaults[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
resolveContent(data, key, options, callback) {
|
||||
// Most plugins call this with the legacy (data, key, callback) signature, which carries
|
||||
// no access policy. The policy belongs to the message, so apply it here. Explicit
|
||||
// options may only tighten it, never reopen what the transporter closed.
|
||||
if (!callback && typeof options === 'function') {
|
||||
callback = options;
|
||||
options = false;
|
||||
}
|
||||
options = options || {};
|
||||
const policy = {
|
||||
disableFileAccess: this.data.disableFileAccess || options.disableFileAccess,
|
||||
disableUrlAccess: this.data.disableUrlAccess || options.disableUrlAccess
|
||||
};
|
||||
return shared.resolveContent(data, key, policy, callback);
|
||||
}
|
||||
resolveAll(callback) {
|
||||
const keys = [
|
||||
[this.data, 'html'],
|
||||
[this.data, 'text'],
|
||||
[this.data, 'watchHtml'],
|
||||
[this.data, 'amp'],
|
||||
[this.data, 'icalEvent']
|
||||
];
|
||||
if (this.data.alternatives && this.data.alternatives.length) {
|
||||
this.data.alternatives.forEach((alternative, i) => {
|
||||
keys.push([this.data.alternatives, i]);
|
||||
});
|
||||
}
|
||||
if (this.data.attachments && this.data.attachments.length) {
|
||||
this.data.attachments.forEach((attachment, i) => {
|
||||
if (!attachment.filename) {
|
||||
attachment.filename =
|
||||
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() ||
|
||||
'attachment-' + (i + 1);
|
||||
if (attachment.filename.indexOf('.') < 0) {
|
||||
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
|
||||
}
|
||||
}
|
||||
if (!attachment.contentType) {
|
||||
attachment.contentType = mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin');
|
||||
}
|
||||
keys.push([this.data.attachments, i]);
|
||||
});
|
||||
}
|
||||
const mimeNode = new index_js_1.default();
|
||||
const addressKeys = ['from', 'to', 'cc', 'bcc', 'sender', 'replyTo'];
|
||||
addressKeys.forEach(address => {
|
||||
let value;
|
||||
if (this.message) {
|
||||
value = [].concat(mimeNode._parseAddresses(this.message.getHeader(address === 'replyTo' ? 'reply-to' : address)) || []);
|
||||
}
|
||||
else if (this.data[address]) {
|
||||
value = [].concat(mimeNode._parseAddresses(this.data[address]) || []);
|
||||
}
|
||||
if (value && value.length) {
|
||||
this.data[address] = value;
|
||||
}
|
||||
else if (address in this.data) {
|
||||
this.data[address] = null;
|
||||
}
|
||||
});
|
||||
const singleKeys = ['from', 'sender'];
|
||||
singleKeys.forEach(address => {
|
||||
if (this.data[address]) {
|
||||
this.data[address] = this.data[address].shift();
|
||||
}
|
||||
});
|
||||
let pos = 0;
|
||||
const resolveNext = () => {
|
||||
if (pos >= keys.length) {
|
||||
return callback(null, this.data);
|
||||
}
|
||||
const args = keys[pos++];
|
||||
if (!args[0] || !args[0][args[1]]) {
|
||||
return resolveNext();
|
||||
}
|
||||
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]])) {
|
||||
// The keys are the caller's, so copying them takes the same "__proto__"
|
||||
// rule as the constructor. `key in node` stays as the already-set test
|
||||
// here, unlike for the defaults: it also skips the Object.prototype
|
||||
// member names, and letting message data land a `toString` string on a
|
||||
// node only buys a TypeError the first time something stringifies it.
|
||||
shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key));
|
||||
}
|
||||
args[0][args[1]] = node;
|
||||
resolveNext();
|
||||
});
|
||||
};
|
||||
setImmediate(() => resolveNext());
|
||||
}
|
||||
normalize(callback) {
|
||||
const envelope = this.message.getEnvelope();
|
||||
const messageId = this.message.messageId();
|
||||
this.resolveAll((err, data) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
data.envelope = envelope;
|
||||
data.messageId = messageId;
|
||||
['html', 'text', 'watchHtml', 'amp'].forEach(key => {
|
||||
if (data[key] && data[key].content) {
|
||||
if (typeof data[key].content === 'string') {
|
||||
data[key] = data[key].content;
|
||||
}
|
||||
else if (Buffer.isBuffer(data[key].content)) {
|
||||
data[key] = data[key].content.toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
if (data.icalEvent && Buffer.isBuffer(data.icalEvent.content)) {
|
||||
data.icalEvent.content = data.icalEvent.content.toString('base64');
|
||||
data.icalEvent.encoding = 'base64';
|
||||
}
|
||||
if (data.alternatives && data.alternatives.length) {
|
||||
data.alternatives.forEach((alternative) => {
|
||||
if (alternative && alternative.content && Buffer.isBuffer(alternative.content)) {
|
||||
alternative.content = alternative.content.toString('base64');
|
||||
alternative.encoding = 'base64';
|
||||
}
|
||||
});
|
||||
}
|
||||
if (data.attachments && data.attachments.length) {
|
||||
data.attachments.forEach((attachment) => {
|
||||
if (attachment && attachment.content && Buffer.isBuffer(attachment.content)) {
|
||||
attachment.content = attachment.content.toString('base64');
|
||||
attachment.encoding = 'base64';
|
||||
}
|
||||
});
|
||||
}
|
||||
data.normalizedHeaders = {};
|
||||
Object.keys(data.headers || {}).forEach(key => {
|
||||
if (shared.isProtoKey(key)) {
|
||||
return;
|
||||
}
|
||||
let value = [].concat(data.headers[key] || []).shift();
|
||||
value = (value && value.value) || value;
|
||||
if (value) {
|
||||
if (['references', 'in-reply-to', 'message-id', 'content-id'].includes(key)) {
|
||||
value = this.message._encodeHeaderValue(key, value);
|
||||
}
|
||||
data.normalizedHeaders[key] = value;
|
||||
}
|
||||
});
|
||||
if (data.list && typeof data.list === 'object') {
|
||||
const listHeaders = this._getListHeaders(data.list);
|
||||
listHeaders.forEach(entry => {
|
||||
data.normalizedHeaders[entry.key] = entry.value.map(val => (val && val.value) || val).join(', ');
|
||||
});
|
||||
}
|
||||
if (data.references) {
|
||||
data.normalizedHeaders.references = this.message._encodeHeaderValue('references', data.references);
|
||||
}
|
||||
if (data.inReplyTo) {
|
||||
data.normalizedHeaders['in-reply-to'] = this.message._encodeHeaderValue('in-reply-to', data.inReplyTo);
|
||||
}
|
||||
return callback(null, data);
|
||||
});
|
||||
}
|
||||
setMailerHeader() {
|
||||
if (!this.message || !this.data.xMailer) {
|
||||
return;
|
||||
}
|
||||
this.message.setHeader('X-Mailer', this.data.xMailer);
|
||||
}
|
||||
setPriorityHeaders() {
|
||||
if (!this.message || !this.data.priority) {
|
||||
return;
|
||||
}
|
||||
switch ((this.data.priority || '').toString().toLowerCase()) {
|
||||
case 'high':
|
||||
this.message.setHeader('X-Priority', '1 (Highest)');
|
||||
this.message.setHeader('X-MSMail-Priority', 'High');
|
||||
this.message.setHeader('Importance', 'High');
|
||||
break;
|
||||
case 'low':
|
||||
this.message.setHeader('X-Priority', '5 (Lowest)');
|
||||
this.message.setHeader('X-MSMail-Priority', 'Low');
|
||||
this.message.setHeader('Importance', 'Low');
|
||||
break;
|
||||
default:
|
||||
// do not add anything, since all messages are 'Normal' by default
|
||||
}
|
||||
}
|
||||
setListHeaders() {
|
||||
if (!this.message || !this.data.list || typeof this.data.list !== 'object') {
|
||||
return;
|
||||
}
|
||||
// add optional List-* headers
|
||||
this._getListHeaders(this.data.list).forEach(listHeader => {
|
||||
listHeader.value.forEach(value => {
|
||||
this.message.addHeader(listHeader.key, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
/** @internal */
|
||||
_getListHeaders(listData) {
|
||||
// make sure an url looks like <protocol:url>
|
||||
return Object.keys(listData).map(key => ({
|
||||
key: 'list-' + key.toLowerCase().trim(),
|
||||
value: [].concat(listData[key] || []).map(value => ({
|
||||
prepared: true,
|
||||
foldLines: true,
|
||||
value: []
|
||||
.concat(value || [])
|
||||
.map(value => {
|
||||
if (typeof value === 'string') {
|
||||
value = {
|
||||
url: value
|
||||
};
|
||||
}
|
||||
if (value && value.url) {
|
||||
// strip CR/LF so a comment can't inject extra header lines. DEL is neither
|
||||
// qtext nor ctext, so it can not be carried literally by either construct
|
||||
// and has to become an encoded word like any other non-plaintext value
|
||||
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
|
||||
const needsEncoding = !mimeFuncs.isPlainText(comment) || /\x7f/.test(comment);
|
||||
if (key.toLowerCase().trim() === 'id') {
|
||||
// List-ID: "comment" <domain>, where an unescaped quote or a trailing
|
||||
// backslash in the comment would swallow the <domain> behind it
|
||||
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
|
||||
// List-ID expects a bare domain-like identifier, so strip the
|
||||
// scheme prefix that _formatListUrl adds or passes through
|
||||
return ((value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<'));
|
||||
}
|
||||
// List-*: <http://domain> (comment)
|
||||
// the ctext specials go out as quoted-pairs, otherwise a ")" closes the
|
||||
// comment early and leaves the rest as junk, an unpaired "(" opens a
|
||||
// nested comment that never closes, and a trailing backslash escapes
|
||||
// the closing ")" so the comment swallows whatever follows it
|
||||
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : comment.replace(/[()\\]/g, '\\$&');
|
||||
return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : '');
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(value => value)
|
||||
.join(', ')
|
||||
}))
|
||||
}));
|
||||
}
|
||||
/** @internal */
|
||||
_formatListUrl(url) {
|
||||
// a url has no way to carry a control char or DEL, and the angle brackets around it
|
||||
// are not a quoting construct, so anything left here lands in the header raw
|
||||
url = url.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\s<]+|[\s>]+/g, '');
|
||||
if (/^(https?|mailto|ftp):/.test(url)) {
|
||||
return '<' + url + '>';
|
||||
}
|
||||
if (/^[^@]+@[^@]+$/.test(url)) {
|
||||
return '<mailto:' + url + '>';
|
||||
}
|
||||
return '<http://' + url + '>';
|
||||
}
|
||||
}
|
||||
exports.default = MailMessage;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* A header value split into the value token and its parameters, the result of parseHeaderValue
|
||||
*/
|
||||
export interface ParsedHeaderValue {
|
||||
/** The value ahead of the parameters, for example the content type */
|
||||
value: string | false;
|
||||
/** Parameter values keyed by lowercase parameter name */
|
||||
params: Record<string, string>;
|
||||
}
|
||||
/**
|
||||
* Header value structure accepted by buildHeaderValue
|
||||
*/
|
||||
export interface StructuredHeaderValue {
|
||||
/** The value ahead of the parameters, for example the content type */
|
||||
value: string | false;
|
||||
/** Parameter values keyed by parameter name */
|
||||
params?: Record<string, string> | undefined;
|
||||
}
|
||||
/**
|
||||
* A single key=value pair of an rfc2231 encoded header parameter
|
||||
*/
|
||||
export interface EncodedHeaderParam {
|
||||
/** Parameter name with the continuation suffix, for example title*0* */
|
||||
key: string;
|
||||
/** Parameter value of this part */
|
||||
value: string;
|
||||
}
|
||||
/**
|
||||
* Checks if a value is plaintext string (uses only printable 7bit chars)
|
||||
*
|
||||
* When isParam is set the value is destined for a header parameter, so HT, CR and LF
|
||||
* are not plaintext either: a header parameter has no way to carry them. HT is a valid
|
||||
* fold point, so folding and unfolding a header would rewrite it as a space, and CR/LF
|
||||
* cannot appear in a header value at all. DEL is neither a token character nor qtext,
|
||||
* so it can not be carried bare or quoted. Such values have to go through the rfc2231
|
||||
* parameter continuation encoding instead, the same way a quote already does.
|
||||
*
|
||||
* @param value String to be tested
|
||||
* @param [isParam] Set to true if the value is a header parameter value
|
||||
* @returns true if it is a plaintext string
|
||||
*/
|
||||
export declare function isPlainText(value: unknown, isParam?: boolean): boolean;
|
||||
/**
|
||||
* Wraps a value into a quoted-string. Inside one a quote would end the string early
|
||||
* and a backslash would escape whatever follows it, so both go out as quoted-pairs.
|
||||
*
|
||||
* @param value String to be quoted
|
||||
* @returns The value as a quoted-string, quotes included
|
||||
*/
|
||||
export declare function quoteString(value?: string): string;
|
||||
/**
|
||||
* Checks if a multi line string containes lines longer than the selected value.
|
||||
*
|
||||
* Useful when detecting if a mail message needs any processing at all:
|
||||
* if only plaintext characters are used and lines are short, then there is
|
||||
* no need to encode the values in any way. If the value is plaintext but has
|
||||
* longer lines then allowed, then use format=flowed
|
||||
*
|
||||
* @param lineLength Max line length to check for
|
||||
* @returns Returns true if there is at least one line longer than lineLength chars
|
||||
*/
|
||||
export declare function hasLongerLines(str: string, lineLength: number): boolean;
|
||||
/**
|
||||
* Encodes a string or an Buffer to an UTF-8 MIME Word (rfc2047)
|
||||
*
|
||||
* @param data String to be encoded
|
||||
* @param mimeWordEncoding='Q' Encoding for the mime word, either Q or B
|
||||
* @param [maxLength=0] If set, split mime words into several chunks if needed
|
||||
* @return Single or several mime words joined together
|
||||
*/
|
||||
export declare function encodeWord(data: string | Buffer, mimeWordEncoding?: string, maxLength?: number): string;
|
||||
/**
|
||||
* Finds word sequences with non ascii text and converts these to mime words
|
||||
*
|
||||
* @param value String to be encoded
|
||||
* @param mimeWordEncoding='Q' Encoding for the mime word, either Q or B
|
||||
* @param [maxLength=0] If set, split mime words into several chunks if needed
|
||||
* @param [encodeAll=false] If true and the value needs encoding then encodes entire string, not just the smallest match
|
||||
* @return String with possible mime words
|
||||
*/
|
||||
export declare function encodeWords(value: string, mimeWordEncoding?: string, maxLength?: number, encodeAll?: boolean): string;
|
||||
/**
|
||||
* Joins parsed header value together as 'value; param1=value1; param2=value2'
|
||||
* PS: We are following RFC 822 for the list of special characters that we need to keep in quotes.
|
||||
* Refer: https://www.w3.org/Protocols/rfc1341/4_Content-Type.html
|
||||
* @param structured Parsed header value
|
||||
* @return joined header value
|
||||
*/
|
||||
export declare function buildHeaderValue(structured: StructuredHeaderValue): string;
|
||||
/**
|
||||
* Encodes a string or an Buffer to an UTF-8 Parameter Value Continuation encoding (rfc2231)
|
||||
* Useful for splitting long parameter values.
|
||||
*
|
||||
* For example
|
||||
* title="unicode string"
|
||||
* becomes
|
||||
* title*0*=utf-8''unicode
|
||||
* title*1*=%20string
|
||||
*
|
||||
* @param data String to be encoded
|
||||
* @param [maxLength=50] Max length for generated chunks
|
||||
* @param [fromCharset='UTF-8'] Source sharacter set
|
||||
* @return A list of encoded keys and headers
|
||||
*/
|
||||
export declare function buildHeaderParam(key: string, data: string | Buffer, maxLength?: number): EncodedHeaderParam[];
|
||||
/**
|
||||
* Parses a header value with key=value arguments into a structured
|
||||
* object.
|
||||
*
|
||||
* parseHeaderValue('content-type: text/plain; CHARSET='UTF-8'') ->
|
||||
* {
|
||||
* 'value': 'text/plain',
|
||||
* 'params': {
|
||||
* 'charset': 'UTF-8'
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @param str Header value
|
||||
* @return Header value as a parsed structure
|
||||
*/
|
||||
export declare function parseHeaderValue(str: string): ParsedHeaderValue;
|
||||
/**
|
||||
* Returns file extension for a content type string. If no suitable extensions
|
||||
* are found, 'bin' is used as the default extension
|
||||
*
|
||||
* @param mimeType Content type to be checked for
|
||||
* @return File extension
|
||||
*/
|
||||
export declare function detectExtension(mimeType?: string | false): string;
|
||||
/**
|
||||
* Returns content type for a file extension. If no suitable content types
|
||||
* are found, 'application/octet-stream' is used as the default content type
|
||||
*
|
||||
* @param extension Extension to be checked for
|
||||
* @return File extension
|
||||
*/
|
||||
export declare function detectMimeType(extension?: string | false): string;
|
||||
/**
|
||||
* Folds long lines, useful for folding header lines (afterSpace=false) and
|
||||
* flowed text (afterSpace=true)
|
||||
*
|
||||
* @param str String to be folded
|
||||
* @param [lineLength=76] Maximum length of a line
|
||||
* @param afterSpace If true, leave a space in th end of a line
|
||||
* @return String with folded lines
|
||||
*/
|
||||
export declare function foldLines(str: string, lineLength?: number, afterSpace?: boolean): string;
|
||||
/**
|
||||
* Splits a mime encoded string. Needed for dividing mime words into smaller chunks
|
||||
*
|
||||
* @param str Mime encoded string to be split up
|
||||
* @param maxlen Maximum length of characters for one part (minimum 12)
|
||||
* @return Split string
|
||||
*/
|
||||
export declare function splitMimeEncodedString(str: string, maxlen?: number): string[];
|
||||
export declare function encodeURICharComponent(chr: string): string;
|
||||
export declare function safeEncodeURIComponent(str: string): string;
|
||||
+673
@@ -0,0 +1,673 @@
|
||||
"use strict";
|
||||
/* eslint no-control-regex:0 */
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.isPlainText = isPlainText;
|
||||
exports.quoteString = quoteString;
|
||||
exports.hasLongerLines = hasLongerLines;
|
||||
exports.encodeWord = encodeWord;
|
||||
exports.encodeWords = encodeWords;
|
||||
exports.buildHeaderValue = buildHeaderValue;
|
||||
exports.buildHeaderParam = buildHeaderParam;
|
||||
exports.parseHeaderValue = parseHeaderValue;
|
||||
exports.detectExtension = detectExtension;
|
||||
exports.detectMimeType = detectMimeType;
|
||||
exports.foldLines = foldLines;
|
||||
exports.splitMimeEncodedString = splitMimeEncodedString;
|
||||
exports.encodeURICharComponent = encodeURICharComponent;
|
||||
exports.safeEncodeURIComponent = safeEncodeURIComponent;
|
||||
const base64 = __importStar(require("../base64/index.js"));
|
||||
const qp = __importStar(require("../qp/index.js"));
|
||||
const mimeTypes = __importStar(require("./mime-types.js"));
|
||||
const objects_js_1 = require("../shared/objects.js");
|
||||
/**
|
||||
* Checks if a value is plaintext string (uses only printable 7bit chars)
|
||||
*
|
||||
* When isParam is set the value is destined for a header parameter, so HT, CR and LF
|
||||
* are not plaintext either: a header parameter has no way to carry them. HT is a valid
|
||||
* fold point, so folding and unfolding a header would rewrite it as a space, and CR/LF
|
||||
* cannot appear in a header value at all. DEL is neither a token character nor qtext,
|
||||
* so it can not be carried bare or quoted. Such values have to go through the rfc2231
|
||||
* parameter continuation encoding instead, the same way a quote already does.
|
||||
*
|
||||
* @param value String to be tested
|
||||
* @param [isParam] Set to true if the value is a header parameter value
|
||||
* @returns true if it is a plaintext string
|
||||
*/
|
||||
function isPlainText(value, isParam) {
|
||||
const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
|
||||
return typeof value === 'string' && !re.test(value);
|
||||
}
|
||||
/**
|
||||
* Wraps a value into a quoted-string. Inside one a quote would end the string early
|
||||
* and a backslash would escape whatever follows it, so both go out as quoted-pairs.
|
||||
*
|
||||
* @param value String to be quoted
|
||||
* @returns The value as a quoted-string, quotes included
|
||||
*/
|
||||
function quoteString(value) {
|
||||
return '"' + (value || '').toString().replace(/["\\]/g, '\\$&') + '"';
|
||||
}
|
||||
/**
|
||||
* Checks if a multi line string containes lines longer than the selected value.
|
||||
*
|
||||
* Useful when detecting if a mail message needs any processing at all:
|
||||
* if only plaintext characters are used and lines are short, then there is
|
||||
* no need to encode the values in any way. If the value is plaintext but has
|
||||
* longer lines then allowed, then use format=flowed
|
||||
*
|
||||
* @param lineLength Max line length to check for
|
||||
* @returns Returns true if there is at least one line longer than lineLength chars
|
||||
*/
|
||||
function hasLongerLines(str, lineLength) {
|
||||
if (str.length > 128 * 1024) {
|
||||
// do not test strings longer than 128kB
|
||||
return true;
|
||||
}
|
||||
return new RegExp('^.{' + (lineLength + 1) + ',}', 'm').test(str);
|
||||
}
|
||||
/**
|
||||
* Encodes a string or an Buffer to an UTF-8 MIME Word (rfc2047)
|
||||
*
|
||||
* @param data String to be encoded
|
||||
* @param mimeWordEncoding='Q' Encoding for the mime word, either Q or B
|
||||
* @param [maxLength=0] If set, split mime words into several chunks if needed
|
||||
* @return Single or several mime words joined together
|
||||
*/
|
||||
function encodeWord(data, mimeWordEncoding, maxLength) {
|
||||
mimeWordEncoding = (mimeWordEncoding || 'Q').toString().toUpperCase().trim().charAt(0);
|
||||
maxLength = maxLength || 0;
|
||||
let encodedStr;
|
||||
const toCharset = 'UTF-8';
|
||||
if (maxLength && maxLength > 7 + toCharset.length) {
|
||||
maxLength -= 7 + toCharset.length;
|
||||
}
|
||||
if (mimeWordEncoding === 'Q') {
|
||||
// https://tools.ietf.org/html/rfc2047#section-5 rule (3)
|
||||
encodedStr = qp.encode(data).replace(/[^a-z0-9!*+\-/=]/gi, chr => {
|
||||
const ord = chr.charCodeAt(0).toString(16).toUpperCase();
|
||||
if (chr === ' ') {
|
||||
return '_';
|
||||
}
|
||||
return '=' + (ord.length === 1 ? '0' + ord : ord);
|
||||
});
|
||||
}
|
||||
else if (mimeWordEncoding === 'B') {
|
||||
// the chunking loop below splits raw text and base64 encodes each part, so a
|
||||
// Buffer goes in as its UTF-8 string: handing it the base64 of the whole input
|
||||
// would encode the encoding itself and decode back to base64 text
|
||||
encodedStr = typeof data === 'string' ? data : data.toString('utf-8');
|
||||
maxLength = maxLength ? Math.max(3, ((maxLength - (maxLength % 4)) / 4) * 3) : 0;
|
||||
}
|
||||
if (maxLength && (mimeWordEncoding !== 'B' ? encodedStr : base64.encode(data)).length > maxLength) {
|
||||
if (mimeWordEncoding === 'Q') {
|
||||
encodedStr = splitMimeEncodedString(encodedStr, maxLength).join('?= =?' + toCharset + '?' + mimeWordEncoding + '?');
|
||||
}
|
||||
else {
|
||||
// RFC2047 6.3 (2) states that encoded-word must include an integral number of characters, so no chopping unicode sequences
|
||||
const parts = [];
|
||||
let lpart = '';
|
||||
for (let i = 0, len = encodedStr.length; i < len; i++) {
|
||||
let chr = encodedStr.charAt(i);
|
||||
if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
|
||||
// leading surrogate, so add the trailing surrogate as well
|
||||
// an unpaired one must not swallow the next unit, that would destroy
|
||||
// a valid pair following it
|
||||
chr += encodedStr.charAt(++i);
|
||||
}
|
||||
// check if we can add this character to the existing string
|
||||
// without breaking byte length limit
|
||||
if (Buffer.byteLength(lpart + chr) <= maxLength || i === 0) {
|
||||
lpart += chr;
|
||||
}
|
||||
else {
|
||||
// we hit the length limit, so push the existing string and start over
|
||||
parts.push(base64.encode(lpart));
|
||||
lpart = chr;
|
||||
}
|
||||
}
|
||||
if (lpart) {
|
||||
parts.push(base64.encode(lpart));
|
||||
}
|
||||
if (parts.length > 1) {
|
||||
encodedStr = parts.join('?= =?' + toCharset + '?' + mimeWordEncoding + '?');
|
||||
}
|
||||
else {
|
||||
encodedStr = parts.join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (mimeWordEncoding === 'B') {
|
||||
encodedStr = base64.encode(data);
|
||||
}
|
||||
return '=?' + toCharset + '?' + mimeWordEncoding + '?' + encodedStr + (encodedStr.substr(-2) === '?=' ? '' : '?=');
|
||||
}
|
||||
/**
|
||||
* Finds word sequences with non ascii text and converts these to mime words
|
||||
*
|
||||
* @param value String to be encoded
|
||||
* @param mimeWordEncoding='Q' Encoding for the mime word, either Q or B
|
||||
* @param [maxLength=0] If set, split mime words into several chunks if needed
|
||||
* @param [encodeAll=false] If true and the value needs encoding then encodes entire string, not just the smallest match
|
||||
* @return String with possible mime words
|
||||
*/
|
||||
function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) {
|
||||
maxLength = maxLength || 0;
|
||||
// find first word with a non-printable ascii or special symbol in it
|
||||
const firstMatch = value.match(/(?:^|\s)([^\s]*["\u0080-\uFFFF])/);
|
||||
if (!firstMatch) {
|
||||
return value;
|
||||
}
|
||||
if (encodeAll) {
|
||||
// if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything
|
||||
return encodeWord(value, mimeWordEncoding, maxLength);
|
||||
}
|
||||
// find the last word with a non-printable ascii in it
|
||||
const lastMatch = value.match(/(["\u0080-\uFFFF][^\s]*)[^"\u0080-\uFFFF]*$/);
|
||||
if (!lastMatch) {
|
||||
// should not happen
|
||||
return value;
|
||||
}
|
||||
const startIndex = firstMatch.index +
|
||||
(firstMatch[0].match(/[^\s]/) || {
|
||||
index: 0
|
||||
}).index;
|
||||
const endIndex = lastMatch.index + (lastMatch[1] || '').length;
|
||||
return ((startIndex ? value.substr(0, startIndex) : '') +
|
||||
encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
|
||||
(endIndex < value.length ? value.substr(endIndex) : ''));
|
||||
}
|
||||
/**
|
||||
* Joins parsed header value together as 'value; param1=value1; param2=value2'
|
||||
* PS: We are following RFC 822 for the list of special characters that we need to keep in quotes.
|
||||
* Refer: https://www.w3.org/Protocols/rfc1341/4_Content-Type.html
|
||||
* @param structured Parsed header value
|
||||
* @return joined header value
|
||||
*/
|
||||
function buildHeaderValue(structured) {
|
||||
const paramsArray = [];
|
||||
Object.keys(structured.params || {}).forEach(key => {
|
||||
// filename might include unicode characters so it is a special case
|
||||
// other values probably do not
|
||||
const value = structured.params[key];
|
||||
// a parameter name is a token too and it is emitted without any quoting around it
|
||||
const param = key.replace(/[\x00-\x1f\x7f]/g, '');
|
||||
if (!isPlainText(value, true) || value.length >= 75) {
|
||||
buildHeaderParam(param, value, 50).forEach(encodedParam => {
|
||||
if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
|
||||
paramsArray.push(encodedParam.key + '=' + encodedParam.value);
|
||||
}
|
||||
else {
|
||||
paramsArray.push(encodedParam.key + '=' + JSON.stringify(encodedParam.value));
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (/[\s'"\\;:/=(),<>@[\]?]|^-/.test(value)) {
|
||||
paramsArray.push(param + '=' + JSON.stringify(value));
|
||||
}
|
||||
else {
|
||||
paramsArray.push(param + '=' + value);
|
||||
}
|
||||
});
|
||||
// the value ahead of the parameters is a token, it has no way to carry a control
|
||||
// char or DEL and there is no quoting construct around it to escape one into
|
||||
const value = typeof structured.value === 'string' ? structured.value.replace(/[\x00-\x1f\x7f]/g, '') : structured.value;
|
||||
return value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
|
||||
}
|
||||
/**
|
||||
* Encodes a string or an Buffer to an UTF-8 Parameter Value Continuation encoding (rfc2231)
|
||||
* Useful for splitting long parameter values.
|
||||
*
|
||||
* For example
|
||||
* title="unicode string"
|
||||
* becomes
|
||||
* title*0*=utf-8''unicode
|
||||
* title*1*=%20string
|
||||
*
|
||||
* @param data String to be encoded
|
||||
* @param [maxLength=50] Max length for generated chunks
|
||||
* @param [fromCharset='UTF-8'] Source sharacter set
|
||||
* @return A list of encoded keys and headers
|
||||
*/
|
||||
function buildHeaderParam(key, data, maxLength) {
|
||||
const list = [];
|
||||
let encodedStr = typeof data === 'string' ? data : (data || '').toString();
|
||||
let chr;
|
||||
let line;
|
||||
let startPos = 0;
|
||||
let i, len;
|
||||
maxLength = maxLength || 50;
|
||||
// process ascii only text
|
||||
if (isPlainText(data, true)) {
|
||||
// check if conversion is even needed
|
||||
if (encodedStr.length <= maxLength) {
|
||||
return [
|
||||
{
|
||||
key,
|
||||
value: encodedStr
|
||||
}
|
||||
];
|
||||
}
|
||||
encodedStr = encodedStr.replace(new RegExp('.{' + maxLength + '}', 'g'), str => {
|
||||
list.push({
|
||||
line: str
|
||||
});
|
||||
return '';
|
||||
});
|
||||
if (encodedStr) {
|
||||
list.push({
|
||||
line: encodedStr
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (/[\uD800-\uDBFF]/.test(encodedStr)) {
|
||||
// string containts surrogate pairs, so normalize it to an array of bytes
|
||||
const encodedStrArr = [];
|
||||
for (i = 0, len = encodedStr.length; i < len; i++) {
|
||||
chr = encodedStr.charAt(i);
|
||||
if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
|
||||
// an unpaired leading surrogate must not consume the next unit, that
|
||||
// would tear apart a valid pair following it
|
||||
chr += encodedStr.charAt(i + 1);
|
||||
encodedStrArr.push(chr);
|
||||
i++;
|
||||
}
|
||||
else {
|
||||
encodedStrArr.push(chr);
|
||||
}
|
||||
}
|
||||
encodedStr = encodedStrArr;
|
||||
}
|
||||
// first line includes the charset and language info and needs to be encoded
|
||||
// even if it does not contain any unicode characters
|
||||
line = "utf-8''";
|
||||
let encoded = true;
|
||||
startPos = 0;
|
||||
// process text with unicode or special chars
|
||||
for (i = 0, len = encodedStr.length; i < len; i++) {
|
||||
chr = encodedStr[i];
|
||||
if (encoded) {
|
||||
chr = safeEncodeURIComponent(chr);
|
||||
}
|
||||
else {
|
||||
// try to urlencode current char
|
||||
chr = chr === ' ' ? chr : safeEncodeURIComponent(chr);
|
||||
// By default it is not required to encode a line, the need
|
||||
// only appears when the string contains unicode or special chars
|
||||
// in this case we start processing the line over and encode all chars
|
||||
if (chr !== encodedStr[i]) {
|
||||
// Check if it is even possible to add the encoded char to the line
|
||||
// If not, there is no reason to use this line, just push it to the list
|
||||
// and start a new line with the char that needs encoding
|
||||
if ((safeEncodeURIComponent(line) + chr).length >= maxLength) {
|
||||
list.push({
|
||||
line,
|
||||
encoded
|
||||
});
|
||||
// the line we start here holds an encoded char, so it has to be
|
||||
// flagged as one. otherwise it gets an unstarred continuation key
|
||||
// and a receiver reads the percent escapes as literal text
|
||||
line = '';
|
||||
encoded = true;
|
||||
}
|
||||
else {
|
||||
encoded = true;
|
||||
i = startPos;
|
||||
line = '';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// if the line is already too long, push it to the list and start a new one
|
||||
if ((line + chr).length >= maxLength) {
|
||||
list.push({
|
||||
line,
|
||||
encoded
|
||||
});
|
||||
line = chr = encodedStr[i] === ' ' ? ' ' : safeEncodeURIComponent(encodedStr[i]);
|
||||
if (chr === encodedStr[i]) {
|
||||
encoded = false;
|
||||
startPos = i - 1;
|
||||
}
|
||||
else {
|
||||
encoded = true;
|
||||
}
|
||||
}
|
||||
else {
|
||||
line += chr;
|
||||
}
|
||||
}
|
||||
if (line) {
|
||||
list.push({
|
||||
line,
|
||||
encoded
|
||||
});
|
||||
}
|
||||
}
|
||||
return list.map((item, i) => ({
|
||||
// encoded lines: {name}*{part}*
|
||||
// unencoded lines: {name}*{part}
|
||||
// if any line needs to be encoded then the first line (part==0) is always encoded
|
||||
key: key + '*' + i + (item.encoded ? '*' : ''),
|
||||
value: item.line
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Parses a header value with key=value arguments into a structured
|
||||
* object.
|
||||
*
|
||||
* parseHeaderValue('content-type: text/plain; CHARSET='UTF-8'') ->
|
||||
* {
|
||||
* 'value': 'text/plain',
|
||||
* 'params': {
|
||||
* 'charset': 'UTF-8'
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @param str Header value
|
||||
* @return Header value as a parsed structure
|
||||
*/
|
||||
function parseHeaderValue(str) {
|
||||
const response = {
|
||||
value: false,
|
||||
params: {}
|
||||
};
|
||||
// Parameter names come from a caller supplied contentType/contentDisposition. A
|
||||
// "__proto__" name would target the prototype chain of the params object instead of
|
||||
// an own property of it, and read back as Object.prototype, so it is dropped.
|
||||
const setParam = (name, value) => {
|
||||
if (!(0, objects_js_1.isProtoKey)(name)) {
|
||||
response.params[name] = value;
|
||||
}
|
||||
};
|
||||
let key = false;
|
||||
let value = '';
|
||||
let type = 'value';
|
||||
let quote = false;
|
||||
let escaped = false;
|
||||
let chr;
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
chr = str.charAt(i);
|
||||
if (type === 'key') {
|
||||
if (chr === '=') {
|
||||
key = value.trim().toLowerCase();
|
||||
type = 'value';
|
||||
value = '';
|
||||
continue;
|
||||
}
|
||||
value += chr;
|
||||
}
|
||||
else {
|
||||
if (escaped) {
|
||||
value += chr;
|
||||
}
|
||||
else if (chr === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
else if (quote && chr === quote) {
|
||||
quote = false;
|
||||
}
|
||||
else if (!quote && chr === '"') {
|
||||
quote = chr;
|
||||
}
|
||||
else if (!quote && chr === ';') {
|
||||
if (key === false) {
|
||||
response.value = value.trim();
|
||||
}
|
||||
else {
|
||||
setParam(key, value.trim());
|
||||
}
|
||||
type = 'key';
|
||||
value = '';
|
||||
}
|
||||
else {
|
||||
value += chr;
|
||||
}
|
||||
escaped = false;
|
||||
}
|
||||
}
|
||||
if (type === 'value') {
|
||||
if (key === false) {
|
||||
response.value = value.trim();
|
||||
}
|
||||
else {
|
||||
setParam(key, value.trim());
|
||||
}
|
||||
}
|
||||
else if (value.trim()) {
|
||||
setParam(value.trim().toLowerCase(), '');
|
||||
}
|
||||
// handle parameter value continuations
|
||||
// https://tools.ietf.org/html/rfc2231#section-3
|
||||
// preprocess values
|
||||
Object.keys(response.params).forEach(key => {
|
||||
let actualKey, nr, match, value;
|
||||
if ((match = key.match(/(\*(\d+)|\*(\d+)\*|\*)$/))) {
|
||||
actualKey = key.substr(0, match.index);
|
||||
nr = Number(match[2] || match[3]) || 0;
|
||||
if ((0, objects_js_1.isProtoKey)(actualKey)) {
|
||||
// see setParam. Reading it back would yield Object.prototype, which is
|
||||
// an object, so the initializer below would be skipped and the write
|
||||
// that follows would throw out of a header build the caller can not catch
|
||||
delete response.params[key];
|
||||
return;
|
||||
}
|
||||
if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
|
||||
response.params[actualKey] = {
|
||||
charset: false,
|
||||
values: []
|
||||
};
|
||||
}
|
||||
value = response.params[key];
|
||||
if (nr === 0 && match[0].substr(-1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) {
|
||||
response.params[actualKey].charset = match[1] || 'iso-8859-1';
|
||||
value = match[2];
|
||||
}
|
||||
response.params[actualKey].values[nr] = value;
|
||||
// remove the old reference
|
||||
delete response.params[key];
|
||||
}
|
||||
});
|
||||
// concatenate split rfc2231 strings and convert encoded strings to mime encoded words
|
||||
Object.keys(response.params).forEach(key => {
|
||||
let value;
|
||||
if (response.params[key] && Array.isArray(response.params[key].values)) {
|
||||
value = response.params[key].values.map((val) => val || '').join('');
|
||||
if (response.params[key].charset) {
|
||||
// convert "%AB" to "=?charset?Q?=AB?="
|
||||
response.params[key] =
|
||||
'=?' +
|
||||
response.params[key].charset +
|
||||
'?Q?' +
|
||||
value
|
||||
// fix invalidly encoded chars
|
||||
.replace(/[=?_\s]/g, s => {
|
||||
const c = s.charCodeAt(0).toString(16);
|
||||
if (s === ' ') {
|
||||
return '_';
|
||||
}
|
||||
return '%' + (c.length < 2 ? '0' : '') + c;
|
||||
})
|
||||
// change from urlencoding to percent encoding
|
||||
.replace(/%/g, '=') +
|
||||
'?=';
|
||||
}
|
||||
else {
|
||||
response.params[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
/**
|
||||
* Returns file extension for a content type string. If no suitable extensions
|
||||
* are found, 'bin' is used as the default extension
|
||||
*
|
||||
* @param mimeType Content type to be checked for
|
||||
* @return File extension
|
||||
*/
|
||||
function detectExtension(mimeType) {
|
||||
return mimeTypes.detectExtension(mimeType);
|
||||
}
|
||||
/**
|
||||
* Returns content type for a file extension. If no suitable content types
|
||||
* are found, 'application/octet-stream' is used as the default content type
|
||||
*
|
||||
* @param extension Extension to be checked for
|
||||
* @return File extension
|
||||
*/
|
||||
function detectMimeType(extension) {
|
||||
return mimeTypes.detectMimeType(extension);
|
||||
}
|
||||
/**
|
||||
* Folds long lines, useful for folding header lines (afterSpace=false) and
|
||||
* flowed text (afterSpace=true)
|
||||
*
|
||||
* @param str String to be folded
|
||||
* @param [lineLength=76] Maximum length of a line
|
||||
* @param afterSpace If true, leave a space in th end of a line
|
||||
* @return String with folded lines
|
||||
*/
|
||||
function foldLines(str, lineLength, afterSpace) {
|
||||
str = (str || '').toString();
|
||||
lineLength = lineLength || 76;
|
||||
let pos = 0;
|
||||
const len = str.length;
|
||||
let result = '';
|
||||
let line, match;
|
||||
while (pos < len) {
|
||||
line = str.substr(pos, lineLength);
|
||||
if (line.length < lineLength) {
|
||||
result += line;
|
||||
break;
|
||||
}
|
||||
if ((match = line.match(/^[^\n\r]*(\r?\n|\r)/))) {
|
||||
line = match[0];
|
||||
result += line;
|
||||
pos += line.length;
|
||||
continue;
|
||||
}
|
||||
else if ((match = line.match(/(\s+)[^\s]*$/)) && match[0].length - (afterSpace ? (match[1] || '').length : 0) < line.length) {
|
||||
line = line.substr(0, line.length - (match[0].length - (afterSpace ? (match[1] || '').length : 0)));
|
||||
}
|
||||
else if ((match = str.substr(pos + line.length).match(/^[^\s]+(\s*)/))) {
|
||||
line = line + match[0].substr(0, match[0].length - (!afterSpace ? (match[1] || '').length : 0));
|
||||
}
|
||||
result += line;
|
||||
pos += line.length;
|
||||
if (pos < len) {
|
||||
result += '\r\n';
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Splits a mime encoded string. Needed for dividing mime words into smaller chunks
|
||||
*
|
||||
* @param str Mime encoded string to be split up
|
||||
* @param maxlen Maximum length of characters for one part (minimum 12)
|
||||
* @return Split string
|
||||
*/
|
||||
function splitMimeEncodedString(str, maxlen) {
|
||||
const lines = [];
|
||||
let curLine, fallbackLine, match, chr, done;
|
||||
// require at least 12 symbols to fit possible 4 octet UTF-8 sequences
|
||||
maxlen = Math.max(maxlen || 0, 12);
|
||||
while (str.length) {
|
||||
curLine = str.substr(0, maxlen);
|
||||
// move incomplete escaped char back to main
|
||||
if ((match = curLine.match(/[=][0-9A-F]?$/i))) {
|
||||
curLine = curLine.substr(0, match.index);
|
||||
}
|
||||
// Malformed input (a run of stray UTF-8 continuation bytes) has no split point
|
||||
// that keeps a character sequence whole, so the loop below walks back to an
|
||||
// empty line looking for one. Keep the widest chunk that at least does not cut
|
||||
// a "=XX" escape in half, so the part stays a decodable encoded word.
|
||||
fallbackLine = curLine.length ? curLine : str.substr(0, maxlen);
|
||||
done = false;
|
||||
while (!done && curLine.length) {
|
||||
done = true;
|
||||
// check if not middle of a unicode char sequence
|
||||
if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
|
||||
chr = parseInt(match[1], 16);
|
||||
// invalid sequence, move one char back anc recheck
|
||||
if (chr < 0xc2 && chr > 0x7f) {
|
||||
curLine = curLine.substr(0, curLine.length - 3);
|
||||
done = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!curLine.length) {
|
||||
curLine = fallbackLine;
|
||||
}
|
||||
lines.push(curLine);
|
||||
str = str.substr(curLine.length);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
function encodeURICharComponent(chr) {
|
||||
let res = '';
|
||||
let ord = chr.charCodeAt(0).toString(16).toUpperCase();
|
||||
if (ord.length % 2) {
|
||||
ord = '0' + ord;
|
||||
}
|
||||
if (ord.length > 2) {
|
||||
for (let i = 0, len = ord.length / 2; i < len; i++) {
|
||||
res += '%' + ord.substr(i, 2);
|
||||
}
|
||||
}
|
||||
else {
|
||||
res += '%' + ord;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
function safeEncodeURIComponent(str) {
|
||||
str = (str || '').toString();
|
||||
try {
|
||||
// might throw if we try to encode invalid sequences, eg. partial emoji
|
||||
str = encodeURIComponent(str);
|
||||
}
|
||||
catch (_E) {
|
||||
// an unpaired surrogate has no utf-8 representation, so run the value through a
|
||||
// utf-8 roundtrip to get the same U+FFFD every other encoder here produces and
|
||||
// retry. the value must never come back unencoded, it goes into a header parameter
|
||||
// where a bare quote or semicolon would break it out into a parameter of its own
|
||||
str = encodeURIComponent(Buffer.from(str, 'utf-8').toString('utf-8'));
|
||||
}
|
||||
// ensure chars that are not handled by encodeURICompent are converted as well
|
||||
return str.replace(/[\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]/g, chr => encodeURICharComponent(chr));
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export declare function detectMimeType(filename?: string | false): string;
|
||||
export declare function detectExtension(mimeType?: string | false): string;
|
||||
+2103
File diff suppressed because it is too large
Load Diff
+330
@@ -0,0 +1,330 @@
|
||||
import { type Duplex, type Readable, type TransformOptions, type Writable } from 'node:stream';
|
||||
import type { NodemailerError } from '../errors.js';
|
||||
import type { OutgoingHttpHeaders } from 'node:http';
|
||||
/**
|
||||
* Options for a MimeNode
|
||||
*/
|
||||
export interface MimeNodeOptions {
|
||||
/** root node for this tree */
|
||||
rootNode?: MimeNode | undefined;
|
||||
/** immediate parent for this node */
|
||||
parentNode?: MimeNode | undefined;
|
||||
/** filename for an attachment node */
|
||||
filename?: string | undefined;
|
||||
/** shared part of the unique multipart boundary */
|
||||
baseBoundary?: string | undefined;
|
||||
/** prefix for the generated multipart boundaries, defaults to '--_NmP' */
|
||||
boundaryPrefix?: string | undefined;
|
||||
/** If true, do not exclude Bcc from the generated headers */
|
||||
keepBcc?: boolean | undefined;
|
||||
/** method to normalize header keys for custom caseing */
|
||||
normalizeHeaderKey?: ((key: string, value: string) => string) | undefined;
|
||||
/** either 'Q' (the default) or 'B' */
|
||||
textEncoding?: string | undefined;
|
||||
/** Hostname for default message-id values */
|
||||
hostname?: string | undefined;
|
||||
/** If set to 'win' then uses \r\n, if 'linux' then \n. If not set (or `raw` is used) then newlines are kept as is */
|
||||
newline?: string | undefined;
|
||||
/** Reject content that points to a file path, for this node and every node below it */
|
||||
disableFileAccess?: boolean | undefined;
|
||||
/** Reject content that points to a URL, for this node and every node below it */
|
||||
disableUrlAccess?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* An address object as accepted in address headers and produced by getAddresses. Either a
|
||||
* mailbox with an address or a group holding a list of addresses
|
||||
*/
|
||||
export interface MimeNodeAddress {
|
||||
name?: string | undefined;
|
||||
address?: string | undefined;
|
||||
group?: MimeNodeAddress[] | undefined;
|
||||
}
|
||||
/**
|
||||
* Addresses as accepted by address headers and envelope fields: an address string, an
|
||||
* address object, or an array of these, nested arrays included
|
||||
*/
|
||||
export type MimeNodeAddressInput = string | MimeNodeAddress | MimeNodeAddressInput[];
|
||||
/**
|
||||
* A header value object. When `prepared` is set the value is emitted as is, without
|
||||
* encoding, and folded only if `foldLines` is set as well
|
||||
*/
|
||||
export interface MimeNodePreparedHeaderValue {
|
||||
value?: unknown;
|
||||
prepared?: boolean | undefined;
|
||||
foldLines?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* The values a header can hold: a string, a number, a Date (for the Date header), address
|
||||
* objects (for the address headers), a prepared value object, or a list of these
|
||||
*/
|
||||
export type MimeNodeHeaderValue = string | number | boolean | Date | MimeNodeAddress | MimeNodePreparedHeaderValue | MimeNodeHeaderValue[] | null | undefined;
|
||||
/**
|
||||
* A single header, the shape the headers are stored in and one of the list forms
|
||||
* setHeader and addHeader accept
|
||||
*/
|
||||
export interface MimeNodeHeader {
|
||||
key: string;
|
||||
value: MimeNodeHeaderValue;
|
||||
}
|
||||
/**
|
||||
* Several headers keyed by header name
|
||||
*/
|
||||
export interface MimeNodeHeaderMap {
|
||||
[key: string]: MimeNodeHeaderValue;
|
||||
}
|
||||
/**
|
||||
* Several headers at once, as accepted by setHeader and addHeader: a single {key, value}
|
||||
* pair, a list of them, or an object keyed by header name
|
||||
*/
|
||||
export type MimeNodeHeaders = MimeNodeHeader | MimeNodeHeader[] | MimeNodeHeaderMap;
|
||||
/**
|
||||
* A content descriptor object, see setContent. Points to the content instead of carrying it
|
||||
*/
|
||||
export interface MimeNodeContentObject {
|
||||
/** File path to read the content from */
|
||||
path?: string | undefined;
|
||||
/** URL to fetch the content from */
|
||||
href?: string | undefined;
|
||||
/** Request headers for a URL fetch */
|
||||
httpHeaders?: OutgoingHttpHeaders | undefined;
|
||||
/** TLS settings for a URL fetch, see nmfetch */
|
||||
tls?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
}
|
||||
/**
|
||||
* Body content as accepted by setContent and setRaw: a string, a Buffer, a readable stream
|
||||
* or an object pointing to the content
|
||||
*/
|
||||
export type MimeNodeContent = string | Buffer | Readable | MimeNodeContentObject;
|
||||
/**
|
||||
* SMTP envelope as returned by getEnvelope. Custom fields of an envelope set with
|
||||
* setEnvelope are carried along
|
||||
*/
|
||||
export interface MimeNodeEnvelope {
|
||||
from: string | false;
|
||||
to: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* Envelope as accepted by setEnvelope. Recipients are collected from to, cc and bcc, any
|
||||
* other field is copied to the envelope as is
|
||||
*/
|
||||
export interface MimeNodeEnvelopeInput {
|
||||
from?: MimeNodeAddressInput | undefined;
|
||||
to?: MimeNodeAddressInput | undefined;
|
||||
cc?: MimeNodeAddressInput | undefined;
|
||||
bcc?: MimeNodeAddressInput | undefined;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* Parsed address headers as returned by getAddresses, keyed by lowercase header name
|
||||
*/
|
||||
export interface MimeNodeAddresses {
|
||||
from?: MimeNodeAddress[] | undefined;
|
||||
sender?: MimeNodeAddress[] | undefined;
|
||||
'reply-to'?: MimeNodeAddress[] | undefined;
|
||||
to?: MimeNodeAddress[] | undefined;
|
||||
cc?: MimeNodeAddress[] | undefined;
|
||||
bcc?: MimeNodeAddress[] | undefined;
|
||||
}
|
||||
/**
|
||||
* Options for createReadStream. Handed to the PassThrough the message is written to and to
|
||||
* the base64 / quoted-printable encoders, which read `lineLength` from it
|
||||
*/
|
||||
export interface MimeNodeStreamOptions extends TransformOptions {
|
||||
/** Maximum line length for base64 and quoted-printable bodies, false disables wrapping */
|
||||
lineLength?: number | false | undefined;
|
||||
}
|
||||
/**
|
||||
* A transform stream the message is piped through, or a function returning one
|
||||
*/
|
||||
export type MimeNodeTransform = Duplex | (() => Duplex);
|
||||
/**
|
||||
* A post process function, takes the message stream and returns the stream to expose instead
|
||||
*/
|
||||
export type MimeNodeProcessFunc = (input: Readable) => Readable;
|
||||
/**
|
||||
* Callback for build
|
||||
*/
|
||||
export type MimeNodeBuildCallback = (err: NodemailerError | null, message: Buffer) => void;
|
||||
/**
|
||||
* Creates a new mime tree node. Assumes 'multipart/*' as the content type
|
||||
* if it is a branch, anything else counts as leaf. If rootNode is missing from
|
||||
* the options, assumes this is the root.
|
||||
*
|
||||
* @param contentType Define the content type for the node. Can be left blank for attachments (derived from filename)
|
||||
* @param [options] optional options
|
||||
* @param [options.rootNode] root node for this tree
|
||||
* @param [options.parentNode] immediate parent for this node
|
||||
* @param [options.filename] filename for an attachment node
|
||||
* @param [options.baseBoundary] shared part of the unique multipart boundary
|
||||
* @param [options.keepBcc] If true, do not exclude Bcc from the generated headers
|
||||
* @param [options.normalizeHeaderKey] method to normalize header keys for custom caseing
|
||||
* @param [options.textEncoding] either 'Q' (the default) or 'B'
|
||||
*/
|
||||
declare class MimeNode {
|
||||
nodeCounter: number;
|
||||
baseBoundary: string;
|
||||
boundaryPrefix: string;
|
||||
disableFileAccess: boolean;
|
||||
disableUrlAccess: boolean;
|
||||
normalizeHeaderKey: MimeNodeOptions['normalizeHeaderKey'];
|
||||
date: Date | null;
|
||||
rootNode: MimeNode;
|
||||
keepBcc: boolean;
|
||||
textEncoding: string;
|
||||
parentNode: MimeNode | undefined;
|
||||
hostname: string | undefined;
|
||||
newline: string | undefined;
|
||||
childNodes: MimeNode[];
|
||||
/** Filename for this node. Useful with attachments */
|
||||
filename?: string | undefined;
|
||||
/** Body content, or the error a content stream emitted before it was read */
|
||||
content?: MimeNodeContent | Error | undefined;
|
||||
/** Lowercase content type, set when the headers are built */
|
||||
contentType?: string | undefined;
|
||||
/** Multipart subtype, false for a non-multipart node, set when the headers are built */
|
||||
multipart?: string | false | undefined;
|
||||
/** Multipart boundary, false for a non-multipart node, set when the headers are built */
|
||||
boundary?: string | false | undefined;
|
||||
constructor(contentType?: string | false, options?: MimeNodeOptions);
|
||||
/**
|
||||
* Creates and appends a child node.Arguments provided are passed to MimeNode constructor
|
||||
*
|
||||
* @param [contentType] Optional content type
|
||||
* @param [options] Optional options object
|
||||
* @return Created node object
|
||||
*/
|
||||
createChild(contentType?: string | false | MimeNodeOptions, options?: MimeNodeOptions): MimeNode;
|
||||
/**
|
||||
* Appends an existing node to the mime tree. Removes the node from an existing
|
||||
* tree if needed
|
||||
*
|
||||
* @param childNode node to be appended
|
||||
* @return Appended node object
|
||||
*/
|
||||
appendChild(childNode: MimeNode): MimeNode;
|
||||
/**
|
||||
* Replaces current node with another node
|
||||
*
|
||||
* @param node Replacement node
|
||||
* @return Replacement node
|
||||
*/
|
||||
replace(node: MimeNode): MimeNode;
|
||||
/**
|
||||
* Removes current node from the mime tree
|
||||
*
|
||||
* @return removed node
|
||||
*/
|
||||
remove(): MimeNode | undefined;
|
||||
/**
|
||||
* Sets a header value. If the value for selected key exists, it is overwritten.
|
||||
* You can set multiple values as well by using [{key:'', value:''}] or
|
||||
* {key: 'value'} as the first argument.
|
||||
*
|
||||
* @param key Header key or a list of key value pairs
|
||||
* @param value Header value
|
||||
* @return current node
|
||||
*/
|
||||
setHeader(key: string | MimeNodeHeaders, value?: MimeNodeHeaderValue): this;
|
||||
/**
|
||||
* Adds a header value. If the value for selected key exists, the value is appended
|
||||
* as a new field and old one is not touched.
|
||||
* You can set multiple values as well by using [{key:'', value:''}] or
|
||||
* {key: 'value'} as the first argument.
|
||||
*
|
||||
* @param key Header key or a list of key value pairs
|
||||
* @param value Header value
|
||||
* @return current node
|
||||
*/
|
||||
addHeader(key: string | MimeNodeHeaders, value?: MimeNodeHeaderValue): this;
|
||||
/**
|
||||
* Retrieves the first mathcing value of a selected key
|
||||
*
|
||||
* @param key Key to search for
|
||||
* @retun Value for the key
|
||||
*/
|
||||
getHeader(key: string): MimeNodeHeaderValue;
|
||||
/**
|
||||
* Sets body content for current node. If the value is a string, charset is added automatically
|
||||
* to Content-Type (if it is text/*). If the value is a Buffer, you need to specify
|
||||
* the charset yourself
|
||||
*
|
||||
* @param content Body content
|
||||
* @return current node
|
||||
*/
|
||||
setContent(content: MimeNodeContent): this;
|
||||
build(): Promise<Buffer>;
|
||||
build(callback: MimeNodeBuildCallback): void;
|
||||
getTransferEncoding(): string | false;
|
||||
/**
|
||||
* Builds the header block for the mime node. Append \r\n\r\n before writing the content
|
||||
*
|
||||
* @returns Headers
|
||||
*/
|
||||
buildHeaders(): string;
|
||||
/**
|
||||
* Streams the rfc2822 message from the current node. If this is a root node,
|
||||
* mandatory header fields are set if missing (Date, Message-Id, MIME-Version)
|
||||
*
|
||||
* @return Compiled message
|
||||
*/
|
||||
createReadStream(options?: MimeNodeStreamOptions): Readable;
|
||||
/**
|
||||
* Appends a transform stream object to the transforms list. Final output
|
||||
* is passed through this stream before exposing
|
||||
*
|
||||
* @param transform Read-Write stream
|
||||
*/
|
||||
transform(transform: MimeNodeTransform): void;
|
||||
/**
|
||||
* Appends a post process function. The functon is run after transforms and
|
||||
* uses the following syntax
|
||||
*
|
||||
* processFunc(input) -> outputStream
|
||||
*
|
||||
* @param processFunc Read-Write stream
|
||||
*/
|
||||
processFunc(processFunc: MimeNodeProcessFunc): void;
|
||||
stream(outputStream: Writable, options: MimeNodeStreamOptions, done: (err?: Error | null) => void): void;
|
||||
/**
|
||||
* Sets envelope to be used instead of the generated one
|
||||
*
|
||||
* @return SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
|
||||
*/
|
||||
setEnvelope(envelope: MimeNodeEnvelopeInput): this;
|
||||
/**
|
||||
* Generates and returns an object with parsed address fields
|
||||
*
|
||||
* @return Address object
|
||||
*/
|
||||
getAddresses(): MimeNodeAddresses;
|
||||
/**
|
||||
* Generates and returns SMTP envelope with the sender address and a list of recipients addresses
|
||||
*
|
||||
* @return SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
|
||||
*/
|
||||
getEnvelope(): MimeNodeEnvelope;
|
||||
/**
|
||||
* Returns Message-Id value. If it does not exist, then creates one
|
||||
*
|
||||
* @return Message-Id value
|
||||
*/
|
||||
messageId(): string;
|
||||
/**
|
||||
* Sets pregenerated content that will be used as the output of this node
|
||||
*
|
||||
* @param raw Raw MIME contents
|
||||
*/
|
||||
setRaw(raw: MimeNodeContent): this;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `MimeNode.Options` style references keep working
|
||||
*/
|
||||
declare namespace MimeNode {
|
||||
type Options = MimeNodeOptions;
|
||||
type Addresses = MimeNodeAddresses;
|
||||
type Envelope = MimeNodeEnvelope;
|
||||
}
|
||||
export default MimeNode;
|
||||
+1492
File diff suppressed because it is too large
Load Diff
+5
@@ -0,0 +1,5 @@
|
||||
import { Transform } from 'node:stream';
|
||||
export default class LastNewline extends Transform {
|
||||
lastByte: number | false;
|
||||
constructor();
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_stream_1 = require("node:stream");
|
||||
class LastNewline extends node_stream_1.Transform {
|
||||
constructor() {
|
||||
super();
|
||||
this.lastByte = false;
|
||||
}
|
||||
/** @internal */
|
||||
_transform(chunk, encoding, done) {
|
||||
if (chunk.length) {
|
||||
this.lastByte = chunk[chunk.length - 1];
|
||||
}
|
||||
this.push(chunk);
|
||||
done();
|
||||
}
|
||||
/** @internal */
|
||||
_flush(done) {
|
||||
if (this.lastByte === 0x0a) {
|
||||
return done();
|
||||
}
|
||||
if (this.lastByte === 0x0d) {
|
||||
this.push(Buffer.from('\n'));
|
||||
return done();
|
||||
}
|
||||
this.push(Buffer.from('\r\n'));
|
||||
return done();
|
||||
}
|
||||
}
|
||||
exports.default = LastNewline;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { Transform, type TransformOptions } from 'node:stream';
|
||||
/**
|
||||
* Ensures that only <LF> is used for linebreaks
|
||||
*
|
||||
* @param options Stream options
|
||||
*/
|
||||
export default class LeUnix extends Transform {
|
||||
constructor(options?: TransformOptions);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_stream_1 = require("node:stream");
|
||||
/**
|
||||
* Ensures that only <LF> is used for linebreaks
|
||||
*
|
||||
* @param options Stream options
|
||||
*/
|
||||
class LeUnix extends node_stream_1.Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
/**
|
||||
* Escapes dots
|
||||
* @internal
|
||||
*/
|
||||
_transform(chunk, encoding, done) {
|
||||
let buf;
|
||||
let lastPos = 0;
|
||||
for (let i = 0, len = chunk.length; i < len; i++) {
|
||||
if (chunk[i] === 0x0d) {
|
||||
// \r
|
||||
buf = chunk.slice(lastPos, i);
|
||||
lastPos = i + 1;
|
||||
this.push(buf);
|
||||
}
|
||||
}
|
||||
if (lastPos && lastPos < chunk.length) {
|
||||
buf = chunk.slice(lastPos);
|
||||
this.push(buf);
|
||||
}
|
||||
else if (!lastPos) {
|
||||
this.push(chunk);
|
||||
}
|
||||
done();
|
||||
}
|
||||
}
|
||||
exports.default = LeUnix;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Transform, type TransformOptions } from 'node:stream';
|
||||
/**
|
||||
* Ensures that only <CR><LF> sequences are used for linebreaks
|
||||
*
|
||||
* @param options Stream options
|
||||
*/
|
||||
export default class LeWindows extends Transform {
|
||||
lastByte: number | false;
|
||||
constructor(options?: TransformOptions);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_stream_1 = require("node:stream");
|
||||
/**
|
||||
* Ensures that only <CR><LF> sequences are used for linebreaks
|
||||
*
|
||||
* @param options Stream options
|
||||
*/
|
||||
class LeWindows extends node_stream_1.Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.lastByte = false;
|
||||
}
|
||||
/**
|
||||
* Escapes dots
|
||||
* @internal
|
||||
*/
|
||||
_transform(chunk, encoding, done) {
|
||||
let buf;
|
||||
let lastPos = 0;
|
||||
for (let i = 0, len = chunk.length; i < len; i++) {
|
||||
if (chunk[i] === 0x0a) {
|
||||
// \n
|
||||
if ((i && chunk[i - 1] !== 0x0d) || (!i && this.lastByte !== 0x0d)) {
|
||||
if (i > lastPos) {
|
||||
buf = chunk.slice(lastPos, i);
|
||||
this.push(buf);
|
||||
}
|
||||
this.push(Buffer.from('\r\n'));
|
||||
lastPos = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastPos && lastPos < chunk.length) {
|
||||
buf = chunk.slice(lastPos);
|
||||
this.push(buf);
|
||||
}
|
||||
else if (!lastPos) {
|
||||
this.push(chunk);
|
||||
}
|
||||
this.lastByte = chunk[chunk.length - 1];
|
||||
done();
|
||||
}
|
||||
}
|
||||
exports.default = LeWindows;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import Mail from './mailer/index.js';
|
||||
import type { MailDefaults, SentMessageInfo, Transport, TransportOptions } from './mailer/index.js';
|
||||
import type { SMTPPoolOptions, SMTPPoolSentMessageInfo } from './smtp-pool/index.js';
|
||||
import type { SMTPTransportOptions, SMTPSentMessageInfo } from './smtp-transport/index.js';
|
||||
import type { SendmailTransportOptions, SendmailSentMessageInfo } from './sendmail-transport/index.js';
|
||||
import type { StreamTransportOptions, StreamSentMessageInfo } from './stream-transport/index.js';
|
||||
import type { JSONTransportOptions, JSONSentMessageInfo } from './json-transport/index.js';
|
||||
import type { SESTransportOptions, SESSentMessageInfo } from './ses-transport/index.js';
|
||||
/**
|
||||
* Connection details of a service endpoint of an Ethereal test account
|
||||
*/
|
||||
export interface TestAccountService {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
}
|
||||
/**
|
||||
* Ethereal test account returned by createTestAccount()
|
||||
*/
|
||||
export interface TestAccount {
|
||||
user: string;
|
||||
pass: string;
|
||||
smtp: TestAccountService;
|
||||
imap: TestAccountService;
|
||||
pop3: TestAccountService;
|
||||
web: string;
|
||||
/** true if the account can also receive external mail */
|
||||
mxEnabled?: boolean | undefined;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* Callback for createTestAccount()
|
||||
*/
|
||||
export type TestAccountCallback = (err: Error | null, account: TestAccount) => void;
|
||||
/**
|
||||
* Configuration object of any of the bundled transports
|
||||
*/
|
||||
export type TransportConfig = SMTPTransportOptions | SMTPPoolOptions | SendmailTransportOptions | StreamTransportOptions | JSONTransportOptions | SESTransportOptions;
|
||||
/**
|
||||
* Creates a transporter object for sending e-mails
|
||||
*
|
||||
* @param transporter Transport configuration object, a connection URL or a transport plugin instance
|
||||
* @param defaults Default message fields that are merged into every message
|
||||
* @returns Mail instance wrapping the transport
|
||||
*/
|
||||
export declare function createTransport(transporter: SMTPPoolOptions & {
|
||||
pool: true;
|
||||
}, defaults?: MailDefaults): Mail<SMTPPoolSentMessageInfo>;
|
||||
export declare function createTransport(transporter: SendmailTransportOptions & {
|
||||
sendmail: true | string;
|
||||
}, defaults?: MailDefaults): Mail<SendmailSentMessageInfo>;
|
||||
export declare function createTransport(transporter: StreamTransportOptions & {
|
||||
streamTransport: true;
|
||||
}, defaults?: MailDefaults): Mail<StreamSentMessageInfo>;
|
||||
export declare function createTransport(transporter: JSONTransportOptions & {
|
||||
jsonTransport: true;
|
||||
}, defaults?: MailDefaults): Mail<JSONSentMessageInfo>;
|
||||
export declare function createTransport(transporter: SESTransportOptions & {
|
||||
SES: object;
|
||||
}, defaults?: MailDefaults): Mail<SESSentMessageInfo>;
|
||||
export declare function createTransport<T = SentMessageInfo>(transporter: Transport<T>, defaults?: MailDefaults): Mail<T>;
|
||||
export declare function createTransport(transporter?: SMTPTransportOptions | string, defaults?: MailDefaults): Mail<SMTPSentMessageInfo>;
|
||||
export declare function createTransport(transporter?: TransportConfig | Transport<any> | string, defaults?: MailDefaults): Mail<any>;
|
||||
/**
|
||||
* Creates a test account from the Ethereal service (https://ethereal.email)
|
||||
*
|
||||
* @param apiUrl Optional API endpoint, defaults to https://api.nodemailer.com
|
||||
* @param callback Callback function to run with the account object. If not set, a Promise is returned
|
||||
*/
|
||||
export declare function createTestAccount(callback: TestAccountCallback): void;
|
||||
export declare function createTestAccount(apiUrl: string | false | null | undefined, callback: TestAccountCallback): void;
|
||||
export declare function createTestAccount(apiUrl?: string | false | null): Promise<TestAccount>;
|
||||
/**
|
||||
* Resolves the Ethereal web URL for a message sent through an Ethereal test account
|
||||
*
|
||||
* @param info Result object of sendMail()
|
||||
* @returns URL of the message in the Ethereal web interface, or false if the response does not carry one
|
||||
*/
|
||||
export declare function getTestMessageUrl(info?: {
|
||||
response?: string | Buffer | null | undefined;
|
||||
} | false | null): string | false;
|
||||
declare const nodemailer: {
|
||||
createTransport: typeof createTransport;
|
||||
createTestAccount: typeof createTestAccount;
|
||||
getTestMessageUrl: typeof getTestMessageUrl;
|
||||
};
|
||||
export default nodemailer;
|
||||
export type { Mail, MailDefaults, SentMessageInfo, Transport, TransportOptions };
|
||||
export type { SendMailOptions, Transporter } from './mailer/index.js';
|
||||
export type { NodemailerError, ErrorCode } from './errors.js';
|
||||
export type { MailMessage, MailMessageData, PluginFunction, VerifyCallback } from './mailer/index.js';
|
||||
export type { MailComposerOptions, MailComposerAttachment as Attachment, MailComposerAlternative as AttachmentLike, MailComposerIcalEvent as IcalAttachment, MailComposerListHeaders as ListHeaders } from './mail-composer/index.js';
|
||||
export type { MimeNodeAddress as Address, MimeNodeHeaders as Headers } from './mime-node/index.js';
|
||||
export type { SMTPConnectionOptions, SMTPConnectionAuth, SMTPEnvelope, SMTPEnvelopeDsn } from './smtp-connection/index.js';
|
||||
export type { DKIMOptions } from './dkim/index.js';
|
||||
export type { XOAuth2Options, XOAuth2Token } from './xoauth2/index.js';
|
||||
export type { SMTPTransportOptions, SMTPSentMessageInfo };
|
||||
export type { SMTPPoolOptions, SMTPPoolSentMessageInfo };
|
||||
export type { SendmailTransportOptions, SendmailSentMessageInfo };
|
||||
export type { StreamTransportOptions, StreamSentMessageInfo };
|
||||
export type { JSONTransportOptions, JSONSentMessageInfo };
|
||||
export type { SESTransportOptions, SESSentMessageInfo };
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createTransport = createTransport;
|
||||
exports.createTestAccount = createTestAccount;
|
||||
exports.getTestMessageUrl = getTestMessageUrl;
|
||||
const index_js_1 = __importDefault(require("./mailer/index.js"));
|
||||
const shared = __importStar(require("./shared/index.js"));
|
||||
const index_js_2 = __importDefault(require("./smtp-pool/index.js"));
|
||||
const index_js_3 = __importDefault(require("./smtp-transport/index.js"));
|
||||
const index_js_4 = __importDefault(require("./sendmail-transport/index.js"));
|
||||
const index_js_5 = __importDefault(require("./stream-transport/index.js"));
|
||||
const index_js_6 = __importDefault(require("./json-transport/index.js"));
|
||||
const index_js_7 = __importDefault(require("./ses-transport/index.js"));
|
||||
const errors = __importStar(require("./errors.js"));
|
||||
const index_js_8 = __importDefault(require("./fetch/index.js"));
|
||||
const packageData = __importStar(require("./package-info.js"));
|
||||
const ETHEREAL_API = (process.env.ETHEREAL_API || 'https://api.nodemailer.com').replace(/\/+$/, '');
|
||||
const ETHEREAL_WEB = (process.env.ETHEREAL_WEB || 'https://ethereal.email').replace(/\/+$/, '');
|
||||
const ETHEREAL_API_KEY = (process.env.ETHEREAL_API_KEY || '').replace(/\s*/g, '') || null;
|
||||
const ETHEREAL_CACHE = ['true', 'yes', 'y', '1'].includes((process.env.ETHEREAL_CACHE || 'yes').toString().trim().toLowerCase());
|
||||
let testAccount = false;
|
||||
function createTransport(transporter, defaults) {
|
||||
let options;
|
||||
if (
|
||||
// provided transporter is a configuration object, not transporter plugin
|
||||
(typeof transporter === 'object' && typeof transporter.send !== 'function') ||
|
||||
// provided transporter looks like a connection url
|
||||
(typeof transporter === 'string' && /^(smtps?|direct):/i.test(transporter))) {
|
||||
const urlConfig = typeof transporter === 'string' ? transporter : transporter.url;
|
||||
if (urlConfig) {
|
||||
// parse a configuration URL into configuration options. The other keys of a
|
||||
// configuration object apply where the url does not set them, merged the same
|
||||
// way the SMTP transports merge their own url option
|
||||
const parsed = shared.parseConnectionUrl(urlConfig);
|
||||
options = (typeof transporter === 'object'
|
||||
? shared.assign(false, shared.copyOwnKeys({}, transporter, key => key === 'url'), parsed)
|
||||
: parsed);
|
||||
}
|
||||
else {
|
||||
options = transporter;
|
||||
}
|
||||
if (options.pool) {
|
||||
transporter = new index_js_2.default(options);
|
||||
}
|
||||
else if (options.sendmail) {
|
||||
transporter = new index_js_4.default(options);
|
||||
}
|
||||
else if (options.streamTransport) {
|
||||
transporter = new index_js_5.default(options);
|
||||
}
|
||||
else if (options.jsonTransport) {
|
||||
transporter = new index_js_6.default(options);
|
||||
}
|
||||
else if (options.SES) {
|
||||
const ses = options.SES;
|
||||
if (ses.ses && ses.aws) {
|
||||
const error = new Error('Using legacy SES configuration, expecting @aws-sdk/client-sesv2, see https://nodemailer.com/transports/ses/');
|
||||
error.code = errors.ECONFIG;
|
||||
throw error;
|
||||
}
|
||||
transporter = new index_js_7.default(options);
|
||||
}
|
||||
else {
|
||||
transporter = new index_js_3.default(options);
|
||||
}
|
||||
}
|
||||
return new index_js_1.default(transporter, options, defaults);
|
||||
}
|
||||
function createTestAccount(apiUrl, callback) {
|
||||
let promise;
|
||||
if (!callback && typeof apiUrl === 'function') {
|
||||
callback = apiUrl;
|
||||
apiUrl = false;
|
||||
}
|
||||
if (!callback) {
|
||||
promise = new Promise((resolve, reject) => {
|
||||
callback = shared.callbackPromise(resolve, reject);
|
||||
});
|
||||
}
|
||||
const done = callback;
|
||||
if (ETHEREAL_CACHE && testAccount) {
|
||||
setImmediate(() => done(null, testAccount));
|
||||
return promise;
|
||||
}
|
||||
apiUrl = apiUrl || ETHEREAL_API;
|
||||
const chunks = [];
|
||||
let chunklen = 0;
|
||||
const requestHeaders = {};
|
||||
const requestBody = {
|
||||
requestor: packageData.name,
|
||||
version: packageData.version
|
||||
};
|
||||
if (ETHEREAL_API_KEY) {
|
||||
requestHeaders.Authorization = 'Bearer ' + ETHEREAL_API_KEY;
|
||||
}
|
||||
const fetchOptions = {
|
||||
contentType: 'application/json',
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: Buffer.from(JSON.stringify(requestBody))
|
||||
};
|
||||
// Credential-bearing request to the Ethereal API. src/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 = (0, index_js_8.default)(apiUrl + '/user', fetchOptions);
|
||||
req.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = req.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
}
|
||||
});
|
||||
req.once('error', err => done(err));
|
||||
req.once('end', () => {
|
||||
const res = Buffer.concat(chunks, chunklen);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(res.toString());
|
||||
}
|
||||
catch (E) {
|
||||
return done(E);
|
||||
}
|
||||
if (data.status !== 'success' || data.error) {
|
||||
return done(new Error(data.error || 'Request failed'));
|
||||
}
|
||||
delete data.status;
|
||||
testAccount = data;
|
||||
done(null, testAccount);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
/**
|
||||
* Resolves the Ethereal web URL for a message sent through an Ethereal test account
|
||||
*
|
||||
* @param info Result object of sendMail()
|
||||
* @returns URL of the message in the Ethereal web interface, or false if the response does not carry one
|
||||
*/
|
||||
function getTestMessageUrl(info) {
|
||||
if (!info || !info.response) {
|
||||
return false;
|
||||
}
|
||||
const infoProps = new Map();
|
||||
// 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);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (infoProps.has('STATUS') && infoProps.has('MSGID')) {
|
||||
return ((testAccount && testAccount.web) || ETHEREAL_WEB) + '/message/' + infoProps.get('MSGID');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const nodemailer = {
|
||||
createTransport,
|
||||
createTestAccount,
|
||||
getTestMessageUrl
|
||||
};
|
||||
exports.default = nodemailer;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export declare const name = "nodemailer";
|
||||
export declare const version = "10.0.8";
|
||||
export declare const homepage = "https://nodemailer.com/";
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build.js from package.json. Do not edit by hand.
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.homepage = exports.version = exports.name = void 0;
|
||||
exports.name = 'nodemailer';
|
||||
exports.version = '10.0.8';
|
||||
exports.homepage = 'https://nodemailer.com/';
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Creates an array containing the numeric code points of each Unicode
|
||||
* character in the string. While JavaScript uses UCS-2 internally,
|
||||
* this function will convert a pair of surrogate halves (each of which
|
||||
* UCS-2 exposes as separate characters) into a single code point,
|
||||
* matching UTF-16.
|
||||
* @see `punycode.ucs2.encode`
|
||||
* @see <https://mathiasbynens.be/notes/javascript-encoding>
|
||||
* @memberOf punycode.ucs2
|
||||
* @name decode
|
||||
* @param string The Unicode input string (UCS-2).
|
||||
* @returns The new array of code points.
|
||||
*/
|
||||
declare function ucs2decode(string: string): number[];
|
||||
/**
|
||||
* Converts a Punycode string of ASCII-only symbols to a string of Unicode
|
||||
* symbols.
|
||||
* @memberOf punycode
|
||||
* @param input The Punycode string of ASCII-only symbols.
|
||||
* @returns The resulting string of Unicode symbols.
|
||||
*/
|
||||
declare const decode: (input: string) => string;
|
||||
/**
|
||||
* Converts a string of Unicode symbols (e.g. a domain name label) to a
|
||||
* Punycode string of ASCII-only symbols.
|
||||
* @memberOf punycode
|
||||
* @param input The string of Unicode symbols.
|
||||
* @returns The resulting Punycode string of ASCII-only symbols.
|
||||
*/
|
||||
declare const encode: (input: string) => string;
|
||||
/**
|
||||
* Converts a Punycode string representing a domain name or an email address
|
||||
* to Unicode. Only the Punycoded parts of the input will be converted, i.e.
|
||||
* it doesn't matter if you call it on a string that has already been
|
||||
* converted to Unicode.
|
||||
* @memberOf punycode
|
||||
* @param input The Punycoded domain name or email address to
|
||||
* convert to Unicode.
|
||||
* @returns The Unicode representation of the given Punycode
|
||||
* string.
|
||||
*/
|
||||
declare const toUnicode: (input: string) => string;
|
||||
/**
|
||||
* Converts a Unicode string representing a domain name or an email address to
|
||||
* Punycode. Only the non-ASCII parts of the domain name will be converted,
|
||||
* i.e. it doesn't matter if you call it with a domain that's already in
|
||||
* ASCII.
|
||||
* @memberOf punycode
|
||||
* @param input The domain name or email address to convert, as a
|
||||
* Unicode string.
|
||||
* @returns The Punycode representation of the given domain name or
|
||||
* email address.
|
||||
*/
|
||||
declare const toASCII: (input: string) => string;
|
||||
/** Define the public API */
|
||||
/**
|
||||
* A string representing the current Punycode.js version number.
|
||||
* @memberOf punycode
|
||||
* @type String
|
||||
*/
|
||||
export declare const version = "2.3.1";
|
||||
/**
|
||||
* An object of methods to convert from JavaScript's internal character
|
||||
* representation (UCS-2) to Unicode code points, and back.
|
||||
* @see <https://mathiasbynens.be/notes/javascript-encoding>
|
||||
* @memberOf punycode
|
||||
* @type Object
|
||||
*/
|
||||
export declare const ucs2: {
|
||||
decode: typeof ucs2decode;
|
||||
encode: (codePoints: number[]) => string;
|
||||
};
|
||||
export { decode, encode, toASCII, toUnicode };
|
||||
Generated
Vendored
+61
-111
@@ -1,3 +1,4 @@
|
||||
"use strict";
|
||||
/*
|
||||
|
||||
Copied from https://github.com/mathiasbynens/punycode.js/blob/ef3505c8abb5143a00d53ce59077c9f7f4b2ac47/punycode.js
|
||||
@@ -24,13 +25,11 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
*/
|
||||
/* eslint callback-return: 0, no-bitwise: 0, eqeqeq: 0, prefer-arrow-callback: 0, object-shorthand: 0 */
|
||||
|
||||
'use strict';
|
||||
|
||||
/* eslint callback-return: 0, no-bitwise: 0, eqeqeq: 0, prefer-arrow-callback: 0 */
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.toUnicode = exports.toASCII = exports.encode = exports.decode = exports.ucs2 = exports.version = void 0;
|
||||
/** Highest positive signed 32-bit float value */
|
||||
const maxInt = 2147483647; // aka. 0x7FFFFFFF or 2^31-1
|
||||
|
||||
/** Bootstring parameters */
|
||||
const base = 36;
|
||||
const tMin = 1;
|
||||
@@ -40,43 +39,37 @@ const damp = 700;
|
||||
const initialBias = 72;
|
||||
const initialN = 128; // 0x80
|
||||
const delimiter = '-'; // '\x2D'
|
||||
|
||||
/** Regular expressions */
|
||||
const regexPunycode = /^xn--/;
|
||||
const regexNonASCII = /[^\0-\x7F]/; // Note: U+007F DEL is excluded too.
|
||||
const regexSeparators = /[\x2E\u3002\uFF0E\uFF61]/g; // RFC 3490 separators
|
||||
|
||||
/** Error messages */
|
||||
const errors = {
|
||||
overflow: 'Overflow: input needs wider integers to process',
|
||||
'not-basic': 'Illegal input >= 0x80 (not a basic code point)',
|
||||
'invalid-input': 'Invalid input'
|
||||
};
|
||||
|
||||
/** Convenience shortcuts */
|
||||
const baseMinusTMin = base - tMin;
|
||||
const floor = Math.floor;
|
||||
const stringFromCharCode = String.fromCharCode;
|
||||
|
||||
/*--------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* A generic error utility function.
|
||||
* @private
|
||||
* @param {String} type The error type.
|
||||
* @returns {Error} Throws a `RangeError` with the applicable error message.
|
||||
* @param type The error type.
|
||||
* @returns Throws a `RangeError` with the applicable error message.
|
||||
*/
|
||||
function error(type) {
|
||||
throw new RangeError(errors[type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic `Array#map` utility function.
|
||||
* @private
|
||||
* @param {Array} array The array to iterate over.
|
||||
* @param {Function} callback The function that gets called for every array
|
||||
* @param array The array to iterate over.
|
||||
* @param callback The function that gets called for every array
|
||||
* item.
|
||||
* @returns {Array} A new array of values returned by the callback function.
|
||||
* @returns A new array of values returned by the callback function.
|
||||
*/
|
||||
function map(array, callback) {
|
||||
const result = [];
|
||||
@@ -86,15 +79,14 @@ function map(array, callback) {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple `Array#map`-like wrapper to work with domain name strings or email
|
||||
* addresses.
|
||||
* @private
|
||||
* @param {String} domain The domain name or email address.
|
||||
* @param {Function} callback The function that gets called for every
|
||||
* @param domain The domain name or email address.
|
||||
* @param callback The function that gets called for every
|
||||
* character.
|
||||
* @returns {String} A new string of characters returned by the callback
|
||||
* @returns A new string of characters returned by the callback
|
||||
* function.
|
||||
*/
|
||||
function mapDomain(domain, callback) {
|
||||
@@ -112,7 +104,6 @@ function mapDomain(domain, callback) {
|
||||
const encoded = map(labels, callback).join('.');
|
||||
return result + encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an array containing the numeric code points of each Unicode
|
||||
* character in the string. While JavaScript uses UCS-2 internally,
|
||||
@@ -123,8 +114,8 @@ function mapDomain(domain, callback) {
|
||||
* @see <https://mathiasbynens.be/notes/javascript-encoding>
|
||||
* @memberOf punycode.ucs2
|
||||
* @name decode
|
||||
* @param {String} string The Unicode input string (UCS-2).
|
||||
* @returns {Array} The new array of code points.
|
||||
* @param string The Unicode input string (UCS-2).
|
||||
* @returns The new array of code points.
|
||||
*/
|
||||
function ucs2decode(string) {
|
||||
const output = [];
|
||||
@@ -138,35 +129,35 @@ function ucs2decode(string) {
|
||||
if ((extra & 0xfc00) == 0xdc00) {
|
||||
// Low surrogate.
|
||||
output.push(((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// It's an unmatched surrogate; only append this code unit, in case the
|
||||
// next code unit is the high surrogate of a surrogate pair.
|
||||
output.push(value);
|
||||
counter--;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
output.push(value);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a string based on an array of numeric code points.
|
||||
* @see `punycode.ucs2.decode`
|
||||
* @memberOf punycode.ucs2
|
||||
* @name encode
|
||||
* @param {Array} codePoints The array of numeric code points.
|
||||
* @returns {String} The new Unicode string (UCS-2).
|
||||
* @param codePoints The array of numeric code points.
|
||||
* @returns The new Unicode string (UCS-2).
|
||||
*/
|
||||
const ucs2encode = codePoints => String.fromCodePoint(...codePoints);
|
||||
|
||||
const ucs2encode = (codePoints) => String.fromCodePoint(...codePoints);
|
||||
/**
|
||||
* Converts a basic code point into a digit/integer.
|
||||
* @see `digitToBasic()`
|
||||
* @private
|
||||
* @param {Number} codePoint The basic numeric code point value.
|
||||
* @returns {Number} The numeric value of a basic code point (for use in
|
||||
* @param codePoint The basic numeric code point value.
|
||||
* @returns The numeric value of a basic code point (for use in
|
||||
* representing integers) in the range `0` to `base - 1`, or `base` if
|
||||
* the code point does not represent a value.
|
||||
*/
|
||||
@@ -182,13 +173,12 @@ const basicToDigit = function (codePoint) {
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a digit/integer into a basic code point.
|
||||
* @see `basicToDigit()`
|
||||
* @private
|
||||
* @param {Number} digit The numeric value of a basic code point.
|
||||
* @returns {Number} The basic code point whose value (when used for
|
||||
* @param digit The numeric value of a basic code point.
|
||||
* @returns The basic code point whose value (when used for
|
||||
* representing integers) is `digit`, which needs to be in the range
|
||||
* `0` to `base - 1`. If `flag` is non-zero, the uppercase form is
|
||||
* used; else, the lowercase form is used. The behavior is undefined
|
||||
@@ -197,9 +187,8 @@ const basicToDigit = function (codePoint) {
|
||||
const digitToBasic = function (digit, flag) {
|
||||
// 0..25 map to ASCII a..z or A..Z
|
||||
// 26..35 map to ASCII 0..9
|
||||
return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5);
|
||||
return digit + 22 + 75 * Number(digit < 26) - (Number(flag != 0) << 5);
|
||||
};
|
||||
|
||||
/**
|
||||
* Bias adaptation function as per section 3.4 of RFC 3492.
|
||||
* https://tools.ietf.org/html/rfc3492#section-3.4
|
||||
@@ -214,13 +203,12 @@ const adapt = function (delta, numPoints, firstTime) {
|
||||
}
|
||||
return floor(k + ((baseMinusTMin + 1) * delta) / (delta + skew));
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Punycode string of ASCII-only symbols to a string of Unicode
|
||||
* symbols.
|
||||
* @memberOf punycode
|
||||
* @param {String} input The Punycode string of ASCII-only symbols.
|
||||
* @returns {String} The resulting string of Unicode symbols.
|
||||
* @param input The Punycode string of ASCII-only symbols.
|
||||
* @returns The resulting string of Unicode symbols.
|
||||
*/
|
||||
const decode = function (input) {
|
||||
// Don't use UCS-2.
|
||||
@@ -229,16 +217,13 @@ const decode = function (input) {
|
||||
let i = 0;
|
||||
let n = initialN;
|
||||
let bias = initialBias;
|
||||
|
||||
// Handle the basic code points: let `basic` be the number of input code
|
||||
// points before the last delimiter, or `0` if there is none, then copy
|
||||
// the first basic code points to the output.
|
||||
|
||||
let basic = input.lastIndexOf(delimiter);
|
||||
if (basic < 0) {
|
||||
basic = 0;
|
||||
}
|
||||
|
||||
for (let j = 0; j < basic; ++j) {
|
||||
// if it's not a basic code point
|
||||
if (input.charCodeAt(j) >= 0x80) {
|
||||
@@ -246,10 +231,8 @@ const decode = function (input) {
|
||||
}
|
||||
output.push(input.charCodeAt(j));
|
||||
}
|
||||
|
||||
// Main decoding loop: start just after the last delimiter if any basic code
|
||||
// points were copied; start at the beginning otherwise.
|
||||
|
||||
for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */;) {
|
||||
// `index` is the index of the next character to be consumed.
|
||||
// Decode a generalized variable-length integer into `delta`,
|
||||
@@ -257,122 +240,100 @@ const decode = function (input) {
|
||||
// if we increase `i` as we go, then subtract off its starting
|
||||
// value at the end to obtain `delta`.
|
||||
const oldi = i;
|
||||
for (let w = 1, k = base /* no condition */; ; k += base) {
|
||||
for (let w = 1, k = base /* no condition */;; k += base) {
|
||||
if (index >= inputLength) {
|
||||
error('invalid-input');
|
||||
}
|
||||
|
||||
const digit = basicToDigit(input.charCodeAt(index++));
|
||||
|
||||
if (digit >= base) {
|
||||
error('invalid-input');
|
||||
}
|
||||
if (digit > floor((maxInt - i) / w)) {
|
||||
error('overflow');
|
||||
}
|
||||
|
||||
i += digit * w;
|
||||
const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias;
|
||||
|
||||
if (digit < t) {
|
||||
break;
|
||||
}
|
||||
|
||||
const baseMinusT = base - t;
|
||||
if (w > floor(maxInt / baseMinusT)) {
|
||||
error('overflow');
|
||||
}
|
||||
|
||||
w *= baseMinusT;
|
||||
}
|
||||
|
||||
const out = output.length + 1;
|
||||
bias = adapt(i - oldi, out, oldi == 0);
|
||||
|
||||
// `i` was supposed to wrap around from `out` to `0`,
|
||||
// incrementing `n` each time, so we'll fix that now:
|
||||
if (floor(i / out) > maxInt - n) {
|
||||
error('overflow');
|
||||
}
|
||||
|
||||
n += floor(i / out);
|
||||
i %= out;
|
||||
|
||||
// Insert `n` at position `i` of the output.
|
||||
output.splice(i++, 0, n);
|
||||
}
|
||||
|
||||
return String.fromCodePoint(...output);
|
||||
};
|
||||
|
||||
exports.decode = decode;
|
||||
/**
|
||||
* Converts a string of Unicode symbols (e.g. a domain name label) to a
|
||||
* Punycode string of ASCII-only symbols.
|
||||
* @memberOf punycode
|
||||
* @param {String} input The string of Unicode symbols.
|
||||
* @returns {String} The resulting Punycode string of ASCII-only symbols.
|
||||
* @param input The string of Unicode symbols.
|
||||
* @returns The resulting Punycode string of ASCII-only symbols.
|
||||
*/
|
||||
const encode = function (input) {
|
||||
const output = [];
|
||||
|
||||
// Convert the input in UCS-2 to an array of Unicode code points.
|
||||
input = ucs2decode(input);
|
||||
|
||||
const codePoints = ucs2decode(input);
|
||||
// Cache the length.
|
||||
const inputLength = input.length;
|
||||
|
||||
const inputLength = codePoints.length;
|
||||
// Initialize the state.
|
||||
let n = initialN;
|
||||
let delta = 0;
|
||||
let bias = initialBias;
|
||||
|
||||
// Handle the basic code points.
|
||||
for (const currentValue of input) {
|
||||
for (const currentValue of codePoints) {
|
||||
if (currentValue < 0x80) {
|
||||
output.push(stringFromCharCode(currentValue));
|
||||
}
|
||||
}
|
||||
|
||||
const basicLength = output.length;
|
||||
let handledCPCount = basicLength;
|
||||
|
||||
// `handledCPCount` is the number of code points that have been handled;
|
||||
// `basicLength` is the number of basic code points.
|
||||
|
||||
// Finish the basic string with a delimiter unless it's empty.
|
||||
if (basicLength) {
|
||||
output.push(delimiter);
|
||||
}
|
||||
|
||||
// Main encoding loop:
|
||||
while (handledCPCount < inputLength) {
|
||||
// All non-basic code points < n have been handled already. Find the next
|
||||
// larger one:
|
||||
let m = maxInt;
|
||||
for (const currentValue of input) {
|
||||
for (const currentValue of codePoints) {
|
||||
if (currentValue >= n && currentValue < m) {
|
||||
m = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Increase `delta` enough to advance the decoder's <n,i> state to <m,0>,
|
||||
// but guard against overflow.
|
||||
const handledCPCountPlusOne = handledCPCount + 1;
|
||||
if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) {
|
||||
error('overflow');
|
||||
}
|
||||
|
||||
delta += (m - n) * handledCPCountPlusOne;
|
||||
n = m;
|
||||
|
||||
for (const currentValue of input) {
|
||||
for (const currentValue of codePoints) {
|
||||
if (currentValue < n && ++delta > maxInt) {
|
||||
error('overflow');
|
||||
}
|
||||
if (currentValue === n) {
|
||||
// Represent delta as a generalized variable-length integer.
|
||||
let q = delta;
|
||||
for (let k = base /* no condition */; ; k += base) {
|
||||
for (let k = base /* no condition */;; k += base) {
|
||||
const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias;
|
||||
if (q < t) {
|
||||
break;
|
||||
@@ -382,29 +343,27 @@ const encode = function (input) {
|
||||
output.push(stringFromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0)));
|
||||
q = floor(qMinusT / baseMinusT);
|
||||
}
|
||||
|
||||
output.push(stringFromCharCode(digitToBasic(q, 0)));
|
||||
bias = adapt(delta, handledCPCountPlusOne, handledCPCount === basicLength);
|
||||
delta = 0;
|
||||
++handledCPCount;
|
||||
}
|
||||
}
|
||||
|
||||
++delta;
|
||||
++n;
|
||||
}
|
||||
return output.join('');
|
||||
};
|
||||
|
||||
exports.encode = encode;
|
||||
/**
|
||||
* Converts a Punycode string representing a domain name or an email address
|
||||
* to Unicode. Only the Punycoded parts of the input will be converted, i.e.
|
||||
* it doesn't matter if you call it on a string that has already been
|
||||
* converted to Unicode.
|
||||
* @memberOf punycode
|
||||
* @param {String} input The Punycoded domain name or email address to
|
||||
* @param input The Punycoded domain name or email address to
|
||||
* convert to Unicode.
|
||||
* @returns {String} The Unicode representation of the given Punycode
|
||||
* @returns The Unicode representation of the given Punycode
|
||||
* string.
|
||||
*/
|
||||
const toUnicode = function (input) {
|
||||
@@ -412,16 +371,16 @@ const toUnicode = function (input) {
|
||||
return regexPunycode.test(string) ? decode(string.slice(4).toLowerCase()) : string;
|
||||
});
|
||||
};
|
||||
|
||||
exports.toUnicode = toUnicode;
|
||||
/**
|
||||
* Converts a Unicode string representing a domain name or an email address to
|
||||
* Punycode. Only the non-ASCII parts of the domain name will be converted,
|
||||
* i.e. it doesn't matter if you call it with a domain that's already in
|
||||
* ASCII.
|
||||
* @memberOf punycode
|
||||
* @param {String} input The domain name or email address to convert, as a
|
||||
* @param input The domain name or email address to convert, as a
|
||||
* Unicode string.
|
||||
* @returns {String} The Punycode representation of the given domain name or
|
||||
* @returns The Punycode representation of the given domain name or
|
||||
* email address.
|
||||
*/
|
||||
const toASCII = function (input) {
|
||||
@@ -429,32 +388,23 @@ const toASCII = function (input) {
|
||||
return regexNonASCII.test(string) ? 'xn--' + encode(string) : string;
|
||||
});
|
||||
};
|
||||
|
||||
exports.toASCII = toASCII;
|
||||
/*--------------------------------------------------------------------------*/
|
||||
|
||||
/** Define the public API */
|
||||
const punycode = {
|
||||
/**
|
||||
* A string representing the current Punycode.js version number.
|
||||
* @memberOf punycode
|
||||
* @type String
|
||||
*/
|
||||
version: '2.3.1',
|
||||
/**
|
||||
* An object of methods to convert from JavaScript's internal character
|
||||
* representation (UCS-2) to Unicode code points, and back.
|
||||
* @see <https://mathiasbynens.be/notes/javascript-encoding>
|
||||
* @memberOf punycode
|
||||
* @type Object
|
||||
*/
|
||||
ucs2: {
|
||||
decode: ucs2decode,
|
||||
encode: ucs2encode
|
||||
},
|
||||
decode: decode,
|
||||
encode: encode,
|
||||
toASCII: toASCII,
|
||||
toUnicode: toUnicode
|
||||
/**
|
||||
* A string representing the current Punycode.js version number.
|
||||
* @memberOf punycode
|
||||
* @type String
|
||||
*/
|
||||
exports.version = '2.3.1';
|
||||
/**
|
||||
* An object of methods to convert from JavaScript's internal character
|
||||
* representation (UCS-2) to Unicode code points, and back.
|
||||
* @see <https://mathiasbynens.be/notes/javascript-encoding>
|
||||
* @memberOf punycode
|
||||
* @type Object
|
||||
*/
|
||||
exports.ucs2 = {
|
||||
decode: ucs2decode,
|
||||
encode: ucs2encode
|
||||
};
|
||||
|
||||
module.exports = punycode;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Transform } from 'node:stream';
|
||||
export declare function encode(buffer: Buffer | string): string;
|
||||
/**
|
||||
* Adds soft line breaks to a Quoted-Printable string
|
||||
*
|
||||
* @param str Quoted-Printable encoded string that might need line wrapping
|
||||
* @param [lineLength=76] Maximum allowed length for a line
|
||||
* @returns Soft-wrapped Quoted-Printable encoded string
|
||||
*/
|
||||
export declare function wrap(str: string, lineLength?: number): string;
|
||||
/**
|
||||
* Options for the Quoted-Printable encoder stream
|
||||
*/
|
||||
export interface QPEncoderOptions {
|
||||
/** Maximum length for lines, set to false to disable wrapping */
|
||||
lineLength?: number | false | undefined;
|
||||
}
|
||||
/**
|
||||
* Creates a transform stream for encoding data to Quoted-Printable encoding
|
||||
*
|
||||
* @constructor
|
||||
* @param options Stream options
|
||||
* @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
|
||||
*/
|
||||
export declare class Encoder extends Transform {
|
||||
options: QPEncoderOptions;
|
||||
inputBytes: number;
|
||||
outputBytes: number;
|
||||
constructor(options?: QPEncoderOptions);
|
||||
}
|
||||
Generated
Vendored
+32
-64
@@ -1,12 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const { Transform } = require('stream');
|
||||
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Encoder = void 0;
|
||||
exports.encode = encode;
|
||||
exports.wrap = wrap;
|
||||
const node_stream_1 = require("node:stream");
|
||||
/**
|
||||
* Encodes a Buffer into a Quoted-Printable encoded string
|
||||
*
|
||||
* @param {Buffer} buffer Buffer to convert
|
||||
* @returns {String} Quoted-Printable encoded string
|
||||
* @param buffer Buffer to convert
|
||||
* @returns Quoted-Printable encoded string
|
||||
*/
|
||||
// usable characters that do not need encoding
|
||||
// https://tools.ietf.org/html/rfc2045#section-6.7
|
||||
@@ -17,52 +19,42 @@ const QP_RANGES = [
|
||||
[0x20, 0x3c], // <SP>!"#$%&'()*+,-./0123456789:;
|
||||
[0x3e, 0x7e] // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}
|
||||
];
|
||||
|
||||
function encode(buffer) {
|
||||
if (typeof buffer === 'string') {
|
||||
buffer = Buffer.from(buffer, 'utf-8');
|
||||
}
|
||||
|
||||
let result = '';
|
||||
let ord;
|
||||
|
||||
for (let i = 0, len = buffer.length; i < len; i++) {
|
||||
ord = buffer[i];
|
||||
// if the char is in allowed range, then keep as is, unless it is a WS in the end of a line
|
||||
if (
|
||||
checkRanges(ord, QP_RANGES) &&
|
||||
!((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))
|
||||
) {
|
||||
if (checkRanges(ord, QP_RANGES) &&
|
||||
!((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))) {
|
||||
result += String.fromCharCode(ord);
|
||||
continue;
|
||||
}
|
||||
result += '=' + (ord < 0x10 ? '0' : '') + ord.toString(16).toUpperCase();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds soft line breaks to a Quoted-Printable string
|
||||
*
|
||||
* @param {String} str Quoted-Printable encoded string that might need line wrapping
|
||||
* @param {Number} [lineLength=76] Maximum allowed length for a line
|
||||
* @returns {String} Soft-wrapped Quoted-Printable encoded string
|
||||
* @param str Quoted-Printable encoded string that might need line wrapping
|
||||
* @param [lineLength=76] Maximum allowed length for a line
|
||||
* @returns Soft-wrapped Quoted-Printable encoded string
|
||||
*/
|
||||
function wrap(str, lineLength) {
|
||||
str = (str || '').toString();
|
||||
lineLength = lineLength || 76;
|
||||
|
||||
if (str.length <= lineLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
let pos = 0;
|
||||
const len = str.length;
|
||||
let match, code, line;
|
||||
const lineMargin = Math.floor(lineLength / 3);
|
||||
let result = '';
|
||||
|
||||
// insert soft linebreaks where needed
|
||||
while (pos < len) {
|
||||
line = str.substr(pos, lineLength);
|
||||
@@ -72,13 +64,11 @@ function wrap(str, lineLength) {
|
||||
pos += line.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.substr(-1) === '\n') {
|
||||
result += line;
|
||||
pos += line.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((match = line.substr(-lineMargin).match(/\n.*?$/))) {
|
||||
// truncate to nearest line break
|
||||
line = line.substr(0, line.length - (match[0].length - 1));
|
||||
@@ -86,60 +76,53 @@ function wrap(str, lineLength) {
|
||||
pos += line.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length > lineLength - lineMargin && (match = line.substr(-lineMargin).match(/[ \t.,!?][^ \t.,!?]*$/))) {
|
||||
// truncate to nearest space
|
||||
line = line.substr(0, line.length - (match[0].length - 1));
|
||||
} else if (line.match(/[=][\da-f]{0,2}$/i)) {
|
||||
}
|
||||
else if (line.match(/[=][\da-f]{0,2}$/i)) {
|
||||
// push incomplete encoding sequences to the next line
|
||||
if ((match = line.match(/[=][\da-f]{0,1}$/i))) {
|
||||
line = line.substr(0, line.length - match[0].length);
|
||||
}
|
||||
|
||||
// ensure that utf-8 sequences are not split
|
||||
while (
|
||||
line.length > 3 &&
|
||||
while (line.length > 3 &&
|
||||
line.length < len - pos &&
|
||||
!line.match(/^(?:=[\da-f]{2}){1,4}$/i) &&
|
||||
(match = line.match(/[=][\da-f]{2}$/gi))
|
||||
) {
|
||||
(match = line.match(/[=][\da-f]{2}$/gi))) {
|
||||
code = parseInt(match[0].substr(1, 2), 16);
|
||||
if (code < 128) {
|
||||
break;
|
||||
}
|
||||
|
||||
line = line.substr(0, line.length - 3);
|
||||
|
||||
if (code >= 0xc0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pos + line.length < len && line.substr(-1) !== '\n') {
|
||||
if (line.length === lineLength && line.match(/[=][\da-f]{2}$/i)) {
|
||||
line = line.substr(0, line.length - 3);
|
||||
} else if (line.length === lineLength) {
|
||||
}
|
||||
else if (line.length === lineLength) {
|
||||
line = line.substr(0, line.length - 1);
|
||||
}
|
||||
pos += line.length;
|
||||
line += '=\r\n';
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
pos += line.length;
|
||||
}
|
||||
|
||||
result += line;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if a number is inside provided ranges
|
||||
*
|
||||
* @param {Number} nr Number to check for
|
||||
* @param {Array} ranges An Array of allowed values
|
||||
* @returns {Boolean} True if the value was found inside allowed ranges, false otherwise
|
||||
* @param nr Number to check for
|
||||
* @param ranges An Array of allowed values
|
||||
* @returns True if the value was found inside allowed ranges, false otherwise
|
||||
*/
|
||||
function checkRanges(nr, ranges) {
|
||||
for (let i = ranges.length - 1; i >= 0; i--) {
|
||||
@@ -156,43 +139,34 @@ function checkRanges(nr, ranges) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a transform stream for encoding data to Quoted-Printable encoding
|
||||
*
|
||||
* @constructor
|
||||
* @param {Object} options Stream options
|
||||
* @param {Number} [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
|
||||
* @param options Stream options
|
||||
* @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
|
||||
*/
|
||||
class Encoder extends Transform {
|
||||
class Encoder extends node_stream_1.Transform {
|
||||
constructor(options) {
|
||||
super();
|
||||
|
||||
this.options = options || {};
|
||||
|
||||
if (this.options.lineLength !== false) {
|
||||
this.options.lineLength = this.options.lineLength || 76;
|
||||
}
|
||||
|
||||
this._curLine = '';
|
||||
|
||||
this.inputBytes = 0;
|
||||
this.outputBytes = 0;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_transform(chunk, encoding, done) {
|
||||
let qp;
|
||||
|
||||
if (encoding !== 'buffer') {
|
||||
chunk = Buffer.from(chunk, encoding);
|
||||
}
|
||||
|
||||
if (!chunk || !chunk.length) {
|
||||
return done();
|
||||
}
|
||||
|
||||
this.inputBytes += chunk.length;
|
||||
|
||||
if (this.options.lineLength) {
|
||||
qp = this._curLine + encode(chunk);
|
||||
qp = wrap(qp, this.options.lineLength);
|
||||
@@ -200,20 +174,19 @@ class Encoder extends Transform {
|
||||
this._curLine = lastLine;
|
||||
return lineBreak;
|
||||
});
|
||||
|
||||
if (qp) {
|
||||
this.outputBytes += qp.length;
|
||||
this.push(qp);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
qp = encode(chunk);
|
||||
this.outputBytes += qp.length;
|
||||
this.push(qp, 'ascii');
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_flush(done) {
|
||||
if (this._curLine) {
|
||||
this.outputBytes += this._curLine.length;
|
||||
@@ -222,9 +195,4 @@ class Encoder extends Transform {
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encode,
|
||||
wrap,
|
||||
Encoder
|
||||
};
|
||||
exports.Encoder = Encoder;
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import type { Logger } from '../shared/index.js';
|
||||
import type { MimeNodeEnvelope } from '../mime-node/index.js';
|
||||
import type MailMessage from '../mailer/mail-message.js';
|
||||
import type { default as Mail, SentMessageInfo, SendMailOptions, TransportOptions } from '../mailer/index.js';
|
||||
/**
|
||||
* Options for the Sendmail transport
|
||||
*/
|
||||
export interface SendmailTransportOptions extends TransportOptions {
|
||||
/** Selects this transport in createTransport, the binary itself is set with `path` */
|
||||
sendmail?: boolean | string | undefined;
|
||||
/** Path to the sendmail binary, defaults to 'sendmail' */
|
||||
path?: string | undefined;
|
||||
/** Either 'windows' or 'unix', the line ending of the message piped to sendmail */
|
||||
newline?: string | undefined;
|
||||
/** Arguments for the sendmail binary, replaces the default '-f <sender>' */
|
||||
args?: string[] | undefined;
|
||||
}
|
||||
/**
|
||||
* The value the Sendmail transport hands to the send callback
|
||||
*/
|
||||
export interface SendmailSentMessageInfo extends SentMessageInfo {
|
||||
/** The envelope the message was sent with */
|
||||
envelope: MimeNodeEnvelope;
|
||||
/** Message-ID value of the message */
|
||||
messageId: string;
|
||||
/** Always 'Messages queued for delivery' */
|
||||
response: string;
|
||||
}
|
||||
/**
|
||||
* Generates a Transport object for Sendmail
|
||||
*
|
||||
* Possible options can be the following:
|
||||
*
|
||||
* * **path** optional path to sendmail binary
|
||||
* * **newline** either 'windows' or 'unix'
|
||||
* * **args** an array of arguments for the sendmail binary
|
||||
*
|
||||
* @constructor
|
||||
* @param optional config parameter for Sendmail
|
||||
*/
|
||||
declare class SendmailTransport {
|
||||
mailer: Mail<SendmailSentMessageInfo>;
|
||||
options: SendmailTransportOptions;
|
||||
name: string;
|
||||
version: string;
|
||||
path: string;
|
||||
args: string[] | false;
|
||||
logger: Logger;
|
||||
winbreak: boolean;
|
||||
constructor(options?: SendmailTransportOptions | string);
|
||||
/**
|
||||
* <p>Compiles a mailcomposer message and forwards it to handler that sends it.</p>
|
||||
*
|
||||
* @param mail MailComposer object
|
||||
* @param done Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail: MailMessage<SendmailSentMessageInfo>, done: (err: Error | null, info?: SendmailSentMessageInfo) => void): void;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `SendmailTransport.Options` style references keep working
|
||||
*/
|
||||
declare namespace SendmailTransport {
|
||||
type Options = SendmailTransportOptions;
|
||||
type MailOptions = SendMailOptions;
|
||||
type SentMessageInfo = SendmailSentMessageInfo;
|
||||
}
|
||||
export default SendmailTransport;
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_child_process_1 = require("node:child_process");
|
||||
const packageData = __importStar(require("../package-info.js"));
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
const le_windows_js_1 = __importDefault(require("../mime-node/le-windows.js"));
|
||||
const le_unix_js_1 = __importDefault(require("../mime-node/le-unix.js"));
|
||||
/**
|
||||
* Generates a Transport object for Sendmail
|
||||
*
|
||||
* Possible options can be the following:
|
||||
*
|
||||
* * **path** optional path to sendmail binary
|
||||
* * **newline** either 'windows' or 'unix'
|
||||
* * **args** an array of arguments for the sendmail binary
|
||||
*
|
||||
* @constructor
|
||||
* @param optional config parameter for Sendmail
|
||||
*/
|
||||
class SendmailTransport {
|
||||
constructor(options) {
|
||||
options = options || {};
|
||||
// use a reference to spawn for mocking purposes
|
||||
this._spawn = node_child_process_1.spawn;
|
||||
this.options = options;
|
||||
this.name = 'Sendmail';
|
||||
this.version = packageData.version;
|
||||
this.path = 'sendmail';
|
||||
this.args = false;
|
||||
this.logger = shared.getLogger(this.options, {
|
||||
component: this.options.component || 'sendmail'
|
||||
});
|
||||
if (typeof options === 'string') {
|
||||
this.path = options;
|
||||
}
|
||||
else if (typeof options === 'object') {
|
||||
if (options.path) {
|
||||
this.path = options.path;
|
||||
}
|
||||
if (Array.isArray(options.args)) {
|
||||
this.args = options.args;
|
||||
}
|
||||
}
|
||||
this.winbreak = ['win', 'windows', 'dos', '\r\n'].includes((options.newline || '').toString().toLowerCase());
|
||||
}
|
||||
/**
|
||||
* <p>Compiles a mailcomposer message and forwards it to handler that sends it.</p>
|
||||
*
|
||||
* @param mail MailComposer object
|
||||
* @param done Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail, done) {
|
||||
// Sendmail strips this header line by itself. send() runs after the message was
|
||||
// compiled, so mail.message is set
|
||||
mail.message.keepBcc = true;
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
let returned;
|
||||
const hasInvalidAddresses = []
|
||||
.concat(envelope.from || [])
|
||||
.concat(envelope.to || [])
|
||||
// a local part is either a dot-atom or a quoted-string, so a leading dash sits at
|
||||
// offset 0 or, behind the opening quote, at offset 1. Only the first shape is read
|
||||
// as an option by sendmail, but both are the address this guard keeps out of argv
|
||||
.some(addr => /^"?-/.test(addr));
|
||||
if (hasInvalidAddresses) {
|
||||
const err = new Error('Can not send mail. Invalid envelope addresses.');
|
||||
err.code = errors.ESENDMAIL;
|
||||
return done(err);
|
||||
}
|
||||
// force -i to keep single dots
|
||||
const args = this.args
|
||||
? ['-i'].concat(this.args).concat(envelope.to)
|
||||
: ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to);
|
||||
const callback = (err) => {
|
||||
if (returned) {
|
||||
// ignore any additional responses, already done
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
if (typeof done === 'function') {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
return done(null, {
|
||||
envelope,
|
||||
messageId,
|
||||
response: 'Messages queued for delivery'
|
||||
});
|
||||
}
|
||||
};
|
||||
let sendmail;
|
||||
try {
|
||||
sendmail = this._spawn(this.path, args);
|
||||
}
|
||||
catch (E) {
|
||||
this.logger.error({
|
||||
err: E,
|
||||
tnx: 'spawn',
|
||||
messageId
|
||||
}, 'Error occurred while spawning sendmail. %s', E.message);
|
||||
return callback(E);
|
||||
}
|
||||
if (sendmail) {
|
||||
sendmail.on('error', err => {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'spawn',
|
||||
messageId
|
||||
}, 'Error occurred when sending message %s. %s', messageId, err.message);
|
||||
callback(err);
|
||||
});
|
||||
sendmail.once('exit', code => {
|
||||
if (!code) {
|
||||
return callback();
|
||||
}
|
||||
const err = new Error(code === 127 ? 'Sendmail command not found, process exited with code ' + code : 'Sendmail exited with code ' + code);
|
||||
err.code = errors.ESENDMAIL;
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'stdin',
|
||||
messageId
|
||||
}, 'Error sending message %s to sendmail. %s', messageId, err.message);
|
||||
callback(err);
|
||||
});
|
||||
// the close listener is handed the exit code as its first argument, so a non-zero
|
||||
// code reaching it before the exit listener did counts as the error value
|
||||
sendmail.once('close', callback);
|
||||
sendmail.stdin.on('error', err => {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'stdin',
|
||||
messageId
|
||||
}, 'Error occurred when piping message %s to sendmail. %s', messageId, err.message);
|
||||
callback(err);
|
||||
});
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
if (recipients.length > 3) {
|
||||
recipients.push('...and ' + recipients.splice(2).length + ' more');
|
||||
}
|
||||
this.logger.info({
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Sending message %s to <%s>', messageId, recipients.join(', '));
|
||||
const sourceStream = mail.message.createReadStream();
|
||||
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 le_windows_js_1.default() : new le_unix_js_1.default());
|
||||
sourceStream.once('error', err => stream.emit('error', err));
|
||||
}
|
||||
stream.once('error', err => {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'stdin',
|
||||
messageId
|
||||
}, 'Error occurred when generating message %s. %s', messageId, err.message);
|
||||
sendmail.kill('SIGINT'); // do not deliver the message
|
||||
callback(err);
|
||||
});
|
||||
stream.pipe(sendmail.stdin);
|
||||
}
|
||||
else {
|
||||
const err = new Error('sendmail was not found');
|
||||
err.code = errors.ESENDMAIL;
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.default = SendmailTransport;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import EventEmitter from 'node:events';
|
||||
import type { Logger } from '../shared/index.js';
|
||||
import { type MimeNodeEnvelope } from '../mime-node/index.js';
|
||||
import type MailMessage from '../mailer/mail-message.js';
|
||||
import type { default as Mail, SentMessageInfo, SendMailOptions, TransportOptions, VerifyCallback } from '../mailer/index.js';
|
||||
/**
|
||||
* Options for the SES transport
|
||||
*/
|
||||
export interface SESTransportOptions extends TransportOptions {
|
||||
/** The AWS SDK v3 objects to send with, `{ sesClient, SendEmailCommand }` from @aws-sdk/client-sesv2 */
|
||||
SES: {
|
||||
/** SESv2Client instance. Its config.region provider is resolved for the domain of the returned Message-ID */
|
||||
sesClient: {
|
||||
config?: {
|
||||
region?: (() => Promise<string>) | undefined;
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
send(command: unknown): Promise<any>;
|
||||
};
|
||||
/** SendEmailCommand class, constructed with the SendEmailCommandInput of every message */
|
||||
SendEmailCommand: new (input: any) => unknown;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* The value the SES transport hands to the send callback
|
||||
*/
|
||||
export interface SESSentMessageInfo extends SentMessageInfo {
|
||||
/** The envelope the message was sent with */
|
||||
envelope: MimeNodeEnvelope;
|
||||
/** Message-ID built from the MessageId SES returned */
|
||||
messageId: string;
|
||||
/** The MessageId SES returned */
|
||||
response: string;
|
||||
/** The raw RFC822 message that was sent */
|
||||
raw: Buffer;
|
||||
}
|
||||
/**
|
||||
* Generates a Transport object for AWS SES
|
||||
*
|
||||
* @constructor
|
||||
* @param optional config parameter
|
||||
*/
|
||||
declare class SESTransport extends EventEmitter {
|
||||
mailer: Mail<SESSentMessageInfo>;
|
||||
options: SESTransportOptions;
|
||||
ses: SESTransportOptions['SES'];
|
||||
name: string;
|
||||
version: string;
|
||||
logger: Logger;
|
||||
constructor(options?: SESTransportOptions);
|
||||
getRegion(cb: (err: Error | null, region?: string | false) => void): void;
|
||||
/**
|
||||
* Compiles a mailcomposer message and forwards it to SES
|
||||
*
|
||||
* @param mail MailComposer object
|
||||
* @param callback Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail: MailMessage<SESSentMessageInfo>, callback: (err: Error | null, info?: SESSentMessageInfo) => void): void;
|
||||
/**
|
||||
* Verifies SES configuration
|
||||
*
|
||||
* @param callback Callback function
|
||||
*/
|
||||
verify(): Promise<true>;
|
||||
verify(callback: VerifyCallback): void;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `SESTransport.Options` style references keep working
|
||||
*/
|
||||
declare namespace SESTransport {
|
||||
type Options = SESTransportOptions;
|
||||
type MailOptions = SendMailOptions;
|
||||
type SentMessageInfo = SESSentMessageInfo;
|
||||
}
|
||||
export default SESTransport;
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_events_1 = __importDefault(require("node:events"));
|
||||
const packageData = __importStar(require("../package-info.js"));
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
const le_windows_js_1 = __importDefault(require("../mime-node/le-windows.js"));
|
||||
const index_js_1 = __importDefault(require("../mime-node/index.js"));
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @constructor
|
||||
* @param optional config parameter
|
||||
*/
|
||||
class SESTransport extends node_events_1.default {
|
||||
constructor(options) {
|
||||
super();
|
||||
if (!options || !options.SES || !options.SES.sesClient) {
|
||||
const error = new Error('Missing SES configuration, expecting { sesClient, SendEmailCommand } from @aws-sdk/client-sesv2, see https://nodemailer.com/transports/ses/');
|
||||
error.code = errors.ECONFIG;
|
||||
throw error;
|
||||
}
|
||||
this.options = options;
|
||||
this.ses = this.options.SES;
|
||||
this.name = 'SESTransport';
|
||||
this.version = packageData.version;
|
||||
this.logger = shared.getLogger(this.options, {
|
||||
component: this.options.component || 'ses-transport'
|
||||
});
|
||||
}
|
||||
getRegion(cb) {
|
||||
if (this.ses.sesClient.config && typeof this.ses.sesClient.config.region === 'function') {
|
||||
// Resolve the region provider. Use the two-argument form of then() so that a
|
||||
// synchronous throw from cb is not recaught here and used to invoke cb a second time.
|
||||
this.ses.sesClient.config.region().then(region => cb(null, region), err => cb(err));
|
||||
return;
|
||||
}
|
||||
return cb(null, false);
|
||||
}
|
||||
/**
|
||||
* Compiles a mailcomposer message and forwards it to SES
|
||||
*
|
||||
* @param mail MailComposer object
|
||||
* @param callback Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail, callback) {
|
||||
// send() runs after the message was compiled, so mail.message is set
|
||||
let fromHeader = mail.message._headers.find(header => /^from$/i.test(header.key));
|
||||
if (fromHeader) {
|
||||
const mimeNode = new index_js_1.default('text/plain');
|
||||
fromHeader = mimeNode._convertAddresses(mimeNode._parseAddresses(fromHeader.value));
|
||||
}
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
if (recipients.length > 3) {
|
||||
recipients.push('...and ' + recipients.splice(2).length + ' more');
|
||||
}
|
||||
this.logger.info({
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Sending message %s to <%s>', messageId, recipients.join(', '));
|
||||
const getRawMessage = (next) => {
|
||||
// do not use Message-ID and Date in DKIM signature
|
||||
if (!mail.data._dkim) {
|
||||
mail.data._dkim = {};
|
||||
}
|
||||
if (mail.data._dkim.skipFields && typeof mail.data._dkim.skipFields === 'string') {
|
||||
mail.data._dkim.skipFields += ':date:message-id';
|
||||
}
|
||||
else {
|
||||
mail.data._dkim.skipFields = 'date:message-id';
|
||||
}
|
||||
const sourceStream = mail.message.createReadStream();
|
||||
const stream = sourceStream.pipe(new le_windows_js_1.default());
|
||||
const chunks = [];
|
||||
let chunklen = 0;
|
||||
stream.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = stream.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
}
|
||||
});
|
||||
sourceStream.once('error', err => stream.emit('error', err));
|
||||
stream.once('error', err => next(err));
|
||||
stream.once('end', () => next(null, Buffer.concat(chunks, chunklen)));
|
||||
};
|
||||
setImmediate(() => getRawMessage((err, raw) => {
|
||||
if (err) {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Failed creating message for %s. %s', messageId, err.message);
|
||||
return callback(err);
|
||||
}
|
||||
// mail.data.ses is caller supplied message data, so copy its own keys only
|
||||
const sesMessage = shared.copyOwnKeys({
|
||||
Content: {
|
||||
Raw: {
|
||||
// required
|
||||
Data: raw // required
|
||||
}
|
||||
},
|
||||
FromEmailAddress: fromHeader || envelope.from,
|
||||
Destination: {
|
||||
ToAddresses: envelope.to
|
||||
}
|
||||
}, mail.data.ses);
|
||||
this.getRegion((err, region) => {
|
||||
if (err || !region) {
|
||||
region = 'us-east-1';
|
||||
}
|
||||
let sendPromise;
|
||||
try {
|
||||
// command construction or dispatch can throw synchronously on a
|
||||
// misconfigured SDK; surface it as a single error callback instead
|
||||
// of letting it escape into getRegion's promise chain
|
||||
const command = new this.ses.SendEmailCommand(sesMessage);
|
||||
sendPromise = this.ses.sesClient.send(command);
|
||||
}
|
||||
catch (err) {
|
||||
tagSesError(err);
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'send'
|
||||
}, 'Send error for %s: %s', messageId, err.message);
|
||||
setImmediate(() => callback(err));
|
||||
return;
|
||||
}
|
||||
sendPromise
|
||||
.then(data => {
|
||||
if (region === 'us-east-1') {
|
||||
region = 'email';
|
||||
}
|
||||
const info = {
|
||||
envelope: {
|
||||
from: envelope.from,
|
||||
to: envelope.to
|
||||
},
|
||||
messageId: '<' + data.MessageId + (!/@/.test(data.MessageId) ? '@' + region + '.amazonses.com' : '') + '>',
|
||||
response: data.MessageId,
|
||||
raw: raw
|
||||
};
|
||||
// invoke the callback outside the promise chain so a throw from it
|
||||
// is not recaught by .catch() and used to call it a second time
|
||||
setImmediate(() => callback(null, info));
|
||||
})
|
||||
.catch(err => {
|
||||
tagSesError(err);
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'send'
|
||||
}, 'Send error for %s: %s', messageId, err.message);
|
||||
setImmediate(() => callback(err));
|
||||
});
|
||||
});
|
||||
}));
|
||||
}
|
||||
verify(callback) {
|
||||
let promise;
|
||||
if (!callback) {
|
||||
promise = new Promise((resolve, reject) => {
|
||||
callback = shared.callbackPromise(resolve, reject);
|
||||
});
|
||||
}
|
||||
const done = callback;
|
||||
const cb = (err) => {
|
||||
if (err && !['InvalidParameterValue', 'MessageRejected'].includes(err.code || err.Code || err.name)) {
|
||||
return done(tagSesError(err));
|
||||
}
|
||||
return done(null, true);
|
||||
};
|
||||
const sesMessage = {
|
||||
Content: {
|
||||
Raw: {
|
||||
Data: Buffer.from('From: <invalid@invalid>\r\nTo: <invalid@invalid>\r\n Subject: Invalid\r\n\r\nInvalid')
|
||||
}
|
||||
},
|
||||
FromEmailAddress: 'invalid@invalid',
|
||||
Destination: {
|
||||
ToAddresses: ['invalid@invalid']
|
||||
}
|
||||
};
|
||||
// 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(() => {
|
||||
let sendPromise;
|
||||
try {
|
||||
const command = new this.ses.SendEmailCommand(sesMessage);
|
||||
sendPromise = this.ses.sesClient.send(command);
|
||||
}
|
||||
catch (err) {
|
||||
setImmediate(() => cb(err));
|
||||
return;
|
||||
}
|
||||
sendPromise.then(() => setImmediate(() => cb(null))).catch(err => setImmediate(() => cb(err)));
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
exports.default = SESTransport;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { isProtoKey, copyOwnKeys } from './objects.js';
|
||||
import os from 'node:os';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type { OutgoingHttpHeaders } from 'node:http';
|
||||
export { isProtoKey, copyOwnKeys };
|
||||
/**
|
||||
* Options for resolveHostname. The object is also handed to dns.Resolver, so its
|
||||
* `timeout` and `tries` settings apply to the lookups
|
||||
*/
|
||||
export interface ResolveHostnameOptions {
|
||||
/** Hostname or IP address to resolve */
|
||||
host?: string | undefined;
|
||||
/** Server name for TLS, used as the host when no host is set */
|
||||
servername?: string | undefined;
|
||||
/** Count loopback interfaces when checking which address families are usable */
|
||||
allowInternalNetworkInterfaces?: boolean | undefined;
|
||||
/** How long a resolved value stays cached, in milliseconds (default 5 minutes) */
|
||||
dnsTtl?: number | undefined;
|
||||
/** Query timeout in milliseconds, passed to dns.Resolver */
|
||||
timeout?: number | undefined;
|
||||
/** Number of query attempts, passed to dns.Resolver */
|
||||
tries?: number | undefined;
|
||||
}
|
||||
/**
|
||||
* Resolved value handed to the resolveHostname callback
|
||||
*/
|
||||
export interface ResolvedHostname {
|
||||
/** Server name to use for TLS, false when an IP literal was given without one */
|
||||
servername?: string | false | undefined;
|
||||
/** Address to connect to, picked at random from the resolved addresses */
|
||||
host?: string | null | undefined;
|
||||
/** Whether the value came from the DNS cache */
|
||||
cached?: boolean | undefined;
|
||||
/** The resolver error when a cached value was used because of it */
|
||||
error?: Error | undefined;
|
||||
}
|
||||
/**
|
||||
* Resolved addresses as stored in the DNS cache
|
||||
*/
|
||||
export interface DnsCacheValue {
|
||||
addresses: string[];
|
||||
}
|
||||
/**
|
||||
* A DNS cache entry
|
||||
*/
|
||||
export interface DnsCacheEntry {
|
||||
value: DnsCacheValue;
|
||||
/** Expiration time as a timestamp, entries without one never expire */
|
||||
expires?: number | undefined;
|
||||
}
|
||||
/**
|
||||
* Configuration object parsed from a connection url. Query parameters become
|
||||
* top level keys, `tls.*` parameters go into `tls`
|
||||
*/
|
||||
export interface ConnectionUrlOptions {
|
||||
secure?: boolean | undefined;
|
||||
direct?: boolean | undefined;
|
||||
port?: number | undefined;
|
||||
host?: string | undefined;
|
||||
/** Well-known service name from the ?service= query parameter */
|
||||
service?: string | undefined;
|
||||
auth?: {
|
||||
user: string;
|
||||
pass: string;
|
||||
} | undefined;
|
||||
tls?: {
|
||||
[key: string]: unknown;
|
||||
} | undefined;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* Structured data attached to a log line. tnx, sid and cid drive the line prefix
|
||||
* of the default console logger
|
||||
*/
|
||||
export interface LogEntry {
|
||||
/** 'server' or 'client' for SMTP transaction lines */
|
||||
tnx?: string | undefined;
|
||||
/** Session id */
|
||||
sid?: string | undefined;
|
||||
/** Connection id */
|
||||
cid?: string | number | undefined;
|
||||
level?: string | undefined;
|
||||
[key: string]: any;
|
||||
}
|
||||
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||
/**
|
||||
* A logger supplied by the caller, bunyan style. Any object works, a level it does not
|
||||
* implement is routed to one it does, see _logFunc
|
||||
*/
|
||||
export interface ExternalLogger {
|
||||
trace?(...args: any[]): any;
|
||||
debug?(...args: any[]): any;
|
||||
info?(...args: any[]): any;
|
||||
warn?(...args: any[]): any;
|
||||
error?(...args: any[]): any;
|
||||
fatal?(...args: any[]): any;
|
||||
log?(...args: any[]): any;
|
||||
[level: string]: any;
|
||||
}
|
||||
/**
|
||||
* Options for getLogger
|
||||
*/
|
||||
export interface GetLoggerOptions {
|
||||
/** A bunyan compatible logger, true for the default console logger, false or unset for no logging */
|
||||
logger?: ExternalLogger | boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* The bunyan compatible logger interface returned by getLogger
|
||||
*/
|
||||
export interface Logger {
|
||||
trace(data?: LogEntry, message?: string, ...args: any[]): void;
|
||||
debug(data?: LogEntry, message?: string, ...args: any[]): void;
|
||||
info(data?: LogEntry, message?: string, ...args: any[]): void;
|
||||
warn(data?: LogEntry, message?: string, ...args: any[]): void;
|
||||
error(data?: LogEntry, message?: string, ...args: any[]): void;
|
||||
fatal(data?: LogEntry, message?: string, ...args: any[]): void;
|
||||
}
|
||||
/**
|
||||
* A parsed data URI
|
||||
*/
|
||||
export interface ParsedDataURI {
|
||||
/** Decoded payload */
|
||||
data: Buffer;
|
||||
/** 'base64', 'utf8' or 'utf-8' when the URI declared one, null otherwise */
|
||||
encoding: string | null;
|
||||
contentType: string;
|
||||
/** Further `key=value` parameters from the metadata section */
|
||||
params: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Access policy for resolveContent
|
||||
*/
|
||||
export interface ResolveContentOptions {
|
||||
/** Reject content that points to a file path */
|
||||
disableFileAccess?: boolean | undefined;
|
||||
/** Reject content that points to a URL */
|
||||
disableUrlAccess?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* An object value resolveContent understands. A plain string or Buffer value is returned as
|
||||
* is and a readable stream is read into a Buffer, an object carries the content in `content`
|
||||
* or points to it with `path` or `href`
|
||||
*/
|
||||
export interface ContentDescriptor {
|
||||
/** The content itself, a string, a Buffer or a readable stream */
|
||||
content?: string | Buffer | Readable | undefined;
|
||||
/** Encoding of a string `content`, it is decoded into a Buffer unless it is utf8 or ascii */
|
||||
encoding?: string | undefined;
|
||||
/** File path, http(s) URL or data URI to read the content from */
|
||||
path?: string | undefined;
|
||||
/** URL to fetch the content from */
|
||||
href?: string | undefined;
|
||||
/** Request headers for a URL fetch */
|
||||
httpHeaders?: OutgoingHttpHeaders | undefined;
|
||||
/** TLS settings for a URL fetch, see nmfetch */
|
||||
tls?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
}
|
||||
export type ResolveContentCallback = (err: Error | null, value?: any) => void;
|
||||
export declare let networkInterfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> | undefined;
|
||||
export declare const dnsCache: Map<string, DnsCacheEntry>;
|
||||
export declare const resolveHostname: (options: ResolveHostnameOptions | undefined, callback: (err: Error | null, result?: ResolvedHostname) => void) => void;
|
||||
/**
|
||||
* Parses connection url to a structured configuration object
|
||||
*
|
||||
* @param str Connection url
|
||||
* @return Configuration object
|
||||
*/
|
||||
export declare const parseConnectionUrl: (str?: string | null) => ConnectionUrlOptions;
|
||||
/**
|
||||
* Returns a bunyan-compatible logger interface. Uses either provided logger or
|
||||
* creates a default console logger
|
||||
*
|
||||
* @param [options] Options object that might include 'logger' value
|
||||
* @return bunyan compatible logger
|
||||
*/
|
||||
export declare const getLogger: (options?: GetLoggerOptions, defaults?: LogEntry) => Logger;
|
||||
/**
|
||||
* Wrapper for creating a callback that either resolves or rejects a promise
|
||||
* based on input
|
||||
*
|
||||
* @param resolve Function to run if callback is called
|
||||
* @param reject Function to run if callback ends with an error
|
||||
*/
|
||||
export declare const callbackPromise: (resolve: (...args: any[]) => void, reject: (reason?: any) => void) => (...args: any[]) => void;
|
||||
export declare const parseDataURI: (uri: unknown) => ParsedDataURI | null;
|
||||
/**
|
||||
* Resolves a String or a Buffer value for content value. Useful if the value
|
||||
* is a Stream or a file or an URL. If the value is a Stream, overwrites
|
||||
* the stream object with the resolved value (you can't stream a value twice).
|
||||
*
|
||||
* This is useful when you want to create a plugin that needs a content value,
|
||||
* for example the `html` or `text` value as a String or a Buffer but not as
|
||||
* a file path or an URL.
|
||||
*
|
||||
* @param data An object or an Array you want to resolve an element for, see ContentDescriptor for the values it understands
|
||||
* @param key Property name or an Array index
|
||||
* @param [options] Optional access policy: { disableFileAccess, disableUrlAccess }
|
||||
* @param callback Callback function with (err, value)
|
||||
*/
|
||||
export declare function resolveContent(data: {
|
||||
[key: string]: any;
|
||||
}, key: string | number, callback: ResolveContentCallback): void;
|
||||
export declare function resolveContent(data: {
|
||||
[key: string]: any;
|
||||
}, key: string | number, options: ResolveContentOptions | false | undefined, callback: ResolveContentCallback): void;
|
||||
export declare function resolveContent(data: {
|
||||
[key: string]: any;
|
||||
}, key: string | number, options?: ResolveContentOptions | false): Promise<any>;
|
||||
export declare function resolveContent(data: {
|
||||
[key: string]: any;
|
||||
}, key: string | number, options: ResolveContentOptions | false | undefined, callback: ResolveContentCallback | undefined): Promise<any> | void;
|
||||
/**
|
||||
* Copies properties from source objects to target objects
|
||||
*/
|
||||
export declare const assign: (...args: ({
|
||||
[key: string]: any;
|
||||
} | false | null | undefined)[]) => {
|
||||
[key: string]: any;
|
||||
};
|
||||
export declare const encodeXText: (str: string) => string;
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
"use strict";
|
||||
/* eslint no-console: 0 */
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.encodeXText = exports.assign = exports.parseDataURI = exports.callbackPromise = exports.getLogger = exports._logFunc = exports.parseConnectionUrl = exports.resolveHostname = exports.dnsCache = exports.networkInterfaces = exports._resetCacheCleanup = exports._lastCacheCleanup = exports.copyOwnKeys = exports.isProtoKey = void 0;
|
||||
exports.resolveContent = resolveContent;
|
||||
const urllib = __importStar(require("./url.js"));
|
||||
const node_util_1 = __importDefault(require("node:util"));
|
||||
const node_fs_1 = __importDefault(require("node:fs"));
|
||||
const index_js_1 = __importDefault(require("../fetch/index.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
const objects_js_1 = require("./objects.js");
|
||||
Object.defineProperty(exports, "isProtoKey", { enumerable: true, get: function () { return objects_js_1.isProtoKey; } });
|
||||
Object.defineProperty(exports, "copyOwnKeys", { enumerable: true, get: function () { return objects_js_1.copyOwnKeys; } });
|
||||
const node_dns_1 = __importDefault(require("node:dns"));
|
||||
const node_net_1 = __importDefault(require("node:net"));
|
||||
const node_os_1 = __importDefault(require("node:os"));
|
||||
const DNS_TTL = 5 * 60 * 1000;
|
||||
const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups
|
||||
const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache
|
||||
let lastCacheCleanup = 0;
|
||||
/** @internal */
|
||||
const _lastCacheCleanup = () => lastCacheCleanup;
|
||||
exports._lastCacheCleanup = _lastCacheCleanup;
|
||||
/** @internal */
|
||||
const _resetCacheCleanup = () => {
|
||||
lastCacheCleanup = 0;
|
||||
};
|
||||
exports._resetCacheCleanup = _resetCacheCleanup;
|
||||
try {
|
||||
exports.networkInterfaces = node_os_1.default.networkInterfaces();
|
||||
}
|
||||
catch (_err) {
|
||||
// fails on some systems
|
||||
}
|
||||
const isFamilySupported = (family, allowInternal) => {
|
||||
const addresses = Object.values(exports.networkInterfaces || {}).flat();
|
||||
if (!addresses.length) {
|
||||
// hope for the best. Runtimes without an interface table (Cloudflare
|
||||
// Workers) report an empty object rather than throwing
|
||||
return true;
|
||||
}
|
||||
return addresses.filter(i => !i.internal || allowInternal).some(i => i.family === 'IPv' + family || i.family === family);
|
||||
};
|
||||
const resolve = (family, hostname, options, callback) => {
|
||||
options = options || {};
|
||||
if (!isFamilySupported(family, options.allowInternalNetworkInterfaces)) {
|
||||
return callback(null, []);
|
||||
}
|
||||
const dnsResolver = node_dns_1.default.Resolver ? new node_dns_1.default.Resolver(options) : node_dns_1.default;
|
||||
dnsResolver['resolve' + family](hostname, (err, addresses) => {
|
||||
if (err) {
|
||||
switch (err.code) {
|
||||
case node_dns_1.default.NODATA:
|
||||
case node_dns_1.default.NOTFOUND:
|
||||
case node_dns_1.default.NOTIMP:
|
||||
case node_dns_1.default.SERVFAIL:
|
||||
case node_dns_1.default.CONNREFUSED:
|
||||
case node_dns_1.default.REFUSED:
|
||||
case 'EAI_AGAIN':
|
||||
return callback(null, []);
|
||||
}
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, Array.isArray(addresses) ? addresses : [].concat(addresses || []));
|
||||
});
|
||||
};
|
||||
exports.dnsCache = new Map();
|
||||
const formatDNSValue = (value, extra) => {
|
||||
if (!value) {
|
||||
return Object.assign({}, extra || {});
|
||||
}
|
||||
const addresses = value.addresses || [];
|
||||
// Select a random address from available addresses, or null if none
|
||||
const host = addresses.length > 0 ? addresses[Math.floor(Math.random() * addresses.length)] : null;
|
||||
return Object.assign({
|
||||
host,
|
||||
// Include all addresses for connection fallback support
|
||||
_addresses: addresses
|
||||
}, extra || {});
|
||||
};
|
||||
const resolveHostname = (options, callback) => {
|
||||
options = options || {};
|
||||
if (!options.host && options.servername) {
|
||||
options.host = options.servername;
|
||||
}
|
||||
if (!options.host || node_net_1.default.isIP(options.host)) {
|
||||
// nothing to do here
|
||||
const value = {
|
||||
addresses: [options.host]
|
||||
};
|
||||
return callback(null, formatDNSValue(value, {
|
||||
servername: options.servername || false,
|
||||
cached: false
|
||||
}));
|
||||
}
|
||||
const host = options.host;
|
||||
// The TLS server name belongs to the connection asking, not to the host it resolves. The
|
||||
// cache is shared by every transport of the process and keyed by host alone, so a server
|
||||
// name stored in it would be the one of whichever transport resolved the host first, and a
|
||||
// later transport with its own tls.servername would present and verify that name instead
|
||||
const servername = options.servername || host;
|
||||
let cached;
|
||||
if (exports.dnsCache.has(options.host)) {
|
||||
cached = exports.dnsCache.get(options.host);
|
||||
// Lazy cleanup with time throttling
|
||||
const now = Date.now();
|
||||
if (now - lastCacheCleanup > CACHE_CLEANUP_INTERVAL) {
|
||||
lastCacheCleanup = now;
|
||||
// Clean up expired entries
|
||||
for (const [host, entry] of exports.dnsCache.entries()) {
|
||||
if (entry.expires && entry.expires < now) {
|
||||
exports.dnsCache.delete(host);
|
||||
}
|
||||
}
|
||||
// If cache is still too large, remove oldest entries
|
||||
if (exports.dnsCache.size > MAX_CACHE_SIZE) {
|
||||
const toDelete = Math.floor(MAX_CACHE_SIZE * 0.1); // Remove 10% of entries
|
||||
const keys = Array.from(exports.dnsCache.keys()).slice(0, toDelete);
|
||||
keys.forEach(key => exports.dnsCache.delete(key));
|
||||
}
|
||||
}
|
||||
if (!cached.expires || cached.expires >= now) {
|
||||
return callback(null, formatDNSValue(cached.value, {
|
||||
servername,
|
||||
cached: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Resolve both IPv4 and IPv6 addresses for fallback support
|
||||
let ipv4Addresses = [];
|
||||
let ipv6Addresses = [];
|
||||
let ipv4Error = null;
|
||||
let ipv6Error = null;
|
||||
resolve(4, options.host, options, (err, addresses) => {
|
||||
if (err) {
|
||||
ipv4Error = err;
|
||||
}
|
||||
else {
|
||||
ipv4Addresses = addresses || [];
|
||||
}
|
||||
resolve(6, host, options, (err, addresses) => {
|
||||
if (err) {
|
||||
ipv6Error = err;
|
||||
}
|
||||
else {
|
||||
ipv6Addresses = addresses || [];
|
||||
}
|
||||
// Combine addresses: IPv4 first, then IPv6
|
||||
const allAddresses = ipv4Addresses.concat(ipv6Addresses);
|
||||
if (allAddresses.length) {
|
||||
const value = {
|
||||
addresses: allAddresses
|
||||
};
|
||||
exports.dnsCache.set(host, {
|
||||
value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(value, {
|
||||
servername,
|
||||
cached: false
|
||||
}));
|
||||
}
|
||||
// No addresses from resolve4/resolve6, try dns.lookup as fallback
|
||||
if (ipv4Error && ipv6Error) {
|
||||
// Both resolvers had errors
|
||||
if (cached) {
|
||||
exports.dnsCache.set(host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(cached.value, {
|
||||
servername,
|
||||
cached: true,
|
||||
error: ipv4Error
|
||||
}));
|
||||
}
|
||||
}
|
||||
try {
|
||||
node_dns_1.default.lookup(host, { all: true }, (err, addresses) => {
|
||||
if (err) {
|
||||
if (cached) {
|
||||
exports.dnsCache.set(host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(cached.value, {
|
||||
servername,
|
||||
cached: true,
|
||||
error: err
|
||||
}));
|
||||
}
|
||||
return callback(err);
|
||||
}
|
||||
// Get all supported addresses from dns.lookup
|
||||
const supportedAddresses = addresses
|
||||
? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address)
|
||||
: [];
|
||||
if (addresses && addresses.length && !supportedAddresses.length) {
|
||||
// there are addresses but none can be used
|
||||
console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`);
|
||||
}
|
||||
if (!supportedAddresses.length && cached) {
|
||||
// nothing was found, fallback to cached value
|
||||
return callback(null, formatDNSValue(cached.value, {
|
||||
servername,
|
||||
cached: true
|
||||
}));
|
||||
}
|
||||
const value = {
|
||||
addresses: supportedAddresses.length ? supportedAddresses : [host]
|
||||
};
|
||||
exports.dnsCache.set(host, {
|
||||
value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(value, {
|
||||
servername,
|
||||
cached: false
|
||||
}));
|
||||
});
|
||||
}
|
||||
catch (lookupErr) {
|
||||
if (cached) {
|
||||
exports.dnsCache.set(host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(cached.value, {
|
||||
servername,
|
||||
cached: true,
|
||||
error: lookupErr
|
||||
}));
|
||||
}
|
||||
return callback(ipv4Error || ipv6Error || lookupErr);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
exports.resolveHostname = resolveHostname;
|
||||
/**
|
||||
* Parses connection url to a structured configuration object
|
||||
*
|
||||
* @param str Connection url
|
||||
* @return Configuration object
|
||||
*/
|
||||
const parseConnectionUrl = (str) => {
|
||||
str = str || '';
|
||||
const options = {};
|
||||
const url = urllib.parse(str, true);
|
||||
switch (url.protocol) {
|
||||
case 'smtp:':
|
||||
options.secure = false;
|
||||
break;
|
||||
case 'smtps:':
|
||||
options.secure = true;
|
||||
break;
|
||||
case 'direct:':
|
||||
options.direct = true;
|
||||
break;
|
||||
}
|
||||
if (!isNaN(url.port) && Number(url.port)) {
|
||||
options.port = Number(url.port);
|
||||
}
|
||||
if (url.hostname) {
|
||||
options.host = url.hostname;
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
options.auth = {
|
||||
user: url.username || '',
|
||||
pass: url.password || ''
|
||||
};
|
||||
}
|
||||
Object.keys(url.query || {}).forEach(key => {
|
||||
let obj = options;
|
||||
let lKey = key;
|
||||
let value = url.query[key];
|
||||
if (!isNaN(value)) {
|
||||
value = Number(value);
|
||||
}
|
||||
switch (value) {
|
||||
case 'true':
|
||||
value = true;
|
||||
break;
|
||||
case 'false':
|
||||
value = false;
|
||||
break;
|
||||
}
|
||||
// tls is nested object
|
||||
if (key.indexOf('tls.') === 0) {
|
||||
lKey = key.substr(4);
|
||||
if (!options.tls) {
|
||||
options.tls = {};
|
||||
}
|
||||
obj = options.tls;
|
||||
}
|
||||
else if (key.indexOf('.') >= 0) {
|
||||
// ignore nested properties besides tls
|
||||
return;
|
||||
}
|
||||
// `in` already keeps "__proto__" out, but only as a side effect of it being an
|
||||
// Object.prototype member. Say it, so the protection survives a change to the check
|
||||
if (!(0, objects_js_1.isProtoKey)(lKey) && !(lKey in obj)) {
|
||||
obj[lKey] = value;
|
||||
}
|
||||
});
|
||||
return options;
|
||||
};
|
||||
exports.parseConnectionUrl = parseConnectionUrl;
|
||||
/** @internal */
|
||||
const _logFunc = (logger, level, defaults, data, message, ...args) => {
|
||||
const entry = Object.assign({}, defaults || {}, data || {});
|
||||
delete entry.level;
|
||||
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);
|
||||
}
|
||||
};
|
||||
exports._logFunc = _logFunc;
|
||||
/**
|
||||
* Returns a bunyan-compatible logger interface. Uses either provided logger or
|
||||
* creates a default console logger
|
||||
*
|
||||
* @param [options] Options object that might include 'logger' value
|
||||
* @return bunyan compatible logger
|
||||
*/
|
||||
const getLogger = (options, defaults) => {
|
||||
options = options || {};
|
||||
const response = {};
|
||||
const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
|
||||
if (!options.logger) {
|
||||
// use vanity logger
|
||||
levels.forEach(level => {
|
||||
response[level] = () => false;
|
||||
});
|
||||
return response;
|
||||
}
|
||||
const logger = options.logger === true ? createDefaultLogger(levels) : options.logger;
|
||||
levels.forEach(level => {
|
||||
response[level] = (data, message, ...args) => {
|
||||
(0, exports._logFunc)(logger, level, defaults, data, message, ...args);
|
||||
};
|
||||
});
|
||||
return response;
|
||||
};
|
||||
exports.getLogger = getLogger;
|
||||
/**
|
||||
* Wrapper for creating a callback that either resolves or rejects a promise
|
||||
* based on input
|
||||
*
|
||||
* @param resolve Function to run if callback is called
|
||||
* @param reject Function to run if callback ends with an error
|
||||
*/
|
||||
const callbackPromise = (resolve, reject) => function (...args) {
|
||||
const err = args.shift();
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
else {
|
||||
resolve(...args);
|
||||
}
|
||||
};
|
||||
exports.callbackPromise = callbackPromise;
|
||||
const parseDataURI = (uri) => {
|
||||
if (typeof uri !== 'string') {
|
||||
return null;
|
||||
}
|
||||
// Early return for non-data URIs to avoid unnecessary processing
|
||||
if (!uri.startsWith('data:')) {
|
||||
return null;
|
||||
}
|
||||
// Find the first comma safely - this prevents ReDoS
|
||||
const commaPos = uri.indexOf(',');
|
||||
if (commaPos === -1) {
|
||||
return null;
|
||||
}
|
||||
const data = uri.substring(commaPos + 1);
|
||||
const metaStr = uri.substring('data:'.length, commaPos);
|
||||
let encoding;
|
||||
const metaEntries = metaStr.split(';');
|
||||
if (metaEntries.length > 0) {
|
||||
const lastEntry = metaEntries[metaEntries.length - 1].toLowerCase().trim();
|
||||
// Only recognize valid encoding types to prevent manipulation
|
||||
if (['base64', 'utf8', 'utf-8'].includes(lastEntry) && lastEntry.indexOf('=') === -1) {
|
||||
encoding = lastEntry;
|
||||
metaEntries.pop();
|
||||
}
|
||||
}
|
||||
const contentType = metaEntries.length > 0 ? metaEntries.shift() : 'application/octet-stream';
|
||||
const params = {};
|
||||
for (let i = 0; i < metaEntries.length; i++) {
|
||||
const entry = metaEntries[i];
|
||||
const sepPos = entry.indexOf('=');
|
||||
if (sepPos > 0) {
|
||||
// Ensure there's a key before the '='
|
||||
const key = entry.substring(0, sepPos).trim();
|
||||
const value = entry.substring(sepPos + 1).trim();
|
||||
if (key && !(0, objects_js_1.isProtoKey)(key)) {
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Decode data based on encoding with proper error handling
|
||||
let bufferData;
|
||||
try {
|
||||
if (encoding === 'base64') {
|
||||
bufferData = Buffer.from(data, 'base64');
|
||||
}
|
||||
else {
|
||||
try {
|
||||
bufferData = Buffer.from(decodeURIComponent(data));
|
||||
}
|
||||
catch (_decodeError) {
|
||||
bufferData = Buffer.from(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (_bufferError) {
|
||||
bufferData = Buffer.alloc(0);
|
||||
}
|
||||
return {
|
||||
data: bufferData,
|
||||
encoding: encoding || null,
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
params
|
||||
};
|
||||
};
|
||||
exports.parseDataURI = parseDataURI;
|
||||
function 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) {
|
||||
promise = new Promise((resolve, reject) => {
|
||||
callback = (0, exports.callbackPromise)(resolve, reject);
|
||||
});
|
||||
}
|
||||
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()
|
||||
.toLowerCase()
|
||||
.replace(/[-_\s]/g, '');
|
||||
if (!content) {
|
||||
return callback(null, content);
|
||||
}
|
||||
if (typeof content === 'object') {
|
||||
if (typeof content.pipe === 'function') {
|
||||
return resolveStream(content, (err, value) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
// we can't stream twice the same content, so we need
|
||||
// to replace the stream object with the streaming result
|
||||
if (data[key].content) {
|
||||
data[key].content = value;
|
||||
}
|
||||
else {
|
||||
data[key] = value;
|
||||
}
|
||||
callback(null, value);
|
||||
});
|
||||
}
|
||||
else if (/^data:/i.test(content.path || content.href)) {
|
||||
const parsedDataUri = (0, exports.parseDataURI)(content.path || content.href);
|
||||
return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
|
||||
}
|
||||
else if (content.href || /^https?:\/\//i.test(content.path)) {
|
||||
// An href is always a URL, and so is a path that looks like one. Let nmfetch
|
||||
// decide whether it is fetchable, it validates the parsed URL. Testing the raw
|
||||
// string here instead would let a file: href fall through to the "return as is"
|
||||
// default below and travel on inside the resolved message.
|
||||
const url = content.href || content.path;
|
||||
if (options.disableUrlAccess) {
|
||||
setImmediate(() => {
|
||||
const err = new Error('Url access rejected for ' + url);
|
||||
err.code = errors.EURLACCESS;
|
||||
callback(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
return resolveStream((0, index_js_1.default)(url, { headers: content.httpHeaders, tls: content.tls }), callback);
|
||||
}
|
||||
else if (content.path) {
|
||||
if (options.disableFileAccess) {
|
||||
setImmediate(() => {
|
||||
const err = new Error('File access rejected for ' + content.path);
|
||||
err.code = errors.EFILEACCESS;
|
||||
callback(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
return resolveStream(node_fs_1.default.createReadStream(content.path), callback);
|
||||
}
|
||||
}
|
||||
if (typeof data[key].content === 'string' && !['utf8', 'usascii', 'ascii'].includes(encoding)) {
|
||||
content = Buffer.from(data[key].content, encoding);
|
||||
}
|
||||
// default action, return as is
|
||||
setImmediate(() => callback(null, content));
|
||||
}
|
||||
/**
|
||||
* Copies properties from source objects to target objects
|
||||
*/
|
||||
const assign = function (...args) {
|
||||
const target = args.shift() || {};
|
||||
args.forEach(source => {
|
||||
Object.keys(source || {}).forEach(key => {
|
||||
if ((0, objects_js_1.isProtoKey)(key)) {
|
||||
return;
|
||||
}
|
||||
if (['tls', 'auth'].includes(key) &&
|
||||
source[key] &&
|
||||
typeof source[key] === 'object') {
|
||||
// tls and auth are special keys that need to be enumerated separately
|
||||
// other objects are passed as is. Enumerating is a copy of user supplied
|
||||
// keys just like the loop above, so it gets the same treatment
|
||||
target[key] = (0, objects_js_1.copyOwnKeys)(target[key] || {}, source[key]);
|
||||
}
|
||||
else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
return target;
|
||||
};
|
||||
exports.assign = assign;
|
||||
const encodeXText = (str) => {
|
||||
// ! 0x21
|
||||
// + 0x2B
|
||||
// = 0x3D
|
||||
// ~ 0x7E
|
||||
if (!/[^\x21-\x2A\x2C-\x3C\x3E-\x7E]/.test(str)) {
|
||||
return str;
|
||||
}
|
||||
const buf = Buffer.from(str);
|
||||
let result = '';
|
||||
for (let i = 0, len = buf.length; i < len; i++) {
|
||||
const c = buf[i];
|
||||
if (c < 0x21 || c > 0x7e || c === 0x2b || c === 0x3d) {
|
||||
result += '+' + (c < 0x10 ? '0' : '') + c.toString(16).toUpperCase();
|
||||
}
|
||||
else {
|
||||
result += String.fromCharCode(c);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
exports.encodeXText = encodeXText;
|
||||
/**
|
||||
* Streams a stream value into a Buffer
|
||||
*
|
||||
* @param stream Readable stream
|
||||
* @param callback Callback function with (err, value)
|
||||
*/
|
||||
function resolveStream(stream, callback) {
|
||||
let responded = false;
|
||||
const chunks = [];
|
||||
let chunklen = 0;
|
||||
stream.on('error', err => {
|
||||
if (responded) {
|
||||
return;
|
||||
}
|
||||
responded = true;
|
||||
callback(err);
|
||||
});
|
||||
stream.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = stream.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (responded) {
|
||||
return;
|
||||
}
|
||||
responded = true;
|
||||
let value;
|
||||
try {
|
||||
value = Buffer.concat(chunks, chunklen);
|
||||
}
|
||||
catch (E) {
|
||||
return callback(E);
|
||||
}
|
||||
callback(null, value);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generates a bunyan-like logger that prints to console
|
||||
*
|
||||
* @returns Bunyan logger instance
|
||||
*/
|
||||
function createDefaultLogger(levels) {
|
||||
const levelMaxLen = levels.reduce((max, level) => Math.max(max, level.length), 0);
|
||||
const levelNames = new Map();
|
||||
levels.forEach(level => {
|
||||
let levelName = level.toUpperCase();
|
||||
if (levelName.length < levelMaxLen) {
|
||||
levelName += ' '.repeat(levelMaxLen - levelName.length);
|
||||
}
|
||||
levelNames.set(level, levelName);
|
||||
});
|
||||
const print = (level, entry, message, ...args) => {
|
||||
let prefix = '';
|
||||
if (entry) {
|
||||
if (entry.tnx === 'server') {
|
||||
prefix = 'S: ';
|
||||
}
|
||||
else if (entry.tnx === 'client') {
|
||||
prefix = 'C: ';
|
||||
}
|
||||
if (entry.sid) {
|
||||
prefix = '[' + entry.sid + '] ' + prefix;
|
||||
}
|
||||
if (entry.cid) {
|
||||
prefix = '[#' + entry.cid + '] ' + prefix;
|
||||
}
|
||||
}
|
||||
message = node_util_1.default.format(message, ...args);
|
||||
message.split(/\r?\n/).forEach((line) => {
|
||||
console.log('[%s] %s %s', new Date().toISOString().substr(0, 19).replace(/T/, ' '), levelNames.get(level), prefix + line);
|
||||
});
|
||||
};
|
||||
const logger = {};
|
||||
levels.forEach(level => {
|
||||
logger[level] = print.bind(null, level);
|
||||
});
|
||||
return logger;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Detects a key that can not be copied onto a plain object with `target[key] = value`.
|
||||
*
|
||||
* "__proto__" is the only one: assigning it runs the inherited setter and replaces the
|
||||
* prototype of the target instead of adding a property to it, so a caller can smuggle
|
||||
* values past validation that only inspects own keys. JSON.parse produces such a key
|
||||
* where an object literal can not. "constructor" and "prototype" have no such setter and
|
||||
* become ordinary own properties, so dropping them would only discard legitimate values.
|
||||
*
|
||||
* @param key Key to check
|
||||
* @returns true if the key must not be copied
|
||||
*/
|
||||
export declare const isProtoKey: (key: string) => boolean;
|
||||
/**
|
||||
* Copies own enumerable keys from a source object to a target object. Every copy that
|
||||
* walks the keys of user supplied data goes through here, see isProtoKey.
|
||||
*
|
||||
* @param target Object to copy the keys to
|
||||
* @param source Object to copy the keys from
|
||||
* @param [skip] Optional predicate, return true to leave a key out
|
||||
* @returns The target object
|
||||
*/
|
||||
export declare const copyOwnKeys: <T extends object>(target: T, source: object | null | undefined, skip?: (key: string) => boolean) => T;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
// Safe copying of objects whose keys come from the caller.
|
||||
//
|
||||
// This lives in its own leaf module, like ./url.ts, so that every layer can reach it.
|
||||
// src/shared/index.ts imports src/fetch, so src/fetch can not import src/shared back,
|
||||
// and src/mime-funcs is a leaf that would otherwise pull in dns/net/os/fs for a string
|
||||
// comparison. src/shared/index.ts re-exports both functions for the callers that already
|
||||
// depend on it.
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.copyOwnKeys = exports.isProtoKey = void 0;
|
||||
/**
|
||||
* Detects a key that can not be copied onto a plain object with `target[key] = value`.
|
||||
*
|
||||
* "__proto__" is the only one: assigning it runs the inherited setter and replaces the
|
||||
* prototype of the target instead of adding a property to it, so a caller can smuggle
|
||||
* values past validation that only inspects own keys. JSON.parse produces such a key
|
||||
* where an object literal can not. "constructor" and "prototype" have no such setter and
|
||||
* become ordinary own properties, so dropping them would only discard legitimate values.
|
||||
*
|
||||
* @param key Key to check
|
||||
* @returns true if the key must not be copied
|
||||
*/
|
||||
const isProtoKey = (key) => key === '__proto__';
|
||||
exports.isProtoKey = isProtoKey;
|
||||
/**
|
||||
* Copies own enumerable keys from a source object to a target object. Every copy that
|
||||
* walks the keys of user supplied data goes through here, see isProtoKey.
|
||||
*
|
||||
* @param target Object to copy the keys to
|
||||
* @param source Object to copy the keys from
|
||||
* @param [skip] Optional predicate, return true to leave a key out
|
||||
* @returns The target object
|
||||
*/
|
||||
const copyOwnKeys = (target, source, skip) => {
|
||||
Object.keys(source || {}).forEach(key => {
|
||||
if ((0, exports.isProtoKey)(key) || (skip && skip(key))) {
|
||||
return;
|
||||
}
|
||||
target[key] = source[key];
|
||||
});
|
||||
return target;
|
||||
};
|
||||
exports.copyOwnKeys = copyOwnKeys;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Parsed URL in the shape of the legacy `url.parse()` result
|
||||
*/
|
||||
export interface ParsedUrl {
|
||||
protocol: string | null;
|
||||
host: string | null;
|
||||
hostname: string | null;
|
||||
port: string | null;
|
||||
pathname: string | null;
|
||||
search: string | null;
|
||||
path: string | null;
|
||||
href: string;
|
||||
auth: string | null;
|
||||
/** Decoded user name, null when the URL carries no credentials */
|
||||
username: string | null;
|
||||
/** Decoded password, null when the URL carries none */
|
||||
password: string | null;
|
||||
query: string | null | Record<string, string | string[]>;
|
||||
}
|
||||
export declare const parse: (input?: string | null, parseQueryString?: boolean) => ParsedUrl;
|
||||
export declare const resolve: (from: string, to: string) => string;
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
"use strict";
|
||||
// URL parsing wrapper around the WHATWG `URL` class. It only falls back to the
|
||||
// legacy, deprecation-warning-emitting `url.parse()` / `url.resolve()` for input
|
||||
// the WHATWG parser rejects.
|
||||
//
|
||||
// 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.
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.resolve = exports.parse = void 0;
|
||||
const node_net_1 = __importDefault(require("node:net"));
|
||||
const node_url_1 = __importDefault(require("node:url"));
|
||||
const punycode = __importStar(require("../punycode/index.js"));
|
||||
// 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+.-]*:)(?!\/\/)([\s\S]+)$/;
|
||||
// Leading and trailing C0 controls and spaces, which the WHATWG parser strips before it
|
||||
// looks at the input. Stripped up front so that the slash-less form is recognized in a
|
||||
// value read from a file with a trailing newline as well.
|
||||
const SURROUNDING_WHITESPACE = /^[\x00-\x20]+|[\x00-\x20]+$/g;
|
||||
// Leading characters legacy url.parse() skips before it reads the scheme
|
||||
const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/;
|
||||
// The authority of a "scheme://authority/..." or a scheme-relative "//authority/..."
|
||||
// string: the scheme, if any (anything the legacy parser takes for one, it is less strict
|
||||
// than WHATWG about the first character), and what the legacy parser has to report as the
|
||||
// host once the userinfo is removed, see legacyParse. The legacy parser treats a backslash
|
||||
// as a slash.
|
||||
const AUTHORITY = /^([a-zA-Z0-9+.-]+:)?[\\/]{2}([^\\/?#]*)/;
|
||||
// The WHATWG forbidden domain code points, except '%' which an opaque host may carry: the
|
||||
// C0 control characters, space, DEL and the URL delimiters. CONTROL_CHARS is the C0 and DEL
|
||||
// subset, checked on its own in legacyParse where the host string still carries the port
|
||||
// and IPv6 brackets.
|
||||
const FORBIDDEN_HOST_CHARS = /[\x00-\x20#/:<>?@[\\\]^|\x7f]/;
|
||||
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
||||
// The error the WHATWG parser throws, for a host that only fails the checks in this module
|
||||
function invalidUrl(input) {
|
||||
const err = new TypeError('Invalid URL');
|
||||
err.code = 'ERR_INVALID_URL';
|
||||
err.input = input;
|
||||
return err;
|
||||
}
|
||||
// Legacy url.parse() for input the WHATWG parser refused. The legacy parser does not
|
||||
// reject a host it can not represent: a NUL byte, a percent-encoded byte, a space or a
|
||||
// '<' inside the host ends the host early and the rest becomes the path, so
|
||||
// 'localhost%00.example.com' silently turns into a request to 'localhost'. When the
|
||||
// input has an authority that the legacy parser reads as the host (always for
|
||||
// "scheme://", for "//host" only when slashesDenoteHost is set, as url.resolve() does
|
||||
// for its target), the legacy result is accepted only if that host is the whole written
|
||||
// authority, lowercased and IDNA mapped the way the legacy parser does it, and carries
|
||||
// no control character. A legacy result with a host (an empty one included, 'http:///x'
|
||||
// is a request to localhost) that no such authority accounts for is refused as well.
|
||||
// Otherwise the WHATWG error is reported. Relative input has no authority to check and
|
||||
// keeps the legacy behavior.
|
||||
function legacyParse(input, parseQueryString, whatwgError, slashesDenoteHost) {
|
||||
const parsed = node_url_1.default.parse(input, parseQueryString, slashesDenoteHost);
|
||||
const authority = AUTHORITY.exec(input.replace(LEGACY_TRIM, ''));
|
||||
if (authority && (authority[1] || parsed.hostname !== null)) {
|
||||
const written = authority[2].slice(authority[2].lastIndexOf('@') + 1);
|
||||
if (!written || CONTROL_CHARS.test(written) || (parsed.host || '').toLowerCase() !== punycode.toASCII(written.toLowerCase())) {
|
||||
throw whatwgError;
|
||||
}
|
||||
// the legacy parser takes any bracketed value for an IPv6 literal
|
||||
if (written.charAt(0) === '[' && !node_net_1.default.isIPv6(written.slice(1, written.indexOf(']')))) {
|
||||
throw whatwgError;
|
||||
}
|
||||
}
|
||||
else if (parsed.hostname !== null) {
|
||||
throw whatwgError;
|
||||
}
|
||||
// the legacy parser only offers the joined form, split it on the first colon
|
||||
const legacyAuth = parsed.auth === null || parsed.auth === undefined ? null : parsed.auth.split(':');
|
||||
const result = parsed;
|
||||
result.username = legacyAuth ? legacyAuth.shift() : null;
|
||||
result.password = legacyAuth && legacyAuth.length ? legacyAuth.join(':') : null;
|
||||
return result;
|
||||
}
|
||||
// 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 IDNA mapped (lowercased, punycode) form. Idempotent on plain
|
||||
// ASCII and already-punycode hosts, so special-scheme hosts (already IDNA-mapped
|
||||
// by WHATWG) pass through.
|
||||
function normalizeHostname(raw, href) {
|
||||
const 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);
|
||||
}
|
||||
const decoded = safeDecode(hostname);
|
||||
// domainToASCII applies the WHATWG host rules (IDNA mapping included) and returns an
|
||||
// empty string for a host it refuses, the forbidden characters among them
|
||||
const mapped = FORBIDDEN_HOST_CHARS.test(decoded) ? '' : node_url_1.default.domainToASCII(decoded);
|
||||
if (!mapped) {
|
||||
throw invalidUrl(href);
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
const parse = (input, parseQueryString) => {
|
||||
input = (input || '').replace(SURROUNDING_WHITESPACE, '');
|
||||
// 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 URL(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 legacyParse(normalized, parseQueryString, err);
|
||||
}
|
||||
const hostname = normalizeHostname(u.hostname, u.href);
|
||||
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;
|
||||
let username = null;
|
||||
let password = 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.
|
||||
username = safeDecode(u.username);
|
||||
password = u.password ? safeDecode(u.password) : null;
|
||||
// the joined form is ambiguous once the user name contains a colon, so
|
||||
// consumers that need the parts read username and password instead
|
||||
auth = username + (password !== null ? ':' + password : '');
|
||||
}
|
||||
let query;
|
||||
if (parseQueryString) {
|
||||
// Mirror querystring.parse(): null-prototype object, repeated keys become an array.
|
||||
const parsed = Object.create(null);
|
||||
u.searchParams.forEach((value, key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, key)) {
|
||||
const existing = parsed[key];
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
}
|
||||
else {
|
||||
parsed[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
else {
|
||||
parsed[key] = value;
|
||||
}
|
||||
});
|
||||
query = parsed;
|
||||
}
|
||||
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,
|
||||
username,
|
||||
password,
|
||||
query
|
||||
};
|
||||
};
|
||||
exports.parse = parse;
|
||||
const resolve = (from, to) => {
|
||||
try {
|
||||
return new URL(to, from).href;
|
||||
}
|
||||
catch (err) {
|
||||
// Malformed target, fall back to the legacy resolver, but only when the legacy
|
||||
// parser reads the same host out of both inputs that was written. The target
|
||||
// decides the host when it is absolute or scheme-relative, the base otherwise
|
||||
legacyParse(from, false, err, true);
|
||||
legacyParse(to, false, err, true);
|
||||
return node_url_1.default.resolve(from, to);
|
||||
}
|
||||
};
|
||||
exports.resolve = resolve;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Transform, type TransformOptions } from 'node:stream';
|
||||
/**
|
||||
* Escapes dots in the beginning of lines. Ends the stream with <CR><LF>.<CR><LF>
|
||||
* Also makes sure that only <CR><LF> sequences are used for linebreaks
|
||||
*
|
||||
* @param options Stream options
|
||||
*/
|
||||
export default class DataStream extends Transform {
|
||||
options: TransformOptions;
|
||||
inByteCount: number;
|
||||
outByteCount: number;
|
||||
lastByte: number | false;
|
||||
constructor(options?: TransformOptions);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_stream_1 = require("node:stream");
|
||||
/**
|
||||
* Escapes dots in the beginning of lines. Ends the stream with <CR><LF>.<CR><LF>
|
||||
* Also makes sure that only <CR><LF> sequences are used for linebreaks
|
||||
*
|
||||
* @param options Stream options
|
||||
*/
|
||||
class DataStream extends node_stream_1.Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.options = options || {};
|
||||
this.inByteCount = 0;
|
||||
this.outByteCount = 0;
|
||||
this.lastByte = false;
|
||||
}
|
||||
/**
|
||||
* Escapes dots
|
||||
* @internal
|
||||
*/
|
||||
_transform(chunk, encoding, done) {
|
||||
const chunks = [];
|
||||
let chunklen = 0;
|
||||
let i, len, lastPos = 0;
|
||||
let buf;
|
||||
if (!chunk || !chunk.length) {
|
||||
return done();
|
||||
}
|
||||
if (typeof chunk === 'string') {
|
||||
chunk = Buffer.from(chunk);
|
||||
}
|
||||
this.inByteCount += chunk.length;
|
||||
for (i = 0, len = chunk.length; i < len; i++) {
|
||||
if (chunk[i] === 0x2e) {
|
||||
// .
|
||||
if ((i && chunk[i - 1] === 0x0a) || (!i && (!this.lastByte || this.lastByte === 0x0a))) {
|
||||
buf = chunk.slice(lastPos, i + 1);
|
||||
chunks.push(buf);
|
||||
chunks.push(Buffer.from('.'));
|
||||
chunklen += buf.length + 1;
|
||||
lastPos = i + 1;
|
||||
}
|
||||
}
|
||||
else if (chunk[i] === 0x0a) {
|
||||
// \n
|
||||
if ((i && chunk[i - 1] !== 0x0d) || (!i && this.lastByte !== 0x0d)) {
|
||||
if (i > lastPos) {
|
||||
buf = chunk.slice(lastPos, i);
|
||||
chunks.push(buf);
|
||||
chunklen += buf.length + 2;
|
||||
}
|
||||
else {
|
||||
chunklen += 2;
|
||||
}
|
||||
chunks.push(Buffer.from('\r\n'));
|
||||
lastPos = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chunklen) {
|
||||
// add last piece
|
||||
if (lastPos < chunk.length) {
|
||||
buf = chunk.slice(lastPos);
|
||||
chunks.push(buf);
|
||||
chunklen += buf.length;
|
||||
}
|
||||
this.outByteCount += chunklen;
|
||||
this.push(Buffer.concat(chunks, chunklen));
|
||||
}
|
||||
else {
|
||||
this.outByteCount += chunk.length;
|
||||
this.push(chunk);
|
||||
}
|
||||
this.lastByte = chunk[chunk.length - 1];
|
||||
done();
|
||||
}
|
||||
/**
|
||||
* Finalizes the stream with a dot on a single line
|
||||
* @internal
|
||||
*/
|
||||
_flush(done) {
|
||||
let buf;
|
||||
if (this.lastByte === 0x0a) {
|
||||
buf = Buffer.from('.\r\n');
|
||||
}
|
||||
else if (this.lastByte === 0x0d) {
|
||||
buf = Buffer.from('\n.\r\n');
|
||||
}
|
||||
else {
|
||||
buf = Buffer.from('\r\n.\r\n');
|
||||
}
|
||||
this.outByteCount += buf.length;
|
||||
this.push(buf);
|
||||
done();
|
||||
}
|
||||
}
|
||||
exports.default = DataStream;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Minimal HTTP/S proxy client
|
||||
*/
|
||||
import net from 'node:net';
|
||||
import type { NodemailerError } from '../errors.js';
|
||||
/**
|
||||
* TLS options for connecting to an HTTPS proxy
|
||||
*/
|
||||
export interface HttpProxyClientOptions {
|
||||
/** Set to false to accept a proxy certificate that fails validation (e.g. self-signed) */
|
||||
rejectUnauthorized?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* Receives the proxied socket once the CONNECT handshake has succeeded, or the error that prevented it
|
||||
*/
|
||||
export type HttpProxyClientCallback = (err: NodemailerError | null, socket?: net.Socket) => void;
|
||||
/**
|
||||
* Establishes proxied connection to destinationPort
|
||||
*
|
||||
* httpProxyClient("http://localhost:3128/", 80, "google.com", function(err, socket){
|
||||
* socket.write("GET / HTTP/1.0\r\n\r\n");
|
||||
* });
|
||||
*
|
||||
* @param proxyUrl proxy configuration, etg "http://proxy.host:3128/"
|
||||
* @param destinationPort Port to open in destination host
|
||||
* @param destinationHost Destination hostname
|
||||
* @param [tlsOptions] Optional TLS options for an HTTPS proxy (e.g. { rejectUnauthorized: false })
|
||||
* @param callback Callback to run with the rocket object once connection is established
|
||||
*/
|
||||
declare function httpProxyClient(proxyUrl: string, destinationPort: number | string, destinationHost: string, callback: HttpProxyClientCallback): void;
|
||||
declare function httpProxyClient(proxyUrl: string, destinationPort: number | string, destinationHost: string, tlsOptions: HttpProxyClientOptions | undefined, callback: HttpProxyClientCallback): void;
|
||||
/**
|
||||
* Socket timeout in milliseconds while the CONNECT handshake is in progress, defaults to 30 seconds.
|
||||
* Settable on the function itself, the same way the CommonJS module exposed it.
|
||||
*/
|
||||
declare namespace httpProxyClient {
|
||||
let timeout: number | undefined;
|
||||
}
|
||||
export default httpProxyClient;
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Minimal HTTP/S proxy client
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_net_1 = __importDefault(require("node:net"));
|
||||
const node_tls_1 = __importDefault(require("node:tls"));
|
||||
const urllib = __importStar(require("../shared/url.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
// Cap the CONNECT response we buffer before the header terminator, so a proxy that
|
||||
// never sends \r\n\r\n cannot grow memory unboundedly before the socket times out.
|
||||
const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
|
||||
function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) {
|
||||
if (typeof tlsOptions === 'function') {
|
||||
callback = tlsOptions;
|
||||
tlsOptions = {};
|
||||
}
|
||||
tlsOptions = tlsOptions || {};
|
||||
// Reject CRLF in the destination before it reaches the CONNECT request line
|
||||
// and Host header. A tainted host/port could otherwise inject additional
|
||||
// request headers into the proxy connection (HTTP request splitting).
|
||||
destinationPort = Number(destinationPort) || 0;
|
||||
if (!destinationPort || /[\r\n]/.test(destinationHost)) {
|
||||
const err = new Error('Invalid proxy destination');
|
||||
err.code = errors.EPROXY;
|
||||
setImmediate(() => callback(err));
|
||||
return;
|
||||
}
|
||||
const proxy = urllib.parse(proxyUrl);
|
||||
const connectOptions = {
|
||||
host: proxy.hostname,
|
||||
port: Number(proxy.port) ? Number(proxy.port) : proxy.protocol === 'https:' ? 443 : 80
|
||||
};
|
||||
let connect;
|
||||
if (proxy.protocol === 'https:') {
|
||||
// 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 = node_tls_1.default.connect.bind(node_tls_1.default);
|
||||
}
|
||||
else {
|
||||
connect = node_net_1.default.connect.bind(node_net_1.default);
|
||||
}
|
||||
let socket;
|
||||
// Error harness for initial connection. Once connection is established, the responsibility
|
||||
// to handle errors is passed to whoever uses this socket
|
||||
let finished = false;
|
||||
const tempSocketErr = (err) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
}
|
||||
catch (_E) {
|
||||
// ignore
|
||||
}
|
||||
callback(err);
|
||||
};
|
||||
const timeoutErr = () => {
|
||||
const err = new Error('Proxy socket timed out');
|
||||
err.code = 'ETIMEDOUT';
|
||||
tempSocketErr(err);
|
||||
};
|
||||
socket = connect(connectOptions, () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
const reqHeaders = {
|
||||
Host: destinationHost + ':' + destinationPort,
|
||||
Connection: 'close'
|
||||
};
|
||||
if (proxy.auth) {
|
||||
reqHeaders['Proxy-Authorization'] = 'Basic ' + Buffer.from(proxy.auth).toString('base64');
|
||||
}
|
||||
socket.write(
|
||||
// HTTP method
|
||||
'CONNECT ' +
|
||||
destinationHost +
|
||||
':' +
|
||||
destinationPort +
|
||||
' HTTP/1.1\r\n' +
|
||||
// HTTP request headers
|
||||
Object.keys(reqHeaders)
|
||||
.map(key => key + ': ' + reqHeaders[key])
|
||||
.join('\r\n') +
|
||||
// End request
|
||||
'\r\n\r\n');
|
||||
let headers = '';
|
||||
const onSocketData = (chunk) => {
|
||||
let match;
|
||||
let remainder;
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
headers += chunk.toString('binary');
|
||||
if ((match = headers.match(/\r\n\r\n/))) {
|
||||
socket.removeListener('data', onSocketData);
|
||||
remainder = headers.substr(match.index + match[0].length);
|
||||
headers = headers.substr(0, match.index);
|
||||
if (remainder) {
|
||||
socket.unshift(Buffer.from(remainder, 'binary'));
|
||||
}
|
||||
// proxy connection is now established
|
||||
finished = true;
|
||||
// check response code
|
||||
match = headers.match(/^HTTP\/\d+\.\d+ (\d+)/i);
|
||||
if (!match || (match[1] || '').charAt(0) !== '2') {
|
||||
try {
|
||||
socket.destroy();
|
||||
}
|
||||
catch (_E) {
|
||||
// ignore
|
||||
}
|
||||
const err = new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || ''));
|
||||
err.code = errors.EPROXY;
|
||||
return callback(err);
|
||||
}
|
||||
socket.removeListener('error', tempSocketErr);
|
||||
socket.removeListener('timeout', timeoutErr);
|
||||
socket.setTimeout(0);
|
||||
return callback(null, socket);
|
||||
}
|
||||
if (headers.length > MAX_RESPONSE_HEADER_BYTES) {
|
||||
socket.removeListener('data', onSocketData);
|
||||
const err = new Error('Proxy response headers too large');
|
||||
err.code = errors.EPROXY;
|
||||
return tempSocketErr(err);
|
||||
}
|
||||
};
|
||||
socket.on('data', onSocketData);
|
||||
});
|
||||
socket.setTimeout(httpProxyClient.timeout || 30 * 1000);
|
||||
socket.on('timeout', timeoutErr);
|
||||
socket.once('error', tempSocketErr);
|
||||
}
|
||||
exports.default = httpProxyClient;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import net from 'node:net';
|
||||
import tls from 'node:tls';
|
||||
import { type Readable } from 'node:stream';
|
||||
import * as shared from '../shared/index.js';
|
||||
import type { NodemailerError } from '../errors.js';
|
||||
import type XOAuth2 from '../xoauth2/index.js';
|
||||
/**
|
||||
* Custom authentication handlers keyed by (case insensitive) SASL method name
|
||||
*/
|
||||
export type SMTPConnectionCustomAuthHandlers = {
|
||||
[method: string]: SMTPConnectionCustomAuthHandler;
|
||||
};
|
||||
/**
|
||||
* Options for the SMTP connection, see the SMTPConnection class description
|
||||
*/
|
||||
export interface SMTPConnectionOptions {
|
||||
/** Port to connect to, defaults to 587, or to 465 when secure is set */
|
||||
port?: number | string | undefined;
|
||||
/** Hostname or IP address to connect to, defaults to 'localhost' */
|
||||
host?: string | undefined;
|
||||
/** Use TLS from the start */
|
||||
secure?: boolean | undefined;
|
||||
/** Marks the provided socket as already upgraded to TLS */
|
||||
secured?: boolean | undefined;
|
||||
/** Server name for SNI, defaults to host when that is not an IP address */
|
||||
servername?: string | undefined;
|
||||
/** Ignore STARTTLS even when the server advertises it */
|
||||
ignoreTLS?: boolean | undefined;
|
||||
/** Force STARTTLS, fail when the server does not support it */
|
||||
requireTLS?: boolean | undefined;
|
||||
/** Continue unencrypted when the STARTTLS upgrade fails */
|
||||
opportunisticTLS?: boolean | undefined;
|
||||
/** Name of the client server, sent with EHLO/HELO, CRLF is stripped */
|
||||
name?: string | undefined;
|
||||
/** Outbound address to bind to */
|
||||
localAddress?: string | undefined;
|
||||
/** Time to wait in ms for the connection to establish, defaults to 2 minutes */
|
||||
connectionTimeout?: number | undefined;
|
||||
/** Time to wait in ms until the greeting is received, defaults to 30 seconds */
|
||||
greetingTimeout?: number | undefined;
|
||||
/** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */
|
||||
socketTimeout?: number | undefined;
|
||||
/** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */
|
||||
dnsTimeout?: number | undefined;
|
||||
/** Use LMTP instead of SMTP */
|
||||
lmtp?: boolean | undefined;
|
||||
/** Bunyan compatible logger interface, true for the default console logger */
|
||||
logger?: shared.ExternalLogger | boolean | undefined;
|
||||
/** Pass SMTP traffic, including the message data, to the logger */
|
||||
debug?: boolean | undefined;
|
||||
/** Pass SMTP commands and responses to the logger */
|
||||
transactionLog?: boolean | undefined;
|
||||
/** Options for tls.connect */
|
||||
tls?: tls.ConnectionOptions | undefined;
|
||||
/** Existing socket to use instead of creating a new one, not connected yet */
|
||||
socket?: net.Socket | undefined;
|
||||
/** Already opened connection to use instead of creating a new one */
|
||||
connection?: net.Socket | undefined;
|
||||
/** Count loopback interfaces when checking which address families are usable */
|
||||
allowInternalNetworkInterfaces?: boolean | undefined;
|
||||
/** Logger component name, defaults to 'smtp-connection' */
|
||||
component?: string | undefined;
|
||||
/** Custom authentication handlers keyed by method name */
|
||||
customAuth?: SMTPConnectionCustomAuthHandlers | undefined;
|
||||
}
|
||||
/**
|
||||
* User and password credentials resolved for a SASL mechanism
|
||||
*/
|
||||
export interface SMTPConnectionCredentials {
|
||||
user?: string | undefined;
|
||||
pass?: string | undefined;
|
||||
/** Extra options for the authentication method, copied from the auth object */
|
||||
options?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
}
|
||||
/**
|
||||
* Authentication data for login()
|
||||
*/
|
||||
export interface SMTPConnectionAuth {
|
||||
/** Authentication type, informational */
|
||||
type?: string | undefined;
|
||||
/** SASL method to use, false or unset picks the first supported one (PLAIN if none is advertised) */
|
||||
method?: string | false | undefined;
|
||||
user?: string | undefined;
|
||||
pass?: string | undefined;
|
||||
/** Extra options for the authentication method */
|
||||
options?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
/** XOAuth2 token generator, selects XOAUTH2 when no method is set */
|
||||
oauth2?: XOAuth2 | undefined;
|
||||
/** Credentials for the SASL mechanism, filled in from user and pass when missing */
|
||||
credentials?: SMTPConnectionCredentials | undefined;
|
||||
/** Custom authentication handlers receive the auth object as is, so it may carry any other value */
|
||||
[key: string]: any;
|
||||
}
|
||||
/**
|
||||
* A parsed server reply, handed to the sendCommand callback of a custom authentication handler
|
||||
*/
|
||||
export interface SMTPConnectionCustomAuthResponse {
|
||||
/** The command that was sent */
|
||||
command: string;
|
||||
/** Raw server response */
|
||||
response: string;
|
||||
/** Numeric status code, 0 if the response did not start with one */
|
||||
status: number;
|
||||
/** Enhanced status code, if any */
|
||||
code?: string | undefined;
|
||||
/** Response text without the status codes */
|
||||
text: string;
|
||||
}
|
||||
/**
|
||||
* Callback for a command sent by a custom authentication handler
|
||||
*/
|
||||
export type SMTPConnectionCustomAuthCommandCallback = (err: Error | null, data: SMTPConnectionCustomAuthResponse) => void;
|
||||
/**
|
||||
* The object a custom authentication handler is run with
|
||||
*/
|
||||
export interface SMTPConnectionCustomAuthContext {
|
||||
/** The auth object handed to login() */
|
||||
auth: SMTPConnectionAuth;
|
||||
/** Selected authentication method name */
|
||||
method: string;
|
||||
/** SMTP extensions the server advertised */
|
||||
extensions: string[];
|
||||
/** SASL methods the server advertised */
|
||||
authMethods: string[];
|
||||
/** Maximum message size the server accepts, false when not advertised */
|
||||
maxAllowedSize: number | false;
|
||||
/** Sends a command to the server. Returns a promise when no callback is given */
|
||||
sendCommand(cmd: string, done?: SMTPConnectionCustomAuthCommandCallback): Promise<SMTPConnectionCustomAuthResponse> | undefined;
|
||||
/** Marks the user as authenticated */
|
||||
resolve(): void;
|
||||
/** Fails the authentication with an error */
|
||||
reject(err: Error | string): void;
|
||||
}
|
||||
/**
|
||||
* A custom authentication handler. Calls resolve() or reject() on the context, or returns a promise
|
||||
*/
|
||||
export type SMTPConnectionCustomAuthHandler = (ctx: SMTPConnectionCustomAuthContext) => void | Promise<unknown>;
|
||||
/**
|
||||
* An envelope address, either a plain string or an object with an address property
|
||||
*/
|
||||
export interface SMTPEnvelopeAddress {
|
||||
address?: string | undefined;
|
||||
name?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* DSN parameters for the envelope (RFC 3461)
|
||||
*/
|
||||
export interface SMTPEnvelopeDsn {
|
||||
/** Return either 'HDRS' (headers) or 'FULL' (body) with the notification */
|
||||
ret?: string | null | undefined;
|
||||
/** Alias of ret */
|
||||
return?: string | undefined;
|
||||
/** Envelope identifier, sent as ENVID */
|
||||
envid?: string | null | undefined;
|
||||
/** Alias of envid */
|
||||
id?: string | undefined;
|
||||
/** When to notify: 'NEVER', or any combination of 'SUCCESS', 'FAILURE' and 'DELAY' */
|
||||
notify?: string | string[] | null | undefined;
|
||||
/** Original recipient, sent as ORCPT */
|
||||
recipient?: string | undefined;
|
||||
/** Alias of recipient, in the 'rfc822;address' form */
|
||||
orcpt?: string | null | undefined;
|
||||
}
|
||||
/**
|
||||
* Envelope object accepted by send()
|
||||
*/
|
||||
export interface SMTPEnvelope {
|
||||
/** Sender address */
|
||||
from?: string | SMTPEnvelopeAddress | undefined;
|
||||
/** Recipient address or addresses */
|
||||
to?: string | SMTPEnvelopeAddress | Array<string | SMTPEnvelopeAddress> | undefined;
|
||||
/** Message size in bytes, sent as the SIZE parameter when the server supports it */
|
||||
size?: number | string | undefined;
|
||||
/** DSN parameters, sent when the server supports the DSN extension */
|
||||
dsn?: SMTPEnvelopeDsn | undefined;
|
||||
/** Declare BODY=8BITMIME when the server supports it */
|
||||
use8BitMime?: boolean | undefined;
|
||||
/** RFC 8689: send the REQUIRETLS parameter, requires a TLS connection and server support */
|
||||
requireTLSExtensionEnabled?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* The envelope as tracked by the connection while a message is being sent. The from and to
|
||||
* values are normalized to strings and the recipient bookkeeping is added by _setEnvelope
|
||||
*/
|
||||
export interface SMTPConnectionEnvelope extends SMTPEnvelope {
|
||||
from?: string | undefined;
|
||||
to?: string[] | undefined;
|
||||
/** Recipients still waiting for RCPT TO */
|
||||
rcptQueue: string[];
|
||||
/** Recipients the server rejected */
|
||||
rejected: string[];
|
||||
/** Errors for the rejected recipients */
|
||||
rejectedErrors: NodemailerError[];
|
||||
/** Recipients the server accepted */
|
||||
accepted: string[];
|
||||
}
|
||||
/**
|
||||
* Result of a sent message
|
||||
*/
|
||||
export interface SMTPConnectionSendInfo {
|
||||
/** Recipients the server accepted */
|
||||
accepted: string[];
|
||||
/** Recipients the server rejected */
|
||||
rejected: string[];
|
||||
/** EHLO response lines, without the greeting line */
|
||||
ehlo?: string[] | undefined;
|
||||
/** Errors for the rejected recipients */
|
||||
rejectedErrors?: NodemailerError[] | undefined;
|
||||
/** Time in ms spent on the envelope commands */
|
||||
envelopeTime?: number | undefined;
|
||||
/** Time in ms spent on streaming the message */
|
||||
messageTime?: number | undefined;
|
||||
/** Size of the encoded message in bytes */
|
||||
messageSize?: number | undefined;
|
||||
/** Final server response for the message */
|
||||
response?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* Callback for send()
|
||||
*/
|
||||
export type SMTPConnectionSendCallback = (err: NodemailerError | null, info?: SMTPConnectionSendInfo) => void;
|
||||
/**
|
||||
* Callback for login() and reset(), the result is true on success
|
||||
*/
|
||||
export type SMTPConnectionCallback = (err: NodemailerError | null, result?: boolean) => void;
|
||||
/**
|
||||
* Callback for the message data response, yields the server response text
|
||||
*/
|
||||
export type SMTPConnectionResponseCallback = (err: NodemailerError | null, response?: string) => void;
|
||||
/**
|
||||
* Callback for connect(), run once the SMTP handshake is finished
|
||||
*/
|
||||
export type SMTPConnectionConnectCallback = (err?: NodemailerError) => void;
|
||||
/**
|
||||
* Options handed to net.connect or tls.connect, resolved hostname values are merged in
|
||||
*/
|
||||
export interface SMTPConnectionConnectOptions extends tls.ConnectionOptions {
|
||||
port: number;
|
||||
host: string;
|
||||
/** Outbound address to bind to */
|
||||
localAddress?: string | undefined;
|
||||
/** Count loopback interfaces when resolving the hostname */
|
||||
allowInternalNetworkInterfaces?: boolean | undefined;
|
||||
/** DNS lookup timeout in ms */
|
||||
timeout?: number | undefined;
|
||||
}
|
||||
/**
|
||||
* A queued handler for the next server response
|
||||
*/
|
||||
export type SMTPConnectionResponseAction = (str: string) => void;
|
||||
/**
|
||||
* Generates a SMTP connection object
|
||||
*
|
||||
* Optional options object takes the following possible properties:
|
||||
*
|
||||
* * **port** - is the port to connect to (defaults to 587 or 465)
|
||||
* * **host** - is the hostname or IP address to connect to (defaults to 'localhost')
|
||||
* * **secure** - use SSL
|
||||
* * **ignoreTLS** - ignore server support for STARTTLS
|
||||
* * **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 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
|
||||
* * **debug** - if true pass SMTP traffic to the logger
|
||||
* * **tls** - options for createCredentials
|
||||
* * **socket** - existing socket to use instead of creating a new one (see: http://nodejs.org/api/net.html#net_class_net_socket)
|
||||
* * **secured** - boolean indicates that the provided socket has already been upgraded to tls
|
||||
*
|
||||
* @constructor
|
||||
* @namespace SMTP Client module
|
||||
* @param [options] Option properties
|
||||
*/
|
||||
declare class SMTPConnection extends EventEmitter {
|
||||
id: string;
|
||||
stage: string;
|
||||
options: SMTPConnectionOptions;
|
||||
secureConnection: boolean;
|
||||
alreadySecured: boolean;
|
||||
port: number;
|
||||
host: string;
|
||||
servername: string | false;
|
||||
allowInternalNetworkInterfaces: boolean;
|
||||
name: string;
|
||||
logger: shared.Logger;
|
||||
customAuth: Map<string, SMTPConnectionCustomAuthHandler>;
|
||||
/**
|
||||
* Expose version nr, just for the reference
|
||||
*/
|
||||
version: string;
|
||||
/**
|
||||
* If true, then the user is authenticated
|
||||
*/
|
||||
authenticated: boolean;
|
||||
/**
|
||||
* If set to true, this instance is no longer active
|
||||
* @private
|
||||
*/
|
||||
destroyed: boolean;
|
||||
/**
|
||||
* Defines if the current connection is secure or not. If not,
|
||||
* STARTTLS can be used if available
|
||||
* @private
|
||||
*/
|
||||
secure: boolean;
|
||||
lastServerResponse: string | false;
|
||||
/**
|
||||
* The socket connecting to the server
|
||||
* @public
|
||||
*/
|
||||
_socket: net.Socket | false | null;
|
||||
/**
|
||||
* Set to true, if EHLO response includes "AUTH".
|
||||
* If false then authentication is not tried
|
||||
*/
|
||||
allowsAuth: boolean;
|
||||
/**
|
||||
* True while the STARTTLS upgrade is in progress
|
||||
* @private
|
||||
*/
|
||||
upgrading?: boolean | undefined;
|
||||
constructor(options?: SMTPConnectionOptions);
|
||||
/**
|
||||
* Creates a connection to a SMTP server and sets up connection
|
||||
* listener
|
||||
*/
|
||||
connect(connectCallback?: SMTPConnectionConnectCallback): void;
|
||||
/**
|
||||
* Sends QUIT
|
||||
*/
|
||||
quit(): void;
|
||||
/**
|
||||
* Closes the connection to the server
|
||||
*/
|
||||
close(): void;
|
||||
/**
|
||||
* Authenticate user
|
||||
*/
|
||||
login(authData: SMTPConnectionAuth | undefined, callback: SMTPConnectionCallback): void;
|
||||
/**
|
||||
* Sends a message
|
||||
*
|
||||
* @param envelope Envelope object, {from: addr, to: [addr]}
|
||||
* @param message String, Buffer or a Stream
|
||||
* @param callback Callback to return once sending is completed
|
||||
*/
|
||||
send(envelope: SMTPEnvelope, message: string | Buffer | Readable, done: SMTPConnectionSendCallback): void;
|
||||
/**
|
||||
* Resets connection state
|
||||
*
|
||||
* @param callback Callback to return once connection is reset
|
||||
*/
|
||||
reset(callback: SMTPConnectionCallback): void;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `SMTPConnection.Options` style references keep working
|
||||
*/
|
||||
declare namespace SMTPConnection {
|
||||
type Options = SMTPConnectionOptions;
|
||||
type AuthenticationType = SMTPConnectionAuth;
|
||||
type Credentials = SMTPConnectionCredentials;
|
||||
type Envelope = SMTPEnvelope;
|
||||
type DSNOptions = SMTPEnvelopeDsn;
|
||||
type SentMessageInfo = SMTPConnectionSendInfo;
|
||||
type CustomAuthenticationContext = SMTPConnectionCustomAuthContext;
|
||||
type CustomAuthenticationResponse = SMTPConnectionCustomAuthResponse;
|
||||
type CustomAuthenticationHandlers = SMTPConnectionCustomAuthHandlers;
|
||||
}
|
||||
export default SMTPConnection;
|
||||
+1712
File diff suppressed because it is too large
Load Diff
+125
@@ -0,0 +1,125 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import * as shared from '../shared/index.js';
|
||||
import type { SMTPTransportOptions, SMTPTransportGetSocketCallback, SMTPTransportSendCallback, SMTPSentMessageInfo } from '../smtp-transport/index.js';
|
||||
import type MailMessage from '../mailer/mail-message.js';
|
||||
import type { default as Mail, SendMailOptions, VerifyCallback } from '../mailer/index.js';
|
||||
/**
|
||||
* Options for the pooled SMTP transport, the SMTP transport options plus the pool settings
|
||||
*/
|
||||
export interface SMTPPoolOptions extends SMTPTransportOptions {
|
||||
/** Set to true to get this pooled transport from createTransport */
|
||||
pool?: boolean | undefined;
|
||||
/** Maximum number of open connections, defaults to 5 */
|
||||
maxConnections?: number | undefined;
|
||||
/** Number of messages a connection sends before it is closed and replaced, defaults to 100 */
|
||||
maxMessages?: number | undefined;
|
||||
/** Maximum number of messages to send in rateDelta milliseconds, unlimited when not set */
|
||||
rateLimit?: number | undefined;
|
||||
/** Time window for rateLimit in milliseconds, defaults to 1000 */
|
||||
rateDelta?: number | undefined;
|
||||
/** How many times a message is requeued when its connection closes while sending, unlimited when not set or negative */
|
||||
maxRequeues?: number | undefined;
|
||||
}
|
||||
/**
|
||||
* The pool options once the constructor has applied the defaults
|
||||
*/
|
||||
export type SMTPPoolResolvedOptions = SMTPPoolOptions & {
|
||||
maxConnections: number;
|
||||
maxMessages: number;
|
||||
};
|
||||
/**
|
||||
* Result of a message sent through the pool, same as for the SMTP transport
|
||||
*/
|
||||
export type SMTPPoolSentMessageInfo = SMTPSentMessageInfo;
|
||||
/**
|
||||
* Callback for send()
|
||||
*/
|
||||
export type SMTPPoolSendCallback = SMTPTransportSendCallback;
|
||||
/**
|
||||
* A message waiting in the pool queue
|
||||
*/
|
||||
export interface SMTPPoolQueueEntry {
|
||||
/** The message to send */
|
||||
mail: MailMessage;
|
||||
/** How many times the entry was put back on the queue after its connection closed */
|
||||
requeueAttempts: number;
|
||||
/** Callback to run once the message is sent or failed */
|
||||
callback: SMTPPoolSendCallback;
|
||||
/** Message-ID value without the angle brackets, set when the entry is assigned to a connection */
|
||||
messageId?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* Rate limiter state of the pool
|
||||
*/
|
||||
export interface SMTPPoolRateLimit {
|
||||
/** Messages assigned within the current window */
|
||||
counter: number;
|
||||
/** Timer that clears the current window */
|
||||
timeout: NodeJS.Timeout | null;
|
||||
/** Availability callbacks waiting for the window to clear */
|
||||
waiting: Array<() => void>;
|
||||
/** Start of the current window as a timestamp, false when no window is open */
|
||||
checkpoint: number | false;
|
||||
/** Window length in milliseconds */
|
||||
delta: number;
|
||||
/** Maximum number of messages per window, 0 for no limit */
|
||||
limit: number;
|
||||
}
|
||||
/**
|
||||
* Creates a SMTP pool transport object for Nodemailer
|
||||
*
|
||||
* @constructor
|
||||
* @param options SMTP Connection options
|
||||
*/
|
||||
declare class SMTPPool extends EventEmitter {
|
||||
options: SMTPPoolResolvedOptions;
|
||||
logger: shared.Logger;
|
||||
name: string;
|
||||
version: string;
|
||||
idling: boolean;
|
||||
/**
|
||||
* The Mail instance using this transport, assigned by Mail
|
||||
*/
|
||||
mailer?: Mail<SMTPPoolSentMessageInfo> | undefined;
|
||||
constructor(options?: SMTPPoolOptions | string);
|
||||
/**
|
||||
* Placeholder function for creating proxy sockets. This method immediatelly returns
|
||||
* without a socket
|
||||
*
|
||||
* @param options Connection options
|
||||
* @param callback Callback function to run with the socket keys
|
||||
*/
|
||||
getSocket(options: SMTPPoolOptions, callback: SMTPTransportGetSocketCallback): void;
|
||||
/**
|
||||
* Queues an e-mail to be sent using the selected settings
|
||||
*
|
||||
* @param mail Mail object
|
||||
* @param callback Callback function
|
||||
*/
|
||||
send(mail: MailMessage, callback: SMTPPoolSendCallback): boolean;
|
||||
/**
|
||||
* Closes all connections in the pool. If there is a message being sent, the connection
|
||||
* is closed later
|
||||
*/
|
||||
close(): void;
|
||||
/**
|
||||
* Returns true if there are free slots in the queue
|
||||
*/
|
||||
isIdle(): boolean;
|
||||
/**
|
||||
* Verifies SMTP configuration
|
||||
*
|
||||
* @param callback Callback function
|
||||
*/
|
||||
verify(): Promise<true>;
|
||||
verify(callback: VerifyCallback): void;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `SMTPPool.Options` style references keep working
|
||||
*/
|
||||
declare namespace SMTPPool {
|
||||
type Options = SMTPPoolOptions;
|
||||
type MailOptions = SendMailOptions;
|
||||
type SentMessageInfo = SMTPPoolSentMessageInfo;
|
||||
}
|
||||
export default SMTPPool;
|
||||
+541
@@ -0,0 +1,541 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_events_1 = require("node:events");
|
||||
const pool_resource_js_1 = __importDefault(require("./pool-resource.js"));
|
||||
const index_js_1 = __importDefault(require("../smtp-connection/index.js"));
|
||||
const index_js_2 = __importDefault(require("../well-known/index.js"));
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
const packageData = __importStar(require("../package-info.js"));
|
||||
/**
|
||||
* Creates a SMTP pool transport object for Nodemailer
|
||||
*
|
||||
* @constructor
|
||||
* @param options SMTP Connection options
|
||||
*/
|
||||
class SMTPPool extends node_events_1.EventEmitter {
|
||||
constructor(options) {
|
||||
super();
|
||||
options = options || {};
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
url: options
|
||||
};
|
||||
}
|
||||
let urlData;
|
||||
let service = options.service;
|
||||
if (typeof options.getSocket === 'function') {
|
||||
this.getSocket = options.getSocket;
|
||||
}
|
||||
if (options.url) {
|
||||
urlData = shared.parseConnectionUrl(options.url);
|
||||
service = service || urlData.service;
|
||||
}
|
||||
this.options = shared.assign(false, // create new object
|
||||
options, // regular options
|
||||
urlData, // url options
|
||||
(service && (0, index_js_2.default)(service)) // wellknown options
|
||||
);
|
||||
this.options.maxConnections = this.options.maxConnections || 5;
|
||||
this.options.maxMessages = this.options.maxMessages || 100;
|
||||
this.logger = shared.getLogger(this.options, {
|
||||
component: this.options.component || 'smtp-pool'
|
||||
});
|
||||
this.name = 'SMTP (pool)';
|
||||
this.version = packageData.version + '[client:' + packageData.version + ']';
|
||||
this._rateLimit = {
|
||||
counter: 0,
|
||||
timeout: null,
|
||||
waiting: [],
|
||||
checkpoint: false,
|
||||
delta: Number(this.options.rateDelta) || 1000,
|
||||
limit: Number(this.options.rateLimit) || 0
|
||||
};
|
||||
this._closed = false;
|
||||
this._queue = [];
|
||||
this._connections = [];
|
||||
this._connectionCounter = 0;
|
||||
this.idling = true;
|
||||
setImmediate(() => {
|
||||
if (this.idling) {
|
||||
this.emit('idle');
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Placeholder function for creating proxy sockets. This method immediatelly returns
|
||||
* without a socket
|
||||
*
|
||||
* @param options Connection options
|
||||
* @param callback Callback function to run with the socket keys
|
||||
*/
|
||||
getSocket(options, callback) {
|
||||
// return immediatelly
|
||||
setImmediate(() => callback(null, false));
|
||||
}
|
||||
/**
|
||||
* Queues an e-mail to be sent using the selected settings
|
||||
*
|
||||
* @param mail Mail object
|
||||
* @param callback Callback function
|
||||
*/
|
||||
send(mail, callback) {
|
||||
if (this._closed) {
|
||||
return false;
|
||||
}
|
||||
this._queue.push({
|
||||
mail,
|
||||
requeueAttempts: 0,
|
||||
callback
|
||||
});
|
||||
if (this.idling && this._queue.length >= this.options.maxConnections) {
|
||||
this.idling = false;
|
||||
}
|
||||
setImmediate(() => this._processMessages());
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Closes all connections in the pool. If there is a message being sent, the connection
|
||||
* is closed later
|
||||
*/
|
||||
close() {
|
||||
let connection;
|
||||
const len = this._connections.length;
|
||||
this._closed = true;
|
||||
// release connections gated by the rate limiter so they become
|
||||
// available and are torn down below instead of leaking with a
|
||||
// cleared timer that never fires
|
||||
this._clearRateLimit();
|
||||
if (!len && !this._queue.length) {
|
||||
return;
|
||||
}
|
||||
// remove all available connections
|
||||
for (let i = len - 1; i >= 0; i--) {
|
||||
if (this._connections[i] && this._connections[i].available) {
|
||||
connection = this._connections[i];
|
||||
connection.close();
|
||||
this.logger.info({
|
||||
tnx: 'connection',
|
||||
cid: connection.id,
|
||||
action: 'removed'
|
||||
}, 'Connection #%s removed', connection.id);
|
||||
}
|
||||
}
|
||||
if (len && !this._connections.length) {
|
||||
this.logger.debug({
|
||||
tnx: 'connection'
|
||||
}, 'All connections removed');
|
||||
}
|
||||
if (!this._queue.length) {
|
||||
return;
|
||||
}
|
||||
// make sure that entire queue would be cleaned
|
||||
const invokeCallbacks = () => {
|
||||
if (!this._queue.length) {
|
||||
this.logger.debug({
|
||||
tnx: 'connection'
|
||||
}, 'Pending queue entries cleared');
|
||||
return;
|
||||
}
|
||||
const entry = this._queue.shift();
|
||||
if (entry && typeof entry.callback === 'function') {
|
||||
try {
|
||||
entry.callback(new Error('Connection pool was closed'));
|
||||
}
|
||||
catch (E) {
|
||||
// the queue is drained without a connection, so there is no cid to log
|
||||
this.logger.error({
|
||||
err: E,
|
||||
tnx: 'callback'
|
||||
}, 'Callback error: %s', E.message);
|
||||
}
|
||||
}
|
||||
setImmediate(invokeCallbacks);
|
||||
};
|
||||
setImmediate(invokeCallbacks);
|
||||
}
|
||||
/**
|
||||
* Check the queue and available connections. If there is a message to be sent and there is
|
||||
* an available connection, then use this connection to send the mail
|
||||
* @internal
|
||||
*/
|
||||
_processMessages() {
|
||||
// do nothing if already closed
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
// do nothing if queue is empty
|
||||
if (!this._queue.length) {
|
||||
if (!this.idling) {
|
||||
// no pending jobs
|
||||
this.idling = true;
|
||||
this.emit('idle');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// find first available connection
|
||||
let connection = this._connections.find(c => c.available);
|
||||
if (!connection && this._connections.length < this.options.maxConnections) {
|
||||
connection = this._createConnection();
|
||||
}
|
||||
if (!connection) {
|
||||
// no more free connection slots available
|
||||
this.idling = false;
|
||||
return;
|
||||
}
|
||||
// check if there is free space in the processing queue
|
||||
if (!this.idling && this._queue.length < this.options.maxConnections) {
|
||||
this.idling = true;
|
||||
this.emit('idle');
|
||||
}
|
||||
const entry = (connection.queueEntry = this._queue.shift());
|
||||
entry.messageId = (connection.queueEntry.mail.message.getHeader('message-id') || '').replace(/[<>\s]/g, '');
|
||||
connection.available = false;
|
||||
this.logger.debug({
|
||||
tnx: 'pool',
|
||||
cid: connection.id,
|
||||
messageId: entry.messageId,
|
||||
action: 'assign'
|
||||
}, 'Assigned message <%s> to #%s (%s)', entry.messageId, connection.id, connection.messages + 1);
|
||||
if (this._rateLimit.limit) {
|
||||
this._rateLimit.counter++;
|
||||
if (!this._rateLimit.checkpoint) {
|
||||
this._rateLimit.checkpoint = Date.now();
|
||||
}
|
||||
}
|
||||
connection.send(entry.mail, (err, info) => {
|
||||
// only process callback if current handler is not changed
|
||||
if (entry === connection.queueEntry) {
|
||||
try {
|
||||
entry.callback(err, info);
|
||||
}
|
||||
catch (E) {
|
||||
this.logger.error({
|
||||
err: E,
|
||||
tnx: 'callback',
|
||||
cid: connection.id
|
||||
}, 'Callback error for #%s: %s', connection.id, E.message);
|
||||
}
|
||||
connection.queueEntry = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Creates a new pool resource
|
||||
* @internal
|
||||
*/
|
||||
_createConnection() {
|
||||
const connection = new pool_resource_js_1.default(this);
|
||||
connection.id = ++this._connectionCounter;
|
||||
this.logger.info({
|
||||
tnx: 'pool',
|
||||
cid: connection.id,
|
||||
action: 'conection'
|
||||
}, 'Created new pool resource #%s', connection.id);
|
||||
// resource comes available
|
||||
connection.on('available', () => {
|
||||
this.logger.debug({
|
||||
tnx: 'connection',
|
||||
cid: connection.id,
|
||||
action: 'available'
|
||||
}, 'Connection #%s became available', connection.id);
|
||||
if (this._closed) {
|
||||
// if already closed run close() that will remove this connections from connections list
|
||||
this.close();
|
||||
}
|
||||
else {
|
||||
// check if there's anything else to send
|
||||
this._processMessages();
|
||||
}
|
||||
});
|
||||
// resource is terminated with an error
|
||||
connection.once('error', (err) => {
|
||||
if (err.code !== errors.EMAXLIMIT) {
|
||||
this.logger.warn({
|
||||
err,
|
||||
tnx: 'pool',
|
||||
cid: connection.id
|
||||
}, 'Pool Error for #%s: %s', connection.id, err.message);
|
||||
}
|
||||
else {
|
||||
this.logger.debug({
|
||||
tnx: 'pool',
|
||||
cid: connection.id,
|
||||
action: 'maxlimit'
|
||||
}, 'Max messages limit exchausted for #%s', connection.id);
|
||||
}
|
||||
if (connection.queueEntry) {
|
||||
try {
|
||||
connection.queueEntry.callback(err);
|
||||
}
|
||||
catch (E) {
|
||||
this.logger.error({
|
||||
err: E,
|
||||
tnx: 'callback',
|
||||
cid: connection.id
|
||||
}, 'Callback error for #%s: %s', connection.id, E.message);
|
||||
}
|
||||
connection.queueEntry = false;
|
||||
}
|
||||
// remove the erroneus connection from connections list
|
||||
this._removeConnection(connection);
|
||||
this._continueProcessing();
|
||||
});
|
||||
connection.once('close', () => {
|
||||
this.logger.info({
|
||||
tnx: 'connection',
|
||||
cid: connection.id,
|
||||
action: 'closed'
|
||||
}, 'Connection #%s was closed', connection.id);
|
||||
this._removeConnection(connection);
|
||||
if (connection.queueEntry) {
|
||||
// If the connection closed when sending, add the message to the queue again
|
||||
// if max number of requeues is not reached yet
|
||||
// Note that we must wait a bit.. because the callback of the 'error' handler might be called
|
||||
// in the next event loop
|
||||
setTimeout(() => {
|
||||
if (connection.queueEntry) {
|
||||
if (this._shouldRequeuOnConnectionClose(connection.queueEntry)) {
|
||||
this._requeueEntryOnConnectionClose(connection);
|
||||
}
|
||||
else {
|
||||
this._failDeliveryOnConnectionClose(connection);
|
||||
}
|
||||
}
|
||||
this._continueProcessing();
|
||||
}, 50);
|
||||
}
|
||||
else {
|
||||
if (!this._closed && this.idling && !this._connections.length) {
|
||||
this.emit('clear');
|
||||
}
|
||||
this._continueProcessing();
|
||||
}
|
||||
});
|
||||
this._connections.push(connection);
|
||||
return connection;
|
||||
}
|
||||
/** @internal */
|
||||
_shouldRequeuOnConnectionClose(queueEntry) {
|
||||
if (this.options.maxRequeues === undefined || this.options.maxRequeues < 0) {
|
||||
return true;
|
||||
}
|
||||
return queueEntry.requeueAttempts < this.options.maxRequeues;
|
||||
}
|
||||
/** @internal */
|
||||
_failDeliveryOnConnectionClose(connection) {
|
||||
if (connection.queueEntry && connection.queueEntry.callback) {
|
||||
try {
|
||||
connection.queueEntry.callback(new Error('Reached maximum number of retries after connection was closed'));
|
||||
}
|
||||
catch (E) {
|
||||
this.logger.error({
|
||||
err: E,
|
||||
tnx: 'callback',
|
||||
messageId: connection.queueEntry.messageId,
|
||||
cid: connection.id
|
||||
}, 'Callback error for #%s: %s', connection.id, E.message);
|
||||
}
|
||||
connection.queueEntry = false;
|
||||
}
|
||||
}
|
||||
/** @internal */
|
||||
_requeueEntryOnConnectionClose(connection) {
|
||||
connection.queueEntry.requeueAttempts += 1;
|
||||
this.logger.debug({
|
||||
tnx: 'pool',
|
||||
cid: connection.id,
|
||||
messageId: connection.queueEntry.messageId,
|
||||
action: 'requeue'
|
||||
}, 'Re-queued message <%s> for #%s. Attempt: #%s', connection.queueEntry.messageId, connection.id, connection.queueEntry.requeueAttempts);
|
||||
this._queue.unshift(connection.queueEntry);
|
||||
connection.queueEntry = false;
|
||||
}
|
||||
/**
|
||||
* Continue to process message if the pool hasn't closed
|
||||
* @internal
|
||||
*/
|
||||
_continueProcessing() {
|
||||
if (this._closed) {
|
||||
this.close();
|
||||
}
|
||||
else {
|
||||
setTimeout(() => this._processMessages(), 100);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Remove resource from pool
|
||||
*
|
||||
* @param connection The PoolResource to remove
|
||||
* @internal
|
||||
*/
|
||||
_removeConnection(connection) {
|
||||
const index = this._connections.indexOf(connection);
|
||||
if (index !== -1) {
|
||||
this._connections.splice(index, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Checks if connections have hit current rate limit and if so, queues the availability callback
|
||||
*
|
||||
* @param callback Callback function to run once rate limiter has been cleared
|
||||
* @internal
|
||||
*/
|
||||
_checkRateLimit(callback) {
|
||||
if (!this._rateLimit.limit) {
|
||||
return callback();
|
||||
}
|
||||
const now = Date.now();
|
||||
if (this._rateLimit.counter < this._rateLimit.limit) {
|
||||
return callback();
|
||||
}
|
||||
this._rateLimit.waiting.push(callback);
|
||||
if (this._rateLimit.checkpoint <= now - this._rateLimit.delta) {
|
||||
return this._clearRateLimit();
|
||||
}
|
||||
if (!this._rateLimit.timeout) {
|
||||
this._rateLimit.timeout = setTimeout(() => this._clearRateLimit(), this._rateLimit.delta - (now - this._rateLimit.checkpoint));
|
||||
this._rateLimit.checkpoint = now;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clears current rate limit limitation and runs paused callback
|
||||
* @internal
|
||||
*/
|
||||
_clearRateLimit() {
|
||||
clearTimeout(this._rateLimit.timeout);
|
||||
this._rateLimit.timeout = null;
|
||||
this._rateLimit.counter = 0;
|
||||
this._rateLimit.checkpoint = false;
|
||||
// resume all paused connections
|
||||
while (this._rateLimit.waiting.length) {
|
||||
const cb = this._rateLimit.waiting.shift();
|
||||
setImmediate(cb);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns true if there are free slots in the queue
|
||||
*/
|
||||
isIdle() {
|
||||
return this.idling;
|
||||
}
|
||||
verify(callback) {
|
||||
let promise;
|
||||
if (!callback) {
|
||||
promise = new Promise((resolve, reject) => {
|
||||
callback = shared.callbackPromise(resolve, reject);
|
||||
});
|
||||
}
|
||||
const auth = new pool_resource_js_1.default(this).auth;
|
||||
this.getSocket(this.options, (err, socketOptions) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
let options = this.options;
|
||||
if (socketOptions && socketOptions.connection) {
|
||||
this.logger.info({
|
||||
tnx: 'proxy',
|
||||
remoteAddress: socketOptions.connection.remoteAddress,
|
||||
remotePort: socketOptions.connection.remotePort,
|
||||
destHost: options.host || '',
|
||||
destPort: options.port || '',
|
||||
action: 'connected'
|
||||
}, 'Using proxied socket from %s:%s to %s:%s', socketOptions.connection.remoteAddress, socketOptions.connection.remotePort, options.host || '', options.port || '');
|
||||
options = Object.assign(shared.assign(false, options), socketOptions);
|
||||
}
|
||||
const connection = new index_js_1.default(options);
|
||||
let returned = false;
|
||||
connection.once('error', err => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
connection.close();
|
||||
return callback(err);
|
||||
});
|
||||
connection.once('end', () => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
return callback(new Error('Connection closed'));
|
||||
});
|
||||
const finalize = () => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
connection.quit();
|
||||
return callback(null, true);
|
||||
};
|
||||
connection.connect(() => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
if (auth && (connection.allowsAuth || options.forceAuth)) {
|
||||
connection.login(auth, err => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
returned = true;
|
||||
connection.close();
|
||||
return callback(err);
|
||||
}
|
||||
finalize();
|
||||
});
|
||||
}
|
||||
else if (!auth && connection.allowsAuth && options.forceAuth) {
|
||||
const err = new Error('Authentication info was not provided');
|
||||
err.code = errors.ENOAUTH;
|
||||
returned = true;
|
||||
connection.close();
|
||||
return callback(err);
|
||||
}
|
||||
else {
|
||||
finalize();
|
||||
}
|
||||
});
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
exports.default = SMTPPool;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import SMTPConnection from '../smtp-connection/index.js';
|
||||
import { type Logger } from '../shared/index.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { SMTPTransportAuth, SMTPTransportSendCallback } from '../smtp-transport/index.js';
|
||||
import type MailMessage from '../mailer/mail-message.js';
|
||||
import type SMTPPool from './index.js';
|
||||
import type { SMTPPoolResolvedOptions, SMTPPoolQueueEntry } from './index.js';
|
||||
/**
|
||||
* Callback for connect(), the result is true once the connection is ready for messages
|
||||
*/
|
||||
export type PoolResourceConnectCallback = (err: Error | null, connected?: true) => void;
|
||||
/**
|
||||
* Callback for send()
|
||||
*/
|
||||
export type PoolResourceSendCallback = SMTPTransportSendCallback;
|
||||
/**
|
||||
* Creates an element for the pool
|
||||
*
|
||||
* @constructor
|
||||
* @param pool SMTPPool instance
|
||||
*/
|
||||
export default class PoolResource extends EventEmitter {
|
||||
pool: SMTPPool;
|
||||
options: SMTPPoolResolvedOptions;
|
||||
logger: Logger;
|
||||
/**
|
||||
* Authentication data for the connection, set when the pool options include auth
|
||||
*/
|
||||
auth?: SMTPTransportAuth | undefined;
|
||||
messages: number;
|
||||
available: boolean;
|
||||
/**
|
||||
* The SMTP connection, set by connect()
|
||||
*/
|
||||
connection: SMTPConnection;
|
||||
/**
|
||||
* Resource id, assigned by the pool
|
||||
*/
|
||||
id: number;
|
||||
/**
|
||||
* The queue entry being sent, assigned by the pool. False once it has been handled
|
||||
*/
|
||||
queueEntry?: SMTPPoolQueueEntry | false | undefined;
|
||||
constructor(pool: SMTPPool);
|
||||
/**
|
||||
* Initiates a connection to the SMTP server
|
||||
*
|
||||
* @param callback Callback function to run once the connection is established or failed
|
||||
*/
|
||||
connect(callback: PoolResourceConnectCallback): void;
|
||||
/**
|
||||
* Sends an e-mail to be sent using the selected settings
|
||||
*
|
||||
* @param mail Mail object
|
||||
* @param callback Callback function
|
||||
*/
|
||||
send(mail: MailMessage, callback: PoolResourceSendCallback): void;
|
||||
/**
|
||||
* Closes the connection
|
||||
*/
|
||||
close(): void;
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const index_js_1 = __importDefault(require("../smtp-connection/index.js"));
|
||||
const index_js_2 = require("../shared/index.js");
|
||||
const index_js_3 = __importDefault(require("../xoauth2/index.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
const node_events_1 = require("node:events");
|
||||
/**
|
||||
* Creates an element for the pool
|
||||
*
|
||||
* @constructor
|
||||
* @param pool SMTPPool instance
|
||||
*/
|
||||
class PoolResource extends node_events_1.EventEmitter {
|
||||
constructor(pool) {
|
||||
super();
|
||||
this.pool = pool;
|
||||
this.options = pool.options;
|
||||
this.logger = this.pool.logger;
|
||||
if (this.options.auth) {
|
||||
switch ((this.options.auth.type || '').toString().toUpperCase()) {
|
||||
case 'OAUTH2': {
|
||||
const oauth2 = new index_js_3.default(this.options.auth, this.logger);
|
||||
oauth2.provisionCallback =
|
||||
(this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
|
||||
this.auth = {
|
||||
type: 'OAUTH2',
|
||||
user: this.options.auth.user,
|
||||
oauth2,
|
||||
method: 'XOAUTH2'
|
||||
};
|
||||
oauth2.on('token', (token) => this.pool.mailer.emit('token', token));
|
||||
oauth2.on('error', err => this.emit('error', err));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
if (!this.options.auth.user && !this.options.auth.pass) {
|
||||
break;
|
||||
}
|
||||
this.auth = {
|
||||
type: (this.options.auth.type || '').toString().toUpperCase() || 'LOGIN',
|
||||
user: this.options.auth.user,
|
||||
credentials: {
|
||||
user: this.options.auth.user || '',
|
||||
pass: this.options.auth.pass,
|
||||
options: this.options.auth.options
|
||||
},
|
||||
method: (this.options.auth.method || '').trim().toUpperCase() || this.options.authMethod || false
|
||||
};
|
||||
}
|
||||
}
|
||||
this._connection = false;
|
||||
this._connected = false;
|
||||
this.messages = 0;
|
||||
this.available = true;
|
||||
}
|
||||
/**
|
||||
* Initiates a connection to the SMTP server
|
||||
*
|
||||
* @param callback Callback function to run once the connection is established or failed
|
||||
*/
|
||||
connect(callback) {
|
||||
this.pool.getSocket(this.options, (err, socketOptions) => {
|
||||
if (err) {
|
||||
// nothing was connected, so no 'close' event is coming that would free the
|
||||
// slot this resource holds in the pool, report the failure the way a failed
|
||||
// login does
|
||||
this.emit('error', err);
|
||||
return callback(err);
|
||||
}
|
||||
let returned = false;
|
||||
let options = this.options;
|
||||
if (socketOptions && socketOptions.connection) {
|
||||
this.logger.info({
|
||||
tnx: 'proxy',
|
||||
remoteAddress: socketOptions.connection.remoteAddress,
|
||||
remotePort: socketOptions.connection.remotePort,
|
||||
destHost: options.host || '',
|
||||
destPort: options.port || '',
|
||||
action: 'connected'
|
||||
}, 'Using proxied socket from %s:%s to %s:%s', socketOptions.connection.remoteAddress, socketOptions.connection.remotePort, options.host || '', options.port || '');
|
||||
options = Object.assign((0, index_js_2.assign)(false, options), socketOptions);
|
||||
}
|
||||
this.connection = new index_js_1.default(options);
|
||||
this.connection.once('error', err => {
|
||||
this.emit('error', err);
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
return callback(err);
|
||||
});
|
||||
this.connection.once('end', () => {
|
||||
this.close();
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
const timer = setTimeout(() => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
// still have not returned, this means we have an unexpected connection close
|
||||
const err = new Error('Unexpected socket close');
|
||||
if (this.connection &&
|
||||
this.connection._socket &&
|
||||
this.connection._socket.upgrading) {
|
||||
// starttls connection errors
|
||||
err.code = errors.ETLS;
|
||||
}
|
||||
callback(err);
|
||||
}, 1000);
|
||||
try {
|
||||
timer.unref();
|
||||
}
|
||||
catch (_E) {
|
||||
// Ignore. Happens on envs with non-node timer implementation
|
||||
}
|
||||
});
|
||||
this.connection.connect(() => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
if (this.auth && (this.connection.allowsAuth || options.forceAuth)) {
|
||||
this.connection.login(this.auth, err => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
if (err) {
|
||||
this.connection.close();
|
||||
this.emit('error', err);
|
||||
return callback(err);
|
||||
}
|
||||
this._connected = true;
|
||||
callback(null, true);
|
||||
});
|
||||
}
|
||||
else {
|
||||
returned = true;
|
||||
this._connected = true;
|
||||
return callback(null, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Sends an e-mail to be sent using the selected settings
|
||||
*
|
||||
* @param mail Mail object
|
||||
* @param callback Callback function
|
||||
*/
|
||||
send(mail, callback) {
|
||||
if (!this._connected) {
|
||||
return this.connect(err => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return this.send(mail, callback);
|
||||
});
|
||||
}
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
if (recipients.length > 3) {
|
||||
recipients.push('...and ' + recipients.splice(2).length + ' more');
|
||||
}
|
||||
this.logger.info({
|
||||
tnx: 'send',
|
||||
messageId,
|
||||
cid: this.id
|
||||
}, 'Sending message %s using #%s to <%s>', messageId, this.id, recipients.join(', '));
|
||||
if (mail.data.dsn) {
|
||||
envelope.dsn = mail.data.dsn;
|
||||
}
|
||||
// RFC 8689: Pass requireTLSExtensionEnabled to envelope for MAIL FROM parameter
|
||||
if (mail.data.requireTLSExtensionEnabled) {
|
||||
envelope.requireTLSExtensionEnabled = mail.data.requireTLSExtensionEnabled;
|
||||
}
|
||||
this.connection.send(envelope, mail.message.createReadStream(), (err, info) => {
|
||||
this.messages++;
|
||||
if (err) {
|
||||
this.connection.close();
|
||||
this.emit('error', err);
|
||||
return callback(err);
|
||||
}
|
||||
info.envelope = {
|
||||
from: envelope.from,
|
||||
to: envelope.to
|
||||
};
|
||||
info.messageId = messageId;
|
||||
setImmediate(() => {
|
||||
if (this.messages >= this.options.maxMessages) {
|
||||
const err = new Error('Resource exhausted');
|
||||
err.code = errors.EMAXLIMIT;
|
||||
this.connection.close();
|
||||
this.emit('error', err);
|
||||
}
|
||||
else {
|
||||
this.pool._checkRateLimit(() => {
|
||||
this.available = true;
|
||||
this.emit('available');
|
||||
});
|
||||
}
|
||||
});
|
||||
callback(null, info);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Closes the connection
|
||||
*/
|
||||
close() {
|
||||
this._connected = false;
|
||||
if (this.auth && this.auth.oauth2) {
|
||||
this.auth.oauth2.removeAllListeners();
|
||||
}
|
||||
if (this.connection) {
|
||||
this.connection.close();
|
||||
}
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
exports.default = PoolResource;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { type SMTPConnectionOptions, type SMTPConnectionAuth, type SMTPConnectionSendInfo } from '../smtp-connection/index.js';
|
||||
import * as shared from '../shared/index.js';
|
||||
import { type XOAuth2Options } from '../xoauth2/index.js';
|
||||
import type { ResultCallback } from '../errors.js';
|
||||
import type MailMessage from '../mailer/mail-message.js';
|
||||
import type { default as Mail, SentMessageInfo, SendMailOptions, TransportOptions, VerifyCallback } from '../mailer/index.js';
|
||||
import type { MimeNodeEnvelope } from '../mime-node/index.js';
|
||||
/**
|
||||
* Authentication settings, either from the transport options or from the message data.
|
||||
* OAuth2 settings are handed to XOAuth2 as is
|
||||
*/
|
||||
export interface SMTPTransportAuthOptions extends XOAuth2Options {
|
||||
/** 'OAuth2' selects XOAUTH2, anything else is a password login, 'LOGIN' when not set */
|
||||
type?: string | undefined;
|
||||
/** Username */
|
||||
user?: string | undefined;
|
||||
/** Password */
|
||||
pass?: string | undefined;
|
||||
/** SASL method to use, e.g. 'PLAIN', 'LOGIN' or 'CRAM-MD5' */
|
||||
method?: string | undefined;
|
||||
/** Extra options for the authentication method, handed to a custom SASL handler */
|
||||
options?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
/** Service identifier, an OAuth2 login needs either this or a user */
|
||||
service?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* Authentication data built by getAuth() and handed to SMTPConnection#login
|
||||
*/
|
||||
export interface SMTPTransportAuth extends SMTPConnectionAuth {
|
||||
/** 'OAUTH2', or the upper cased type from the settings, 'LOGIN' when none was given */
|
||||
type: string;
|
||||
/** 'XOAUTH2' for OAuth2, otherwise the configured SASL method, false lets the connection pick one */
|
||||
method: string | false;
|
||||
}
|
||||
/**
|
||||
* Receives the socket details from getSocket, false when a new socket should be opened. The
|
||||
* object is merged into the connection options, a proxy handler provides the connected socket
|
||||
* as `connection`
|
||||
*/
|
||||
export type SMTPTransportGetSocketCallback = (err: Error | null, socketOptions?: SMTPConnectionOptions | false) => void;
|
||||
/**
|
||||
* Custom socket provider, replaces the getSocket method of the transport
|
||||
*/
|
||||
export type SMTPTransportGetSocket = (options: SMTPTransportOptions, callback: SMTPTransportGetSocketCallback) => void;
|
||||
/**
|
||||
* Options for the SMTP transport, the connection options plus the transport level settings
|
||||
*/
|
||||
export interface SMTPTransportOptions extends SMTPConnectionOptions, TransportOptions {
|
||||
/** Well-known service name, e.g. 'Gmail', fills in host, port and secure */
|
||||
service?: string | undefined;
|
||||
/** Connection url, e.g. 'smtps://user:pass@smtp.example.com', parsed into options */
|
||||
url?: string | undefined;
|
||||
/** Authentication settings, no authentication when not set */
|
||||
auth?: SMTPTransportAuthOptions | undefined;
|
||||
/** Custom socket provider, e.g. for connecting through a proxy, replaces the getSocket method */
|
||||
getSocket?: SMTPTransportGetSocket | undefined;
|
||||
/** Authenticate even when the server does not advertise AUTH, and fail verify() when it does but no credentials were given */
|
||||
forceAuth?: boolean | undefined;
|
||||
/** Default SASL method for the password logins when the auth settings do not name one */
|
||||
authMethod?: string | undefined;
|
||||
/** Logger component name, defaults to 'smtp-transport' */
|
||||
component?: string | undefined;
|
||||
/** Set to true to get a pooled transport from createTransport, this transport does not read it */
|
||||
pool?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* Result of a sent message, the connection result plus the envelope and the Message-ID
|
||||
*/
|
||||
export interface SMTPSentMessageInfo extends SMTPConnectionSendInfo, SentMessageInfo {
|
||||
/** Envelope the message was sent with */
|
||||
envelope: MimeNodeEnvelope;
|
||||
/** Message-ID value of the sent message */
|
||||
messageId: string;
|
||||
/** Recipients the server accepted */
|
||||
accepted: string[];
|
||||
/** Recipients the server rejected */
|
||||
rejected: string[];
|
||||
}
|
||||
/**
|
||||
* Callback for send()
|
||||
*/
|
||||
export type SMTPTransportSendCallback = ResultCallback<SMTPSentMessageInfo>;
|
||||
/**
|
||||
* Creates a SMTP transport object for Nodemailer
|
||||
*
|
||||
* @constructor
|
||||
* @param options Connection options
|
||||
*/
|
||||
declare class SMTPTransport extends EventEmitter {
|
||||
options: SMTPTransportOptions;
|
||||
logger: shared.Logger;
|
||||
name: string;
|
||||
version: string;
|
||||
/**
|
||||
* Transport level authentication data, set when the options include auth
|
||||
*/
|
||||
auth?: SMTPTransportAuth | false | undefined;
|
||||
/**
|
||||
* The Mail instance using this transport, assigned by Mail
|
||||
*/
|
||||
mailer?: Mail<SMTPSentMessageInfo> | undefined;
|
||||
constructor(options?: SMTPTransportOptions | string);
|
||||
/**
|
||||
* Placeholder function for creating proxy sockets. This method immediatelly returns
|
||||
* without a socket
|
||||
*
|
||||
* @param options Connection options
|
||||
* @param callback Callback function to run with the socket keys
|
||||
*/
|
||||
getSocket(options: SMTPTransportOptions, callback: SMTPTransportGetSocketCallback): void;
|
||||
getAuth(authOpts?: SMTPTransportAuthOptions | false | null): SMTPTransportAuth | false | undefined;
|
||||
/**
|
||||
* Sends an e-mail using the selected settings
|
||||
*
|
||||
* @param mail Mail object
|
||||
* @param callback Callback function
|
||||
*/
|
||||
send(mail: MailMessage, callback: SMTPTransportSendCallback): void;
|
||||
/**
|
||||
* Verifies SMTP configuration
|
||||
*
|
||||
* @param callback Callback function
|
||||
*/
|
||||
verify(): Promise<true>;
|
||||
verify(callback: VerifyCallback): void;
|
||||
/**
|
||||
* Releases resources
|
||||
*/
|
||||
close(): void;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `SMTPTransport.Options` style references keep working
|
||||
*/
|
||||
declare namespace SMTPTransport {
|
||||
type Options = SMTPTransportOptions;
|
||||
type MailOptions = SendMailOptions;
|
||||
type SentMessageInfo = SMTPSentMessageInfo;
|
||||
type AuthenticationType = SMTPTransportAuth;
|
||||
}
|
||||
export default SMTPTransport;
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_events_1 = require("node:events");
|
||||
const index_js_1 = __importDefault(require("../smtp-connection/index.js"));
|
||||
const index_js_2 = __importDefault(require("../well-known/index.js"));
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
const index_js_3 = __importDefault(require("../xoauth2/index.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
const packageData = __importStar(require("../package-info.js"));
|
||||
/**
|
||||
* Creates a SMTP transport object for Nodemailer
|
||||
*
|
||||
* @constructor
|
||||
* @param options Connection options
|
||||
*/
|
||||
class SMTPTransport extends node_events_1.EventEmitter {
|
||||
constructor(options) {
|
||||
super();
|
||||
options = options || {};
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
url: options
|
||||
};
|
||||
}
|
||||
let urlData;
|
||||
let service = options.service;
|
||||
if (typeof options.getSocket === 'function') {
|
||||
this.getSocket = options.getSocket;
|
||||
}
|
||||
if (options.url) {
|
||||
urlData = shared.parseConnectionUrl(options.url);
|
||||
service = service || urlData.service;
|
||||
}
|
||||
this.options = shared.assign(false, // create new object
|
||||
options, // regular options
|
||||
urlData, // url options
|
||||
(service && (0, index_js_2.default)(service)) // wellknown options
|
||||
);
|
||||
this.logger = shared.getLogger(this.options, {
|
||||
component: this.options.component || 'smtp-transport'
|
||||
});
|
||||
this.name = 'SMTP';
|
||||
this.version = packageData.version + '[client:' + packageData.version + ']';
|
||||
if (this.options.auth) {
|
||||
this.auth = this.getAuth({});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Placeholder function for creating proxy sockets. This method immediatelly returns
|
||||
* without a socket
|
||||
*
|
||||
* @param options Connection options
|
||||
* @param callback Callback function to run with the socket keys
|
||||
*/
|
||||
getSocket(options, callback) {
|
||||
// return immediatelly
|
||||
setImmediate(() => callback(null, false));
|
||||
}
|
||||
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 : {}, typeof authOpts === 'object' ? authOpts : {});
|
||||
if (Object.keys(authData).length === 0) {
|
||||
return false;
|
||||
}
|
||||
switch ((authData.type || '').toString().toUpperCase()) {
|
||||
case 'OAUTH2': {
|
||||
if (!authData.service && !authData.user) {
|
||||
return false;
|
||||
}
|
||||
const oauth2 = new index_js_3.default(authData, this.logger);
|
||||
oauth2.provisionCallback = (this.mailer && this.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
|
||||
oauth2.on('token', (token) => this.mailer.emit('token', token));
|
||||
oauth2.on('error', err => this.emit('error', err));
|
||||
return {
|
||||
type: 'OAUTH2',
|
||||
user: authData.user,
|
||||
oauth2,
|
||||
method: 'XOAUTH2'
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
type: (authData.type || '').toString().toUpperCase() || 'LOGIN',
|
||||
user: authData.user,
|
||||
credentials: {
|
||||
user: authData.user || '',
|
||||
pass: authData.pass,
|
||||
options: authData.options
|
||||
},
|
||||
method: (authData.method || '').trim().toUpperCase() || this.options.authMethod || false
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Sends an e-mail using the selected settings
|
||||
*
|
||||
* @param mail Mail object
|
||||
* @param callback Callback function
|
||||
*/
|
||||
send(mail, callback) {
|
||||
this.getSocket(this.options, (err, socketOptions) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
let returned = false;
|
||||
let options = this.options;
|
||||
if (socketOptions && socketOptions.connection) {
|
||||
this.logger.info({
|
||||
tnx: 'proxy',
|
||||
remoteAddress: socketOptions.connection.remoteAddress,
|
||||
remotePort: socketOptions.connection.remotePort,
|
||||
destHost: options.host || '',
|
||||
destPort: options.port || '',
|
||||
action: 'connected'
|
||||
}, 'Using proxied socket from %s:%s to %s:%s', socketOptions.connection.remoteAddress, socketOptions.connection.remotePort, options.host || '', options.port || '');
|
||||
// only copy options if we need to modify it
|
||||
options = Object.assign(shared.assign(false, options), socketOptions);
|
||||
}
|
||||
const connection = new index_js_1.default(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);
|
||||
});
|
||||
connection.once('end', () => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (returned) {
|
||||
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) {
|
||||
// starttls connection errors
|
||||
err.code = errors.ETLS;
|
||||
}
|
||||
callback(err);
|
||||
}, 1000);
|
||||
try {
|
||||
timer.unref();
|
||||
}
|
||||
catch (_E) {
|
||||
// Ignore. Happens on envs with non-node timer implementation
|
||||
}
|
||||
});
|
||||
const sendMessage = () => {
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
if (recipients.length > 3) {
|
||||
recipients.push('...and ' + recipients.splice(2).length + ' more');
|
||||
}
|
||||
if (mail.data.dsn) {
|
||||
envelope.dsn = mail.data.dsn;
|
||||
}
|
||||
// RFC 8689: Pass requireTLSExtensionEnabled to envelope for MAIL FROM parameter
|
||||
if (mail.data.requireTLSExtensionEnabled) {
|
||||
envelope.requireTLSExtensionEnabled = mail.data.requireTLSExtensionEnabled;
|
||||
}
|
||||
this.logger.info({
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Sending message %s to <%s>', messageId, recipients.join(', '));
|
||||
connection.send(envelope, mail.message.createReadStream(), (err, info) => {
|
||||
returned = true;
|
||||
cleanupPerCallAuth();
|
||||
connection.close();
|
||||
if (err) {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'send'
|
||||
}, 'Send error for %s: %s', messageId, err.message);
|
||||
return callback(err);
|
||||
}
|
||||
info.envelope = {
|
||||
from: envelope.from,
|
||||
to: envelope.to
|
||||
};
|
||||
info.messageId = messageId;
|
||||
try {
|
||||
return callback(null, info);
|
||||
}
|
||||
catch (E) {
|
||||
this.logger.error({
|
||||
err: E,
|
||||
tnx: 'callback'
|
||||
}, 'Callback error for %s: %s', messageId, E.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
connection.connect(() => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
perCallAuth = this.getAuth(mail.data.auth);
|
||||
if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
|
||||
connection.login(perCallAuth, err => {
|
||||
cleanupPerCallAuth();
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
returned = true;
|
||||
connection.close();
|
||||
return callback(err);
|
||||
}
|
||||
sendMessage();
|
||||
});
|
||||
}
|
||||
else {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
verify(callback) {
|
||||
let promise;
|
||||
if (!callback) {
|
||||
promise = new Promise((resolve, reject) => {
|
||||
callback = shared.callbackPromise(resolve, reject);
|
||||
});
|
||||
}
|
||||
this.getSocket(this.options, (err, socketOptions) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
let options = this.options;
|
||||
if (socketOptions && socketOptions.connection) {
|
||||
this.logger.info({
|
||||
tnx: 'proxy',
|
||||
remoteAddress: socketOptions.connection.remoteAddress,
|
||||
remotePort: socketOptions.connection.remotePort,
|
||||
destHost: options.host || '',
|
||||
destPort: options.port || '',
|
||||
action: 'connected'
|
||||
}, 'Using proxied socket from %s:%s to %s:%s', socketOptions.connection.remoteAddress, socketOptions.connection.remotePort, options.host || '', options.port || '');
|
||||
options = Object.assign(shared.assign(false, options), socketOptions);
|
||||
}
|
||||
const connection = new index_js_1.default(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);
|
||||
});
|
||||
connection.once('end', () => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
cleanupPerCallAuth();
|
||||
return callback(new Error('Connection closed'));
|
||||
});
|
||||
const finalize = () => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
cleanupPerCallAuth();
|
||||
connection.quit();
|
||||
return callback(null, true);
|
||||
};
|
||||
connection.connect(() => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
perCallAuth = this.getAuth({});
|
||||
if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
|
||||
connection.login(perCallAuth, err => {
|
||||
cleanupPerCallAuth();
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
returned = true;
|
||||
connection.close();
|
||||
return callback(err);
|
||||
}
|
||||
finalize();
|
||||
});
|
||||
}
|
||||
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 {
|
||||
finalize();
|
||||
}
|
||||
});
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
/**
|
||||
* Releases resources
|
||||
*/
|
||||
close() {
|
||||
if (this.auth && this.auth.oauth2) {
|
||||
this.auth.oauth2.removeAllListeners();
|
||||
}
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
exports.default = SMTPTransport;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
import type { Logger } from '../shared/index.js';
|
||||
import type { MimeNodeEnvelope } from '../mime-node/index.js';
|
||||
import type MailMessage from '../mailer/mail-message.js';
|
||||
import type { default as Mail, SentMessageInfo, SendMailOptions, TransportOptions } from '../mailer/index.js';
|
||||
/**
|
||||
* Options for the Stream transport
|
||||
*/
|
||||
export interface StreamTransportOptions extends TransportOptions {
|
||||
/** Selects this transport in createTransport */
|
||||
streamTransport?: boolean | undefined;
|
||||
/** If true, the message is returned as a Buffer object instead of a stream */
|
||||
buffer?: boolean | undefined;
|
||||
/** Either 'windows' or 'unix', the line ending of the generated message */
|
||||
newline?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* The value the Stream transport hands to the send callback
|
||||
*/
|
||||
export interface StreamSentMessageInfo extends SentMessageInfo {
|
||||
/** The envelope the message was generated with */
|
||||
envelope: MimeNodeEnvelope;
|
||||
/** Message-ID value of the message */
|
||||
messageId: string;
|
||||
/** The generated message, a Buffer when the buffer option is set, a readable stream otherwise */
|
||||
message: Readable | Buffer;
|
||||
}
|
||||
/**
|
||||
* Generates a Transport object for streaming
|
||||
*
|
||||
* Possible options can be the following:
|
||||
*
|
||||
* * **buffer** if true, then returns the message as a Buffer object instead of a stream
|
||||
* * **newline** either 'windows' or 'unix'
|
||||
*
|
||||
* @constructor
|
||||
* @param optional config parameter
|
||||
*/
|
||||
declare class StreamTransport {
|
||||
mailer: Mail<StreamSentMessageInfo>;
|
||||
options: StreamTransportOptions;
|
||||
name: string;
|
||||
version: string;
|
||||
logger: Logger;
|
||||
winbreak: boolean;
|
||||
constructor(options?: StreamTransportOptions);
|
||||
/**
|
||||
* Compiles a mailcomposer message and forwards it to handler that sends it
|
||||
*
|
||||
* @param mail MailComposer object
|
||||
* @param done Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail: MailMessage<StreamSentMessageInfo>, done: (err: Error | null, info?: StreamSentMessageInfo) => void): void;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `StreamTransport.Options` style references keep working
|
||||
*/
|
||||
declare namespace StreamTransport {
|
||||
type Options = StreamTransportOptions;
|
||||
type MailOptions = SendMailOptions;
|
||||
type SentMessageInfo = StreamSentMessageInfo;
|
||||
}
|
||||
export default StreamTransport;
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const packageData = __importStar(require("../package-info.js"));
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
const le_windows_js_1 = __importDefault(require("../mime-node/le-windows.js"));
|
||||
const le_unix_js_1 = __importDefault(require("../mime-node/le-unix.js"));
|
||||
/**
|
||||
* Generates a Transport object for streaming
|
||||
*
|
||||
* Possible options can be the following:
|
||||
*
|
||||
* * **buffer** if true, then returns the message as a Buffer object instead of a stream
|
||||
* * **newline** either 'windows' or 'unix'
|
||||
*
|
||||
* @constructor
|
||||
* @param optional config parameter
|
||||
*/
|
||||
class StreamTransport {
|
||||
constructor(options) {
|
||||
options = options || {};
|
||||
this.options = options;
|
||||
this.name = 'StreamTransport';
|
||||
this.version = packageData.version;
|
||||
this.logger = shared.getLogger(this.options, {
|
||||
component: this.options.component || 'stream-transport'
|
||||
});
|
||||
this.winbreak = ['win', 'windows', 'dos', '\r\n'].includes((options.newline || '').toString().toLowerCase());
|
||||
}
|
||||
/**
|
||||
* Compiles a mailcomposer message and forwards it to handler that sends it
|
||||
*
|
||||
* @param mail MailComposer object
|
||||
* @param done Callback function to run when the sending is completed
|
||||
*/
|
||||
send(mail, done) {
|
||||
// We probably need this in the output. send() runs after the message was compiled,
|
||||
// so mail.message is set
|
||||
mail.message.keepBcc = true;
|
||||
const envelope = mail.message.getEnvelope();
|
||||
const messageId = mail.message.messageId();
|
||||
const recipients = [].concat(envelope.to || []);
|
||||
if (recipients.length > 3) {
|
||||
recipients.push('...and ' + recipients.splice(2).length + ' more');
|
||||
}
|
||||
this.logger.info({
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Sending message %s to <%s> using %s line breaks', messageId, recipients.join(', '), this.winbreak ? '<CR><LF>' : '<LF>');
|
||||
setImmediate(() => {
|
||||
let stream;
|
||||
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 le_windows_js_1.default() : new le_unix_js_1.default());
|
||||
sourceStream.once('error', err => stream.emit('error', err));
|
||||
}
|
||||
}
|
||||
catch (E) {
|
||||
this.logger.error({
|
||||
err: E,
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Creating send stream failed for %s. %s', messageId, E.message);
|
||||
return done(E);
|
||||
}
|
||||
if (!this.options.buffer) {
|
||||
stream.once('error', err => {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Failed creating message for %s. %s', messageId, err.message);
|
||||
});
|
||||
return done(null, {
|
||||
envelope,
|
||||
messageId,
|
||||
message: stream
|
||||
});
|
||||
}
|
||||
const chunks = [];
|
||||
let chunklen = 0;
|
||||
stream.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = stream.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
}
|
||||
});
|
||||
stream.once('error', err => {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'send',
|
||||
messageId
|
||||
}, 'Failed creating message for %s. %s', messageId, err.message);
|
||||
return done(err);
|
||||
});
|
||||
stream.on('end', () => done(null, {
|
||||
envelope,
|
||||
messageId,
|
||||
message: Buffer.concat(chunks, chunklen)
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
exports.default = StreamTransport;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Connection settings of a well-known e-mail service
|
||||
*/
|
||||
export interface WellKnownServiceDefinition {
|
||||
/** Human readable description of the service */
|
||||
description?: string | undefined;
|
||||
/** Domains of e-mail addresses hosted by the service */
|
||||
domains?: string[] | undefined;
|
||||
/** Alternative names the service can be looked up by */
|
||||
aliases?: string[] | undefined;
|
||||
/** SMTP hostname */
|
||||
host?: string | undefined;
|
||||
/** SMTP port (a numeric string in a few entries) */
|
||||
port?: number | string | undefined;
|
||||
/** true for implicit TLS (usually port 465) */
|
||||
secure?: boolean | undefined;
|
||||
/** Preferred authentication method */
|
||||
authMethod?: string | undefined;
|
||||
/** Set to true to require STARTTLS on a plaintext connection */
|
||||
requireTLS?: boolean | undefined;
|
||||
/** Set to true to skip STARTTLS */
|
||||
ignoreTLS?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* SMTP settings of a well-known service, without the lookup keys
|
||||
*/
|
||||
export type WellKnownService = Omit<WellKnownServiceDefinition, 'domains' | 'aliases'>;
|
||||
/**
|
||||
* Resolves SMTP config for given key. Key can be a name (like 'Gmail'), alias (like 'Google Mail') or
|
||||
* an email address (like 'test@googlemail.com').
|
||||
*
|
||||
* @param key Service name, alias or an email address
|
||||
* @returns SMTP config or false if not found
|
||||
*/
|
||||
export default function wellKnown(key: string): WellKnownService | false;
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.default = wellKnown;
|
||||
const services_js_1 = require("./services.js");
|
||||
const normalized = {};
|
||||
Object.keys(services_js_1.services).forEach(key => {
|
||||
const service = services_js_1.services[key];
|
||||
const normalizedService = normalizeService(service);
|
||||
normalized[normalizeKey(key)] = normalizedService;
|
||||
[].concat(service.aliases || []).forEach(alias => {
|
||||
normalized[normalizeKey(alias)] = normalizedService;
|
||||
});
|
||||
[].concat(service.domains || []).forEach(domain => {
|
||||
normalized[normalizeKey(domain)] = normalizedService;
|
||||
});
|
||||
});
|
||||
function normalizeKey(key) {
|
||||
return key.replace(/[^a-zA-Z0-9.-]/g, '').toLowerCase();
|
||||
}
|
||||
function normalizeService(service) {
|
||||
const response = {};
|
||||
Object.keys(service).forEach(key => {
|
||||
if (!['domains', 'aliases'].includes(key)) {
|
||||
response[key] = service[key];
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
/**
|
||||
* Resolves SMTP config for given key. Key can be a name (like 'Gmail'), alias (like 'Google Mail') or
|
||||
* an email address (like 'test@googlemail.com').
|
||||
*
|
||||
* @param key Service name, alias or an email address
|
||||
* @returns SMTP config or false if not found
|
||||
*/
|
||||
function wellKnown(key) {
|
||||
key = normalizeKey(key.split('@').pop());
|
||||
return normalized[key] || false;
|
||||
}
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
import type { WellKnownServiceDefinition } from './index.js';
|
||||
export declare const services: Record<string, WellKnownServiceDefinition>;
|
||||
+668
@@ -0,0 +1,668 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build.js from services.json. Do not edit by hand.
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.services = void 0;
|
||||
exports.services = {
|
||||
"126": {
|
||||
"description": "126 Mail (NetEase)",
|
||||
"host": "smtp.126.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"163": {
|
||||
"description": "163 Mail (NetEase)",
|
||||
"host": "smtp.163.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"1und1": {
|
||||
"description": "1&1 Mail (German hosting provider)",
|
||||
"host": "smtp.1und1.de",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
"Aliyun": {
|
||||
"description": "Alibaba Cloud Mail",
|
||||
"domains": [
|
||||
"aliyun.com"
|
||||
],
|
||||
"host": "smtp.aliyun.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"AliyunQiye": {
|
||||
"description": "Alibaba Cloud Enterprise Mail",
|
||||
"host": "smtp.qiye.aliyun.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"AOL": {
|
||||
"description": "AOL Mail",
|
||||
"domains": [
|
||||
"aol.com"
|
||||
],
|
||||
"host": "smtp.aol.com",
|
||||
"port": 587
|
||||
},
|
||||
"Aruba": {
|
||||
"description": "Aruba PEC (Italian email provider)",
|
||||
"domains": [
|
||||
"aruba.it",
|
||||
"pec.aruba.it"
|
||||
],
|
||||
"aliases": [
|
||||
"Aruba PEC"
|
||||
],
|
||||
"host": "smtps.aruba.it",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
"Bluewin": {
|
||||
"description": "Bluewin (Swiss email provider)",
|
||||
"host": "smtpauths.bluewin.ch",
|
||||
"domains": [
|
||||
"bluewin.ch"
|
||||
],
|
||||
"port": 465
|
||||
},
|
||||
"BOL": {
|
||||
"description": "BOL Mail (Brazilian provider)",
|
||||
"domains": [
|
||||
"bol.com.br"
|
||||
],
|
||||
"host": "smtp.bol.com.br",
|
||||
"port": 587,
|
||||
"requireTLS": true
|
||||
},
|
||||
"DebugMail": {
|
||||
"description": "DebugMail (email testing service)",
|
||||
"host": "debugmail.io",
|
||||
"port": 25
|
||||
},
|
||||
"Disroot": {
|
||||
"description": "Disroot (privacy-focused provider)",
|
||||
"domains": [
|
||||
"disroot.org"
|
||||
],
|
||||
"host": "disroot.org",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
"DynectEmail": {
|
||||
"description": "Dyn Email Delivery",
|
||||
"aliases": [
|
||||
"Dynect"
|
||||
],
|
||||
"host": "smtp.dynect.net",
|
||||
"port": 25
|
||||
},
|
||||
"ElasticEmail": {
|
||||
"description": "Elastic Email",
|
||||
"aliases": [
|
||||
"Elastic Email"
|
||||
],
|
||||
"host": "smtp.elasticemail.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Ethereal": {
|
||||
"description": "Ethereal Email (email testing service)",
|
||||
"aliases": [
|
||||
"ethereal.email"
|
||||
],
|
||||
"host": "smtp.ethereal.email",
|
||||
"port": 587
|
||||
},
|
||||
"FastMail": {
|
||||
"description": "FastMail",
|
||||
"domains": [
|
||||
"fastmail.com",
|
||||
"fastmail.fm"
|
||||
],
|
||||
"host": "smtp.fastmail.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Feishu Mail": {
|
||||
"description": "Feishu Mail (Lark)",
|
||||
"aliases": [
|
||||
"Feishu",
|
||||
"FeishuMail"
|
||||
],
|
||||
"domains": [
|
||||
"www.feishu.cn"
|
||||
],
|
||||
"host": "smtp.feishu.cn",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Forward Email": {
|
||||
"description": "Forward Email (email forwarding service)",
|
||||
"aliases": [
|
||||
"FE",
|
||||
"ForwardEmail"
|
||||
],
|
||||
"domains": [
|
||||
"forwardemail.net"
|
||||
],
|
||||
"host": "smtp.forwardemail.net",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"GandiMail": {
|
||||
"description": "Gandi Mail",
|
||||
"aliases": [
|
||||
"Gandi",
|
||||
"Gandi Mail"
|
||||
],
|
||||
"host": "mail.gandi.net",
|
||||
"port": 587
|
||||
},
|
||||
"Gmail": {
|
||||
"description": "Gmail",
|
||||
"aliases": [
|
||||
"Google Mail"
|
||||
],
|
||||
"domains": [
|
||||
"gmail.com",
|
||||
"googlemail.com"
|
||||
],
|
||||
"host": "smtp.gmail.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"GmailWorkspace": {
|
||||
"description": "Gmail Workspace",
|
||||
"aliases": [
|
||||
"Google Workspace Mail"
|
||||
],
|
||||
"host": "smtp-relay.gmail.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"GMX": {
|
||||
"description": "GMX Mail",
|
||||
"domains": [
|
||||
"gmx.com",
|
||||
"gmx.net",
|
||||
"gmx.de"
|
||||
],
|
||||
"host": "mail.gmx.com",
|
||||
"port": 587
|
||||
},
|
||||
"Godaddy": {
|
||||
"description": "GoDaddy Email (US)",
|
||||
"host": "smtpout.secureserver.net",
|
||||
"port": 25
|
||||
},
|
||||
"GodaddyAsia": {
|
||||
"description": "GoDaddy Email (Asia)",
|
||||
"host": "smtp.asia.secureserver.net",
|
||||
"port": 25
|
||||
},
|
||||
"GodaddyEurope": {
|
||||
"description": "GoDaddy Email (Europe)",
|
||||
"host": "smtp.europe.secureserver.net",
|
||||
"port": 25
|
||||
},
|
||||
"hot.ee": {
|
||||
"description": "Hot.ee (Estonian email provider)",
|
||||
"host": "mail.hot.ee"
|
||||
},
|
||||
"Hotmail": {
|
||||
"description": "Outlook.com / Hotmail",
|
||||
"aliases": [
|
||||
"Outlook",
|
||||
"Outlook.com",
|
||||
"Hotmail.com"
|
||||
],
|
||||
"domains": [
|
||||
"hotmail.com",
|
||||
"outlook.com"
|
||||
],
|
||||
"host": "smtp-mail.outlook.com",
|
||||
"port": 587
|
||||
},
|
||||
"iCloud": {
|
||||
"description": "iCloud Mail",
|
||||
"aliases": [
|
||||
"Me",
|
||||
"Mac"
|
||||
],
|
||||
"domains": [
|
||||
"icloud.com",
|
||||
"me.com",
|
||||
"mac.com"
|
||||
],
|
||||
"host": "smtp.mail.me.com",
|
||||
"port": 587
|
||||
},
|
||||
"Infomaniak": {
|
||||
"description": "Infomaniak Mail (Swiss hosting provider)",
|
||||
"host": "mail.infomaniak.com",
|
||||
"domains": [
|
||||
"ik.me",
|
||||
"ikmail.com",
|
||||
"etik.com"
|
||||
],
|
||||
"port": 587
|
||||
},
|
||||
"KolabNow": {
|
||||
"description": "KolabNow (secure email service)",
|
||||
"domains": [
|
||||
"kolabnow.com"
|
||||
],
|
||||
"aliases": [
|
||||
"Kolab"
|
||||
],
|
||||
"host": "smtp.kolabnow.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authMethod": "LOGIN"
|
||||
},
|
||||
"Loopia": {
|
||||
"description": "Loopia (Swedish hosting provider)",
|
||||
"host": "mailcluster.loopia.se",
|
||||
"port": 465
|
||||
},
|
||||
"Loops": {
|
||||
"description": "Loops",
|
||||
"host": "smtp.loops.so",
|
||||
"port": 587
|
||||
},
|
||||
"mail.ee": {
|
||||
"description": "Mail.ee (Estonian email provider)",
|
||||
"host": "smtp.mail.ee"
|
||||
},
|
||||
"Mail.ru": {
|
||||
"description": "Mail.ru",
|
||||
"host": "smtp.mail.ru",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Mailcatch.app": {
|
||||
"description": "Mailcatch (email testing service)",
|
||||
"host": "sandbox-smtp.mailcatch.app",
|
||||
"port": 2525
|
||||
},
|
||||
"Maildev": {
|
||||
"description": "MailDev (local email testing)",
|
||||
"port": 1025,
|
||||
"ignoreTLS": true
|
||||
},
|
||||
"MailerSend": {
|
||||
"description": "MailerSend",
|
||||
"host": "smtp.mailersend.net",
|
||||
"port": 587
|
||||
},
|
||||
"Mailgun": {
|
||||
"description": "Mailgun",
|
||||
"host": "smtp.mailgun.org",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Mailjet": {
|
||||
"description": "Mailjet",
|
||||
"host": "in.mailjet.com",
|
||||
"port": 587
|
||||
},
|
||||
"Mailosaur": {
|
||||
"description": "Mailosaur (email testing service)",
|
||||
"host": "mailosaur.io",
|
||||
"port": 25
|
||||
},
|
||||
"Mailtrap": {
|
||||
"description": "Mailtrap",
|
||||
"host": "live.smtp.mailtrap.io",
|
||||
"port": 587
|
||||
},
|
||||
"Mandrill": {
|
||||
"description": "Mandrill (by Mailchimp)",
|
||||
"host": "smtp.mandrillapp.com",
|
||||
"port": 587
|
||||
},
|
||||
"Naver": {
|
||||
"description": "Naver Mail (Korean email provider)",
|
||||
"host": "smtp.naver.com",
|
||||
"port": 587
|
||||
},
|
||||
"OhMySMTP": {
|
||||
"description": "OhMySMTP (email delivery service)",
|
||||
"host": "smtp.ohmysmtp.com",
|
||||
"port": 587,
|
||||
"secure": false
|
||||
},
|
||||
"One": {
|
||||
"description": "One.com Email",
|
||||
"host": "send.one.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"OpenMailBox": {
|
||||
"description": "OpenMailBox",
|
||||
"aliases": [
|
||||
"OMB",
|
||||
"openmailbox.org"
|
||||
],
|
||||
"host": "smtp.openmailbox.org",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Outlook365": {
|
||||
"description": "Microsoft 365 / Office 365",
|
||||
"host": "smtp.office365.com",
|
||||
"port": 587,
|
||||
"secure": false
|
||||
},
|
||||
"Postmark": {
|
||||
"description": "Postmark",
|
||||
"aliases": [
|
||||
"PostmarkApp"
|
||||
],
|
||||
"host": "smtp.postmarkapp.com",
|
||||
"port": 2525
|
||||
},
|
||||
"Proton": {
|
||||
"description": "Proton Mail",
|
||||
"aliases": [
|
||||
"ProtonMail",
|
||||
"Proton.me",
|
||||
"Protonmail.com",
|
||||
"Protonmail.ch"
|
||||
],
|
||||
"domains": [
|
||||
"proton.me",
|
||||
"protonmail.com",
|
||||
"pm.me",
|
||||
"protonmail.ch"
|
||||
],
|
||||
"host": "smtp.protonmail.ch",
|
||||
"port": 587,
|
||||
"requireTLS": true
|
||||
},
|
||||
"qiye.aliyun": {
|
||||
"description": "Alibaba Mail Enterprise Edition",
|
||||
"host": "smtp.mxhichina.com",
|
||||
"port": "465",
|
||||
"secure": true
|
||||
},
|
||||
"QQ": {
|
||||
"description": "QQ Mail",
|
||||
"domains": [
|
||||
"qq.com"
|
||||
],
|
||||
"host": "smtp.qq.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"QQex": {
|
||||
"description": "QQ Enterprise Mail",
|
||||
"aliases": [
|
||||
"QQ Enterprise"
|
||||
],
|
||||
"domains": [
|
||||
"exmail.qq.com"
|
||||
],
|
||||
"host": "smtp.exmail.qq.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Resend": {
|
||||
"description": "Resend",
|
||||
"host": "smtp.resend.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Runbox": {
|
||||
"description": "Runbox (Norwegian email provider)",
|
||||
"domains": [
|
||||
"runbox.com"
|
||||
],
|
||||
"host": "smtp.runbox.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SendCloud": {
|
||||
"description": "SendCloud (Chinese email delivery)",
|
||||
"host": "smtp.sendcloud.net",
|
||||
"port": 2525
|
||||
},
|
||||
"SendGrid": {
|
||||
"description": "SendGrid",
|
||||
"host": "smtp.sendgrid.net",
|
||||
"port": 587
|
||||
},
|
||||
"SendinBlue": {
|
||||
"description": "Brevo (formerly Sendinblue)",
|
||||
"aliases": [
|
||||
"Brevo"
|
||||
],
|
||||
"host": "smtp-relay.brevo.com",
|
||||
"port": 587
|
||||
},
|
||||
"SendPulse": {
|
||||
"description": "SendPulse",
|
||||
"host": "smtp-pulse.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES": {
|
||||
"description": "AWS SES US East (N. Virginia)",
|
||||
"host": "email-smtp.us-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-AP-NORTHEAST-1": {
|
||||
"description": "AWS SES Asia Pacific (Tokyo)",
|
||||
"host": "email-smtp.ap-northeast-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-AP-NORTHEAST-2": {
|
||||
"description": "AWS SES Asia Pacific (Seoul)",
|
||||
"host": "email-smtp.ap-northeast-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-AP-NORTHEAST-3": {
|
||||
"description": "AWS SES Asia Pacific (Osaka)",
|
||||
"host": "email-smtp.ap-northeast-3.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-AP-SOUTH-1": {
|
||||
"description": "AWS SES Asia Pacific (Mumbai)",
|
||||
"host": "email-smtp.ap-south-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-AP-SOUTHEAST-1": {
|
||||
"description": "AWS SES Asia Pacific (Singapore)",
|
||||
"host": "email-smtp.ap-southeast-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-AP-SOUTHEAST-2": {
|
||||
"description": "AWS SES Asia Pacific (Sydney)",
|
||||
"host": "email-smtp.ap-southeast-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-CA-CENTRAL-1": {
|
||||
"description": "AWS SES Canada (Central)",
|
||||
"host": "email-smtp.ca-central-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-EU-CENTRAL-1": {
|
||||
"description": "AWS SES Europe (Frankfurt)",
|
||||
"host": "email-smtp.eu-central-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-EU-NORTH-1": {
|
||||
"description": "AWS SES Europe (Stockholm)",
|
||||
"host": "email-smtp.eu-north-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-EU-WEST-1": {
|
||||
"description": "AWS SES Europe (Ireland)",
|
||||
"host": "email-smtp.eu-west-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-EU-WEST-2": {
|
||||
"description": "AWS SES Europe (London)",
|
||||
"host": "email-smtp.eu-west-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-EU-WEST-3": {
|
||||
"description": "AWS SES Europe (Paris)",
|
||||
"host": "email-smtp.eu-west-3.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-SA-EAST-1": {
|
||||
"description": "AWS SES South America (São Paulo)",
|
||||
"host": "email-smtp.sa-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-US-EAST-1": {
|
||||
"description": "AWS SES US East (N. Virginia)",
|
||||
"host": "email-smtp.us-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-US-EAST-2": {
|
||||
"description": "AWS SES US East (Ohio)",
|
||||
"host": "email-smtp.us-east-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-US-GOV-EAST-1": {
|
||||
"description": "AWS SES GovCloud (US-East)",
|
||||
"host": "email-smtp.us-gov-east-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-US-GOV-WEST-1": {
|
||||
"description": "AWS SES GovCloud (US-West)",
|
||||
"host": "email-smtp.us-gov-west-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-US-WEST-1": {
|
||||
"description": "AWS SES US West (N. California)",
|
||||
"host": "email-smtp.us-west-1.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SES-US-WEST-2": {
|
||||
"description": "AWS SES US West (Oregon)",
|
||||
"host": "email-smtp.us-west-2.amazonaws.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Seznam": {
|
||||
"description": "Seznam Email (Czech email provider)",
|
||||
"aliases": [
|
||||
"Seznam Email"
|
||||
],
|
||||
"domains": [
|
||||
"seznam.cz",
|
||||
"email.cz",
|
||||
"post.cz",
|
||||
"spoluzaci.cz"
|
||||
],
|
||||
"host": "smtp.seznam.cz",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"SMTP2GO": {
|
||||
"description": "SMTP2GO",
|
||||
"host": "mail.smtp2go.com",
|
||||
"port": 2525
|
||||
},
|
||||
"Sparkpost": {
|
||||
"description": "SparkPost",
|
||||
"aliases": [
|
||||
"SparkPost",
|
||||
"SparkPost Mail"
|
||||
],
|
||||
"domains": [
|
||||
"sparkpost.com"
|
||||
],
|
||||
"host": "smtp.sparkpostmail.com",
|
||||
"port": 587,
|
||||
"secure": false
|
||||
},
|
||||
"Tipimail": {
|
||||
"description": "Tipimail (email delivery service)",
|
||||
"host": "smtp.tipimail.com",
|
||||
"port": 587
|
||||
},
|
||||
"TurboSMTP": {
|
||||
"description": "TurboSMTP",
|
||||
"host": "pro.turbo-smtp.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"TurboSMTP-EU": {
|
||||
"description": "TurboSMTP (EU region)",
|
||||
"host": "pro.eu.turbo-smtp.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Tutanota": {
|
||||
"description": "Tutanota (Tuta Mail)",
|
||||
"domains": [
|
||||
"tutanota.com",
|
||||
"tuta.com",
|
||||
"tutanota.de",
|
||||
"tuta.io"
|
||||
],
|
||||
"host": "smtp.tutanota.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Yahoo": {
|
||||
"description": "Yahoo Mail",
|
||||
"domains": [
|
||||
"yahoo.com"
|
||||
],
|
||||
"host": "smtp.mail.yahoo.com",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Yandex": {
|
||||
"description": "Yandex Mail",
|
||||
"domains": [
|
||||
"yandex.ru"
|
||||
],
|
||||
"host": "smtp.yandex.ru",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
"Zimbra": {
|
||||
"description": "Zimbra Mail Server",
|
||||
"aliases": [
|
||||
"Zimbra Collaboration"
|
||||
],
|
||||
"host": "smtp.zimbra.com",
|
||||
"port": 587,
|
||||
"requireTLS": true
|
||||
},
|
||||
"Zoho": {
|
||||
"description": "Zoho Mail",
|
||||
"host": "smtp.zoho.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authMethod": "LOGIN"
|
||||
}
|
||||
};
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { Stream } from 'node:stream';
|
||||
import crypto from 'node:crypto';
|
||||
import * as shared from '../shared/index.js';
|
||||
import type { OutgoingHttpHeaders } from 'node:http';
|
||||
/**
|
||||
* Receives the result of a provisionCallback run: an error, or the new access token and
|
||||
* an optional expire time in milliseconds
|
||||
*/
|
||||
export type XOAuth2ProvisionResultCallback = (err: Error | null, accessToken?: string, expires?: number) => void;
|
||||
/**
|
||||
* Custom access token provider. `renew` is true when the existing token failed and a new
|
||||
* one is needed
|
||||
*/
|
||||
export type XOAuth2ProvisionCallback = (user: string, renew: boolean, callback: XOAuth2ProvisionResultCallback) => void;
|
||||
/**
|
||||
* Receives an access token, or the error that prevented generating one
|
||||
*/
|
||||
export type XOAuth2TokenCallback = (err: Error | null, accessToken?: string) => void;
|
||||
/**
|
||||
* A private key accepted by crypto.createSign().sign()
|
||||
*/
|
||||
export type XOAuth2PrivateKey = crypto.KeyLike | crypto.SignKeyObjectInput | crypto.SignPrivateKeyInput;
|
||||
/**
|
||||
* Client information for token generation
|
||||
*/
|
||||
export interface XOAuth2Options {
|
||||
/** User e-mail address */
|
||||
user?: string | undefined;
|
||||
/** Client ID value */
|
||||
clientId?: string | undefined;
|
||||
/** Client secret value */
|
||||
clientSecret?: string | undefined;
|
||||
/** Refresh token for an user */
|
||||
refreshToken?: string | undefined;
|
||||
/** Endpoint for token generation, defaults to 'https://accounts.google.com/o/oauth2/token' */
|
||||
accessUrl?: string | undefined;
|
||||
/** An existing valid accessToken */
|
||||
accessToken?: string | undefined;
|
||||
/** Private key for JSW */
|
||||
privateKey?: XOAuth2PrivateKey | undefined;
|
||||
/** Optional Access Token expire time in ms */
|
||||
expires?: number | undefined;
|
||||
/** Optional TTL for Access Token in seconds */
|
||||
timeout?: number | undefined;
|
||||
/** Function to run when a new access token is required */
|
||||
provisionCallback?: XOAuth2ProvisionCallback | undefined;
|
||||
/** 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. */
|
||||
tls?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
/** Service account client id (the JWT issuer), switches to the JWT bearer flow */
|
||||
serviceClient?: string | undefined;
|
||||
/** Lifetime of the service account JWT in seconds, defaults to 5 minutes, capped at an hour */
|
||||
serviceRequestTimeout?: number | undefined;
|
||||
/** OAuth2 scope for the service account flow, defaults to 'https://mail.google.com/' */
|
||||
scope?: string | undefined;
|
||||
/** Logger component name, defaults to 'OAuth2' */
|
||||
component?: string | undefined;
|
||||
/** Extra headers for the token request */
|
||||
customHeaders?: OutgoingHttpHeaders | undefined;
|
||||
/** Extra form fields for the token request */
|
||||
customParams?: {
|
||||
[key: string]: any;
|
||||
} | undefined;
|
||||
}
|
||||
/**
|
||||
* The object emitted with the 'token' event once a new access token has been generated
|
||||
*/
|
||||
export interface XOAuth2Token {
|
||||
/** User e-mail address */
|
||||
user?: string | undefined;
|
||||
/** The new access token */
|
||||
accessToken: string;
|
||||
/** Expire time as a timestamp in milliseconds, 0 when unknown */
|
||||
expires: number;
|
||||
}
|
||||
/**
|
||||
* A getToken request waiting for an in-flight renewal to complete
|
||||
*/
|
||||
export interface XOAuth2QueuedRequest {
|
||||
renew: boolean;
|
||||
callback: XOAuth2TokenCallback;
|
||||
}
|
||||
/**
|
||||
* XOAUTH2 access_token generator for Gmail.
|
||||
* Create client ID for web applications in Google API console to use it.
|
||||
* See Offline Access for receiving the needed refreshToken for an user
|
||||
* https://developers.google.com/accounts/docs/OAuth2WebServer#offline
|
||||
*
|
||||
* Usage for generating access tokens with a custom method using provisionCallback:
|
||||
* provisionCallback(user, renew, callback)
|
||||
* * user is the username to get the token for
|
||||
* * renew is a boolean that if true indicates that existing token failed and needs to be renewed
|
||||
* * callback is the callback to run with (error, accessToken [, expires])
|
||||
* * accessToken is a string
|
||||
* * expires is an optional expire time in milliseconds
|
||||
* If provisionCallback is used, then Nodemailer does not try to attempt generating the token by itself
|
||||
*
|
||||
* @constructor
|
||||
* @param options Client information for token generation
|
||||
* @param options.user User e-mail address
|
||||
* @param options.clientId Client ID value
|
||||
* @param options.clientSecret Client secret value
|
||||
* @param options.refreshToken Refresh token for an user
|
||||
* @param options.accessUrl Endpoint for token generation, defaults to 'https://accounts.google.com/o/oauth2/token'
|
||||
* @param options.accessToken An existing valid accessToken
|
||||
* @param options.privateKey Private key for JSW
|
||||
* @param options.expires Optional Access Token expire time in ms
|
||||
* @param options.timeout Optional TTL for Access Token in seconds
|
||||
* @param options.provisionCallback Function to run when a new access token is required
|
||||
* @param 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.
|
||||
*/
|
||||
declare class XOAuth2 extends Stream {
|
||||
options: XOAuth2Options;
|
||||
logger: shared.Logger;
|
||||
provisionCallback: XOAuth2ProvisionCallback | false;
|
||||
accessToken: string | false;
|
||||
expires: number;
|
||||
renewing: boolean;
|
||||
renewalQueue: XOAuth2QueuedRequest[];
|
||||
constructor(options?: XOAuth2Options, logger?: shared.ExternalLogger | boolean);
|
||||
/**
|
||||
* Returns or generates (if previous has expired) a XOAuth2 token
|
||||
*
|
||||
* @param renew If false then use cached access token (if available)
|
||||
* @param callback Callback function with error object and token string
|
||||
*/
|
||||
getToken(renew: boolean, callback: XOAuth2TokenCallback): void;
|
||||
/**
|
||||
* Updates token values
|
||||
*
|
||||
* @param accessToken New access token
|
||||
* @param timeout Access token lifetime in seconds
|
||||
*
|
||||
* Emits 'token': { user: User email-address, accessToken: the new accessToken, timeout: TTL in seconds}
|
||||
*/
|
||||
updateToken(accessToken: string, timeout?: number | string): void;
|
||||
/**
|
||||
* Generates a new XOAuth2 token with the credentials provided at initialization
|
||||
*
|
||||
* @param callback Callback function with error object and token string
|
||||
*/
|
||||
generateToken(callback: XOAuth2TokenCallback): void;
|
||||
/**
|
||||
* Converts an access_token and user id into a base64 encoded XOAuth2 token
|
||||
*
|
||||
* @param [accessToken] Access token string
|
||||
* @return Base64 encoded token for IMAP or SMTP login
|
||||
*/
|
||||
buildXOAuth2Token(accessToken?: string): string;
|
||||
/**
|
||||
* Custom POST request handler.
|
||||
* This is only needed to keep paths short in Windows, usually this module
|
||||
* is a dependency of a dependency and if it tries to require something
|
||||
* like the request module the paths get way too long to handle for Windows.
|
||||
* As we do only a simple POST request we do not actually require complicated
|
||||
* logic support (no redirects, no nothing) anyway.
|
||||
*
|
||||
* @param url Url to POST to
|
||||
* @param payload Payload to POST
|
||||
* @param params Client options, the customHeaders and tls values are used for the request
|
||||
* @param callback Callback function with (err, buff)
|
||||
*/
|
||||
postRequest(url: string, payload: {
|
||||
[key: string]: any;
|
||||
} | string | Buffer, params: XOAuth2Options, callback: (err: Error | null, buff?: Buffer) => void): void;
|
||||
/**
|
||||
* Encodes a buffer or a string into Base64url format
|
||||
*
|
||||
* @param data The data to convert
|
||||
* @return The encoded string
|
||||
*/
|
||||
toBase64URL(data: Buffer | string): string;
|
||||
/**
|
||||
* Creates a JSON Web Token signed with RS256 (SHA256 + RSA)
|
||||
*
|
||||
* @param payload The payload to include in the generated token
|
||||
* @return The generated and signed token
|
||||
*/
|
||||
jwtSignRS256(payload: {
|
||||
[key: string]: any;
|
||||
}): string;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `XOAuth2.Options` style references keep working
|
||||
*/
|
||||
declare namespace XOAuth2 {
|
||||
type Options = XOAuth2Options;
|
||||
type Token = XOAuth2Token;
|
||||
}
|
||||
export default XOAuth2;
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_stream_1 = require("node:stream");
|
||||
const index_js_1 = __importDefault(require("../fetch/index.js"));
|
||||
const node_crypto_1 = __importDefault(require("node:crypto"));
|
||||
const shared = __importStar(require("../shared/index.js"));
|
||||
const errors = __importStar(require("../errors.js"));
|
||||
/**
|
||||
* XOAUTH2 access_token generator for Gmail.
|
||||
* Create client ID for web applications in Google API console to use it.
|
||||
* See Offline Access for receiving the needed refreshToken for an user
|
||||
* https://developers.google.com/accounts/docs/OAuth2WebServer#offline
|
||||
*
|
||||
* Usage for generating access tokens with a custom method using provisionCallback:
|
||||
* provisionCallback(user, renew, callback)
|
||||
* * user is the username to get the token for
|
||||
* * renew is a boolean that if true indicates that existing token failed and needs to be renewed
|
||||
* * callback is the callback to run with (error, accessToken [, expires])
|
||||
* * accessToken is a string
|
||||
* * expires is an optional expire time in milliseconds
|
||||
* If provisionCallback is used, then Nodemailer does not try to attempt generating the token by itself
|
||||
*
|
||||
* @constructor
|
||||
* @param options Client information for token generation
|
||||
* @param options.user User e-mail address
|
||||
* @param options.clientId Client ID value
|
||||
* @param options.clientSecret Client secret value
|
||||
* @param options.refreshToken Refresh token for an user
|
||||
* @param options.accessUrl Endpoint for token generation, defaults to 'https://accounts.google.com/o/oauth2/token'
|
||||
* @param options.accessToken An existing valid accessToken
|
||||
* @param options.privateKey Private key for JSW
|
||||
* @param options.expires Optional Access Token expire time in ms
|
||||
* @param options.timeout Optional TTL for Access Token in seconds
|
||||
* @param options.provisionCallback Function to run when a new access token is required
|
||||
* @param 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 node_stream_1.Stream {
|
||||
constructor(options, logger) {
|
||||
super();
|
||||
this.options = options || {};
|
||||
if (options && options.serviceClient) {
|
||||
if (!options.privateKey || !options.user) {
|
||||
const err = new Error('Options "privateKey" and "user" are required for service account!');
|
||||
err.code = errors.EOAUTH2;
|
||||
setImmediate(() => this.emit('error', err));
|
||||
return;
|
||||
}
|
||||
const serviceRequestTimeout = Math.min(Math.max(Number(this.options.serviceRequestTimeout) || 0, 0), 3600);
|
||||
this.options.serviceRequestTimeout = serviceRequestTimeout || 5 * 60;
|
||||
}
|
||||
this.logger = shared.getLogger({
|
||||
logger
|
||||
}, {
|
||||
component: this.options.component || 'OAuth2'
|
||||
});
|
||||
this.provisionCallback = typeof this.options.provisionCallback === 'function' ? this.options.provisionCallback : false;
|
||||
this.options.accessUrl = this.options.accessUrl || 'https://accounts.google.com/o/oauth2/token';
|
||||
this.options.customHeaders = this.options.customHeaders || {};
|
||||
this.options.customParams = this.options.customParams || {};
|
||||
this.accessToken = this.options.accessToken || false;
|
||||
if (this.options.expires && Number(this.options.expires)) {
|
||||
this.expires = this.options.expires;
|
||||
}
|
||||
else {
|
||||
const timeout = Math.max(Number(this.options.timeout) || 0, 0);
|
||||
this.expires = (timeout && Date.now() + timeout * 1000) || 0;
|
||||
}
|
||||
this.renewing = false; // Track if renewal is in progress
|
||||
this.renewalQueue = []; // Queue for pending requests during renewal
|
||||
}
|
||||
/**
|
||||
* Returns or generates (if previous has expired) a XOAuth2 token
|
||||
*
|
||||
* @param renew If false then use cached access token (if available)
|
||||
* @param callback Callback function with error object and token string
|
||||
*/
|
||||
getToken(renew, callback) {
|
||||
if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
|
||||
this.logger.debug({
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'reuse'
|
||||
}, 'Reusing existing access token for %s', this.options.user);
|
||||
return callback(null, this.accessToken);
|
||||
}
|
||||
// check if it is possible to renew, if not, return the current token or error
|
||||
if (!this.provisionCallback && !this.options.refreshToken && !this.options.serviceClient) {
|
||||
if (this.accessToken) {
|
||||
this.logger.debug({
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'reuse'
|
||||
}, 'Reusing existing access token (no refresh capability) for %s', this.options.user);
|
||||
return callback(null, this.accessToken);
|
||||
}
|
||||
this.logger.error({
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'renew'
|
||||
}, 'Cannot renew access token for %s: No refresh mechanism available', this.options.user);
|
||||
const 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 (this.renewing) {
|
||||
this.renewalQueue.push({ renew, callback });
|
||||
return;
|
||||
}
|
||||
this.renewing = true;
|
||||
// Handles token renewal completion - processes queued requests and cleans up
|
||||
const generateCallback = (err, accessToken) => {
|
||||
this.renewalQueue.forEach(item => item.callback(err, accessToken));
|
||||
this.renewalQueue = [];
|
||||
this.renewing = false;
|
||||
if (err) {
|
||||
this.logger.error({
|
||||
err,
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'renew'
|
||||
}, 'Failed generating new Access Token for %s', this.options.user);
|
||||
}
|
||||
else {
|
||||
this.logger.info({
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'renew'
|
||||
}, 'Generated new Access Token for %s', this.options.user);
|
||||
}
|
||||
// Complete original request
|
||||
callback(err, accessToken);
|
||||
};
|
||||
if (this.provisionCallback) {
|
||||
this.provisionCallback(this.options.user, !!renew, (err, accessToken, expires) => {
|
||||
if (!err && accessToken) {
|
||||
this.accessToken = accessToken;
|
||||
this.expires = expires || 0;
|
||||
}
|
||||
generateCallback(err, accessToken);
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.generateToken(generateCallback);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Updates token values
|
||||
*
|
||||
* @param accessToken New access token
|
||||
* @param timeout Access token lifetime in seconds
|
||||
*
|
||||
* Emits 'token': { user: User email-address, accessToken: the new accessToken, timeout: TTL in seconds}
|
||||
*/
|
||||
updateToken(accessToken, timeout) {
|
||||
this.accessToken = accessToken;
|
||||
timeout = Math.max(Number(timeout) || 0, 0);
|
||||
this.expires = (timeout && Date.now() + timeout * 1000) || 0;
|
||||
this.emit('token', {
|
||||
user: this.options.user,
|
||||
accessToken: accessToken || '',
|
||||
expires: this.expires
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generates a new XOAuth2 token with the credentials provided at initialization
|
||||
*
|
||||
* @param callback Callback function with error object and token string
|
||||
*/
|
||||
generateToken(callback) {
|
||||
let urlOptions;
|
||||
let loggedUrlOptions;
|
||||
if (this.options.serviceClient) {
|
||||
// service account - https://developers.google.com/identity/protocols/OAuth2ServiceAccount
|
||||
const iat = Math.floor(Date.now() / 1000); // unix time
|
||||
const tokenData = {
|
||||
iss: this.options.serviceClient,
|
||||
scope: this.options.scope || 'https://mail.google.com/',
|
||||
sub: this.options.user,
|
||||
aud: this.options.accessUrl,
|
||||
iat,
|
||||
exp: iat + this.options.serviceRequestTimeout
|
||||
};
|
||||
let token;
|
||||
try {
|
||||
token = this.jwtSignRS256(tokenData);
|
||||
}
|
||||
catch (_err) {
|
||||
const err = new Error("Can't generate token. Check your auth options");
|
||||
err.code = errors.EOAUTH2;
|
||||
return callback(err);
|
||||
}
|
||||
urlOptions = {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: token
|
||||
};
|
||||
loggedUrlOptions = {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: tokenData
|
||||
};
|
||||
}
|
||||
else {
|
||||
if (!this.options.refreshToken) {
|
||||
const 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
|
||||
urlOptions = {
|
||||
client_id: this.options.clientId || '',
|
||||
client_secret: this.options.clientSecret || '',
|
||||
refresh_token: this.options.refreshToken,
|
||||
grant_type: 'refresh_token'
|
||||
};
|
||||
loggedUrlOptions = {
|
||||
client_id: this.options.clientId || '',
|
||||
client_secret: (this.options.clientSecret || '').substr(0, 6) + '...',
|
||||
refresh_token: (this.options.refreshToken || '').substr(0, 6) + '...',
|
||||
grant_type: 'refresh_token'
|
||||
};
|
||||
}
|
||||
Object.assign(urlOptions, this.options.customParams);
|
||||
Object.assign(loggedUrlOptions, this.options.customParams);
|
||||
this.logger.debug({
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'generate'
|
||||
}, 'Requesting token using: %s', JSON.stringify(loggedUrlOptions));
|
||||
this.postRequest(this.options.accessUrl, urlOptions, this.options, (error, body) => {
|
||||
let data;
|
||||
if (error) {
|
||||
return callback(error);
|
||||
}
|
||||
try {
|
||||
data = JSON.parse(body.toString());
|
||||
}
|
||||
catch (E) {
|
||||
return callback(E);
|
||||
}
|
||||
if (!data || typeof data !== 'object') {
|
||||
this.logger.debug({
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'post'
|
||||
}, 'Response: %s', (body || '').toString());
|
||||
const err = new Error('Invalid authentication response');
|
||||
err.code = errors.EOAUTH2;
|
||||
return callback(err);
|
||||
}
|
||||
const logData = Object.assign({}, data);
|
||||
if (logData.access_token) {
|
||||
logData.access_token = (logData.access_token || '').toString().substr(0, 6) + '...';
|
||||
}
|
||||
this.logger.debug({
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'post'
|
||||
}, 'Response: %s', JSON.stringify(logData));
|
||||
if (data.error) {
|
||||
// Error Response : https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
let errorMessage = data.error;
|
||||
if (data.error_description) {
|
||||
errorMessage += ': ' + data.error_description;
|
||||
}
|
||||
if (data.error_uri) {
|
||||
errorMessage += ' (' + data.error_uri + ')';
|
||||
}
|
||||
const err = new Error(errorMessage);
|
||||
err.code = errors.EOAUTH2;
|
||||
return callback(err);
|
||||
}
|
||||
if (data.access_token) {
|
||||
this.updateToken(data.access_token, data.expires_in);
|
||||
return callback(null, this.accessToken);
|
||||
}
|
||||
const err = new Error('No access token');
|
||||
err.code = errors.EOAUTH2;
|
||||
return callback(err);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Converts an access_token and user id into a base64 encoded XOAuth2 token
|
||||
*
|
||||
* @param [accessToken] Access token string
|
||||
* @return Base64 encoded token for IMAP or SMTP login
|
||||
*/
|
||||
buildXOAuth2Token(accessToken) {
|
||||
const authData = ['user=' + (this.options.user || ''), 'auth=Bearer ' + (accessToken || this.accessToken), '', ''];
|
||||
return Buffer.from(authData.join('\x01'), 'utf-8').toString('base64');
|
||||
}
|
||||
/**
|
||||
* Custom POST request handler.
|
||||
* This is only needed to keep paths short in Windows, usually this module
|
||||
* is a dependency of a dependency and if it tries to require something
|
||||
* like the request module the paths get way too long to handle for Windows.
|
||||
* As we do only a simple POST request we do not actually require complicated
|
||||
* logic support (no redirects, no nothing) anyway.
|
||||
*
|
||||
* @param url Url to POST to
|
||||
* @param payload Payload to POST
|
||||
* @param params Client options, the customHeaders and tls values are used for the request
|
||||
* @param callback Callback function with (err, buff)
|
||||
*/
|
||||
postRequest(url, payload, params, callback) {
|
||||
let returned = false;
|
||||
const chunks = [];
|
||||
let chunklen = 0;
|
||||
const fetchOptions = {
|
||||
method: 'post',
|
||||
headers: params.customHeaders,
|
||||
body: payload,
|
||||
allowErrorResponse: true
|
||||
};
|
||||
// OAuth2 token endpoints are credential-bearing. src/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 = (0, index_js_1.default)(url, fetchOptions);
|
||||
req.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = req.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
}
|
||||
});
|
||||
req.once('error', err => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
return callback(err);
|
||||
});
|
||||
req.once('end', () => {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
returned = true;
|
||||
return callback(null, Buffer.concat(chunks, chunklen));
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Encodes a buffer or a string into Base64url format
|
||||
*
|
||||
* @param data The data to convert
|
||||
* @return The encoded string
|
||||
*/
|
||||
toBase64URL(data) {
|
||||
if (typeof data === 'string') {
|
||||
data = Buffer.from(data);
|
||||
}
|
||||
return data
|
||||
.toString('base64')
|
||||
.replace(/[=]+/g, '') // remove '='s
|
||||
.replace(/\+/g, '-') // '+' → '-'
|
||||
.replace(/\//g, '_'); // '/' → '_'
|
||||
}
|
||||
/**
|
||||
* Creates a JSON Web Token signed with RS256 (SHA256 + RSA)
|
||||
*
|
||||
* @param payload The payload to include in the generated token
|
||||
* @return The generated and signed token
|
||||
*/
|
||||
jwtSignRS256(payload) {
|
||||
const signedPayload = ['{"alg":"RS256","typ":"JWT"}', JSON.stringify(payload)].map(val => this.toBase64URL(val)).join('.');
|
||||
const signature = node_crypto_1.default
|
||||
.createSign('RSA-SHA256')
|
||||
.update(signedPayload)
|
||||
.sign(this.options.privateKey);
|
||||
return signedPayload + '.' + this.toBase64URL(signature);
|
||||
}
|
||||
}
|
||||
exports.default = XOAuth2;
|
||||
module.exports = exports.default;
|
||||
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Options for addressparser
|
||||
*/
|
||||
export interface AddressParserOptions {
|
||||
/** Flatten groups into a single list of mailboxes */
|
||||
flatten?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* A single mailbox. Either value may be an empty string when the input did not carry it
|
||||
*/
|
||||
export interface MailboxAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
group?: undefined;
|
||||
}
|
||||
/**
|
||||
* An address group. RFC 5322 does not allow nested groups, so any nesting is flattened
|
||||
* into `group`
|
||||
*/
|
||||
export interface GroupAddress {
|
||||
name: string;
|
||||
group: Address[];
|
||||
address?: undefined;
|
||||
}
|
||||
/**
|
||||
* A parsed address entry, either a mailbox or a group
|
||||
*/
|
||||
export type Address = MailboxAddress | GroupAddress;
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address field
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* 'Name <address@domain>'
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: 'Name', address: 'address@domain'}]
|
||||
*
|
||||
* @param str Address field
|
||||
* @param options Optional options object
|
||||
* @param options._depth Internal recursion depth counter (do not set manually)
|
||||
* @return An array of address objects
|
||||
*/
|
||||
export default function addressparser(str?: string | null, options?: AddressParserOptions): Address[];
|
||||
Generated
Vendored
+170
-105
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Restores the quoting of a local part that was read out of a quoted string.
|
||||
*
|
||||
@@ -10,12 +8,12 @@
|
||||
* recipient separator once the consumer puts the address back into a header.
|
||||
*
|
||||
* This module has no dependencies so that it can ship on its own, which is why the two
|
||||
* grammar tests below are spelled out here instead of shared with lib/mime-node. Keeping
|
||||
* grammar tests below are spelled out here instead of shared with src/mime-node. Keeping
|
||||
* only what is ambiguous quoted is deliberate, mime-node applies the stricter RFC 5321
|
||||
* dot-atom rule on top of this when it emits an address.
|
||||
*
|
||||
* @param {String} address Address with an unquoted local part
|
||||
* @return {String} Address with the local part as a quoted-string
|
||||
* @param address Address with an unquoted local part
|
||||
* @return Address with the local part as a quoted-string
|
||||
*/
|
||||
function _quoteLocalPart(address) {
|
||||
const lastAt = address.lastIndexOf('@');
|
||||
@@ -23,39 +21,137 @@ function _quoteLocalPart(address) {
|
||||
// no domain to split off, nothing can be misrouted
|
||||
return address;
|
||||
}
|
||||
|
||||
const user = address.substr(0, lastAt);
|
||||
if (/^[^\s"(),:;<>@[\\\]]+$/.test(user) || /^"(?:[^"\\]|\\[\s\S])*"$/.test(user)) {
|
||||
// a local part that carries no special reads the same with or without the quotes,
|
||||
// and one that is already a complete quoted-string needs nothing either
|
||||
return address;
|
||||
}
|
||||
|
||||
return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reached for every parsed address, so it is built once rather than per call.
|
||||
*/
|
||||
const HAS_WHITESPACE = /\s/;
|
||||
|
||||
/**
|
||||
* An addr-spec that carries its whitespace legally, inside a quoted local part. The
|
||||
* optional tail is the malformed shape: a real mailbox with wreckage trailing it.
|
||||
*/
|
||||
const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
||||
|
||||
/**
|
||||
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
||||
*/
|
||||
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
||||
|
||||
/**
|
||||
* The looser reading applied once the strict one finds nothing, which tolerates the
|
||||
* further '@' that a domain should not have but malformed headers carry anyway.
|
||||
*/
|
||||
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
||||
|
||||
/**
|
||||
* An addr-spec sitting inside free text, together with the whitespace around it. Sticky
|
||||
* on purpose: it is run at the one offset _looseAddressStart picks rather than being let
|
||||
* loose to search, see there.
|
||||
*/
|
||||
const LOOSE_TEXT_ADDR = /\s*\b[^@\s]+@[^\s]+\b\s*/y;
|
||||
/**
|
||||
* The characters JS `\s` matches, which the scan below has to agree with to land on the
|
||||
* same match the pattern would.
|
||||
*/
|
||||
function _isSpaceCode(code) {
|
||||
return (code === 0x20 ||
|
||||
(code >= 0x09 && code <= 0x0d) ||
|
||||
code === 0xa0 ||
|
||||
code === 0x1680 ||
|
||||
(code >= 0x2000 && code <= 0x200a) ||
|
||||
code === 0x2028 ||
|
||||
code === 0x2029 ||
|
||||
code === 0x202f ||
|
||||
code === 0x205f ||
|
||||
code === 0x3000 ||
|
||||
code === 0xfeff);
|
||||
}
|
||||
/**
|
||||
* The characters JS `\w` matches without the unicode flag, the set the `\b` in
|
||||
* LOOSE_TEXT_ADDR is read against. charCodeAt off either end of the string gives NaN,
|
||||
* which compares false throughout, so out of range reads as the non-word the pattern
|
||||
* treats them as.
|
||||
*/
|
||||
function _isWordCode(code) {
|
||||
return (code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || code === 0x5f;
|
||||
}
|
||||
/**
|
||||
* Whether `\b` holds at an offset
|
||||
*/
|
||||
function _isBoundary(text, at) {
|
||||
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
|
||||
}
|
||||
/**
|
||||
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
||||
*
|
||||
* Letting the pattern search for itself is quadratic: '[^@\s]+' is retried from every
|
||||
* offset and rescans the run to the next '@' each time, so 140KB of header holding no
|
||||
* usable '@' blocks the event loop for about ten seconds (GHSA-v53p-9fqp-m79j). The search is also unnecessary.
|
||||
* '[^@\s]+' crosses neither whitespace nor a '@', so a match can only begin at the head of
|
||||
* a whitespace delimited run or just past a '@' inside one, and '[^\s]+\b' gives characters
|
||||
* back until it lands on a boundary, so the only end it can take in that run is the last
|
||||
* boundary in it. Both are found in one pass, and the pattern is then run at that single
|
||||
* offset.
|
||||
*
|
||||
* @param text Free text to look in
|
||||
* @return Offset to match at, or -1
|
||||
*/
|
||||
function _looseAddressStart(text) {
|
||||
const len = text.length;
|
||||
let pos = 0;
|
||||
while (pos < len) {
|
||||
while (pos < len && _isSpaceCode(text.charCodeAt(pos))) {
|
||||
pos++;
|
||||
}
|
||||
if (pos >= len) {
|
||||
break;
|
||||
}
|
||||
const runStart = pos;
|
||||
let runEnd = pos;
|
||||
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
|
||||
runEnd++;
|
||||
}
|
||||
let at = text.indexOf('@', runStart);
|
||||
if (at >= 0 && at < runEnd) {
|
||||
let lastBoundary = -1;
|
||||
for (let k = runEnd; k > runStart; k--) {
|
||||
if (_isBoundary(text, k)) {
|
||||
lastBoundary = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let atomStart = runStart;
|
||||
while (lastBoundary >= 0 && at >= 0 && at < runEnd) {
|
||||
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
|
||||
// and the boundary that ends the match has to sit past both
|
||||
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
|
||||
for (let start = atomStart; start < at; start++) {
|
||||
if (_isBoundary(text, start)) {
|
||||
if (start > runStart) {
|
||||
return start;
|
||||
}
|
||||
// the leading '\s*' is greedy, so a match that begins at the run
|
||||
// takes the whitespace in front of it along
|
||||
let padded = runStart;
|
||||
while (padded > 0 && _isSpaceCode(text.charCodeAt(padded - 1))) {
|
||||
padded--;
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
}
|
||||
}
|
||||
atomStart = at + 1;
|
||||
at = text.indexOf('@', atomStart);
|
||||
}
|
||||
}
|
||||
pos = runEnd;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
/**
|
||||
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
|
||||
*
|
||||
@@ -75,28 +171,26 @@ const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
||||
* A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
|
||||
* '<"user name"@example.com>' is well formed and means exactly what it says.
|
||||
*
|
||||
* @param {Object} data Collected address parts, mutated in place
|
||||
* @param data Collected address parts, mutated in place
|
||||
*/
|
||||
function _recoverAddrSpec(data) {
|
||||
if (!HAS_WHITESPACE.test(data.address)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let address;
|
||||
let rest;
|
||||
|
||||
const quoted = data.address.match(QUOTED_LOCAL_ADDR);
|
||||
if (quoted) {
|
||||
if (!quoted[2]) {
|
||||
// the whitespace sits inside the quoted local part, this is a well formed mailbox
|
||||
return;
|
||||
}
|
||||
|
||||
// a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
|
||||
// than splitting into the quotes
|
||||
address = quoted[1];
|
||||
rest = [quoted[2]];
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
if (data.address.indexOf('"') >= 0) {
|
||||
// Splitting on whitespace loses track of where the quoted string starts and ends,
|
||||
// and this module does not take addresses out of quoted strings: the run picked out
|
||||
@@ -105,36 +199,30 @@ function _recoverAddrSpec(data) {
|
||||
// left is wreckage either way and the original is the honest answer
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = data.address.split(/\s+/);
|
||||
|
||||
let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
|
||||
if (addrIndex < 0) {
|
||||
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
||||
}
|
||||
|
||||
if (addrIndex < 0) {
|
||||
// nothing in there reads as an address, there is no better answer than the original
|
||||
return;
|
||||
}
|
||||
|
||||
address = parts.splice(addrIndex, 1)[0];
|
||||
rest = parts;
|
||||
}
|
||||
|
||||
data.address = address;
|
||||
data.text = [data.text]
|
||||
.concat(rest)
|
||||
.filter(part => part)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tokens for a single address into an address object
|
||||
*
|
||||
* @param {Array} tokens Tokens object
|
||||
* @param {Number} depth Current recursion depth for nested group protection
|
||||
* @return {Object} Address object
|
||||
* @param tokens Tokens object
|
||||
* @param depth Current recursion depth for nested group protection
|
||||
* @return Address object
|
||||
*/
|
||||
function _handleAddress(tokens, depth) {
|
||||
let isGroup = false;
|
||||
@@ -148,7 +236,12 @@ function _handleAddress(tokens, depth) {
|
||||
textWasQuoted: []
|
||||
};
|
||||
let insideQuotes = false;
|
||||
|
||||
// Last character of the run each state is currently accumulating. Reading it back off
|
||||
// the accumulator with slice(-1) makes the engine flatten the whole growing string on
|
||||
// every token, which is quadratic over an address built from many comment-joined atoms
|
||||
// (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the
|
||||
// character is carried along instead of re-read.
|
||||
const lastChars = { address: '', comment: '', group: '', text: '' };
|
||||
// Filter out <addresses>, (comments) and regular text
|
||||
for (let i = 0, len = tokens.length; i < len; i++) {
|
||||
const token = tokens[i];
|
||||
@@ -177,48 +270,48 @@ function _handleAddress(tokens, depth) {
|
||||
insideQuotes = false;
|
||||
break;
|
||||
}
|
||||
} else if (token.value) {
|
||||
}
|
||||
else if (token.value) {
|
||||
if (state === 'address') {
|
||||
// Handle unquoted name that includes a "<".
|
||||
// Apple Mail truncates everything between an unexpected < and an address.
|
||||
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
||||
}
|
||||
|
||||
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
|
||||
// of the '@', but it cannot join two atoms into one: gluing across it would read
|
||||
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
|
||||
// deliver to a domain the sender never named.
|
||||
const parts = data[state];
|
||||
const joins =
|
||||
prevToken &&
|
||||
const joins = prevToken &&
|
||||
prevToken.noBreak &&
|
||||
parts.length &&
|
||||
(prevToken.value !== ')' || parts[parts.length - 1].slice(-1) === '@' || token.value.charAt(0) === '@');
|
||||
|
||||
(prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
|
||||
if (joins) {
|
||||
data[state][data[state].length - 1] += token.value;
|
||||
if (token.value) {
|
||||
lastChars[state] = token.value.charAt(token.value.length - 1);
|
||||
}
|
||||
if (state === 'text' && insideQuotes) {
|
||||
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
data[state].push(token.value);
|
||||
lastChars[state] = token.value.charAt(token.value.length - 1);
|
||||
if (state === 'text') {
|
||||
data.textWasQuoted.push(insideQuotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is no text but a comment, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||
data.text = data.text.join(' ');
|
||||
|
||||
// Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
|
||||
let groupMembers = [];
|
||||
if (data.group.length) {
|
||||
@@ -226,17 +319,18 @@ function _handleAddress(tokens, depth) {
|
||||
parsedGroup.forEach(member => {
|
||||
if (member.group) {
|
||||
groupMembers = groupMembers.concat(member.group);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
groupMembers.push(member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addresses.push({
|
||||
name: data.text || '',
|
||||
group: groupMembers
|
||||
});
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.address.length && data.text.length) {
|
||||
for (let i = data.text.length - 1; i >= 0; i--) {
|
||||
@@ -249,23 +343,25 @@ function _handleAddress(tokens, depth) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try a looser regex match if strict match found nothing
|
||||
if (!data.address.length) {
|
||||
let extracted = false;
|
||||
for (let i = data.text.length - 1; i >= 0; i--) {
|
||||
// Security: Do not extract email addresses from quoted strings
|
||||
if (!data.textWasQuoted[i]) {
|
||||
data.text[i] = data.text[i]
|
||||
.replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, match => {
|
||||
if (!extracted) {
|
||||
data.address = [match.trim()];
|
||||
extracted = true;
|
||||
return ' ';
|
||||
}
|
||||
return match;
|
||||
})
|
||||
.trim();
|
||||
const part = data.text[i];
|
||||
let remainder = part;
|
||||
const at = _looseAddressStart(part);
|
||||
if (at >= 0) {
|
||||
LOOSE_TEXT_ADDR.lastIndex = at;
|
||||
const match = LOOSE_TEXT_ADDR.exec(part);
|
||||
if (match) {
|
||||
data.address = [match[0].trim()];
|
||||
extracted = true;
|
||||
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
||||
}
|
||||
}
|
||||
data.text[i] = remainder.trim();
|
||||
if (extracted) {
|
||||
break;
|
||||
}
|
||||
@@ -273,56 +369,46 @@ function _handleAddress(tokens, depth) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no text but a comment exists, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
// Keep only the first address occurrence, push others to regular text
|
||||
if (data.address.length > 1) {
|
||||
data.text = data.text.concat(data.address.splice(1));
|
||||
}
|
||||
|
||||
// An address is only taken from unquoted text, so anything left in the text at this
|
||||
// point that still has to serve as the address carries its quoting in this flag
|
||||
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
|
||||
|
||||
// Join values with spaces
|
||||
data.text = data.text.join(' ');
|
||||
data.address = data.address.join(' ');
|
||||
|
||||
_recoverAddrSpec(data);
|
||||
|
||||
const address = {
|
||||
address: data.address || data.text || '',
|
||||
name: data.text || data.address || ''
|
||||
};
|
||||
|
||||
if (address.address === address.name) {
|
||||
if (/@/.test(address.address || '')) {
|
||||
address.name = '';
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
address.address = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (addressFromQuotedText && address.address) {
|
||||
address.address = _quoteLocalPart(address.address);
|
||||
}
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Tokenizer object for tokenizing address field strings
|
||||
*
|
||||
* @constructor
|
||||
* @param {String} str Address field string
|
||||
* @param str Address field string
|
||||
*/
|
||||
class Tokenizer {
|
||||
constructor(str) {
|
||||
@@ -332,7 +418,6 @@ class Tokenizer {
|
||||
this.node = null;
|
||||
this.escaped = false;
|
||||
this.inDomainLiteral = false;
|
||||
|
||||
this.list = [];
|
||||
/**
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
@@ -352,35 +437,30 @@ class Tokenizer {
|
||||
';': ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenizes the original input string
|
||||
*
|
||||
* @return {Array} An array of operator|text tokens
|
||||
* @return An array of operator|text tokens
|
||||
*/
|
||||
tokenize() {
|
||||
const list = [];
|
||||
|
||||
for (let i = 0, len = this.str.length; i < len; i++) {
|
||||
const chr = this.str.charAt(i);
|
||||
const nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
|
||||
this.checkChar(chr, nextChr);
|
||||
}
|
||||
|
||||
this.list.forEach(node => {
|
||||
node.value = (node.value || '').toString().trim();
|
||||
if (node.value) {
|
||||
list.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a character is an operator or text and acts accordingly
|
||||
*
|
||||
* @param {String} chr Character from the address field
|
||||
* @param chr Character from the address field
|
||||
*/
|
||||
checkChar(chr, nextChr) {
|
||||
// Track RFC 5322 domain-literals ("[" *dtext "]"). Operator characters such
|
||||
@@ -393,30 +473,29 @@ class Tokenizer {
|
||||
if (!this.escaped && !this.operatorExpecting) {
|
||||
if (!this.inDomainLiteral && chr === '[') {
|
||||
this.inDomainLiteral = true;
|
||||
} else if (this.inDomainLiteral && (chr === ']' || chr === ',' || chr === ';')) {
|
||||
}
|
||||
else if (this.inDomainLiteral && (chr === ']' || chr === ',' || chr === ';')) {
|
||||
this.inDomainLiteral = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.escaped) {
|
||||
// ignore next condition blocks
|
||||
} else if (chr === this.operatorExpecting) {
|
||||
}
|
||||
else if (chr === this.operatorExpecting) {
|
||||
this.node = {
|
||||
type: 'operator',
|
||||
value: chr
|
||||
};
|
||||
|
||||
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
|
||||
this.node.noBreak = true;
|
||||
}
|
||||
|
||||
this.list.push(this.node);
|
||||
this.node = null;
|
||||
this.operatorExpecting = '';
|
||||
this.escaped = false;
|
||||
|
||||
return;
|
||||
} else if (!this.operatorExpecting && !this.inDomainLiteral && chr in this.operators) {
|
||||
}
|
||||
else if (!this.operatorExpecting && !this.inDomainLiteral && chr in this.operators) {
|
||||
this.node = {
|
||||
type: 'operator',
|
||||
value: chr
|
||||
@@ -426,11 +505,11 @@ class Tokenizer {
|
||||
this.operatorExpecting = this.operators[chr];
|
||||
this.escaped = false;
|
||||
return;
|
||||
} else if (['"', "'"].includes(this.operatorExpecting) && chr === '\\') {
|
||||
}
|
||||
else if (['"', "'"].includes(this.operatorExpecting) && chr === '\\') {
|
||||
this.escaped = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.node) {
|
||||
this.node = {
|
||||
type: 'text',
|
||||
@@ -438,29 +517,24 @@ class Tokenizer {
|
||||
};
|
||||
this.list.push(this.node);
|
||||
}
|
||||
|
||||
if (chr === '\n') {
|
||||
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually
|
||||
// go together anyway and there already is a WS for \n. Lone \r means something is fishy.
|
||||
chr = ' ';
|
||||
}
|
||||
|
||||
if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
|
||||
// skip command bytes
|
||||
this.node.value += chr;
|
||||
}
|
||||
|
||||
this.escaped = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum recursion depth for parsing nested groups.
|
||||
* RFC 5322 doesn't allow nested groups, so this is a safeguard against
|
||||
* malicious input that could cause stack overflow.
|
||||
*/
|
||||
const MAX_NESTED_GROUP_DEPTH = 50;
|
||||
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address field
|
||||
*
|
||||
@@ -472,42 +546,37 @@ const MAX_NESTED_GROUP_DEPTH = 50;
|
||||
*
|
||||
* [{name: 'Name', address: 'address@domain'}]
|
||||
*
|
||||
* @param {String} str Address field
|
||||
* @param {Object} options Optional options object
|
||||
* @param {Number} options._depth Internal recursion depth counter (do not set manually)
|
||||
* @return {Array} An array of address objects
|
||||
* @param str Address field
|
||||
* @param options Optional options object
|
||||
* @param options._depth Internal recursion depth counter (do not set manually)
|
||||
* @return An array of address objects
|
||||
*/
|
||||
function addressparser(str, options) {
|
||||
export default function addressparser(str, options) {
|
||||
options = options || {};
|
||||
const depth = options._depth || 0;
|
||||
|
||||
// Prevent stack overflow from deeply nested groups (DoS protection)
|
||||
if (depth > MAX_NESTED_GROUP_DEPTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tokenizer = new Tokenizer(str);
|
||||
const tokens = tokenizer.tokenize();
|
||||
|
||||
const addresses = [];
|
||||
let address = [];
|
||||
let parsedAddresses = [];
|
||||
|
||||
tokens.forEach(token => {
|
||||
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
|
||||
if (address.length) {
|
||||
addresses.push(address);
|
||||
}
|
||||
address = [];
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
address.push(token);
|
||||
}
|
||||
});
|
||||
|
||||
if (address.length) {
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
addresses.forEach(addr => {
|
||||
const handled = _handleAddress(addr, depth);
|
||||
// Appended in place. Rebuilding the accumulator with concat() would copy every
|
||||
@@ -516,7 +585,6 @@ function addressparser(str, options) {
|
||||
parsedAddresses.push(handled[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// Merge fragments produced when unquoted display names contain commas.
|
||||
// "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
||||
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
|
||||
@@ -529,16 +597,16 @@ function addressparser(str, options) {
|
||||
const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
|
||||
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
||||
next.name = current.name + ', ' + next.name;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
mergedAddresses.push(current);
|
||||
}
|
||||
}
|
||||
mergedAddresses.reverse();
|
||||
parsedAddresses = mergedAddresses;
|
||||
|
||||
if (options.flatten) {
|
||||
const flatAddresses = [];
|
||||
const walkAddressList = list => {
|
||||
const walkAddressList = (list) => {
|
||||
list.forEach(entry => {
|
||||
if (entry.group) {
|
||||
return walkAddressList(entry.group);
|
||||
@@ -549,8 +617,5 @@ function addressparser(str, options) {
|
||||
walkAddressList(parsedAddresses);
|
||||
return flatAddresses;
|
||||
}
|
||||
|
||||
return parsedAddresses;
|
||||
}
|
||||
|
||||
module.exports = addressparser;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { Transform } from 'node:stream';
|
||||
/**
|
||||
* Encodes a Buffer into a base64 encoded string
|
||||
*
|
||||
* @param buffer Buffer to convert
|
||||
* @returns base64 encoded string
|
||||
*/
|
||||
export declare function encode(buffer: Buffer | string): string;
|
||||
/**
|
||||
* Adds soft line breaks to a base64 string
|
||||
*
|
||||
* @param str base64 encoded string that might need line wrapping
|
||||
* @param [lineLength=76] Maximum allowed length for a line
|
||||
* @returns Soft-wrapped base64 encoded string
|
||||
*/
|
||||
export declare function wrap(str: string, lineLength?: number | false): string;
|
||||
/**
|
||||
* Options for the base64 encoder stream
|
||||
*/
|
||||
export interface EncoderOptions {
|
||||
/** Maximum length for lines, set to false to disable wrapping */
|
||||
lineLength?: number | false | undefined;
|
||||
}
|
||||
/**
|
||||
* Creates a transform stream for encoding data to base64 encoding
|
||||
*
|
||||
* @constructor
|
||||
* @param options Stream options
|
||||
* @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
|
||||
*/
|
||||
export declare class Encoder extends Transform {
|
||||
options: EncoderOptions;
|
||||
inputBytes: number;
|
||||
outputBytes: number;
|
||||
constructor(options?: EncoderOptions);
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { Transform } from 'node:stream';
|
||||
/**
|
||||
* Encodes a Buffer into a base64 encoded string
|
||||
*
|
||||
* @param buffer Buffer to convert
|
||||
* @returns base64 encoded string
|
||||
*/
|
||||
export function encode(buffer) {
|
||||
if (typeof buffer === 'string') {
|
||||
buffer = Buffer.from(buffer, 'utf-8');
|
||||
}
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
/**
|
||||
* Adds soft line breaks to a base64 string
|
||||
*
|
||||
* @param str base64 encoded string that might need line wrapping
|
||||
* @param [lineLength=76] Maximum allowed length for a line
|
||||
* @returns Soft-wrapped base64 encoded string
|
||||
*/
|
||||
export function wrap(str, lineLength) {
|
||||
str = (str || '').toString();
|
||||
lineLength = lineLength || 76;
|
||||
if (str.length <= lineLength) {
|
||||
return str;
|
||||
}
|
||||
const result = [];
|
||||
let pos = 0;
|
||||
const chunkLength = lineLength * 1024;
|
||||
const wrapRegex = new RegExp('.{' + lineLength + '}', 'g');
|
||||
while (pos < str.length) {
|
||||
const wrappedLines = str.substr(pos, chunkLength).replace(wrapRegex, '$&\r\n').trim();
|
||||
result.push(wrappedLines);
|
||||
pos += chunkLength;
|
||||
}
|
||||
return result.join('\r\n').trim();
|
||||
}
|
||||
/**
|
||||
* Creates a transform stream for encoding data to base64 encoding
|
||||
*
|
||||
* @constructor
|
||||
* @param options Stream options
|
||||
* @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
|
||||
*/
|
||||
export class Encoder extends Transform {
|
||||
constructor(options) {
|
||||
super();
|
||||
this.options = options || {};
|
||||
if (this.options.lineLength !== false) {
|
||||
this.options.lineLength = this.options.lineLength || 76;
|
||||
}
|
||||
this._curLine = '';
|
||||
this._remainingBytes = false;
|
||||
this.inputBytes = 0;
|
||||
this.outputBytes = 0;
|
||||
}
|
||||
/** @internal */
|
||||
_transform(chunk, encoding, done) {
|
||||
let buf = encoding !== 'buffer' ? Buffer.from(chunk, encoding) : chunk;
|
||||
if (!buf || !buf.length) {
|
||||
setImmediate(done);
|
||||
return;
|
||||
}
|
||||
this.inputBytes += buf.length;
|
||||
if (this._remainingBytes && this._remainingBytes.length) {
|
||||
buf = Buffer.concat([this._remainingBytes, buf], this._remainingBytes.length + buf.length);
|
||||
this._remainingBytes = false;
|
||||
}
|
||||
if (buf.length % 3) {
|
||||
this._remainingBytes = buf.slice(buf.length - (buf.length % 3));
|
||||
buf = buf.slice(0, buf.length - (buf.length % 3));
|
||||
}
|
||||
else {
|
||||
this._remainingBytes = false;
|
||||
}
|
||||
let b64 = this._curLine + encode(buf);
|
||||
if (this.options.lineLength) {
|
||||
b64 = wrap(b64, this.options.lineLength);
|
||||
// remove last line as it is still most probably incomplete
|
||||
const lastLF = b64.lastIndexOf('\n');
|
||||
if (lastLF < 0) {
|
||||
this._curLine = b64;
|
||||
b64 = '';
|
||||
}
|
||||
else if (lastLF === b64.length - 1) {
|
||||
this._curLine = '';
|
||||
}
|
||||
else {
|
||||
this._curLine = b64.substring(lastLF + 1);
|
||||
b64 = b64.substring(0, lastLF + 1);
|
||||
}
|
||||
}
|
||||
if (b64) {
|
||||
this.outputBytes += b64.length;
|
||||
this.push(Buffer.from(b64, 'ascii'));
|
||||
}
|
||||
setImmediate(done);
|
||||
}
|
||||
/** @internal */
|
||||
_flush(done) {
|
||||
if (this._remainingBytes && this._remainingBytes.length) {
|
||||
this._curLine += encode(this._remainingBytes);
|
||||
}
|
||||
if (this._curLine) {
|
||||
this._curLine = wrap(this._curLine, this.options.lineLength);
|
||||
this.outputBytes += this._curLine.length;
|
||||
this.push(Buffer.from(this._curLine, 'ascii'));
|
||||
this._curLine = '';
|
||||
}
|
||||
done();
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { type DKIMKey, type DKIMPrivateKey, type DKIMSignOptions } from './sign.js';
|
||||
import { PassThrough, type Readable } from 'node:stream';
|
||||
/**
|
||||
* A single DKIM signing key
|
||||
*/
|
||||
export type { DKIMKey, DKIMPrivateKey, DKIMSignOptions };
|
||||
/**
|
||||
* Options for the DKIM signer
|
||||
*/
|
||||
export interface DKIMOptions extends DKIMSignOptions {
|
||||
/** One or more signing keys, used instead of the domainName, keySelector and privateKey options */
|
||||
keys?: DKIMKey | DKIMKey[] | undefined;
|
||||
/** Directory for buffering large message bodies to disk, no buffering when not set */
|
||||
cacheDir?: string | false | undefined;
|
||||
/** Body size in bytes from which the body is buffered to cacheDir, defaults to 10 MB */
|
||||
cacheTreshold?: number | undefined;
|
||||
/** Hash algorithm for the body hash and the signature, defaults to sha256 */
|
||||
hashAlgo?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* The signed message as returned by DKIM#sign
|
||||
*/
|
||||
export interface DKIMSignedStream extends PassThrough {
|
||||
/** true if the message body was buffered to cacheDir while signing */
|
||||
usingCache: boolean;
|
||||
}
|
||||
declare class DKIM {
|
||||
options: DKIMOptions;
|
||||
keys: DKIMKey[];
|
||||
constructor(options: DKIMOptions);
|
||||
sign(input: Readable | Buffer | string, extraOptions?: DKIMOptions): DKIMSignedStream;
|
||||
}
|
||||
/**
|
||||
* Type aliases in the layout of @types/nodemailer, so `DKIM.Options` style references keep working
|
||||
*/
|
||||
declare namespace DKIM {
|
||||
type Options = DKIMOptions;
|
||||
type SingleKeyOptions = Omit<DKIMOptions, 'keys'>;
|
||||
}
|
||||
export default DKIM;
|
||||
Generated
Vendored
+24
-70
@@ -1,45 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
// FIXME:
|
||||
// replace this Transform mess with a method that pipes input argument to output argument
|
||||
|
||||
const MessageParser = require('./message-parser');
|
||||
const RelaxedBody = require('./relaxed-body');
|
||||
const sign = require('./sign');
|
||||
const { PassThrough } = require('stream');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { copyOwnKeys } = require('../shared/objects');
|
||||
|
||||
import MessageParser from './message-parser.js';
|
||||
import RelaxedBody from './relaxed-body.js';
|
||||
import sign from './sign.js';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { copyOwnKeys } from '../shared/objects.js';
|
||||
const DKIM_ALGO = 'sha256';
|
||||
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
|
||||
|
||||
/*
|
||||
// Usage:
|
||||
|
||||
let dkim = new DKIM({
|
||||
domainName: 'example.com',
|
||||
keySelector: 'key-selector',
|
||||
privateKey,
|
||||
cacheDir: '/tmp'
|
||||
});
|
||||
dkim.sign(input).pipe(process.stdout);
|
||||
|
||||
// Where inputStream is a rfc822 message (either a stream, string or Buffer)
|
||||
// and outputStream is a DKIM signed rfc822 message
|
||||
*/
|
||||
|
||||
const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // buffer messages larger than this to disk
|
||||
class DKIMSigner {
|
||||
constructor(options, keys, input, output) {
|
||||
this.options = options || {};
|
||||
this.keys = keys;
|
||||
|
||||
this.cacheTreshold = Number(this.options.cacheTreshold) || MAX_MESSAGE_SIZE;
|
||||
this.hashAlgo = this.options.hashAlgo || DKIM_ALGO;
|
||||
|
||||
this.cacheDir = this.options.cacheDir || false;
|
||||
|
||||
this.chunks = [];
|
||||
this.chunklen = 0;
|
||||
this.readPos = 0;
|
||||
@@ -47,32 +24,26 @@ class DKIMSigner {
|
||||
? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex'))
|
||||
: false;
|
||||
this.cache = false;
|
||||
|
||||
this.headers = false;
|
||||
this.bodyHash = false;
|
||||
this.parser = false;
|
||||
this.relaxedBody = false;
|
||||
|
||||
this.input = input;
|
||||
this.output = output;
|
||||
this.output.usingCache = false;
|
||||
|
||||
this.hasErrored = false;
|
||||
|
||||
this.input.on('error', err => {
|
||||
this.hasErrored = true;
|
||||
this.cleanup();
|
||||
output.emit('error', err);
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (!this.cache || !this.cachePath) {
|
||||
return;
|
||||
}
|
||||
fs.unlink(this.cachePath, () => false);
|
||||
}
|
||||
|
||||
createReadCache() {
|
||||
// pipe remainings to cache file
|
||||
this.cache = fs.createReadStream(this.cachePath);
|
||||
@@ -85,33 +56,33 @@ class DKIMSigner {
|
||||
});
|
||||
this.cache.pipe(this.output);
|
||||
}
|
||||
|
||||
sendNextChunk() {
|
||||
if (this.hasErrored) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.readPos >= this.chunks.length) {
|
||||
if (!this.cache) {
|
||||
return this.output.end();
|
||||
this.output.end();
|
||||
return;
|
||||
}
|
||||
return this.createReadCache();
|
||||
}
|
||||
const chunk = this.chunks[this.readPos++];
|
||||
if (this.output.write(chunk) === false) {
|
||||
return this.output.once('drain', () => {
|
||||
this.output.once('drain', () => {
|
||||
this.sendNextChunk();
|
||||
});
|
||||
return;
|
||||
}
|
||||
setImmediate(() => this.sendNextChunk());
|
||||
}
|
||||
|
||||
sendSignedOutput() {
|
||||
let keyPos = 0;
|
||||
const signNextKey = () => {
|
||||
if (keyPos >= this.keys.length) {
|
||||
this.output.write(this.parser.rawHeaders);
|
||||
return setImmediate(() => this.sendNextChunk());
|
||||
setImmediate(() => this.sendNextChunk());
|
||||
return;
|
||||
}
|
||||
const key = this.keys[keyPos++];
|
||||
const dkimField = sign(this.headers, this.hashAlgo, this.bodyHash, {
|
||||
@@ -124,17 +95,14 @@ class DKIMSigner {
|
||||
if (dkimField) {
|
||||
this.output.write(Buffer.from(dkimField + '\r\n'));
|
||||
}
|
||||
return setImmediate(signNextKey);
|
||||
setImmediate(signNextKey);
|
||||
};
|
||||
|
||||
if (this.bodyHash && this.headers) {
|
||||
return signNextKey();
|
||||
}
|
||||
|
||||
this.output.write(this.parser.rawHeaders);
|
||||
this.sendNextChunk();
|
||||
}
|
||||
|
||||
createWriteCache() {
|
||||
this.output.usingCache = true;
|
||||
// pipe remainings to cache file
|
||||
@@ -158,21 +126,17 @@ class DKIMSigner {
|
||||
this.relaxedBody.removeAllListeners('readable');
|
||||
this.relaxedBody.pipe(this.cache);
|
||||
}
|
||||
|
||||
signStream() {
|
||||
this.parser = new MessageParser();
|
||||
this.relaxedBody = new RelaxedBody({
|
||||
hashAlgo: this.hashAlgo
|
||||
});
|
||||
|
||||
this.parser.on('headers', value => {
|
||||
this.headers = value;
|
||||
});
|
||||
|
||||
this.relaxedBody.on('hash', value => {
|
||||
this.bodyHash = value;
|
||||
});
|
||||
|
||||
this.relaxedBody.on('readable', () => {
|
||||
let chunk;
|
||||
if (this.cache) {
|
||||
@@ -186,44 +150,37 @@ class DKIMSigner {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.relaxedBody.on('end', () => {
|
||||
if (this.cache) {
|
||||
return;
|
||||
}
|
||||
this.sendSignedOutput();
|
||||
});
|
||||
|
||||
this.parser.pipe(this.relaxedBody);
|
||||
setImmediate(() => this.input.pipe(this.parser));
|
||||
}
|
||||
}
|
||||
|
||||
class DKIM {
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
this.keys = [].concat(
|
||||
this.options.keys || {
|
||||
domainName: options.domainName,
|
||||
keySelector: options.keySelector,
|
||||
privateKey: options.privateKey
|
||||
}
|
||||
);
|
||||
this.keys = [].concat(this.options.keys || {
|
||||
domainName: options.domainName,
|
||||
keySelector: options.keySelector,
|
||||
privateKey: options.privateKey
|
||||
});
|
||||
}
|
||||
|
||||
sign(input, extraOptions) {
|
||||
const output = new PassThrough();
|
||||
let inputStream = input;
|
||||
let writeValue = false;
|
||||
|
||||
if (Buffer.isBuffer(input)) {
|
||||
writeValue = input;
|
||||
inputStream = new PassThrough();
|
||||
} else if (typeof input === 'string') {
|
||||
}
|
||||
else if (typeof input === 'string') {
|
||||
writeValue = Buffer.from(input);
|
||||
inputStream = new PassThrough();
|
||||
}
|
||||
|
||||
let options = this.options;
|
||||
if (extraOptions && Object.keys(extraOptions).length) {
|
||||
// extraOptions is mail.data._dkim, caller supplied message data. An own
|
||||
@@ -232,7 +189,6 @@ class DKIM {
|
||||
options = copyOwnKeys({}, extraOptions);
|
||||
copyOwnKeys(options, this.options);
|
||||
}
|
||||
|
||||
const signer = new DKIMSigner(options, this.keys, inputStream, output);
|
||||
setImmediate(() => {
|
||||
signer.signStream();
|
||||
@@ -242,9 +198,7 @@ class DKIM {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DKIM;
|
||||
export default DKIM;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Transform, type TransformOptions } from 'node:stream';
|
||||
/**
|
||||
* A header line as emitted with the 'headers' event
|
||||
*/
|
||||
export interface MessageParserHeaderLine {
|
||||
/** Lowercase header field name */
|
||||
key: string;
|
||||
/** Full header line, folded continuation lines included, one character per byte ('binary' encoding) */
|
||||
line: string;
|
||||
}
|
||||
/**
|
||||
* MessageParser instance is a transform stream that separates message headers
|
||||
* from the rest of the body. Headers are emitted with the 'headers' event. Message
|
||||
* body is passed on as the resulting stream.
|
||||
*/
|
||||
export default class MessageParser extends Transform {
|
||||
lastBytes: Buffer;
|
||||
headersParsed: boolean;
|
||||
headerBytes: number;
|
||||
headerChunks: Buffer[] | null;
|
||||
rawHeaders: Buffer | false;
|
||||
bodySize: number;
|
||||
constructor(options?: TransformOptions);
|
||||
/**
|
||||
* Keeps count of the last 4 bytes in order to detect line breaks on chunk boundaries
|
||||
*
|
||||
* @param data Next data chunk from the stream
|
||||
*/
|
||||
updateLastBytes(data: Buffer): void;
|
||||
/**
|
||||
* Finds and removes message headers from the remaining body. We want to keep
|
||||
* headers separated until final delivery to be able to modify these
|
||||
*
|
||||
* @param data Next chunk of data
|
||||
* @return Returns true if headers are already found or false otherwise
|
||||
*/
|
||||
checkHeaders(data: Buffer): boolean;
|
||||
parseHeaders(): MessageParserHeaderLine[];
|
||||
}
|
||||
Generated
Vendored
+30
-40
@@ -1,13 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const { Transform } = require('stream');
|
||||
|
||||
import { Transform } from 'node:stream';
|
||||
/**
|
||||
* MessageParser instance is a transform stream that separates message headers
|
||||
* from the rest of the body. Headers are emitted with the 'headers' event. Message
|
||||
* body is passed on as the resulting stream.
|
||||
*/
|
||||
class MessageParser extends Transform {
|
||||
export default class MessageParser extends Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.lastBytes = Buffer.alloc(4);
|
||||
@@ -17,46 +14,42 @@ class MessageParser extends Transform {
|
||||
this.rawHeaders = false;
|
||||
this.bodySize = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps count of the last 4 bytes in order to detect line breaks on chunk boundaries
|
||||
*
|
||||
* @param {Buffer} data Next data chunk from the stream
|
||||
* @param data Next data chunk from the stream
|
||||
*/
|
||||
updateLastBytes(data) {
|
||||
const lblen = this.lastBytes.length;
|
||||
const nblen = Math.min(data.length, lblen);
|
||||
|
||||
// shift existing bytes
|
||||
for (let i = 0, len = lblen - nblen; i < len; i++) {
|
||||
this.lastBytes[i] = this.lastBytes[i + nblen];
|
||||
}
|
||||
|
||||
// add new bytes
|
||||
for (let i = 1; i <= nblen; i++) {
|
||||
this.lastBytes[lblen - i] = data[data.length - i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and removes message headers from the remaining body. We want to keep
|
||||
* headers separated until final delivery to be able to modify these
|
||||
*
|
||||
* @param {Buffer} data Next chunk of data
|
||||
* @return {Boolean} Returns true if headers are already found or false otherwise
|
||||
* @param data Next chunk of data
|
||||
* @return Returns true if headers are already found or false otherwise
|
||||
*/
|
||||
checkHeaders(data) {
|
||||
if (this.headersParsed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lblen = this.lastBytes.length;
|
||||
let headerPos = 0;
|
||||
for (let i = 0, len = this.lastBytes.length + data.length; i < len; i++) {
|
||||
let chr;
|
||||
if (i < lblen) {
|
||||
chr = this.lastBytes[i];
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
chr = data[i - lblen];
|
||||
}
|
||||
if (chr === 0x0a && i) {
|
||||
@@ -67,7 +60,8 @@ class MessageParser extends Transform {
|
||||
headerPos = i - lblen + 1;
|
||||
this.headerBytes += headerPos;
|
||||
break;
|
||||
} else if (pr1 === 0x0d && pr2 === 0x0a) {
|
||||
}
|
||||
else if (pr1 === 0x0d && pr2 === 0x0a) {
|
||||
this.headersParsed = true;
|
||||
headerPos = i - lblen + 1;
|
||||
this.headerBytes += headerPos;
|
||||
@@ -75,13 +69,12 @@ class MessageParser extends Transform {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.headersParsed) {
|
||||
this.headerChunks.push(data.slice(0, headerPos));
|
||||
this.rawHeaders = Buffer.concat(this.headerChunks, this.headerBytes);
|
||||
this.headerChunks = null;
|
||||
this.emit('headers', this.parseHeaders());
|
||||
if (data.length - 1 > headerPos) {
|
||||
if (data.length > headerPos) {
|
||||
const chunk = data.slice(headerPos);
|
||||
this.bodySize += chunk.length;
|
||||
// this would be the first chunk of data sent downstream
|
||||
@@ -89,66 +82,63 @@ class MessageParser extends Transform {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
this.headerBytes += data.length;
|
||||
this.headerChunks.push(data);
|
||||
|
||||
// store last 4 bytes to catch header break
|
||||
this.updateLastBytes(data);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (!chunk || !chunk.length) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
if (typeof chunk === 'string') {
|
||||
chunk = Buffer.from(chunk, encoding);
|
||||
}
|
||||
|
||||
let headersFound;
|
||||
|
||||
try {
|
||||
headersFound = this.checkHeaders(chunk);
|
||||
} catch (E) {
|
||||
}
|
||||
catch (E) {
|
||||
return callback(E);
|
||||
}
|
||||
|
||||
if (headersFound) {
|
||||
this.bodySize += chunk.length;
|
||||
this.push(chunk);
|
||||
}
|
||||
|
||||
setImmediate(callback);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_flush(callback) {
|
||||
if (this.headerChunks) {
|
||||
const chunk = Buffer.concat(this.headerChunks, this.headerBytes);
|
||||
this.bodySize += chunk.length;
|
||||
this.push(chunk);
|
||||
// no empty line was seen, so the message consists of headers only
|
||||
this.rawHeaders = Buffer.concat(this.headerChunks, this.headerBytes);
|
||||
this.headerChunks = null;
|
||||
this.emit('headers', this.parseHeaders());
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
parseHeaders() {
|
||||
const lines = (this.rawHeaders || '').toString().split(/\r?\n/);
|
||||
// the header bytes are kept as they are, one character per byte, so the
|
||||
// signature covers exactly the bytes the receiving side canonicalizes
|
||||
// Only SP and HTAB fold a line, and only they are trimmed from the field name, the
|
||||
// same whitespace the relaxed canonicalization in sign.ts works with
|
||||
const lines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/);
|
||||
for (let i = lines.length - 1; i > 0; i--) {
|
||||
if (/^\s/.test(lines[i])) {
|
||||
if (/^[ \t]/.test(lines[i])) {
|
||||
lines[i - 1] += '\n' + lines[i];
|
||||
lines.splice(i, 1);
|
||||
}
|
||||
}
|
||||
return lines
|
||||
.filter(line => line.trim())
|
||||
.filter(line => /[^ \t\r]/.test(line))
|
||||
.map(line => ({
|
||||
key: line.substr(0, line.indexOf(':')).trim().toLowerCase(),
|
||||
line
|
||||
}));
|
||||
key: line
|
||||
.substr(0, line.indexOf(':'))
|
||||
.replace(/^[ \t]+|[ \t]+$/g, '')
|
||||
.toLowerCase(),
|
||||
line
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MessageParser;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Transform } from 'node:stream';
|
||||
import crypto from 'node:crypto';
|
||||
/**
|
||||
* Options for the relaxed body hash stream
|
||||
*/
|
||||
export interface RelaxedBodyOptions {
|
||||
/** Hash algorithm for the body hash, defaults to sha256 */
|
||||
hashAlgo?: string | undefined;
|
||||
/** Collect the canonicalized body and emit it with the 'hash' event */
|
||||
debug?: boolean | undefined;
|
||||
}
|
||||
/**
|
||||
* Passes the message body through unchanged and hashes its relaxed
|
||||
* canonicalization (RFC 6376 section 3.4.4) on the side: whitespace at the end
|
||||
* of a line is dropped, runs of whitespace within a line become a single space,
|
||||
* every line ends with CRLF, empty lines at the end of the body are ignored and
|
||||
* a non-empty body always ends with CRLF. Bytes are canonicalized as they arrive,
|
||||
* so a line of any length costs constant memory.
|
||||
*/
|
||||
export default class RelaxedBody extends Transform {
|
||||
bodyHash: crypto.Hash;
|
||||
/** Bytes of the original body seen so far */
|
||||
byteLength: number;
|
||||
debug: boolean | undefined;
|
||||
constructor(options?: RelaxedBodyOptions);
|
||||
updateHash(chunk: Buffer, final?: boolean): void;
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
// streams through a message body and calculates relaxed body hash
|
||||
import { Transform } from 'node:stream';
|
||||
import crypto from 'node:crypto';
|
||||
const CHAR_CR = 0x0d;
|
||||
const CHAR_LF = 0x0a;
|
||||
const CHAR_SPACE = 0x20;
|
||||
const CHAR_TAB = 0x09;
|
||||
const CRLF = Buffer.from('\r\n');
|
||||
// a run of empty lines is hashed from this buffer in slices
|
||||
const EMPTY_LINES = Buffer.alloc(4096, CRLF);
|
||||
/**
|
||||
* Passes the message body through unchanged and hashes its relaxed
|
||||
* canonicalization (RFC 6376 section 3.4.4) on the side: whitespace at the end
|
||||
* of a line is dropped, runs of whitespace within a line become a single space,
|
||||
* every line ends with CRLF, empty lines at the end of the body are ignored and
|
||||
* a non-empty body always ends with CRLF. Bytes are canonicalized as they arrive,
|
||||
* so a line of any length costs constant memory.
|
||||
*/
|
||||
export default class RelaxedBody extends Transform {
|
||||
constructor(options) {
|
||||
super();
|
||||
options = options || {};
|
||||
this.bodyHash = crypto.createHash(options.hashAlgo || 'sha256');
|
||||
this.byteLength = 0;
|
||||
this.debug = options.debug;
|
||||
this._debugBody = options.debug ? [] : false;
|
||||
this._lineHasContent = false;
|
||||
this._pendingWsp = false;
|
||||
this._pendingCr = false;
|
||||
this._pendingEmptyLines = 0;
|
||||
}
|
||||
/** @internal */
|
||||
_hashCanonical(data) {
|
||||
if (!data.length) {
|
||||
return;
|
||||
}
|
||||
this.bodyHash.update(data);
|
||||
if (this._debugBody) {
|
||||
this._debugBody.push(Buffer.from(data));
|
||||
}
|
||||
}
|
||||
/** @internal */
|
||||
_hashEmptyLines() {
|
||||
while (this._pendingEmptyLines > 0) {
|
||||
const count = Math.min(this._pendingEmptyLines, EMPTY_LINES.length / 2);
|
||||
this._hashCanonical(EMPTY_LINES.subarray(0, count * 2));
|
||||
this._pendingEmptyLines -= count;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Writes a content byte, with the space a pending run of whitespace collapses to,
|
||||
* into the output buffer and returns the new write position. Kept a method rather
|
||||
* than a closure so the write position stays a plain local in the byte loop
|
||||
* @internal
|
||||
*/
|
||||
_emitContent(out, outPos, c) {
|
||||
if (!this._lineHasContent) {
|
||||
if (this._pendingEmptyLines) {
|
||||
// the first content byte of a line is where the empty lines before it
|
||||
// become part of the body, so hash what is in the buffer before them
|
||||
this._hashCanonical(out.subarray(0, outPos));
|
||||
outPos = 0;
|
||||
this._hashEmptyLines();
|
||||
}
|
||||
this._lineHasContent = true;
|
||||
}
|
||||
if (this._pendingWsp) {
|
||||
out[outPos++] = CHAR_SPACE;
|
||||
this._pendingWsp = false;
|
||||
}
|
||||
out[outPos++] = c;
|
||||
return outPos;
|
||||
}
|
||||
updateHash(chunk, final) {
|
||||
// every byte contributes itself at most once, plus a CR for a bare LF
|
||||
// and, once per chunk, a pending space and CR carried over from before
|
||||
const out = Buffer.allocUnsafe(chunk.length * 2 + 2);
|
||||
let outPos = 0;
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const c = chunk[i];
|
||||
if (c === CHAR_LF) {
|
||||
// end of line, a CR right before it and any trailing whitespace are dropped
|
||||
if (this._lineHasContent) {
|
||||
out[outPos++] = CHAR_CR;
|
||||
out[outPos++] = CHAR_LF;
|
||||
this._lineHasContent = false;
|
||||
}
|
||||
else {
|
||||
this._pendingEmptyLines++;
|
||||
}
|
||||
this._pendingWsp = false;
|
||||
this._pendingCr = false;
|
||||
continue;
|
||||
}
|
||||
if (this._pendingCr) {
|
||||
// not followed by LF, so the CR is content
|
||||
outPos = this._emitContent(out, outPos, CHAR_CR);
|
||||
this._pendingCr = false;
|
||||
}
|
||||
if (c === CHAR_CR) {
|
||||
this._pendingCr = true;
|
||||
}
|
||||
else if (c === CHAR_SPACE || c === CHAR_TAB) {
|
||||
this._pendingWsp = true;
|
||||
}
|
||||
else {
|
||||
outPos = this._emitContent(out, outPos, c);
|
||||
}
|
||||
}
|
||||
if (final && this._pendingCr) {
|
||||
// a CR at the very end of the body is content
|
||||
outPos = this._emitContent(out, outPos, CHAR_CR);
|
||||
this._pendingCr = false;
|
||||
}
|
||||
this._hashCanonical(out.subarray(0, outPos));
|
||||
}
|
||||
/** @internal */
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (!chunk || !chunk.length) {
|
||||
return callback();
|
||||
}
|
||||
if (typeof chunk === 'string') {
|
||||
chunk = Buffer.from(chunk, encoding);
|
||||
}
|
||||
this.updateHash(chunk);
|
||||
this.byteLength += chunk.length;
|
||||
this.push(chunk);
|
||||
callback();
|
||||
}
|
||||
/** @internal */
|
||||
_flush(callback) {
|
||||
this.updateHash(Buffer.alloc(0), true);
|
||||
if (this._lineHasContent) {
|
||||
// the body does not end with a line break, add one
|
||||
this._hashCanonical(CRLF);
|
||||
}
|
||||
this.emit('hash', this.bodyHash.digest('base64'), this.debug ? Buffer.concat(this._debugBody) : false);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { MessageParserHeaderLine } from './message-parser.js';
|
||||
/**
|
||||
* Private key accepted by crypto.Sign#sign: a PEM string, a Buffer, a KeyObject or an
|
||||
* object with the key and its passphrase
|
||||
*/
|
||||
export type DKIMPrivateKey = crypto.KeyLike | crypto.SignKeyObjectInput | crypto.SignPrivateKeyInput;
|
||||
/**
|
||||
* Options for the DKIM signature header generator
|
||||
*/
|
||||
export interface DKIMKey {
|
||||
/** Domain name to be signed for */
|
||||
domainName?: string | undefined;
|
||||
/** DKIM key selector to use */
|
||||
keySelector?: string | undefined;
|
||||
/** DKIM private key to use */
|
||||
privateKey?: DKIMPrivateKey | undefined;
|
||||
}
|
||||
export interface DKIMSignOptions extends DKIMKey {
|
||||
/** Colon separated list of header field names to sign, defaults to the RFC4871 list */
|
||||
headerFieldNames?: string | undefined;
|
||||
/** Colon separated list of header field names to leave out of the signature */
|
||||
skipFields?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* Canonicalized headers and the list of field names that went into them
|
||||
*/
|
||||
export interface DKIMRelaxedHeaders {
|
||||
/** Relaxed header lines, each terminated with CRLF, one character per byte ('binary' encoding) */
|
||||
headers: string;
|
||||
/** Colon separated list of the field names that were included */
|
||||
fieldNames: string;
|
||||
}
|
||||
/**
|
||||
* Returns DKIM signature header line
|
||||
*
|
||||
* @param headers Parsed headers object from MessageParser
|
||||
* @param bodyHash Base64 encoded hash of the message
|
||||
* @param options DKIM options
|
||||
* @param options.domainName Domain name to be signed for
|
||||
* @param options.keySelector DKIM key selector to use
|
||||
* @param options.privateKey DKIM private key to use
|
||||
* @return Complete header line
|
||||
*/
|
||||
declare function sign(headers: MessageParserHeaderLine[], hashAlgo: string, bodyHash: string, options?: DKIMSignOptions): string | false;
|
||||
declare namespace sign {
|
||||
var relaxedHeaders: (headers: MessageParserHeaderLine[], fieldNames?: string, skipFields?: string) => DKIMRelaxedHeaders;
|
||||
}
|
||||
export default sign;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user