@postalsys/email-text-tools 2.1.16 → 2.2.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.
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ const { Worker } = require('worker_threads');
4
+ const path = require('path');
5
+ const { EventEmitter } = require('events');
6
+
7
+ class WorkerPool extends EventEmitter {
8
+ constructor(options = {}) {
9
+ super();
10
+
11
+ this.workerPath = options.workerPath || path.join(__dirname, 'juice-worker.js');
12
+ this.minWorkers = options.minWorkers || 2;
13
+ this.maxWorkers = options.maxWorkers || 4;
14
+ this.maxQueueSize = options.maxQueueSize || 100;
15
+ this.workerTimeout = options.workerTimeout || 10000;
16
+ this.idleTimeout = options.idleTimeout || 30000;
17
+
18
+ this.workers = [];
19
+ this.freeWorkers = [];
20
+ this.queue = [];
21
+ this.closing = false;
22
+
23
+ // Initialize minimum number of workers
24
+ this._initWorkers();
25
+ }
26
+
27
+ _initWorkers() {
28
+ for (let i = 0; i < this.minWorkers; i++) {
29
+ this._createWorker();
30
+ }
31
+ }
32
+
33
+ _createWorker() {
34
+ if (this.closing) return null;
35
+
36
+ const worker = new Worker(this.workerPath);
37
+ const workerInfo = {
38
+ worker,
39
+ busy: false,
40
+ lastUsed: Date.now(),
41
+ timeoutHandle: null,
42
+ currentTask: null
43
+ };
44
+
45
+ worker.on('message', msg => {
46
+ if (workerInfo.currentTask) {
47
+ const { resolve, reject, timeoutHandle } = workerInfo.currentTask;
48
+ clearTimeout(timeoutHandle);
49
+
50
+ if (msg.success) {
51
+ resolve(msg.result);
52
+ } else {
53
+ reject(new Error(msg.error));
54
+ }
55
+
56
+ workerInfo.currentTask = null;
57
+ workerInfo.busy = false;
58
+ workerInfo.lastUsed = Date.now();
59
+
60
+ // Add worker back to free list if not already there
61
+ if (!this.freeWorkers.includes(workerInfo)) {
62
+ this.freeWorkers.push(workerInfo);
63
+ }
64
+
65
+ // Process next item in queue
66
+ this._processQueue();
67
+ }
68
+ });
69
+
70
+ worker.on('error', err => {
71
+ if (workerInfo.currentTask) {
72
+ const { reject, timeoutHandle } = workerInfo.currentTask;
73
+ clearTimeout(timeoutHandle);
74
+ reject(err);
75
+ workerInfo.currentTask = null;
76
+ }
77
+
78
+ // Remove failed worker
79
+ const index = this.workers.indexOf(workerInfo);
80
+ if (index > -1) {
81
+ this.workers.splice(index, 1);
82
+ }
83
+
84
+ const freeIndex = this.freeWorkers.indexOf(workerInfo);
85
+ if (freeIndex > -1) {
86
+ this.freeWorkers.splice(freeIndex, 1);
87
+ }
88
+
89
+ // Create replacement if below minimum
90
+ if (this.workers.length < this.minWorkers && !this.closing) {
91
+ this._createWorker();
92
+ }
93
+
94
+ // Process queue with remaining workers
95
+ this._processQueue();
96
+ });
97
+
98
+ worker.on('exit', () => {
99
+ // Clean up worker references
100
+ const index = this.workers.indexOf(workerInfo);
101
+ if (index > -1) {
102
+ this.workers.splice(index, 1);
103
+ }
104
+
105
+ const freeIndex = this.freeWorkers.indexOf(workerInfo);
106
+ if (freeIndex > -1) {
107
+ this.freeWorkers.splice(freeIndex, 1);
108
+ }
109
+ });
110
+
111
+ this.workers.push(workerInfo);
112
+ this.freeWorkers.push(workerInfo);
113
+
114
+ // Set up idle timeout for workers above minimum
115
+ if (this.workers.length > this.minWorkers) {
116
+ this._setupIdleTimeout(workerInfo);
117
+ }
118
+
119
+ return workerInfo;
120
+ }
121
+
122
+ _setupIdleTimeout(workerInfo) {
123
+ if (workerInfo.timeoutHandle) {
124
+ clearTimeout(workerInfo.timeoutHandle);
125
+ }
126
+
127
+ workerInfo.timeoutHandle = setTimeout(() => {
128
+ if (!workerInfo.busy && this.workers.length > this.minWorkers) {
129
+ this._removeWorker(workerInfo);
130
+ }
131
+ }, this.idleTimeout);
132
+ }
133
+
134
+ _removeWorker(workerInfo) {
135
+ const index = this.workers.indexOf(workerInfo);
136
+ if (index > -1) {
137
+ this.workers.splice(index, 1);
138
+ }
139
+
140
+ const freeIndex = this.freeWorkers.indexOf(workerInfo);
141
+ if (freeIndex > -1) {
142
+ this.freeWorkers.splice(freeIndex, 1);
143
+ }
144
+
145
+ if (workerInfo.timeoutHandle) {
146
+ clearTimeout(workerInfo.timeoutHandle);
147
+ }
148
+
149
+ workerInfo.worker.terminate();
150
+ }
151
+
152
+ async process(data, options = {}) {
153
+ if (this.closing) {
154
+ throw new Error('Worker pool is closing');
155
+ }
156
+
157
+ const timeout = options.timeout || this.workerTimeout;
158
+
159
+ return new Promise((resolve, reject) => {
160
+ const task = { data, resolve, reject, timeout };
161
+
162
+ // Try to get a free worker
163
+ const workerInfo = this.freeWorkers.shift();
164
+
165
+ if (workerInfo) {
166
+ this._executeTask(workerInfo, task);
167
+ } else if (this.workers.length < this.maxWorkers) {
168
+ // Create new worker if below max
169
+ const newWorker = this._createWorker();
170
+ if (newWorker) {
171
+ this.freeWorkers.shift(); // Remove from free list
172
+ this._executeTask(newWorker, task);
173
+ }
174
+ } else if (this.queue.length < this.maxQueueSize) {
175
+ // Queue the task
176
+ this.queue.push(task);
177
+ } else {
178
+ reject(new Error('Worker pool queue is full'));
179
+ }
180
+ });
181
+ }
182
+
183
+ _executeTask(workerInfo, task) {
184
+ workerInfo.busy = true;
185
+ workerInfo.lastUsed = Date.now();
186
+
187
+ // Clear idle timeout while working
188
+ if (workerInfo.timeoutHandle) {
189
+ clearTimeout(workerInfo.timeoutHandle);
190
+ workerInfo.timeoutHandle = null;
191
+ }
192
+
193
+ // Set up task timeout
194
+ const timeoutHandle = setTimeout(() => {
195
+ task.reject(new Error(`Worker task timed out after ${task.timeout}ms`));
196
+ workerInfo.currentTask = null;
197
+ workerInfo.busy = false;
198
+
199
+ // Terminate and replace the worker
200
+ this._removeWorker(workerInfo);
201
+ if (this.workers.length < this.minWorkers && !this.closing) {
202
+ this._createWorker();
203
+ }
204
+
205
+ this._processQueue();
206
+ }, task.timeout);
207
+
208
+ workerInfo.currentTask = { ...task, timeoutHandle };
209
+ workerInfo.worker.postMessage(task.data);
210
+ }
211
+
212
+ _processQueue() {
213
+ if (this.queue.length === 0) {
214
+ // Mark free workers and set up idle timeouts
215
+ this.workers.forEach(workerInfo => {
216
+ if (!workerInfo.busy && !this.freeWorkers.includes(workerInfo)) {
217
+ this.freeWorkers.push(workerInfo);
218
+ if (this.workers.length > this.minWorkers) {
219
+ this._setupIdleTimeout(workerInfo);
220
+ }
221
+ }
222
+ });
223
+ return;
224
+ }
225
+
226
+ const workerInfo = this.freeWorkers.shift();
227
+ if (workerInfo) {
228
+ const task = this.queue.shift();
229
+ if (task) {
230
+ this._executeTask(workerInfo, task);
231
+ }
232
+ }
233
+ }
234
+
235
+ async close() {
236
+ this.closing = true;
237
+
238
+ // Clear the queue
239
+ while (this.queue.length > 0) {
240
+ const task = this.queue.shift();
241
+ task.reject(new Error('Worker pool is closing'));
242
+ }
243
+
244
+ // Terminate all workers
245
+ const terminatePromises = this.workers.map(workerInfo => {
246
+ if (workerInfo.timeoutHandle) {
247
+ clearTimeout(workerInfo.timeoutHandle);
248
+ }
249
+ return workerInfo.worker.terminate();
250
+ });
251
+
252
+ await Promise.all(terminatePromises);
253
+
254
+ this.workers = [];
255
+ this.freeWorkers = [];
256
+ }
257
+
258
+ getStats() {
259
+ return {
260
+ totalWorkers: this.workers.length,
261
+ busyWorkers: this.workers.filter(w => w.busy).length,
262
+ freeWorkers: this.freeWorkers.length,
263
+ queuedTasks: this.queue.length,
264
+ minWorkers: this.minWorkers,
265
+ maxWorkers: this.maxWorkers
266
+ };
267
+ }
268
+ }
269
+
270
+ module.exports = WorkerPool;
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "@postalsys/email-text-tools",
3
- "version": "2.1.16",
3
+ "version": "2.2.0",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1",
7
+ "test": "node --test test/*.test.js",
8
+ "test:coverage": "node --test --experimental-test-coverage test/*.test.js",
9
+ "test:watch": "node --test --watch test/*.test.js",
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix",
12
+ "format": "prettier --write .",
13
+ "format:check": "prettier --check .",
8
14
  "update": "rm -rf node_modules package-lock.json && ncu -u && npm install"
