@postalsys/email-text-tools 2.1.15 → 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.
@@ -6,48 +6,279 @@ const fs = require('fs');
6
6
  const type = process.argv[3] || 'html';
7
7
  const file = fs.readFileSync(process.argv[2]);
8
8
 
9
- const HTML_TEMPLATE = `<html>
10
- <head>
11
- <meta charset="utf-8">
12
- <title>test email</title>
9
+ const HTML_TEMPLATE = `<!doctype html><html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" style="background-color: #fff;"><head>
10
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8"><title>Neue Nachricht!</title>
11
+ <!--[if !mso]><!-->
12
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
13
+ <!--<![endif]-->
13
14
 
14
- <style>
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <style type="text/css">
17
+ #outlook a { padding:0; }
18
+ body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
19
+ table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
20
+ img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
21
+ p { display:block;margin:13px 0; }
22
+ </style>
23
+ <!--[if mso]>
24
+ <noscript>
25
+ <xml>
26
+ <o:OfficeDocumentSettings>
27
+ <o:AllowPNG/>
28
+ <o:PixelsPerInch>96</o:PixelsPerInch>
29
+ </o:OfficeDocumentSettings>
30
+ </xml>
31
+ </noscript>
32
+ <![endif]-->
33
+ <!--[if lte mso 11]>
34
+ <style type="text/css">
35
+ .mj-outlook-group-fix { width:100% !important; }
36
+ </style>
37
+ <![endif]-->
15
38
 
