Compare commits

...

4 Commits

Author SHA1 Message Date
Dawid Dziurla 67ce3558d6 node_modules: update (#322)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
2026-09-15 13:00:32 +02:00
dependabot[bot] 4a3d0f9ca3 build(deps): bump nodemailer from 10.0.3 to 10.0.8 (#320)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 10.0.3 to 10.0.8.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.0.3...v10.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 09:01:07 +02:00
dependabot[bot] 364e471101 build(deps): bump nodemailer from 10.0.1 to 10.0.3 (#319)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 10.0.1 to 10.0.3.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.0.1...v10.0.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 08:21:54 +02:00
dependabot[bot] 420aeba895 build(deps): bump nodemailer from 10.0.0 to 10.0.1 (#318)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 10.0.0 to 10.0.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.0.0...v10.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 11:50:05 +02:00
176 changed files with 25666 additions and 5545 deletions
+4 -4
View File
@@ -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": {
-6
View File
@@ -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
View File
@@ -1,9 +0,0 @@
'use strict';
module.exports = {
upgrade: true,
reject: [
// API changes break existing tests
'proxy'
]
};
-8
View File
@@ -1,8 +0,0 @@
node_modules
coverage
*.min.js
dist
build
.nyc_output
package-lock.json
CHANGELOG.md
-12
View File
@@ -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
View File
@@ -1,10 +0,0 @@
'use strict';
module.exports = {
printWidth: 160,
tabWidth: 4,
singleQuote: true,
endOfLine: 'lf',
trailingComma: 'none',
arrowParens: 'avoid'
};
-9
View File
@@ -1,9 +0,0 @@
{
"packages": {
".": {
"release-type": "node",
"package-name": "nodemailer",
"pull-request-title-pattern": "chore${scope}: release ${version} [skip-ci]"
}
}
}
+92
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,2 @@
export declare function detectMimeType(filename?: string | false): string;
export declare function detectExtension(mimeType?: string | false): string;
File diff suppressed because it is too large Load Diff
+330
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import { Transform } from 'node:stream';
export default class LastNewline extends Transform {
lastByte: number | false;
constructor();
}
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,3 @@
export declare const name = "nodemailer";
export declare const version = "10.0.8";
export declare const homepage = "https://nodemailer.com/";
+7
View File
@@ -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
View File
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
+73
View File
@@ -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 };
@@ -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
View File
@@ -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);
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
@@ -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
View File
@@ -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
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+125
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,2 @@
import type { WellKnownServiceDefinition } from './index.js';
export declare const services: Record<string, WellKnownServiceDefinition>;
+668
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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[];
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
@@ -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
View File
@@ -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[];
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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