9
15
  },
10
16
  "repository": {
@@ -19,18 +25,21 @@
19
25
  },
20
26
  "homepage": "https://github.com/postalsys/email-text-tools#readme",
21
27
  "devDependencies": {
28
+ "@eslint/js": "^9.36.0",
29
+ "eslint": "^9.36.0",
22
30
  "eslint-config-nodemailer": "1.2.0",
23
- "eslint-config-prettier": "9.1.0"
31
+ "eslint-config-prettier": "9.1.0",
32
+ "prettier": "^3.6.2"
24
33
  },
25
34
  "dependencies": {
26
- "dompurify": "3.2.6",
35
+ "dompurify": "3.2.7",
27
36
  "he": "1.2.0",
28
37
  "html-to-text": "9.0.5",
29
- "jsdom": "26.1.0",
30
- "juice": "11.0.1",
38
+ "jsdom": "27.0.0",
39
+ "juice": "^11.0.2",
31
40
  "linkify-it": "5.0.0",
32
41
  "moment-timezone": "0.6.0",
33
42
  "node-html-parser": "7.0.1",
34
- "tlds": "1.259.0"
43
+ "tlds": "1.260.0"
35
44
  }
36
45
  }
@@ -0,0 +1,347 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, before: _before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const mimeHtml = require('../lib/mime-html');
6
+ const textToHtml = require('../lib/text-to-html');
7
+ const _fs = require('fs');
8
+ const path = require('path');
9
+ const { Worker } = require('worker_threads');
10
+
11
+ describe('Comprehensive Test Suite', () => {
12
+ describe('text-to-html conversion', () => {
13
+ it('should convert plain text to HTML', () => {
14
+ const text = 'Hello World\nThis is a test';
15
+ const html = textToHtml(text);
16
+
17
+ assert.ok(html.includes('Hello World'));
18
+ assert.ok(html.includes('<br'));
19
+ assert.ok(html.includes('This is a test'));
20
+ });
21
+
22
+ it('should handle quoted text', () => {
23
+ const text = 'Normal text\n> Quoted text\n> More quoted\nNormal again';
24
+ const html = textToHtml(text);
25
+
26
+ assert.ok(html.includes('blockquote'));
27
+ assert.ok(html.includes('Quoted text'));
28
+ assert.ok(html.includes('Normal again'));
29
+ });
30
+
31
+ it('should linkify URLs', () => {
32
+ const text = 'Visit https://example.com for more info';
33
+ const html = textToHtml(text);
34
+
35
+ assert.ok(html.includes('<a'));
36
+ assert.ok(html.includes('href='));
37
+ assert.ok(html.includes('https://example.com'));
38
+ });
39
+
40
+ it('should handle email addresses', () => {
41
+ const text = 'Contact me at test@example.com';
42
+ const html = textToHtml(text);
43
+
44
+ assert.ok(html.includes('mailto:'));
45
+ assert.ok(html.includes('test@example.com'));
46
+ });
47
+
48
+ it('should escape HTML entities', () => {
49
+ const text = 'Test <script>alert("xss")</script> & special chars';
50
+ const html = textToHtml(text);
51
+
52
+ assert.ok(!html.includes('<script>'));
53
+ assert.ok(html.includes('&lt;script'));
54
+ assert.ok(html.includes('&amp;'));
55
+ });
56
+
57
+ it('should handle empty text', () => {
58
+ const html = textToHtml('');
59
+ assert.ok(typeof html === 'string');
60
+ });
61
+
62
+ it('should handle null/undefined', () => {
63
+ const html1 = textToHtml(null);
64
+ const html2 = textToHtml(undefined);
65
+ const html3 = textToHtml('');
66
+
67
+ // textToHtml returns minimal HTML for empty input
68
+ assert.ok(typeof html1 === 'string');
69
+ assert.ok(typeof html2 === 'string');
70
+ assert.ok(typeof html3 === 'string');
71
+ // Should return consistent output for all empty inputs
72
+ assert.equal(html1, html2);
73
+ assert.equal(html2, html3);
74
+ });
75
+ });
76
+
77
+ describe('juice-worker.js', () => {
78
+ it('should process HTML in worker thread', async () => {
79
+ const worker = new Worker(path.join(__dirname, '../lib/juice-worker.js'));
80
+
81
+ const html = '<style>p { color: red; }</style><p>Test</p>';
82
+
83
+ const result = await new Promise((resolve, reject) => {
84
+ worker.on('message', msg => {
85
+ worker.terminate();
86
+ if (msg.success) {
87
+ resolve(msg.result);
88
+ } else {
89
+ reject(new Error(msg.error));
90
+ }
91
+ });
92
+
93
+ worker.on('error', reject);
94
+ worker.postMessage(html);
95
+ });
96
+
97
+ assert.ok(result.includes('style='));
98
+ assert.ok(result.includes('Test'));
99
+ });
100
+
101
+ it('should handle worker errors gracefully', async () => {
102
+ const worker = new Worker(path.join(__dirname, '../lib/juice-worker.js'));
103
+
104
+ // Send invalid data that should cause an error
105
+ const invalidData = null;
106
+
107
+ try {
108
+ await new Promise((resolve, reject) => {
109
+ worker.on('message', msg => {
110
+ worker.terminate();
111
+ if (msg.success) {
112
+ resolve(msg.result);
113
+ } else {
114
+ reject(new Error(msg.error));
115
+ }
116
+ });
117
+
118
+ worker.on('error', reject);
119
+ worker.postMessage(invalidData);
120
+ });
121
+
122
+ // If we get here without error, that's also OK
123
+ assert.ok(true);
124
+ } catch (err) {
125
+ // Expected to error
126
+ assert.ok(err);
127
+ }
128
+ });
129
+ });
130
+
131
+ describe('mime-html with text input', () => {
132
+ it('should properly convert text to HTML with default styles', () => {
133
+ const input = {
134
+ text: 'Hello\nWorld\n\nThis is a test'
135
+ };
136
+
137
+ const result = mimeHtml.sync(input);
138
+
139
+ assert.ok(result.includes('Hello'));
140
+ assert.ok(result.includes('World'));
141
+ assert.ok(result.includes('font-family')); // Default styles applied
142
+ });
143
+
144
+ it('should handle quoted text in text mode', () => {
145
+ const input = {
146
+ text: 'Reply:\n> Original message\n> Second line\n\nMy response'
147
+ };
148
+
149
+ const result = mimeHtml.sync(input);
150
+
151
+ assert.ok(result.includes('blockquote'));
152
+ assert.ok(result.includes('Original message'));
153
+ });
154
+ });
155
+
156
+ describe('error recovery and edge cases', () => {
157
+ it('should handle circular references in DOM', () => {
158
+ // This tests the DOM walking logic
159
+ const input = {
160
+ html: '<div><p>Test</p></div>'
161
+ };
162
+
163
+ const result = mimeHtml.sync(input);
164
+ assert.ok(result.includes('Test'));
165
+ });
166
+
167
+ it('should handle very long attribute values', () => {
168
+ const longValue = 'x'.repeat(10000);
169
+ const input = {
170
+ html: `<div data-test="${longValue}">Content</div>`
171
+ };
172
+
173
+ const result = mimeHtml.sync(input);
174
+ assert.ok(result.includes('Content'));
175
+ });
176
+
177
+ it('should handle style tags in body', () => {
178
+ const input = {
179
+ html: `
180
+ <body>
181
+ <style>p { color: red; }</style>
182
+ <p>Test</p>
183
+ <style>div { color: blue; }</style>
184
+ <div>More</div>
185
+ </body>
186
+ `
187
+ };
188
+
189
+ const result = mimeHtml.sync(input);
190
+ assert.ok(result.includes('Test'));
191
+ assert.ok(result.includes('More'));
192
+ // Style tags in body should be removed
193
+ assert.ok(!result.includes('<style'));
194
+ });
195
+
196
+ it('should handle CDATA sections', () => {
197
+ const input = {
198
+ html: '<div><![CDATA[Some text]]></div>'
199
+ };
200
+
201
+ const result = mimeHtml.sync(input);
202
+ assert.ok(typeof result === 'string');
203
+ });
204
+
205
+ it('should handle XML namespaces', () => {
206
+ const input = {
207
+ html: '<html xmlns="http://www.w3.org/1999/xhtml"><body><p>Test</p></body></html>'
208
+ };
209
+
210
+ const result = mimeHtml.sync(input);
211
+ assert.ok(result.includes('Test'));
212
+ });
213
+ });
214
+
215
+ describe('async version configuration', () => {
216
+ it('should respect timeout configuration', async () => {
217
+ const input = {
218
+ html: '<p>Quick test</p>'
219
+ };
220
+
221
+ const start = Date.now();
222
+ const result = await mimeHtml.async(input, {
223
+ timeout: 100,
224
+ useWorkerPool: false
225
+ });
226
+ const elapsed = Date.now() - start;
227
+
228
+ assert.ok(result.includes('Quick test'));
229
+ // Should complete without timing out
230
+ assert.ok(elapsed < 200);
231
+ });
232
+
233
+ it('should respect logErrors configuration', async () => {
234
+ const input = {
235
+ html: null
236
+ };
237
+
238
+ // Capture console.warn
239
+ const originalWarn = console.warn;
240
+ let warnCalled = false;
241
+ console.warn = () => {
242
+ warnCalled = true;
243
+ };
244
+
245
+ await mimeHtml.async(input, {
246
+ logErrors: false,
247
+ fallbackOnError: true
248
+ });
249
+
250
+ console.warn = originalWarn;
251
+ assert.ok(!warnCalled, 'Should not log errors when logErrors is false');
252
+ });
253
+
254
+ it('should respect fallbackOnError configuration', async () => {
255
+ const input = {
256
+ html: '<p>Test</p>'
257
+ };
258
+
259
+ // With fallback
260
+ const result1 = await mimeHtml.async(input, {
261
+ fallbackOnError: true,
262
+ logErrors: false
263
+ });
264
+ assert.ok(typeof result1 === 'string');
265
+ });
266
+ });
267
+
268
+ describe('worker pool configuration', () => {
269
+ it('should respect min/max worker settings', async () => {
270
+ const input = {
271
+ html: '<p>Pool config test</p>'
272
+ };
273
+
274
+ // First close any existing pool
275
+ await mimeHtml.closeWorkerPool();
276
+
277
+ // Process with custom pool settings
278
+ await mimeHtml.async(input, {
279
+ useWorkerPool: true,
280
+ minWorkers: 1,
281
+ maxWorkers: 2
282
+ });
283
+
284
+ const stats = mimeHtml.getWorkerPoolStats();
285
+ assert.ok(stats);
286
+ assert.equal(stats.minWorkers, 1);
287
+ assert.equal(stats.maxWorkers, 2);
288
+ });
289
+ });
290
+
291
+ describe('concurrent stress test', () => {
292
+ it('should handle rapid concurrent requests without deadlock', async () => {
293
+ const promises = [];
294
+
295
+ // Fire off 50 concurrent requests rapidly
296
+ for (let i = 0; i < 50; i++) {
297
+ promises.push(
298
+ mimeHtml.async(
299
+ {
300
+ html: `<p>Stress test ${i}</p>`
301
+ },
302
+ {
303
+ useWorkerPool: true,
304
+ timeout: 5000
305
+ }
306
+ )
307
+ );
308
+ }
309
+
310
+ const results = await Promise.all(promises);
311
+
312
+ assert.equal(results.length, 50);
313
+ results.forEach((result, i) => {
314
+ assert.ok(result.includes(`Stress test ${i}`));
315
+ });
316
+ });
317
+ });
318
+
319
+ describe('memory leak prevention', () => {
320
+ it('should clean up workers after timeout', async () => {
321
+ const input = {
322
+ html: '<p>Timeout test</p>'
323
+ };
324
+
325
+ try {
326
+ // This should timeout and clean up the worker
327
+ await mimeHtml.async(input, {
328
+ timeout: 1,
329
+ useWorkerPool: false,
330
+ fallbackOnError: false,
331
+ logErrors: false
332
+ });
333
+ } catch (err) {
334
+ assert.ok(err.message.includes('timed out'));
335
+ }
336
+
337
+ // Worker should be cleaned up, no leak
338
+ const memAfter = process.memoryUsage();
339
+ assert.ok(memAfter.heapUsed < 500 * 1024 * 1024); // Less than 500MB
340
+ });
341
+ });
342
+
343
+ // Clean up
344
+ after(async () => {
345
+ await mimeHtml.closeWorkerPool();
346
+ });
347
+ });