@postalsys/email-text-tools 2.4.17 → 2.4.18

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "2.4.17"
2
+ ".": "2.4.18"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.18](https://github.com/postalsys/email-text-tools/compare/v2.4.17...v2.4.18) (2026-09-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * keep the text of an over-deep HTML body in the fallback rendering ([a2e06b7](https://github.com/postalsys/email-text-tools/commit/a2e06b75878d3763bb35d225a93225aa6a3f18ab))
9
+
3
10
  ## [2.4.17](https://github.com/postalsys/email-text-tools/compare/v2.4.16...v2.4.17) (2026-09-28)
4
11
 
5
12
 
package/lib/mime-html.js CHANGED
@@ -11,7 +11,6 @@ const path = require('path');
11
11
  const WorkerPool = require('./worker-pool');
12
12
 
13
13
  const textToHtml = require('./text-to-html');
14
- const htmlToText = require('./html-to-text');
15
14
 
16
15
  const FORBID_TAGS = [
17
16
  'title',
@@ -275,10 +274,47 @@ function exceedsNestingDepth(html, maxDepth) {
275
274
 
276
275
  // Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
277
276
  // example when a hostile nesting depth overflows the stack of the parser or the serializer.
277
+ // The text of an HTML body for the fallback rendering. The fallback runs when the HTML could
278
+ // not be processed (hostile nesting, a parser or serializer overflow), so it cannot lean on a
279
+ // real HTML parser either: html-to-text gives up on a body nested past its own limit and
280
+ // returned nothing for exactly the input that lands here. One pass over the string: tags become
281
+ // a space, style and script blocks are dropped whole, entities are decoded once
282
+ function fallbackText(html) {
283
+ const lower = html.toLowerCase();
284
+ const parts = [];
285
+ let pos = 0;
286
+ while (pos < html.length) {
287
+ const lt = html.indexOf('<', pos);
288
+ if (lt < 0) {
289
+ parts.push(html.slice(pos));
290
+ break;
291
+ }
292
+ parts.push(html.slice(pos, lt));
293
+ const gt = html.indexOf('>', lt + 1);
294
+ if (gt < 0) {
295
+ // an unterminated tag runs to the end of the input
296
+ break;
297
+ }
298
+ const name = /^(style|script)(?:[\s/]|$)/.exec(lower.slice(lt + 1, gt));
299
+ if (name) {
300
+ const close = lower.indexOf('</' + name[1], gt + 1);
301
+ const closeEnd = close < 0 ? -1 : html.indexOf('>', close);
302
+ if (closeEnd < 0) {
303
+ break;
304
+ }
305
+ pos = closeEnd + 1;
306
+ } else {
307
+ pos = gt + 1;
308
+ }
309
+ parts.push(' ');
310
+ }
311
+ return he.decode(parts.join(''));
312
+ }
313
+
278
314
  function fallbackHtml(message) {
279
315
  let text = message.text;
280
316
  if (!text && message.html) {
281
- text = htmlToText(message.html.toString());
317
+ text = fallbackText(message.html.toString());
282
318
  }
283
319
  return `<div style="overflow: auto;"><div style="white-space: pre-wrap;">${he.encode((text || '').toString(), { useNamedReferences: true })}</div></div>`;
284
320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postalsys/email-text-tools",
3
- "version": "2.4.17",
3
+ "version": "2.4.18",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -146,8 +146,8 @@ describe('hostile input hardening', () => {
146
146
  assert.ok(result.startsWith(FALLBACK_PREFIX));
147
147
  assert.ok(result.includes('hello &lt;img'));
148
148
  assert.doesNotMatch(result, /<img/);
149
- // text below the html-to-text depth limit is dropped
150
- assert.doesNotMatch(result, /deep/);
149
+ // the fallback strips tags itself, so text below any parser depth limit survives
150
+ assert.ok(result.includes('deep'));
151
151
  assert.ok(ms < 10000, `took ${ms} ms`);
152
152
  });
153
153
 
@@ -155,6 +155,7 @@ describe('hostile input hardening', () => {
155
155
  const { result, ms } = timed(() => mimeHtml({ html: 'shallow' + '<b>'.repeat(50000) + 'bold' }));
156
156
  assert.ok(result.startsWith(FALLBACK_PREFIX));
157
157
  assert.ok(result.includes('shallow'));
158
+ assert.ok(result.includes('bold'));
158
159
  assert.ok(ms < 15000, `took ${ms} ms`);
159
160
  });
160
161
 
@@ -198,6 +199,25 @@ describe('hostile input hardening', () => {
198
199
  const result = await mimeHtml.async({ html: 'async shallow' + '<div>'.repeat(4000) + 'async deep' });
199
200
  assert.ok(result.startsWith(FALLBACK_PREFIX));
200
201
  assert.ok(result.includes('async shallow'));
202
+ assert.ok(result.includes('async deep'));
203
+ });
204
+
205
+ it('the fallback keeps the text of an over-deep body that has no plain part', async () => {
206
+ const html = '<style>.x{color:red}</style><script>alert(1)</script>' + '<div>'.repeat(4000) + 'only &lt;html&gt; deep' + '</div>'.repeat(4000);
207
+ const result = await mimeHtml.async({ html });
208
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
209
+ assert.ok(result.includes('only &lt;html&gt; deep'));
210
+ assert.ok(!result.includes('<div>'));
211
+ assert.ok(!result.includes('color:red'));
212
+ assert.ok(!result.includes('alert(1)'));
213
+ });
214
+
215
+ it('the fallback drops an unterminated style block and tag instead of rendering them', async () => {
216
+ const result = await mimeHtml.async({ html: 'kept <style>.x{}' + '<div>'.repeat(4000) });
217
+ assert.ok(result.includes('kept'));
218
+ assert.ok(!result.includes('.x{}'));
219
+ const unterminated = await mimeHtml.async({ html: 'kept <div' + '<div>'.repeat(4000) });
220
+ assert.ok(unterminated.includes('kept'));
201
221
  });
202
222
 
203
223
  it('mimeHtml.async rejects on hostile nesting with fallbackOnError: false', async () => {