16
- body {
17
- background: white;
18
- }
39
+ <!--[if !mso]><!-->
40
+ <link href="https://fonts.googleapis.com/css?family=Inter:400,600" rel="stylesheet" type="text/css">
41
+ <style type="text/css">
42
+ @import url(https://fonts.googleapis.com/css?family=Inter:400,600);
43
+ </style>
44
+ <!--<![endif]-->
19
45
 
20
- .body-container {
21
- max-width: 840px;
22
- margin: 30px auto;
23
- padding: 20px;
24
- background: #fafafa;
25
- }
26
46
 
27
- .email-container {
28
- padding: 20px;
29
- border: 1px solid #ccc;
30
- background: white;
31
- }
32
47
 
33
- </style>
48
+ <style type="text/css">
49
+ @media only screen and (min-width:480px) {
50
+ .mj-column-per-100 { width:100% !important; max-width: 100%; }
51
+ }
52
+ </style>
53
+ <style media="screen and (min-width:480px)">
54
+ .moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
55
+ </style>
34
56
 
35
- <head>
36
- <body>
37
57
 
38
- <div class="body-container">
39
58
 
40
- <h1>Email message</h1>
41
59
 
42
- <div class="email-container">
43
- HTML_BODY
44
- </div>
60
+ <style type="text/css">
45
61
 
46
- <p>Footer text</p>
62
+ @media only screen and (max-width:479px) {
63
+ table.mj-full-width-mobile { width: 100% !important; }
64
+ td.mj-full-width-mobile { width: auto !important; }
65
+ }
47
66
 
48
- </div>
67
+ </style>
49
68
 
50
- </body>
51
- </html>`;
69
+ <style type="text/css">
70
+ .wfix {
71
+ max-width: 600px !important;
72
+ }
73
+
74
+ @media(max-width: 480px) {
75
+ .body-image-mobile-width-14 td { width: 14px !important; }
76
+ .body-image td { max-width: 100% !important; }
77
+ .body-image img { box-sizing: border-box; }
78
+ .social-icon-mobile-spacer {
79
+ display: inline-block !important;
80
+ }
81
+ }
82
+
83
+ .mobile-only { display: none !important; } .desktop-only { display: block !important; }
84
+ @media(max-width: 480px) { .column-stack { width: 100% !important; max-width: 100% !important; display: block !important; } .column-stack td,.column-stack th { padding-left: 0 !important; padding-right: 0 !important; } .mobile-only { display: block !important; } .desktop-only { display: none !important; } }
85
+ </style>
86
+ <style>
87
+ .wrapper {
88
+ padding: 0 12px;
89
+ }
90
+
91
+ /* Superhuman selector */
92
+ .ShadowHTML .wrapper {
93
+ padding: 0 !important;
94
+ }
95
+
96
+
97
+
98
+
99
+ .ShadowHTML :is(.styleUnquotedContent .unquoted-content, .styleUnquotedContent .sh-unquoted-content, .styleQuotedContent .sh-quoted-content, .sh-signature, .Signature-settings) :is(td.column-container, div[class^="mj-column-per-"]){font-size:0!important}
100
+ html.sh-color :is(td.column-container, div[class^="mj-column-per-"], div.mj-column-per-100.mj-outlook-group-fix){font-size:0!important}
101
+ @media(max-width:480px){
102
+ html.sh-color .column-stack{width:100%!important;max-width:100%!important;display:block!important}
103
+ html.sh-color :is(.column-stack td,.column-stack th){padding-left:0!important;padding-right:0!important;width:100%!important;max-width:100%!important;display:block!important}
104
+ html.sh-color :is(.column-stack, .column-stack *){word-break:break-word!important;overflow-wrap:anywhere!important}
105
+ html.sh-color :is(.column-persist, .column-persist *){word-break:break-word!important;overflow-wrap:anywhere!important}
106
+ }
107
+ html.sh-color a[href^="tel:"]{color:inherit!important;text-decoration:none!important;font-size:inherit!important;line-height:inherit!important;letter-spacing:inherit!important;vertical-align:baseline!important}
108
+
109
+
110
+ .isMarketing { background-color: #fff !important; }
111
+ .isMarketing .styled-primary-column { background-color: #fff !important; border-radius: 0px; }
112
+
113
+
114
+ [class~="x_social-icon-dark-mode"] { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; }
115
+
116
+ /* Gmail selectors */
117
+ u + .body .wrapper {
118
+ padding: 0 !important;
119
+ }
120
+ div > u + .body .wrapper {
121
+ padding: 0 !important;
122
+ }
123
+
124
+ a { text-decoration: none; color: #333; }
125
+
126
+
127
+
128
+
129
+ .link-unsubscribe { color: #9ca3af !important; }
130
+ </style>
131
+ <meta name="color-scheme" content="light">
132
+ <meta name="supported-color-schemes" content="light">
133
+ </head>
134
+ <body class="body" style="word-spacing:normal;background-color:#ffffff;">
135
+
136
+
137
+ <div class="email-body" style="background-color:#ffffff;" lang="und" dir="auto">
138
+ <!--[if mso]><style type="text/css"> a { text-decoration: underline !important; } .button a { text-decoration: none !important; } .outlook-list-fix { margin-left:4px !important; } .outlook-break-full-link { word-break: break-all !important; } .outlook-hide-social-icon { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; } .outlook-hide-social-icon * { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; } .outlook-hide-social-icon table { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; } .outlook-hide-social-icon tbody { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; } .outlook-hide-social-icon tr { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; } .outlook-hide-social-icon td { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; } .outlook-hide-social-icon img { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; } .outlook-hide-social-icon a { display: none !important; mso-hide:all; width: 0px !important; padding: 0px !important; }
139
+ </style><table align="center" border="0" cellpadding="0" cellspacing="0" class="wfix-outlook gmail-no-padding-outlook wrapper-outlook" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
140
+
141
+
142
+ <div class="wfix gmail-no-padding wrapper" style="margin:0px auto;max-width:600px;">
143
+
144
+ <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
145
+ <tbody>
146
+ <tr>
147
+ <td style="direction:ltr;font-size:0px;padding:20px 0;padding-top:0;text-align:center;">
148
+ <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="wfix-outlook" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="wfix-outlook" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
149
+
150
+
151
+ <div class="wfix" style="margin:0px auto;max-width:600px;">
152
+
153
+ <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
154
+ <tbody>
155
+ <tr>
156
+ <td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:30px;padding-left:0px;padding-right:0px;padding-top:30px;text-align:center;">
157
+ <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td align="center" class="wfix-outlook rounded-corners-outlook styled-primary-column-outlook" style="vertical-align:top;width:600px;" ><![endif]-->
158
+
159
+ <div class="mj-column-per-100 mj-outlook-group-fix wfix rounded-corners styled-primary-column" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
160
+
161
+ <table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="border-collapse: separate;">
162
+ <tbody>
163
+ <tr>
164
+ <td style="background-color:#ffffff;border-radius:0px;vertical-align:top;padding-top:0px;padding-right:15px;padding-bottom:0px;padding-left:15px;">
165
+
166
+ <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
167
+ <tbody>
168
+
169
+ <tr>
170
+ <td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;word-break:break-word;">
171
+
172
+ <div style="font-family:Inter, ui-sans-serif, sans-serif;font-size:14px;letter-spacing:0px;line-height:21px !important;text-align:left;color:#666666;"><span>&nbsp;</span></div>
173
+
174
+ </td>
175
+ </tr>
176
+
177
+ <tr>
178
+ <td align="left" class="body-image body-image-mobile-width-14" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;word-break:break-word;">
179
+
180
+ <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
181
+ <tbody>
182
+ <tr>
183
+ <td style="width:14px;">
184
+
185
+ <a href="https://framer.com" target="_blank" style="text-decoration: none; color: #333;">
186
+
187
+ <img alt="Framer Logo" src="https://d3b9kr64nievew.cloudfront.net/cm915sac71jhgzjjtr8rmq42q/cmb54yn83cy1vzx0ib1pj5jrq.png" style="border:0;border-radius:0px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="14" height="auto">
188
+
189
+ </a>
190
+
191
+ </td>
192
+ </tr>
193
+ </tbody>
194
+ </table>
195
+
196
+ </td>
197
+ </tr>
198
+
199
+ <tr>
200
+ <td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;word-break:break-word;">
201
+
202
+ <div style="font-family:Inter, ui-sans-serif, sans-serif;font-size:14px;letter-spacing:0px;line-height:21px !important;text-align:left;color:#666666;"><span>&nbsp;</span></div>
203
+
204
+ </td>
205
+ </tr>
206
+
207
+ <tr>
208
+ <td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;word-break:break-word;">
209
+
210
+ <div style="font-family:Inter, ui-sans-serif, sans-serif;font-size:14px;letter-spacing:0px;line-height:21px !important;text-align:left;color:#666666;"><span style="line-height: 21px !important; font-size: 14px; font-size: 14px !important; white-space: pre-wrap;">Sie haben eine neue Nachricht über das Kontaktformular auf der Webseite erhalten!</span></div>
211
+
212
+ </td>
213
+ </tr>
214
+
215
+ <tr>
216
+ <td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;word-break:break-word;">
217
+
218
+ <div style="font-family:Inter, ui-sans-serif, sans-serif;font-size:14px;letter-spacing:0px;line-height:22px !important;text-align:left;color:#666666;"><span>&nbsp;</span></div>
219
+
220
+ </td>
221
+ </tr>
222
+
223
+ <tr>
224
+ <td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;word-break:break-word;">
225
+
226
+ <div style="font-family:Inter, ui-sans-serif, sans-serif;font-size:14px;letter-spacing:0px;line-height:21px !important;text-align:left;color:#666666;"><span style="line-height: 21px !important; font-size: 14px; font-size: 14px !important; white-space: pre-wrap;"><style type="text/css">a:link {text-decoration: none !important; }</style><div style="line-height:19px;padding-bottom:5px"><span style="color:#999999">Location: </span>Haus kaufen</div><div style="line-height:19px;padding-bottom:5px"><span style="color:#999999">Name: </span>Hayati Kaya</div><div style="line-height:19px;padding-bottom:5px"><span style="color:#999999">Phone Number: </span>+41796336385</div><div style="line-height:19px;padding-bottom:5px"><span style="color:#999999">Email Address: </span>kayahayati@windowslive.com</div><div style="line-height:19px;padding-bottom:5px"><span style="color:#999999">Message: </span>Ich bin an diesem Objekt in Remetchwil interessiert, für das Frau Rudhani zuständig ist, und würde es gerne besichtigen.</div></span></div>
227
+
228
+ </td>
229
+ </tr>
230
+
231
+ <tr>
232
+ <td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;word-break:break-word;">
233
+
234
+ <div style="font-family:Inter, ui-sans-serif, sans-serif;font-size:14px;letter-spacing:0px;line-height:21px !important;text-align:left;color:#666666;"><span>&nbsp;</span></div>
235
+
236
+ </td>
237
+ </tr>
238
+
239
+ <tr>
240
+ <td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;word-break:break-word;">
241
+
242
+ <div style="font-family:Inter, ui-sans-serif, sans-serif;font-size:14px;letter-spacing:0px;line-height:21px !important;text-align:left;color:#666666;"><span>&nbsp;</span></div>
243
+
244
+ </td>
245
+ </tr>
246
+
247
+ </tbody>
248
+ </table>
249
+
250
+ </td>
251
+ </tr>
252
+ </tbody>
253
+ </table>
254
+
255
+ </div>
256
+
257
+ <!--[if mso | IE]></td></tr></table><![endif]-->
258
+ </td>
259
+ </tr>
260
+ </tbody>
261
+ </table>
262
+
263
+ </div>
264
+
265
+
266
+ <!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
267
+ </td>
268
+ </tr>
269
+ </tbody>
270
+ </table>
271
+
272
+ </div>
273
+
274
+
275
+ <!--[if mso | IE]></td></tr></table><![endif]-->
276
+
277
+
278
+ </div>
279
+
280
+ </body>
281
+ </html>
282
+ `;
52
283
 
53
284
  console.log(HTML_TEMPLATE.replace(/HTML_BODY/g, mimeHtml({ [type]: file })));
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ const { parentPort } = require('worker_threads');
4
+ const juice = require('juice');
5
+
6
+ parentPort.on('message', html => {
7
+ try {
8
+ const result = juice(html);
9
+ parentPort.postMessage({ success: true, result });
10
+ } catch (error) {
11
+ parentPort.postMessage({ success: false, error: error.message });
12
+ }
13
+ });
package/lib/mime-html.js CHANGED
@@ -4,6 +4,9 @@ const createDOMPurify = require('dompurify');
4
4
  const { JSDOM } = require('jsdom');
5
5
  const juice = require('juice');
6
6
  const he = require('he');
7
+ const { Worker } = require('worker_threads');
8
+ const path = require('path');
9
+ const WorkerPool = require('./worker-pool');
7
10
 
8
11
  const textToHtml = require('./text-to-html');
9
12
 
@@ -60,7 +63,72 @@ const FORBID_STYLES = [
60
63
  // keep the style at first
61
64
  const FORBID_TAGS_ALL = ['style'].concat(FORBID_TAGS);
62
65
 
66
+ // Global worker pool instance (lazy initialization)
67
+ let globalWorkerPool = null;
68
+
69
+ function getWorkerPool(options = {}) {
70
+ if (!globalWorkerPool) {
71
+ globalWorkerPool = new WorkerPool({
72
+ minWorkers: options.minWorkers || 2,
73
+ maxWorkers: options.maxWorkers || 4,
74
+ workerTimeout: options.timeout || 5000,
75
+ idleTimeout: options.idleTimeout || 30000
76
+ });
77
+ }
78
+ return globalWorkerPool;
79
+ }
80
+
63
81
  // define styles for plaintext emails
82
+ // Wrapper for juice that runs in a worker thread with timeout protection
83
+ async function juiceWithTimeout(html, options = {}) {
84
+ const timeout = options.timeout || 5000;
85
+ const usePool = options.useWorkerPool !== false; // Default to true
86
+
87
+ if (usePool) {
88
+ try {
89
+ const pool = getWorkerPool(options);
90
+ return await pool.process(html, { timeout });
91
+ } catch (err) {
92
+ // Fall back to single worker if pool fails
93
+ if (options.fallbackOnError === false) {
94
+ throw err;
95
+ }
96
+ }
97
+ }
98
+
99
+ // Single worker implementation (fallback or when pool disabled)
100
+ const timeoutMs = timeout;
101
+ return new Promise((resolve, reject) => {
102
+ const worker = new Worker(path.join(__dirname, 'juice-worker.js'));
103
+ let timedOut = false;
104
+
105
+ const timeout = setTimeout(() => {
106
+ timedOut = true;
107
+ worker.terminate();
108
+ reject(new Error('Juice processing timed out - likely due to unsupported CSS selectors'));
109
+ }, timeoutMs);
110
+
111
+ worker.on('message', msg => {
112
+ clearTimeout(timeout);
113
+ worker.terminate();
114
+ if (msg.success) {
115
+ resolve(msg.result);
116
+ } else {
117
+ reject(new Error(msg.error));
118
+ }
119
+ });
120
+
121
+ worker.on('error', err => {
122
+ clearTimeout(timeout);
123
+ if (!timedOut) {
124
+ reject(err);
125
+ }
126
+ });
127
+
128
+ worker.postMessage(html);
129
+ });
130
+ }
131
+
64
132
  const INLINE_STYLE_BLOCK = `<style>
65
133
 
66
134
  body, td, th, p {
@@ -96,7 +164,7 @@ blockquote blockquote blockquote blockquote{
96
164
 
97
165
  </style>`;
98
166
 
99
- function mimeHtml(message) {
167
+ function mimeHtmlSync(message) {
100
168
  let html = message.html;
101
169
  if (!html && message.text) {
102
170
  html = textToHtml(message.text) || '';
@@ -105,32 +173,46 @@ function mimeHtml(message) {
105
173
  }
106
174
  }
107
175
 
108
- html = html.toString().replace(/^"data:[^"]+"/g, '');
109
- console.log('HTML', html);
110
- try {
111
- //inline styles
112
- //html = juice(html);
113
- } catch (err) {
114
- // should log?
115
- }
176
+ html = (html || '').toString().replace(/^"data:[^"]+"/g, '');
116
177
 
117
- console.log(1, Date.now());
118
178
  const window = new JSDOM('').window;
119
- console.log(2, Date.now());
179
+
120
180
  const DOMPurify = createDOMPurify(window);
121
- console.log(3, Date.now());
181
+
122
182
  // first pass, outputs a fixed HTML page
123
183
  html = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS });
124
- console.log(4, Date.now());
184
+
125
185
  try {
126
- //inline styles
186
+ // Pre-process HTML to remove known problematic CSS that causes juice to hang
187
+ // This is a fallback for the sync version - the async version with timeout is preferred
188
+ const tempDoc = new JSDOM(html).window.document;
189
+
190
+ // Remove any style tags from within body content
191
+ const bodyStyleTags = tempDoc.body ? tempDoc.body.querySelectorAll('style') : [];
192
+ bodyStyleTags.forEach(tag => tag.remove());
193
+
194
+ // Process remaining style tags to remove known problematic selectors
195
+ const styleTags = tempDoc.querySelectorAll('style');
196
+ styleTags.forEach(styleTag => {
197
+ let cssContent = styleTag.textContent;
198
+
199
+ // Remove rules with known problematic pseudo-classes
200
+ const problematicSelectors = [':is\\(', ':where\\(', ':has\\(', ':not\\(.*:.*\\)'];
201
+ problematicSelectors.forEach(selector => {
202
+ const regex = new RegExp(`[^{}]*${selector}[^{}]*\\{[^}]*\\}`, 'gi');
203
+ cssContent = cssContent.replace(regex, '');
204
+ });
205
+
206
+ styleTag.textContent = cssContent;
207
+ });
208
+
209
+ html = tempDoc.documentElement.outerHTML;
210
+
211
+ // Run juice synchronously - risky but backwards compatible
127
212
  html = juice(html);
128
- } catch (err) {
129
- console.log(err);
213
+ } catch (_err) {
130
214
  // not so important, so we'll ignore if style inlining fails
131
- // should log?
132
215
  }
133
- console.log(5, Date.now());
134
216
 
135
217
  // second pass, outputs a BODY DOM element
136
218
  const dom = DOMPurify.sanitize(html, {
@@ -139,7 +221,6 @@ function mimeHtml(message) {
139
221
  FORCE_BODY: true,
140
222
  FORBID_TAGS: FORBID_TAGS_ALL
141
223
  });
142
- console.log(6, Date.now());
143
224
 
144
225
  // mark all links to be opened in a new window
145
226
  for (let elm of dom.querySelectorAll('a')) {
@@ -157,7 +238,6 @@ function mimeHtml(message) {
157
238
  dom.style.removeProperty('min-height');
158
239
  dom.style.removeProperty('max-height');
159
240
 
160
- console.log(7, Date.now());
161
241
  // remove specific style properties for all DOM nodes
162
242
  let walkDom = node => {
163
243
  if (node.style) {
@@ -167,18 +247,162 @@ function mimeHtml(message) {
167
247
  }
168
248
 
169
249
  if (node.childNodes) {
170
- for (let childNode of node.childNodes) {
250
+ // Convert NodeList to array to avoid infinite loops if DOM changes during iteration
251
+ const children = Array.from(node.childNodes);
252
+ for (let childNode of children) {
171
253
  walkDom(childNode);
172
254
  }
173
255
  }
174
256
  };
175
257
  walkDom(dom);
176
258
 
177
- console.log(8, Date.now());
178
259
  let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
179
260
 
180
261
  // embed into a styled container
181
262
  return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
182
263
  }
183
264
 
265
+ async function mimeHtmlAsync(message, options = {}) {
266
+ let html = message.html;
267
+ if (!html && message.text) {
268
+ html = textToHtml(message.text) || '';
269
+ if (html) {
270
+ html = INLINE_STYLE_BLOCK + html;
271
+ }
272
+ }
273
+
274
+ html = (html || '').toString().replace(/^"data:[^"]+"/g, '');
275
+
276
+ const window = new JSDOM('').window;
277
+ const DOMPurify = createDOMPurify(window);
278
+
279
+ // first pass, outputs a fixed HTML page
280
+ html = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS });
281
+
282
+ try {
283
+ // Pre-process HTML to remove known problematic CSS that causes juice to hang
284
+ // This is the same filtering as the sync version to avoid unnecessary timeouts
285
+ const tempDoc = new JSDOM(html).window.document;
286
+
287
+ // Remove any style tags from within body content
288
+ const bodyStyleTags = tempDoc.body ? tempDoc.body.querySelectorAll('style') : [];
289
+ bodyStyleTags.forEach(tag => tag.remove());
290
+
291
+ // Process remaining style tags to remove known problematic selectors
292
+ const styleTags = tempDoc.querySelectorAll('style');
293
+ styleTags.forEach(styleTag => {
294
+ let cssContent = styleTag.textContent;
295
+
296
+ // Remove rules with known problematic pseudo-classes
297
+ const problematicSelectors = [':is\\(', ':where\\(', ':has\\(', ':not\\(.*:.*\\)'];
298
+ problematicSelectors.forEach(selector => {
299
+ const regex = new RegExp(`[^{}]*${selector}[^{}]*\\{[^}]*\\}`, 'gi');
300
+ cssContent = cssContent.replace(regex, '');
301
+ });
302
+
303
+ styleTag.textContent = cssContent;
304
+ });
305
+
306
+ html = tempDoc.documentElement.outerHTML;
307
+
308
+ // Use worker thread with timeout for juice - provides protection against ANY
309
+ // unexpected CSS that might cause hangs (beyond our known problematic selectors)
310
+ html = await juiceWithTimeout(html, options);
311
+ } catch (err) {
312
+ // If juice fails or times out, continue without inlining styles
313
+ // The HTML is still usable, just without inlined CSS
314
+ // Note: logErrors option can be used to control error visibility
315
+
316
+ if (options.fallbackOnError === false) {
317
+ throw err;
318
+ }
319
+ }
320
+
321
+ // second pass, outputs a BODY DOM element
322
+ const dom = DOMPurify.sanitize(html, {
323
+ RETURN_DOM: true,
324
+ WHOLE_DOCUMENT: false,
325
+ FORCE_BODY: true,
326
+ FORBID_TAGS: FORBID_TAGS_ALL
327
+ });
328
+
329
+ // mark all links to be opened in a new window
330
+ for (let elm of dom.querySelectorAll('a')) {
331
+ elm.setAttribute('target', '_blank');
332
+ }
333
+
334
+ dom.style.overflow = 'auto';
335
+
336
+ // return <BODY> HTML
337
+ dom.style.removeProperty('width');
338
+ dom.style.removeProperty('min-width');
339
+ dom.style.removeProperty('max-width');
340
+ dom.style.removeProperty('height');
341
+ dom.style.removeProperty('min-height');
342
+ dom.style.removeProperty('max-height');
343
+
344
+ // remove specific style properties for all DOM nodes
345
+ let walkDom = node => {
346
+ if (node.style) {
347
+ for (let disallowedStyle of FORBID_STYLES) {
348
+ node.style.removeProperty(disallowedStyle);
349
+ }
350
+ }
351
+
352
+ if (node.childNodes) {
353
+ // Convert NodeList to array to avoid infinite loops if DOM changes during iteration
354
+ const children = Array.from(node.childNodes);
355
+ for (let childNode of children) {
356
+ walkDom(childNode);
357
+ }
358
+ }
359
+ };
360
+ walkDom(dom);
361
+
362
+ let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
363
+
364
+ // embed into a styled container
365
+ return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
366
+ }
367
+
368
+ // Main export - maintains backward compatibility with sync API
369
+ // Architecture:
370
+ // - Both sync and async versions pre-filter known problematic CSS selectors
371
+ // - Sync version: Pre-filters selectors, then runs juice synchronously (can still hang on unknown issues)
372
+ // - Async version: Pre-filters selectors, then runs juice in worker with timeout (fully protected)
373
+ // The timeout in async is a safety net for unknown edge cases, not the primary defense
374
+ function mimeHtml(message) {
375
+ // Use the sync version for backward compatibility
376
+ return mimeHtmlSync(message);
377
+ }
378
+
379
+ // Export both versions
380
+ mimeHtml.async = mimeHtmlAsync;
381
+ mimeHtml.sync = mimeHtmlSync;
382
+
383
+ // Close worker pool when process exits
384
+ if (globalWorkerPool) {
385
+ process.on('exit', () => {
386
+ if (globalWorkerPool) {
387
+ globalWorkerPool.close().catch(() => {});
388
+ }
389
+ });
390
+ }
391
+
392
+ // Export function to manually close worker pool
393
+ mimeHtml.closeWorkerPool = async () => {
394
+ if (globalWorkerPool) {
395
+ await globalWorkerPool.close();
396
+ globalWorkerPool = null;
397
+ }
398
+ };
399
+
400
+ // Export function to get worker pool stats
401
+ mimeHtml.getWorkerPoolStats = () => {
402
+ if (globalWorkerPool) {
403
+ return globalWorkerPool.getStats();
404
+ }
405
+ return null;
406
+ };
407
+
184
408
  module.exports = mimeHtml;
@@ -114,7 +114,7 @@ function linkifyAndHtmlEscape(text) {
114
114
  })
115
115
  .join('');
116
116
  }
117
- } catch (err) {
117
+ } catch (_err) {
118
118
  // ignore?
119
119
  }
120
120