@postalsys/email-text-tools 2.4.26 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +12 -0
- package/dist/cjs/index.d.ts +20 -0
- package/dist/cjs/index.js +31 -0
- package/dist/cjs/lib/commonjs.cjs +17 -0
- package/dist/cjs/lib/commonjs.d.cts +4 -0
- package/dist/cjs/lib/format-date.d.ts +15 -0
- package/dist/cjs/lib/format-date.js +60 -0
- package/dist/cjs/lib/html-guard.d.ts +3 -0
- package/dist/cjs/lib/html-guard.js +119 -0
- package/dist/cjs/lib/html-to-text.d.ts +1 -0
- package/dist/cjs/lib/html-to-text.js +49 -0
- package/dist/cjs/lib/inline-html.d.ts +19 -0
- package/dist/cjs/lib/inline-html.js +150 -0
- package/dist/cjs/lib/inline-style.d.ts +2 -0
- package/dist/cjs/lib/inline-style.js +42 -0
- package/dist/cjs/lib/inline-text.d.ts +3 -0
- package/dist/cjs/lib/inline-text.js +111 -0
- package/dist/cjs/lib/juice-worker.d.ts +1 -0
- package/dist/cjs/lib/juice-worker.js +19 -0
- package/dist/cjs/lib/mime-html.d.ts +34 -0
- package/dist/cjs/lib/mime-html.js +496 -0
- package/dist/cjs/lib/text-to-html.d.ts +1 -0
- package/dist/cjs/lib/text-to-html.js +182 -0
- package/dist/cjs/lib/worker-pool.d.ts +61 -0
- package/dist/cjs/lib/worker-pool.js +229 -0
- package/dist/cjs/package.json +3 -0
- package/dist/esm/index.d.ts +20 -0
- package/dist/esm/index.js +13 -0
- package/dist/esm/lib/commonjs.cjs +17 -0
- package/dist/esm/lib/commonjs.d.cts +4 -0
- package/dist/esm/lib/format-date.d.ts +15 -0
- package/{lib → dist/esm/lib}/format-date.js +8 -16
- package/dist/esm/lib/html-guard.d.ts +3 -0
- package/{lib → dist/esm/lib}/html-guard.js +8 -24
- package/dist/esm/lib/html-to-text.d.ts +1 -0
- package/{lib → dist/esm/lib}/html-to-text.js +2 -14
- package/dist/esm/lib/inline-html.d.ts +19 -0
- package/{lib → dist/esm/lib}/inline-html.js +17 -55
- package/dist/esm/lib/inline-style.d.ts +2 -0
- package/dist/esm/lib/inline-style.js +39 -0
- package/dist/esm/lib/inline-text.d.ts +3 -0
- package/{lib → dist/esm/lib}/inline-text.js +8 -31
- package/dist/esm/lib/juice-worker.d.ts +1 -0
- package/{lib → dist/esm/lib}/juice-worker.js +7 -8
- package/dist/esm/lib/mime-html.d.ts +34 -0
- package/{lib → dist/esm/lib}/mime-html.js +80 -161
- package/dist/esm/lib/text-to-html.d.ts +1 -0
- package/{lib → dist/esm/lib}/text-to-html.js +35 -63
- package/dist/esm/lib/worker-pool.d.ts +61 -0
- package/{lib → dist/esm/lib}/worker-pool.js +18 -64
- package/dist/esm/package.json +3 -0
- package/package.json +67 -10
- package/.github/VULNERABILITY_REPORT.yml +0 -75
- package/.github/workflows/release.yaml +0 -38
- package/.github/workflows/test.yaml +0 -35
- package/.ncurc.js +0 -28
- package/.prettierignore +0 -14
- package/.prettierrc.js +0 -10
- package/.release-please-manifest.json +0 -3
- package/CLAUDE.md +0 -105
- package/benchmark/benchmark.js +0 -348
- package/benchmark/concurrency-benchmark.js +0 -176
- package/benchmark/quick-benchmark.js +0 -200
- package/benchmark/quick-concurrency.js +0 -64
- package/benchmark/real-world-benchmark.js +0 -257
- package/docs/PERFORMANCE.md +0 -262
- package/eslint.config.js +0 -71
- package/examples/failing.html +0 -617
- package/examples/fixtures/message-1.json +0 -74
- package/examples/inline-html.js +0 -24
- package/examples/inline-text.js +0 -24
- package/examples/mime-html.js +0 -284
- package/index.js +0 -22
- package/release-please-config.json +0 -10
- package/test/comprehensive.test.js +0 -358
- package/test/containment.test.js +0 -128
- package/test/edge-cases.test.js +0 -62
- package/test/hardening.test.js +0 -504
- package/test/html-to-text.test.js +0 -253
- package/test/inline-html.test.js +0 -230
- package/test/inline-text.test.js +0 -204
- package/test/mime-html-extra.test.js +0 -39
- package/test/mime-html.test.js +0 -302
- package/test/text-to-html-extra.test.js +0 -42
- package/test/worker-pool-extra.test.js +0 -132
- package/test/worker-pool-regression.test.js +0 -128
- package/test/worker-pool.test.js +0 -282
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
name: '@postalsys/email-text-tools security vulnerability report'
|
|
2
|
-
description: 'Report an exploitable security vulnerability in @postalsys/email-text-tools'
|
|
3
|
-
|
|
4
|
-
body:
|
|
5
|
-
- type: markdown
|
|
6
|
-
attributes:
|
|
7
|
-
value: |
|
|
8
|
-
Before reporting, read our
|
|
9
|
-
[Security Policy](https://github.com/postalsys/email-text-tools/security/policy).
|
|
10
|
-
|
|
11
|
-
Reports must demonstrate a credible security impact.
|
|
12
|
-
Technically valid bugs without a realistic attack
|
|
13
|
-
scenario belong in the regular issue tracker.
|
|
14
|
-
|
|
15
|
-
- type: textarea
|
|
16
|
-
id: summary
|
|
17
|
-
attributes:
|
|
18
|
-
label: Vulnerability summary
|
|
19
|
-
description: Describe the vulnerability and affected versions.
|
|
20
|
-
validations:
|
|
21
|
-
required: true
|
|
22
|
-
|
|
23
|
-
- type: textarea
|
|
24
|
-
id: attacker
|
|
25
|
-
attributes:
|
|
26
|
-
label: Attacker capabilities
|
|
27
|
-
description: |
|
|
28
|
-
What can an attacker control without already having
|
|
29
|
-
privileged access to the application or its configuration?
|
|
30
|
-
validations:
|
|
31
|
-
required: true
|
|
32
|
-
min_length: 100
|
|
33
|
-
|
|
34
|
-
- type: textarea
|
|
35
|
-
id: exploit
|
|
36
|
-
attributes:
|
|
37
|
-
label: Realistic exploitation scenario
|
|
38
|
-
description: |
|
|
39
|
-
Describe how this can be exploited in a real application.
|
|
40
|
-
Explain the prerequisites and security boundary crossed.
|
|
41
|
-
validations:
|
|
42
|
-
required: true
|
|
43
|
-
min_length: 150
|
|
44
|
-
|
|
45
|
-
- type: textarea
|
|
46
|
-
id: poc
|
|
47
|
-
attributes:
|
|
48
|
-
label: Proof of concept
|
|
49
|
-
description: Provide reproducible steps or working code.
|
|
50
|
-
validations:
|
|
51
|
-
required: true
|
|
52
|
-
|
|
53
|
-
- type: textarea
|
|
54
|
-
id: impact
|
|
55
|
-
attributes:
|
|
56
|
-
label: Security impact
|
|
57
|
-
description: |
|
|
58
|
-
What can the attacker achieve? Who is affected?
|
|
59
|
-
Explain why this is a security vulnerability rather
|
|
60
|
-
than an ordinary bug.
|
|
61
|
-
validations:
|
|
62
|
-
required: true
|
|
63
|
-
min_length: 100
|
|
64
|
-
|
|
65
|
-
- type: checkboxes
|
|
66
|
-
id: confirmation
|
|
67
|
-
attributes:
|
|
68
|
-
label: Reporting requirements
|
|
69
|
-
options:
|
|
70
|
-
- label: I have read the Security Policy.
|
|
71
|
-
required: true
|
|
72
|
-
- label: I have verified the finding against a supported version.
|
|
73
|
-
required: true
|
|
74
|
-
- label: I have described a credible attacker-controlled scenario.
|
|
75
|
-
required: true
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
on:
|
|
2
|
-
push:
|
|
3
|
-
branches:
|
|
4
|
-
- main
|
|
5
|
-
|
|
6
|
-
permissions:
|
|
7
|
-
contents: write
|
|
8
|
-
pull-requests: write
|
|
9
|
-
issues: write
|
|
10
|
-
id-token: write
|
|
11
|
-
|
|
12
|
-
name: release
|
|
13
|
-
jobs:
|
|
14
|
-
release-please:
|
|
15
|
-
runs-on: ubuntu-latest
|
|
16
|
-
outputs:
|
|
17
|
-
release_created: ${{ steps.release.outputs.release_created }}
|
|
18
|
-
steps:
|
|
19
|
-
- uses: googleapis/release-please-action@v5
|
|
20
|
-
id: release
|
|
21
|
-
|
|
22
|
-
publish:
|
|
23
|
-
needs: release-please
|
|
24
|
-
if: ${{ needs.release-please.outputs.release_created }}
|
|
25
|
-
runs-on: ubuntu-latest
|
|
26
|
-
environment: npm-publish
|
|
27
|
-
permissions:
|
|
28
|
-
contents: read
|
|
29
|
-
id-token: write
|
|
30
|
-
steps:
|
|
31
|
-
- uses: actions/checkout@v7
|
|
32
|
-
- uses: actions/setup-node@v7
|
|
33
|
-
with:
|
|
34
|
-
node-version: 24
|
|
35
|
-
package-manager-cache: false
|
|
36
|
-
registry-url: 'https://registry.npmjs.org'
|
|
37
|
-
- run: npm ci
|
|
38
|
-
- run: npm publish --provenance --access public
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
name: Run tests
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
pull_request:
|
|
6
|
-
|
|
7
|
-
permissions:
|
|
8
|
-
contents: read
|
|
9
|
-
|
|
10
|
-
concurrency:
|
|
11
|
-
group: ${{ github.workflow }}-${{ github.ref }}
|
|
12
|
-
cancel-in-progress: true
|
|
13
|
-
|
|
14
|
-
jobs:
|
|
15
|
-
test:
|
|
16
|
-
strategy:
|
|
17
|
-
matrix:
|
|
18
|
-
node: [20.x, 22.x, 24.x, latest]
|
|
19
|
-
os: [ubuntu-latest]
|
|
20
|
-
runs-on: ${{ matrix.os }}
|
|
21
|
-
timeout-minutes: 10
|
|
22
|
-
steps:
|
|
23
|
-
- uses: actions/checkout@v7
|
|
24
|
-
- name: Use Node.js ${{ matrix.node }}
|
|
25
|
-
uses: actions/setup-node@v7
|
|
26
|
-
with:
|
|
27
|
-
node-version: ${{ matrix.node }}
|
|
28
|
-
cache: npm
|
|
29
|
-
- run: npm ci
|
|
30
|
-
- run: npm run lint
|
|
31
|
-
- run: npm run format:check
|
|
32
|
-
- run: npm test
|
|
33
|
-
- name: CommonJS dependency graph (require(esm) disabled)
|
|
34
|
-
if: matrix.node != '20.x'
|
|
35
|
-
run: npm run test:cjs
|
package/.ncurc.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
module.exports = {
|
|
4
|
-
upgrade: true,
|
|
5
|
-
reject: [
|
|
6
|
-
// Block package upgrades that moved to ESM
|
|
7
|
-
'nanoid',
|
|
8
|
-
'jsdom', // parse5 dependency in 27.0.1 is ESM only
|
|
9
|
-
'juice', // 12+ is pure ESM (requires Node >=22.12) and breaks pkg builds
|
|
10
|
-
// node-html-parser was rejected here on the grounds that 8+ depends on the pure-ESM entities@8.
|
|
11
|
-
// That is not how it ships: node-html-parser 9 bundles entities inline into dist/index.cjs and
|
|
12
|
-
// never require()s the package, so the ESM copy is installed but never loaded. Unpinned 2026-08,
|
|
13
|
-
// which also drops a duplicate parser from EmailEngine's tree (@postalsys/email-content already
|
|
14
|
-
// pulls 9.x). Keep it unpinned unless a release starts require()ing entities for real.
|
|
15
|
-
// 6.0.0 changed the CommonJS export shape (named bindings instead of the constructor) and,
|
|
16
|
-
// more importantly, stopped linkifying bare domains such as 'example.com' or 'www.example.org'
|
|
17
|
-
// by default, which silently breaks autolinking in plain text emails. Stay on 5.x.
|
|
18
|
-
'linkify-it',
|
|
19
|
-
// 2.0.0 is ESM-only (exports only src/he.mjs) and requires Node >=22, so require('he') throws
|
|
20
|
-
// and pkg cannot bundle it. 1.2.0 is the last CommonJS release; EmailEngine and mailparser
|
|
21
|
-
// hold it there too, and a different copy here would be a second he in their trees
|
|
22
|
-
'he',
|
|
23
|
-
// @asamuzakjp/css-color >=4.1.2 pulls in @csstools/* which are pure ESM, so require(jsdom) fails
|
|
24
|
-
// without require(esm) (jsdom -> cssstyle -> @asamuzakjp/css-color). Held at 4.1.1 through
|
|
25
|
-
// package.json "overrides"; overrides do not propagate to consumers, EmailEngine carries its own.
|
|
26
|
-
'@asamuzakjp/css-color'
|
|
27
|
-
]
|
|
28
|
-
};
|
package/.prettierignore
DELETED
package/.prettierrc.js
DELETED
package/CLAUDE.md
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Commands
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm test # run the full suite (node --test test/*.test.js)
|
|
9
|
-
npm run test:cjs # the suite with require(esm) disabled (CommonJS graph guard)
|
|
10
|
-
npm run test:coverage # with experimental coverage
|
|
11
|
-
npm run test:watch # watch mode
|
|
12
|
-
npm run lint # eslint .
|
|
13
|
-
npm run lint:fix # eslint . --fix
|
|
14
|
-
npm run format # prettier --write .
|
|
15
|
-
npm run format:check # prettier --check .
|
|
16
|
-
npm run update # rm -rf node_modules package-lock.json && ncu -u && npm install
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Run a single test file or a single test by name (uses Node's built-in test runner):
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
node --test test/mime-html.test.js
|
|
23
|
-
node --test --test-name-pattern="should process HTML in worker thread"
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Benchmarks are not wired into npm scripts; run them directly, e.g. `node benchmark/benchmark.js` (see `benchmark/` and `docs/PERFORMANCE.md`).
|
|
27
|
-
|
|
28
|
-
## Critical constraints
|
|
29
|
-
|
|
30
|
-
- **CommonJS only, Node 20 minimum.** See Platform constraints below. All source uses `require`/`module.exports` with a global `'use strict'` (ESLint enforces `sourceType: 'commonjs'`). The constraint is about the module format, not the syntax level: ESLint parses `ecmaVersion: 2022`, which every supported Node (>=20) and pkg target runs natively. The `require('juice').default || require('juice')` idiom at the require sites is a deliberate CJS-interop fallback, not dead code.
|
|
31
|
-
- **Releases are automated via release-please.** Version in `package.json` and `CHANGELOG.md` are owned by the bot, do not bump them by hand. Conventional Commits drive it (`fix:` -> patch, `feat:` -> minor); `fix:`/`feat:` commits land directly on `main` and release-please opens the release PR. See Conventions below for the full commit rules.
|
|
32
|
-
- **ESLint uses the shared flat config** (`eslint.config.js`, ESLint 10). It is the CommonJS template shared across the Postal Systems / Nodemailer packages (eslint:recommended plus the retired eslint-config-nodemailer rules, eslint-config-prettier last); keep it identical to the other repos except for the "Repo-specific" block at the end, where each override carries a one-line reason. The only override here turns `no-await-in-loop` off for `benchmark/`, which awaits sequentially on purpose. CI (`.github/workflows/test.yaml`) runs lint, `format:check` and the tests on Node 20, 22 and 24, plus `npm run test:cjs` on 22 and 24.
|
|
33
|
-
|
|
34
|
-
## Platform constraints
|
|
35
|
-
|
|
36
|
-
- **Node.js 20 is the minimum supported version** (`engines.node` is `>=20.0.0`). Runtime code, tests and tooling must work on Node 20, 22 and 24.
|
|
37
|
-
- **Everything that reaches runtime must be genuinely CommonJS-loadable.** EmailEngine consumes this library and ships as a single-file binary built with [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg), which snapshots a CommonJS module graph and cannot load ES modules. `require()` of this package and of every runtime dependency must work with `--no-experimental-require-module`: Node 22+ (and 20.19) enable require(esm) by default, which hides an ESM-only dependency until the pkg binary fails. A CommonJS entry that only wraps a dynamic `import()` of ESM code does not count.
|
|
38
|
-
- **How to check:** `npm run test:cjs` runs the whole suite under `NODE_OPTIONS=--no-experimental-require-module`; CI runs it on Node 22 and 24.
|
|
39
|
-
- **Pins:** never add or upgrade to an ESM-only dependency. Hold the last CommonJS major in `.ncurc.js` (`reject` or `target`) with a one-line reason; `npm run update` honors it. Currently rejected: `nanoid`, `jsdom` (27.0.1 pulls an ESM-only parse5), `juice` (12+ is pure ESM and needs Node >=22.12), `linkify-it` (6.0.0 changed exports and stopped linkifying bare domains), `he` (2.0.0 is ESM-only), `@asamuzakjp/css-color`.
|
|
40
|
-
- **The jsdom chain:** jsdom -> cssstyle -> `@asamuzakjp/css-color` >=4.1.2 pulls in `@csstools/*` v4, which are pure ESM. `package.json` `"overrides"` holds `@asamuzakjp/css-color` at 4.1.1 so this repo's own tests and CI exercise the CommonJS graph. Overrides do not propagate to consumers: an app installing this package resolves css-color itself, and EmailEngine carries its own identical override and `.ncurc.js` reject.
|
|
41
|
-
|
|
42
|
-
## Conventions
|
|
43
|
-
|
|
44
|
-
These mirror the conventions used across Postal Systems projects (see `../emailengine`).
|
|
45
|
-
|
|
46
|
-
**Git commits**
|
|
47
|
-
|
|
48
|
-
- Use Conventional Commits: `fix:` (patch release), `feat:` (minor release), and `chore:`/`build:`/`ci:`/`docs:`/`refactor:`/`style:`/`test:` for changes that should not trigger a release. release-please derives the version and changelog from these prefixes.
|
|
49
|
-
- Do **not** list Claude or any AI assistant as a co-author / co-contributor in commit messages or PR descriptions (no `Co-Authored-By` trailer).
|
|
50
|
-
- Append `[skip ci]` to commits that do not change runtime behavior (docs, comments, CI/workflow tweaks, formatting) to avoid spending CI minutes. Exception: never add `[skip ci]` to a `fix:` or `feat:` commit, those must run so the release workflow triggers.
|
|
51
|
-
|
|
52
|
-
**Code & docs style**
|
|
53
|
-
|
|
54
|
-
- No emojis anywhere in code or documentation, printable ASCII only.
|
|
55
|
-
- Use a single hyphen-minus (`-`) as a dash in user-facing strings and docs. Never use double hyphens (`--`), em dashes, or en dashes.
|
|
56
|
-
|
|
57
|
-
**Before committing code changes**
|
|
58
|
-
|
|
59
|
-
1. `npm run format` and `npm run lint`
|
|
60
|
-
2. `npm test`
|
|
61
|
-
3. For non-trivial changes, consider `/simplify` (quality cleanup) and `/security-review`. This library sanitizes untrusted HTML, so anything touching the `mimeHtml` pipeline is security-sensitive.
|
|
62
|
-
|
|
63
|
-
**After pushing**
|
|
64
|
-
|
|
65
|
-
- Check the workflow runs for the push (`gh run list --branch <branch>`) and report their status. If a run fails for an unrelated/infrastructure reason (checkout reporting "account suspended", HTTP 403, other auth errors), check <https://www.githubstatus.com/> for an active GitHub incident before assuming the change caused it.
|
|
66
|
-
|
|
67
|
-
## Architecture
|
|
68
|
-
|
|
69
|
-
A small CommonJS library exposing five text-transformation functions for email content, all re-exported from `index.js` (`mimeHtml` through a lazy getter, so jsdom only loads on first use):
|
|
70
|
-
|
|
71
|
-
| Export | File | Purpose |
|
|
72
|
-
| ------------ | --------------------- | ----------------------------------------------------------- |
|
|
73
|
-
| `textToHtml` | `lib/text-to-html.js` | plain text -> HTML (linkified, quote-aware) |
|
|
74
|
-
| `htmlToText` | `lib/html-to-text.js` | HTML -> plain text |
|
|
75
|
-
| `inlineHtml` | `lib/inline-html.js` | build reply/forward HTML body with quoted original |
|
|
76
|
-
| `inlineText` | `lib/inline-text.js` | build reply/forward plain-text body with quoted original |
|
|
77
|
-
| `mimeHtml` | `lib/mime-html.js` | sanitize + inline-CSS arbitrary email HTML for safe display |
|
|
78
|
-
|
|
79
|
-
`lib/html-guard.js` holds what `mimeHtml` and `inlineHtml` share before they parse anything: the nesting-depth estimate (`exceedsNestingDepth`, `MAX_NESTING_DEPTH`) and the plain-text fallback projection (`fallbackText`). It does not load jsdom, so `inline-html.js` can require it eagerly.
|
|
80
|
-
|
|
81
|
-
### `mimeHtml`: the core, and where the complexity lives
|
|
82
|
-
|
|
83
|
-
Pipeline (same for sync and async): **DOMPurify sanitize (whole document)** -> **pre-filter problematic CSS** -> **juice (inline `<style>` into element `style=` attributes)** -> **DOMPurify sanitize again (return DOM body)** -> strip forbidden style properties / size constraints, force `target="_blank"` on links, wrap in a styled `<div>`. When the input has only `text` (no `html`), it is first run through `textToHtml` and given a default `<style>` block (`INLINE_STYLE_BLOCK`).
|
|
84
|
-
|
|
85
|
-
The non-obvious part is **juice hang protection**. Certain modern CSS selectors (`:is()`, `:where()`, `:has()`, `:not(... : ...)`) can make `juice` hang. Two layers defend against this:
|
|
86
|
-
|
|
87
|
-
1. A linear pre-filter (`filterCss()`, split on `}`, never a regex: the old regex backtracked catastrophically) strips rules using those selectors from head `<style>` tags before juice runs, after capping style text at 256 KB. Input nested deeper than `MAX_NESTING_DEPTH` is not parsed (jsdom tree building is quadratic in depth) and, like any pipeline error, renders the escaped-text `fallbackHtml()`.
|
|
88
|
-
2. The async path additionally runs juice inside a **worker thread with a timeout** as a safety net for unknown edge cases. On timeout/failure it falls back to returning the HTML without inlined CSS (unless `fallbackOnError: false`).
|
|
89
|
-
|
|
90
|
-
`mimeHtml` exposes three forms. Note the default is sync for backward compatibility, but only the async form is fully hang-protected:
|
|
91
|
-
|
|
92
|
-
- `mimeHtml(message)` / `mimeHtml.sync(message)`: synchronous, runs juice in-process (can still hang on unknown selectors).
|
|
93
|
-
- `mimeHtml.async(message, options)`: async, juice in a worker pool with timeout. Options: `timeout`, `useWorkerPool`, `fallbackOnError`, `minWorkers`/`maxWorkers`/`idleTimeout`.
|
|
94
|
-
- `mimeHtml.closeWorkerPool()` / `mimeHtml.getWorkerPoolStats()`: manage the lazily-created global worker pool.
|
|
95
|
-
|
|
96
|
-
`lib/worker-pool.js` (`WorkerPool`) manages a min/max pool of `lib/juice-worker.js` workers with task queueing, per-task timeouts, worker replacement on error/timeout, and idle-worker reaping. `lib/juice-worker.js` is a tiny worker entry that just runs `juice(html)` and posts the result back.
|
|
97
|
-
|
|
98
|
-
### Other modules
|
|
99
|
-
|
|
100
|
-
- `text-to-html.js` parses plain text into a nested tree of `text`/`quote` nodes (by `>` prefixes) so multi-level email quotes become nested `<blockquote class="ee-block-N">`. Linkification uses `linkify-it` + full `tlds` list (plus `.onion` and a `git:` alias).
|
|
101
|
-
- `inline-html.js` / `inline-text.js` produce Apple-Mail-style reply/forward bodies, formatting From/Subject/Date/To/Cc/Bcc headers and quoting the original (HTML via `<blockquote>`, text via `> ` prefixes). Dates use `Intl.DateTimeFormat` via `lib/format-date.js`, honoring `options.locale` (unsupported -> 'en') and `options.tz` (unknown -> UTC).
|
|
102
|
-
|
|
103
|
-
### Testing notes
|
|
104
|
-
|
|
105
|
-
Tests use Node's built-in `node:test` runner. Suites that exercise `mimeHtml.async` or `WorkerPool` **must call `await mimeHtml.closeWorkerPool()` (or close the pool) in an `after()` hook**, otherwise live workers keep the process alive and the run hangs.
|
package/benchmark/benchmark.js
DELETED
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const mimeHtml = require('../lib/mime-html');
|
|
7
|
-
|
|
8
|
-
// Test data sets
|
|
9
|
-
const testCases = {
|
|
10
|
-
simple: {
|
|
11
|
-
name: 'Simple HTML',
|
|
12
|
-
html: `
|
|
13
|
-
<!DOCTYPE html>
|
|
14
|
-
<html>
|
|
15
|
-
<head>
|
|
16
|
-
<style>
|
|
17
|
-
body { font-family: Arial; }
|
|
18
|
-
p { color: blue; }
|
|
19
|
-
</style>
|
|
20
|
-
</head>
|
|
21
|
-
<body>
|
|
22
|
-
<h1>Hello World</h1>
|
|
23
|
-
<p>This is a simple test email.</p>
|
|
24
|
-
</body>
|
|
25
|
-
</html>
|
|
26
|
-
`
|
|
27
|
-
},
|
|
28
|
-
|
|
29
|
-
moderate: {
|
|
30
|
-
name: 'Moderate HTML with inline styles',
|
|
31
|
-
html: `
|
|
32
|
-
<!DOCTYPE html>
|
|
33
|
-
<html>
|
|
34
|
-
<head>
|
|
35
|
-
<style>
|
|
36
|
-
body { font-family: Arial, sans-serif; background: #f0f0f0; }
|
|
37
|
-
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
38
|
-
h1 { color: #333; border-bottom: 2px solid #007bff; }
|
|
39
|
-
p { line-height: 1.6; color: #666; }
|
|
40
|
-
.button { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; }
|
|
41
|
-
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; }
|
|
42
|
-
</style>
|
|
43
|
-
</head>
|
|
44
|
-
<body>
|
|
45
|
-
<div class="container">
|
|
46
|
-
<h1>Newsletter Title</h1>
|
|
47
|
-
<p>This is a moderately complex email template with various styles.</p>
|
|
48
|
-
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
|
49
|
-
<a href="#" class="button">Call to Action</a>
|
|
50
|
-
<div class="footer">
|
|
51
|
-
<p>Footer content here</p>
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
</body>
|
|
55
|
-
</html>
|
|
56
|
-
`
|
|
57
|
-
},
|
|
58
|
-
|
|
59
|
-
complex: {
|
|
60
|
-
name: 'Complex HTML with media queries',
|
|
61
|
-
html: `
|
|
62
|
-
<!DOCTYPE html>
|
|
63
|
-
<html>
|
|
64
|
-
<head>
|
|
65
|
-
<style>
|
|
66
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
67
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; }
|
|
68
|
-
.wrapper { max-width: 600px; margin: 0 auto; }
|
|
69
|
-
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; }
|
|
70
|
-
.header h1 { color: white; font-size: 32px; }
|
|
71
|
-
.content { padding: 40px 20px; background: white; }
|
|
72
|
-
.card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
|
73
|
-
.card h2 { color: #333; margin-bottom: 10px; }
|
|
74
|
-
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
|
|
75
|
-
@media only screen and (max-width: 600px) {
|
|
76
|
-
.grid { grid-template-columns: 1fr; }
|
|
77
|
-
.header h1 { font-size: 24px; }
|
|
78
|
-
}
|
|
79
|
-
table { width: 100%; border-collapse: collapse; }
|
|
80
|
-
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
|
|
81
|
-
.button { display: inline-block; padding: 12px 24px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; }
|
|
82
|
-
.footer { background: #f8f9fa; padding: 30px 20px; text-align: center; color: #6c757d; }
|
|
83
|
-
</style>
|
|
84
|
-
</head>
|
|
85
|
-
<body>
|
|
86
|
-
<div class="wrapper">
|
|
87
|
-
<div class="header">
|
|
88
|
-
<h1>Complex Email Template</h1>
|
|
89
|
-
</div>
|
|
90
|
-
<div class="content">
|
|
91
|
-
<div class="card">
|
|
92
|
-
<h2>Feature One</h2>
|
|
93
|
-
<p>Description of the first feature with detailed information.</p>
|
|
94
|
-
</div>
|
|
95
|
-
<div class="grid">
|
|
96
|
-
<div class="card">
|
|
97
|
-
<h2>Item 1</h2>
|
|
98
|
-
<p>Grid item content</p>
|
|
99
|
-
</div>
|
|
100
|
-
<div class="card">
|
|
101
|
-
<h2>Item 2</h2>
|
|
102
|
-
<p>Grid item content</p>
|
|
103
|
-
</div>
|
|
104
|
-
</div>
|
|
105
|
-
<table>
|
|
106
|
-
<tr><th>Product</th><th>Price</th><th>Quantity</th></tr>
|
|
107
|
-
<tr><td>Item A</td><td>$10</td><td>2</td></tr>
|
|
108
|
-
<tr><td>Item B</td><td>$20</td><td>1</td></tr>
|
|
109
|
-
</table>
|
|
110
|
-
<a href="#" class="button">Shop Now</a>
|
|
111
|
-
</div>
|
|
112
|
-
<div class="footer">
|
|
113
|
-
<p>Copyright 2024. All rights reserved.</p>
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
</body>
|
|
117
|
-
</html>
|
|
118
|
-
`
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
problematic: {
|
|
122
|
-
name: 'HTML with problematic CSS (modern selectors)',
|
|
123
|
-
html: `
|
|
124
|
-
<!DOCTYPE html>
|
|
125
|
-
<html>
|
|
126
|
-
<head>
|
|
127
|
-
<style>
|
|
128
|
-
body { font-family: system-ui, sans-serif; }
|
|
129
|
-
/* Modern CSS that might cause issues */
|
|
130
|
-
.container:is(.active, .highlighted) { background: yellow; }
|
|
131
|
-
.item:where(.primary) { color: blue; }
|
|
132
|
-
.parent:has(> .child) { border: 1px solid red; }
|
|
133
|
-
/* Standard CSS */
|
|
134
|
-
h1 { color: #333; }
|
|
135
|
-
p { line-height: 1.6; }
|
|
136
|
-
.box:not(.special) { padding: 10px; }
|
|
137
|
-
</style>
|
|
138
|
-
</head>
|
|
139
|
-
<body>
|
|
140
|
-
<div class="container active">
|
|
141
|
-
<h1>Test with Modern CSS</h1>
|
|
142
|
-
<div class="parent">
|
|
143
|
-
<div class="child">Child element</div>
|
|
144
|
-
</div>
|
|
145
|
-
<p class="item primary">This uses modern selectors</p>
|
|
146
|
-
<div class="box">Regular box</div>
|
|
147
|
-
<div class="box special">Special box</div>
|
|
148
|
-
</div>
|
|
149
|
-
</body>
|
|
150
|
-
</html>
|
|
151
|
-
`
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
// Load the failing.html if it exists
|
|
156
|
-
try {
|
|
157
|
-
const failingHtml = fs.readFileSync(path.join(__dirname, '../examples/failing.html'), 'utf8');
|
|
158
|
-
testCases.realWorld = {
|
|
159
|
-
name: 'Real-world problematic HTML',
|
|
160
|
-
html: failingHtml
|
|
161
|
-
};
|
|
162
|
-
} catch (_err) {
|
|
163
|
-
// File doesn't exist, skip it
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Benchmark runner
|
|
167
|
-
class Benchmark {
|
|
168
|
-
constructor() {
|
|
169
|
-
this.results = [];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async runTest(name, fn, iterations = 100) {
|
|
173
|
-
const times = [];
|
|
174
|
-
const errors = [];
|
|
175
|
-
|
|
176
|
-
// Warmup
|
|
177
|
-
for (let i = 0; i < 5; i++) {
|
|
178
|
-
try {
|
|
179
|
-
await fn();
|
|
180
|
-
} catch (_err) {
|
|
181
|
-
// Ignore warmup errors
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Actual test
|
|
186
|
-
for (let i = 0; i < iterations; i++) {
|
|
187
|
-
const start = process.hrtime.bigint();
|
|
188
|
-
try {
|
|
189
|
-
await fn();
|
|
190
|
-
const end = process.hrtime.bigint();
|
|
191
|
-
times.push(Number(end - start) / 1000000); // Convert to milliseconds
|
|
192
|
-
} catch (err) {
|
|
193
|
-
errors.push(err.message);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (times.length > 0) {
|
|
198
|
-
times.sort((a, b) => a - b);
|
|
199
|
-
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
|
200
|
-
const median = times[Math.floor(times.length / 2)];
|
|
201
|
-
const min = times[0];
|
|
202
|
-
const max = times[times.length - 1];
|
|
203
|
-
const p95 = times[Math.floor(times.length * 0.95)];
|
|
204
|
-
const p99 = times[Math.floor(times.length * 0.99)];
|
|
205
|
-
|
|
206
|
-
return {
|
|
207
|
-
name,
|
|
208
|
-
iterations,
|
|
209
|
-
successful: times.length,
|
|
210
|
-
failed: errors.length,
|
|
211
|
-
avg: avg.toFixed(2),
|
|
212
|
-
median: median.toFixed(2),
|
|
213
|
-
min: min.toFixed(2),
|
|
214
|
-
max: max.toFixed(2),
|
|
215
|
-
p95: p95.toFixed(2),
|
|
216
|
-
p99: p99.toFixed(2),
|
|
217
|
-
errors: errors.length > 0 ? errors[0] : null
|
|
218
|
-
};
|
|
219
|
-
} else {
|
|
220
|
-
return {
|
|
221
|
-
name,
|
|
222
|
-
iterations,
|
|
223
|
-
successful: 0,
|
|
224
|
-
failed: errors.length,
|
|
225
|
-
errors: errors[0] || 'All iterations failed'
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
formatResults(results) {
|
|
231
|
-
console.log('\n' + '='.repeat(80));
|
|
232
|
-
console.log(`Test Case: ${results.testCase}`);
|
|
233
|
-
console.log('='.repeat(80));
|
|
234
|
-
|
|
235
|
-
const table = [];
|
|
236
|
-
results.tests.forEach(test => {
|
|
237
|
-
if (test.successful > 0) {
|
|
238
|
-
table.push({
|
|
239
|
-
Method: test.name,
|
|
240
|
-
'Success Rate': `${test.successful}/${test.iterations}`,
|
|
241
|
-
'Avg (ms)': test.avg,
|
|
242
|
-
'Median (ms)': test.median,
|
|
243
|
-
'Min (ms)': test.min,
|
|
244
|
-
'Max (ms)': test.max,
|
|
245
|
-
'P95 (ms)': test.p95,
|
|
246
|
-
'P99 (ms)': test.p99
|
|
247
|
-
});
|
|
248
|
-
} else {
|
|
249
|
-
table.push({
|
|
250
|
-
Method: test.name,
|
|
251
|
-
'Success Rate': `${test.successful}/${test.iterations}`,
|
|
252
|
-
Error: test.errors
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
console.table(table);
|
|
258
|
-
|
|
259
|
-
// Calculate speedup if both succeeded
|
|
260
|
-
const syncTest = results.tests.find(t => t.name === 'Sync');
|
|
261
|
-
const asyncTest = results.tests.find(t => t.name === 'Async (with pool)');
|
|
262
|
-
|
|
263
|
-
if (syncTest && asyncTest && syncTest.successful > 0 && asyncTest.successful > 0) {
|
|
264
|
-
const speedup = (parseFloat(syncTest.avg) / parseFloat(asyncTest.avg)).toFixed(2);
|
|
265
|
-
if (speedup > 1) {
|
|
266
|
-
console.log(`\nAsync is ${speedup}x faster than Sync`);
|
|
267
|
-
} else {
|
|
268
|
-
console.log(`\nSync is ${(1 / speedup).toFixed(2)}x faster than Async`);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async runBenchmarks() {
|
|
274
|
-
console.log('Starting Mime HTML Benchmarks...');
|
|
275
|
-
console.log('================================\n');
|
|
276
|
-
|
|
277
|
-
for (const [_key, testCase] of Object.entries(testCases)) {
|
|
278
|
-
const results = {
|
|
279
|
-
testCase: testCase.name,
|
|
280
|
-
tests: []
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
// Test sync version
|
|
284
|
-
results.tests.push(await this.runTest('Sync', () => Promise.resolve(mimeHtml.sync({ html: testCase.html })), 100));
|
|
285
|
-
|
|
286
|
-
// Test async version with pool
|
|
287
|
-
results.tests.push(
|
|
288
|
-
await this.runTest(
|
|
289
|
-
'Async (with pool)',
|
|
290
|
-
async () =>
|
|
291
|
-
await mimeHtml.async(
|
|
292
|
-
{ html: testCase.html },
|
|
293
|
-
{
|
|
294
|
-
useWorkerPool: true,
|
|
295
|
-
timeout: 5000,
|
|
296
|
-
minWorkers: 2,
|
|
297
|
-
maxWorkers: 4
|
|
298
|
-
}
|
|
299
|
-
),
|
|
300
|
-
100
|
|
301
|
-
)
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
// Test async version without pool
|
|
305
|
-
results.tests.push(
|
|
306
|
-
await this.runTest(
|
|
307
|
-
'Async (no pool)',
|
|
308
|
-
async () =>
|
|
309
|
-
await mimeHtml.async(
|
|
310
|
-
{ html: testCase.html },
|
|
311
|
-
{
|
|
312
|
-
useWorkerPool: false,
|
|
313
|
-
timeout: 5000
|
|
314
|
-
}
|
|
315
|
-
),
|
|
316
|
-
100
|
|
317
|
-
)
|
|
318
|
-
);
|
|
319
|
-
|
|
320
|
-
this.formatResults(results);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Show worker pool stats
|
|
324
|
-
const stats = mimeHtml.getWorkerPoolStats();
|
|
325
|
-
if (stats) {
|
|
326
|
-
console.log('\n' + '='.repeat(80));
|
|
327
|
-
console.log('Worker Pool Stats:');
|
|
328
|
-
console.log('='.repeat(80));
|
|
329
|
-
console.table([stats]);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Clean up
|
|
333
|
-
await mimeHtml.closeWorkerPool();
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Run benchmarks
|
|
338
|
-
const benchmark = new Benchmark();
|
|
339
|
-
benchmark
|
|
340
|
-
.runBenchmarks()
|
|
341
|
-
.then(() => {
|
|
342
|
-
console.log('\nBenchmark complete!');
|
|
343
|
-
process.exit(0);
|
|
344
|
-
})
|
|
345
|
-
.catch(err => {
|
|
346
|
-
console.error('Benchmark failed:', err);
|
|
347
|
-
process.exit(1);
|
|
348
|
-
});
|