@postalsys/email-text-tools 2.6.4 → 3.0.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.0.0](https://github.com/postalsys/email-text-tools/compare/v2.6.4...v3.0.0) (2026-11-15)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * Node.js 22.22.2, 24.15.0 or newer is required (engines.node ^22.22.2 || ^24.15.0 || >=26.0.0). On Node 20 the jsdom bundle throws when mimeHtml is first loaded.
9
+
10
+ ### Bug Fixes
11
+
12
+ * stop retaining every rendered message, move to jsdom 31 and juice 12 ([dc6028d](https://github.com/postalsys/email-text-tools/commit/dc6028df0fa342c6504a54267a4d7ac1d7734729))
13
+
3
14
  ## [2.6.4](https://github.com/postalsys/email-text-tools/compare/v2.6.3...v2.6.4) (2026-11-10)
4
15
 
5
16
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Email text tools
4
4
 
5
- Ships ES module and CommonJS builds with type declarations, Node.js 20 or newer:
5
+ Ships ES module and CommonJS builds with type declarations, Node.js 22.22.2, 24.15.0 or newer:
6
6
 
7
7
  ```js
8
8
  import { mimeHtml, textToHtml } from '@postalsys/email-text-tools';
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // jsdom, loaded from the CommonJS bundle scripts/bundle-jsdom.js builds into dist/vendor/jsdom.cjs.
2
+ // jsdom, loaded from the CommonJS bundle scripts/bundle-vendor.js builds into dist/vendor/jsdom.cjs.
3
3
  // jsdom pulls in ES module only packages (parse5 among them), which the CommonJS build must not
4
4
  // require(), so the package itself is a devDependency and only its bundle ships. This file compiles
5
5
  // to jsdom.cjs in dist/cjs/lib and dist/esm/lib alike, both two levels below dist/vendor.
@@ -4,13 +4,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const node_worker_threads_1 = __importDefault(require("node:worker_threads"));
7
- // Pinned to juice 11.x: juice 12+ is pure ESM (requires Node >=22.12) and breaks pkg builds.
8
- const juice_1 = __importDefault(require("juice"));
7
+ // juice comes from the CommonJS bundle in dist/vendor, see juice.cts
8
+ const juice_cjs_1 = require("./juice.cjs");
9
9
  // Runs as a worker thread (see worker-pool.ts), the parent port is always there
10
10
  const parentPort = node_worker_threads_1.default.parentPort;
11
11
  parentPort.on('message', (html) => {
12
12
  try {
13
- const result = (0, juice_1.default)(html);
13
+ const result = (0, juice_cjs_1.juice)(html);
14
14
  parentPort.postMessage({ success: true, result });
15
15
  }
16
16
  catch (error) {
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ // juice, loaded from the CommonJS bundle scripts/bundle-vendor.js builds into dist/vendor/juice.cjs.
3
+ // juice 12 is an ES module only package, which the CommonJS build must not require(), so the
4
+ // package itself is a devDependency and only its bundle ships. This file compiles to juice.cjs in
5
+ // dist/cjs/lib and dist/esm/lib alike, both two levels below dist/vendor.
6
+ //
7
+ // Not part of commonjs.cts on purpose: that module is loaded eagerly, and juice must only load
8
+ // when mimeHtml or the juice worker is first used.
9
+ //
10
+ // The literal require() path is what @yao-pkg/pkg follows to put the bundle into a binary.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.juice = void 0;
13
+ // A named export, like jsdom.cts: the ES module build reads it through the CommonJS export names
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ exports.juice = require('../../vendor/juice.cjs');
@@ -0,0 +1,2 @@
1
+ import type JuiceModule from 'juice';
2
+ export declare const juice: typeof JuiceModule;
@@ -4,8 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const dompurify_1 = __importDefault(require("dompurify"));
7
- // Pinned to juice 11.x: juice 12+ is pure ESM (requires Node >=22.12) and breaks pkg builds.
8
- const juice_1 = __importDefault(require("juice"));
7
+ // juice comes from the CommonJS bundle in dist/vendor, see juice.cts
8
+ const juice_cjs_1 = require("./juice.cjs");
9
9
  const he_1 = __importDefault(require("he"));
10
10
  const node_worker_threads_1 = require("node:worker_threads");
11
11
  const worker_pool_js_1 = __importDefault(require("./worker-pool.js"));
@@ -43,8 +43,8 @@ const FORBID_TAGS = [
43
43
  ];
44
44
  // form submission targets and popovers, which let sender markup act on or overlay the viewer
45
45
  const FORBID_ATTR = ['action', 'formaction', 'popover', 'popovertarget'];
46
+ // `all` is forbidden too, but whole attributes at a time, see ALL_RULE
46
47
  const FORBID_STYLES = [
47
- 'all',
48
48
  'position',
49
49
  'clip',
50
50
  'animation',
@@ -70,10 +70,17 @@ const FORBID_STYLES = [
70
70
  'scrollbar-width',
71
71
  'zoom'
72
72
  ];
73
+ // A CSS comment, written so that a run of them can only be split one way (no backtracking blowup)
74
+ const CSS_COMMENT = '/\\*[^*]*\\*+(?:[^/*][^*]*\\*+)*/';
73
75
  function styleRule(properties, hint) {
74
- return { properties, hint, declaration: new RegExp('(?:^|[^a-z0-9-])(?:' + properties.join('|') + ')\\s*:', 'i') };
76
+ return { properties, hint, declaration: new RegExp(`(?:^|[^a-z0-9-])(?:${properties.join('|')})\\s*(?:${CSS_COMMENT}\\s*)*:`, 'i') };
75
77
  }
76
- const FORBID_STYLES_RULE = styleRule(FORBID_STYLES, /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i);
78
+ const FORBID_STYLES_RULE = styleRule(FORBID_STYLES, /position|float|clip|zoom|anim|offset|scrollbar|paint|clear/i);
79
+ // The `all` property resets every other one. jsdom 30+ reads it as the shorthand of every longhand
80
+ // (over five hundred of them), so a declared one would serialize as all of them the moment the
81
+ // attribute is written back from the CSSOM: a few bytes of input per element would become kilobytes
82
+ // of output. An element that declares it loses its style attribute instead, see finishHtml()
83
+ const ALL_RULE = styleRule(['all'], /\ball\b/i);
77
84
  // Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
78
85
  // name, so that a forbidden property cannot hide behind an escaped character
79
86
  function decodeCssEscapes(value) {
@@ -341,7 +348,11 @@ function removeStyleProperties(node, rule) {
341
348
  return;
342
349
  }
343
350
  for (const property of rule.properties) {
344
- node.style.removeProperty(property);
351
+ // Only what the element holds: removing a shorthand removes its longhands too, and with a
352
+ // shorthand like `all` (see ALL_RULE) that would be the whole declaration block
353
+ if (node.style.getPropertyValue(property)) {
354
+ node.style.removeProperty(property);
355
+ }
345
356
  }
346
357
  // removeProperty() rewrites the attribute from the CSSOM, which only holds the declarations
347
358
  // cssstyle managed to parse. A forbidden property still named in the attribute after that was
@@ -387,12 +398,19 @@ function finishHtml(html, inliningSkipped) {
387
398
  if (!node.style) {
388
399
  continue;
389
400
  }
390
- const styleText = node.getAttribute('style') || '';
401
+ let styleText = node.getAttribute('style') || '';
391
402
  if (styleText.indexOf('\\') >= 0) {
392
403
  // A browser resolves CSS escapes in property names (p\osition is position) but jsdom's
393
404
  // CSSOM does not, so the checks below would miss them. Decode first and write the
394
405
  // decoded text back so the CSSOM sees the same properties the browser will
395
- node.setAttribute('style', decodeCssEscapes(styleText));
406
+ styleText = decodeCssEscapes(styleText);
407
+ node.setAttribute('style', styleText);
408
+ }
409
+ if (ALL_RULE.hint.test(styleText) && ALL_RULE.declaration.test(styleText)) {
410
+ // A browser would still apply the declarations that follow an `all`, so they are lost
411
+ // too: the price of never writing the expanded block back, see ALL_RULE
412
+ node.removeAttribute('style');
413
+ continue;
396
414
  }
397
415
  removeStyleProperties(node, FORBID_STYLES_RULE);
398
416
  }
@@ -434,7 +452,7 @@ function mimeHtmlSync(message) {
434
452
  if (hasStyle) {
435
453
  try {
436
454
  // Run juice synchronously - risky but backwards compatible
437
- html = (0, juice_1.default)(html);
455
+ html = (0, juice_cjs_1.juice)(html);
438
456
  }
439
457
  catch (_err) {
440
458
  // not so important, so we'll ignore if style inlining fails
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // jsdom, loaded from the CommonJS bundle scripts/bundle-jsdom.js builds into dist/vendor/jsdom.cjs.
2
+ // jsdom, loaded from the CommonJS bundle scripts/bundle-vendor.js builds into dist/vendor/jsdom.cjs.
3
3
  // jsdom pulls in ES module only packages (parse5 among them), which the CommonJS build must not
4
4
  // require(), so the package itself is a devDependency and only its bundle ships. This file compiles
5
5
  // to jsdom.cjs in dist/cjs/lib and dist/esm/lib alike, both two levels below dist/vendor.
@@ -1,6 +1,6 @@
1
1
  import workerThreads from 'node:worker_threads';
2
- // Pinned to juice 11.x: juice 12+ is pure ESM (requires Node >=22.12) and breaks pkg builds.
3
- import juice from 'juice';
2
+ // juice comes from the CommonJS bundle in dist/vendor, see juice.cts
3
+ import { juice } from './juice.cjs';
4
4
  // Runs as a worker thread (see worker-pool.ts), the parent port is always there
5
5
  const parentPort = workerThreads.parentPort;
6
6
  parentPort.on('message', (html) => {
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ // juice, loaded from the CommonJS bundle scripts/bundle-vendor.js builds into dist/vendor/juice.cjs.
3
+ // juice 12 is an ES module only package, which the CommonJS build must not require(), so the
4
+ // package itself is a devDependency and only its bundle ships. This file compiles to juice.cjs in
5
+ // dist/cjs/lib and dist/esm/lib alike, both two levels below dist/vendor.
6
+ //
7
+ // Not part of commonjs.cts on purpose: that module is loaded eagerly, and juice must only load
8
+ // when mimeHtml or the juice worker is first used.
9
+ //
10
+ // The literal require() path is what @yao-pkg/pkg follows to put the bundle into a binary.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.juice = void 0;
13
+ // A named export, like jsdom.cts: the ES module build reads it through the CommonJS export names
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ exports.juice = require('../../vendor/juice.cjs');
@@ -0,0 +1,2 @@
1
+ import type JuiceModule from 'juice';
2
+ export declare const juice: typeof JuiceModule;
@@ -1,6 +1,6 @@
1
1
  import createDOMPurify from 'dompurify';
2
- // Pinned to juice 11.x: juice 12+ is pure ESM (requires Node >=22.12) and breaks pkg builds.
3
- import juice from 'juice';
2
+ // juice comes from the CommonJS bundle in dist/vendor, see juice.cts
3
+ import { juice } from './juice.cjs';
4
4
  import he from 'he';
5
5
  import { Worker } from 'node:worker_threads';
6
6
  import WorkerPool from './worker-pool.js';
@@ -38,8 +38,8 @@ const FORBID_TAGS = [
38
38
  ];
39
39
  // form submission targets and popovers, which let sender markup act on or overlay the viewer
40
40
  const FORBID_ATTR = ['action', 'formaction', 'popover', 'popovertarget'];
41
+ // `all` is forbidden too, but whole attributes at a time, see ALL_RULE
41
42
  const FORBID_STYLES = [
42
- 'all',
43
43
  'position',
44
44
  'clip',
45
45
  'animation',
@@ -65,10 +65,17 @@ const FORBID_STYLES = [
65
65
  'scrollbar-width',
66
66
  'zoom'
67
67
  ];
68
+ // A CSS comment, written so that a run of them can only be split one way (no backtracking blowup)
69
+ const CSS_COMMENT = '/\\*[^*]*\\*+(?:[^/*][^*]*\\*+)*/';
68
70
  function styleRule(properties, hint) {
69
- return { properties, hint, declaration: new RegExp('(?:^|[^a-z0-9-])(?:' + properties.join('|') + ')\\s*:', 'i') };
71
+ return { properties, hint, declaration: new RegExp(`(?:^|[^a-z0-9-])(?:${properties.join('|')})\\s*(?:${CSS_COMMENT}\\s*)*:`, 'i') };
70
72
  }
71
- const FORBID_STYLES_RULE = styleRule(FORBID_STYLES, /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i);
73
+ const FORBID_STYLES_RULE = styleRule(FORBID_STYLES, /position|float|clip|zoom|anim|offset|scrollbar|paint|clear/i);
74
+ // The `all` property resets every other one. jsdom 30+ reads it as the shorthand of every longhand
75
+ // (over five hundred of them), so a declared one would serialize as all of them the moment the
76
+ // attribute is written back from the CSSOM: a few bytes of input per element would become kilobytes
77
+ // of output. An element that declares it loses its style attribute instead, see finishHtml()
78
+ const ALL_RULE = styleRule(['all'], /\ball\b/i);
72
79
  // Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
73
80
  // name, so that a forbidden property cannot hide behind an escaped character
74
81
  function decodeCssEscapes(value) {
@@ -336,7 +343,11 @@ function removeStyleProperties(node, rule) {
336
343
  return;
337
344
  }
338
345
  for (const property of rule.properties) {
339
- node.style.removeProperty(property);
346
+ // Only what the element holds: removing a shorthand removes its longhands too, and with a
347
+ // shorthand like `all` (see ALL_RULE) that would be the whole declaration block
348
+ if (node.style.getPropertyValue(property)) {
349
+ node.style.removeProperty(property);
350
+ }
340
351
  }
341
352
  // removeProperty() rewrites the attribute from the CSSOM, which only holds the declarations
342
353
  // cssstyle managed to parse. A forbidden property still named in the attribute after that was
@@ -382,12 +393,19 @@ function finishHtml(html, inliningSkipped) {
382
393
  if (!node.style) {
383
394
  continue;
384
395
  }
385
- const styleText = node.getAttribute('style') || '';
396
+ let styleText = node.getAttribute('style') || '';
386
397
  if (styleText.indexOf('\\') >= 0) {
387
398
  // A browser resolves CSS escapes in property names (p\osition is position) but jsdom's
388
399
  // CSSOM does not, so the checks below would miss them. Decode first and write the
389
400
  // decoded text back so the CSSOM sees the same properties the browser will
390
- node.setAttribute('style', decodeCssEscapes(styleText));
401
+ styleText = decodeCssEscapes(styleText);
402
+ node.setAttribute('style', styleText);
403
+ }
404
+ if (ALL_RULE.hint.test(styleText) && ALL_RULE.declaration.test(styleText)) {
405
+ // A browser would still apply the declarations that follow an `all`, so they are lost
406
+ // too: the price of never writing the expanded block back, see ALL_RULE
407
+ node.removeAttribute('style');
408
+ continue;
391
409
  }
392
410
  removeStyleProperties(node, FORBID_STYLES_RULE);
393
411
  }