@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.6.0](https://github.com/postalsys/email-text-tools/compare/v2.5.0...v2.6.0) (2026-10-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* export INLINE_STYLE_BLOCK from the package entry ([36b8c30](https://github.com/postalsys/email-text-tools/commit/36b8c305b3841202ae6d7407e80e0c6189d556a6))
|
|
9
|
+
|
|
10
|
+
## [2.5.0](https://github.com/postalsys/email-text-tools/compare/v2.4.26...v2.5.0) (2026-10-19)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* migrate to TypeScript with ES module and CommonJS builds ([b709d60](https://github.com/postalsys/email-text-tools/commit/b709d60a6a43cd842ad60da554d9f6859350c9af))
|
|
16
|
+
|
|
3
17
|
## [2.4.26](https://github.com/postalsys/email-text-tools/compare/v2.4.25...v2.4.26) (2026-10-19)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Email text tools
|
|
4
4
|
|
|
5
|
+
Ships ES module and CommonJS builds with type declarations, Node.js 20 or newer:
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { mimeHtml, textToHtml } from '@postalsys/email-text-tools';
|
|
9
|
+
// or
|
|
10
|
+
const { mimeHtml, textToHtml } = require('@postalsys/email-text-tools');
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`require()` loads `mimeHtml` (and with it jsdom) only on first access; an ES module import loads it up front.
|
|
14
|
+
|
|
15
|
+
`INLINE_STYLE_BLOCK` is the `<style>` block `mimeHtml()` prepends to a message that only has a text body (font and the colours of nested quotes), for a caller that converts text with `textToHtml()` itself and wants the same styling. Reading it does not load jsdom.
|
|
16
|
+
|
|
5
17
|
## mimeHtml sanitizer policy
|
|
6
18
|
|
|
7
19
|
`mimeHtml()` sanitizes with DOMPurify and additionally removes:
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import textToHtml from './lib/text-to-html.js';
|
|
2
|
+
import htmlToText from './lib/html-to-text.js';
|
|
3
|
+
import inlineHtml from './lib/inline-html.js';
|
|
4
|
+
import inlineText from './lib/inline-text.js';
|
|
5
|
+
import mimeHtml from './lib/mime-html.js';
|
|
6
|
+
import { INLINE_STYLE_BLOCK } from './lib/inline-style.js';
|
|
7
|
+
export type { FormatDateOptions } from './lib/format-date.js';
|
|
8
|
+
export type { InlineAction, MessageAddress, QuotedMessage } from './lib/inline-html.js';
|
|
9
|
+
export type { MimeHtmlAsyncOptions, MimeHtmlMessage } from './lib/mime-html.js';
|
|
10
|
+
export type { WorkerPoolOptions, WorkerPoolStats } from './lib/worker-pool.js';
|
|
11
|
+
export { textToHtml, htmlToText, inlineHtml, inlineText, mimeHtml, INLINE_STYLE_BLOCK };
|
|
12
|
+
declare const textTools: {
|
|
13
|
+
textToHtml: typeof textToHtml;
|
|
14
|
+
htmlToText: typeof htmlToText;
|
|
15
|
+
inlineHtml: typeof inlineHtml;
|
|
16
|
+
inlineText: typeof inlineText;
|
|
17
|
+
mimeHtml: typeof mimeHtml;
|
|
18
|
+
INLINE_STYLE_BLOCK: string;
|
|
19
|
+
};
|
|
20
|
+
export default textTools;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The CommonJS entry point, compiled by scripts/build.js into dist/cjs/index.js. index.ts is the
|
|
3
|
+
// ES module entry and the source of the type declarations of both.
|
|
4
|
+
//
|
|
5
|
+
// require() returns the same object as before the TypeScript build: the five helpers as plain
|
|
6
|
+
// enumerable properties, mimeHtml through a getter. mimeHtml loads jsdom, which costs tens of
|
|
7
|
+
// megabytes of heap, so it is only loaded on first access and callers that only need the other
|
|
8
|
+
// helpers never pay for it. INLINE_STYLE_BLOCK was added after them and comes from a leaf module,
|
|
9
|
+
// so reading it does not load jsdom either.
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
const text_to_html_js_1 = __importDefault(require("./lib/text-to-html.js"));
|
|
15
|
+
const html_to_text_js_1 = __importDefault(require("./lib/html-to-text.js"));
|
|
16
|
+
const inline_html_js_1 = __importDefault(require("./lib/inline-html.js"));
|
|
17
|
+
const inline_text_js_1 = __importDefault(require("./lib/inline-text.js"));
|
|
18
|
+
const inline_style_js_1 = require("./lib/inline-style.js");
|
|
19
|
+
const textTools = { textToHtml: text_to_html_js_1.default, htmlToText: html_to_text_js_1.default, inlineHtml: inline_html_js_1.default, inlineText: inline_text_js_1.default };
|
|
20
|
+
Object.defineProperty(textTools, 'mimeHtml', {
|
|
21
|
+
enumerable: true,
|
|
22
|
+
get() {
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
|
+
return require('./lib/mime-html.js');
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
textTools.INLINE_STYLE_BLOCK = inline_style_js_1.INLINE_STYLE_BLOCK;
|
|
28
|
+
// What TypeScript and Babel generated `import textTools from '...'` code reads, as the
|
|
29
|
+
// declarations promise a default export
|
|
30
|
+
Object.defineProperty(textTools, 'default', { value: textTools, enumerable: false, writable: true, configurable: true });
|
|
31
|
+
module.exports = textTools;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What an ES module can not do the same way in both builds. This file compiles to
|
|
3
|
+
// commonjs.cjs in dist/cjs and in dist/esm alike, so __dirname is the directory of the
|
|
4
|
+
// build that loaded it and require() is available to both.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TLDS = exports.JUICE_WORKER_PATH = void 0;
|
|
7
|
+
// A plain require() keeps the call below in the literal path.join(__dirname, '...') form, the
|
|
8
|
+
// one form @yao-pkg/pkg recognizes when it collects the files a binary needs (a default import
|
|
9
|
+
// compiles to path_1.default.join, which it does not). Without it the worker script is left out
|
|
10
|
+
// of an EmailEngine binary
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
/** The worker script that runs juice off the main thread, the one of the same build */
|
|
14
|
+
exports.JUICE_WORKER_PATH = path.join(__dirname, 'juice-worker.js');
|
|
15
|
+
/** The top level domain list of the tlds package, which only ships it as index.json */
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
17
|
+
exports.TLDS = require('tlds');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface FormatDateOptions {
|
|
2
|
+
/** BCP 47 language tag, defaults to 'en' */
|
|
3
|
+
locale?: string | undefined;
|
|
4
|
+
/** IANA time zone, defaults to the local time zone */
|
|
5
|
+
tz?: string | undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Formats the date of a quoted message for the reply/forward header, for example
|
|
9
|
+
* "Sat, Jun 15, 2024, 2:30 PM" in English.
|
|
10
|
+
*
|
|
11
|
+
* @param value Date to format
|
|
12
|
+
* @param options Locale and time zone
|
|
13
|
+
* @returns Formatted date, or the input as text when it is not a valid date
|
|
14
|
+
*/
|
|
15
|
+
export default function formatDate(value: Date | string | number, options?: FormatDateOptions | null): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = formatDate;
|
|
4
|
+
const DATE_FORMAT = {
|
|
5
|
+
weekday: 'short',
|
|
6
|
+
year: 'numeric',
|
|
7
|
+
month: 'short',
|
|
8
|
+
day: 'numeric',
|
|
9
|
+
hour: 'numeric',
|
|
10
|
+
minute: '2-digit'
|
|
11
|
+
};
|
|
12
|
+
// An unsupported locale would silently fall back to the host's default locale, and a Node build
|
|
13
|
+
// with only English ICU data (pkg binaries use small-icu) renders other locales as broken
|
|
14
|
+
// placeholders such as "M09 28, Mon", so anything not actually supported becomes 'en'.
|
|
15
|
+
function resolveLocale(locale) {
|
|
16
|
+
if (locale) {
|
|
17
|
+
try {
|
|
18
|
+
const supported = Intl.DateTimeFormat.supportedLocalesOf([locale]);
|
|
19
|
+
if (supported.length) {
|
|
20
|
+
return supported[0];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (_err) {
|
|
24
|
+
// structurally invalid language tag
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return 'en';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Formats the date of a quoted message for the reply/forward header, for example
|
|
31
|
+
* "Sat, Jun 15, 2024, 2:30 PM" in English.
|
|
32
|
+
*
|
|
33
|
+
* @param value Date to format
|
|
34
|
+
* @param options Locale and time zone
|
|
35
|
+
* @returns Formatted date, or the input as text when it is not a valid date
|
|
36
|
+
*/
|
|
37
|
+
function formatDate(value, options) {
|
|
38
|
+
options = options || {};
|
|
39
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
40
|
+
if (isNaN(date.getTime())) {
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
const locale = resolveLocale(options.locale);
|
|
44
|
+
let formatter;
|
|
45
|
+
try {
|
|
46
|
+
formatter = new Intl.DateTimeFormat(locale, Object.assign({ timeZone: options.tz || undefined }, DATE_FORMAT));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (!(err instanceof RangeError)) {
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
// an unknown time zone falls back to UTC rather than to whatever zone the host runs in
|
|
53
|
+
formatter = new Intl.DateTimeFormat(locale, Object.assign({ timeZone: 'UTC' }, DATE_FORMAT));
|
|
54
|
+
}
|
|
55
|
+
const formatted = formatter.format(date);
|
|
56
|
+
// ICU puts a narrow no-break space before "PM", which older mail clients show as a box
|
|
57
|
+
return formatted.replace(/[\u00a0\u202f]/g, ' ');
|
|
58
|
+
}
|
|
59
|
+
module.exports = exports.default;
|
|
60
|
+
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_NESTING_DEPTH = void 0;
|
|
7
|
+
exports.exceedsNestingDepth = exceedsNestingDepth;
|
|
8
|
+
exports.fallbackText = fallbackText;
|
|
9
|
+
const he_1 = __importDefault(require("he"));
|
|
10
|
+
// jsdom builds a tree in time quadratic to its depth (10,000 unclosed <b> tags take about 9 s) and
|
|
11
|
+
// its serializer overflows the stack a few thousand levels down; node-html-parser spends cubic time
|
|
12
|
+
// on the elements left open at the end of a document. Deeper input is not parsed at all. Real
|
|
13
|
+
// email nests a few dozen levels.
|
|
14
|
+
exports.MAX_NESTING_DEPTH = 1000;
|
|
15
|
+
// Elements that never stay open in HTML content, either void or closed implicitly by a sibling,
|
|
16
|
+
// so they do not add to the depth an HTML parser ends up with. Inside SVG and MathML they can nest,
|
|
17
|
+
// so there every start tag counts.
|
|
18
|
+
const NON_NESTING_TAGS = new Set('area,base,br,col,embed,hr,img,input,keygen,link,meta,param,source,track,wbr,p,li,dt,dd,td,th,tr,tbody,thead,tfoot,colgroup,caption,option,optgroup,rt,rp'.split(','));
|
|
19
|
+
// A single linear pass estimating how deep the parsed tree would get. An end tag closes the
|
|
20
|
+
// nearest open element of its name together with everything opened inside it, and one that names
|
|
21
|
+
// no open element is ignored, as an HTML parser ignores it: counting every end tag down instead
|
|
22
|
+
// let "<b></i>" repeated nest as deep as the input allowed, and 10,000 copies took jsdom ten
|
|
23
|
+
// seconds. Otherwise the estimate only ever overestimates (tags inside comments, attribute values
|
|
24
|
+
// and raw text count too), which at worst means an extreme message is shown as plain text.
|
|
25
|
+
function exceedsNestingDepth(html, maxDepth) {
|
|
26
|
+
const tagName = /[a-z][a-z0-9:-]*/iy;
|
|
27
|
+
const stack = [];
|
|
28
|
+
const open = new Map();
|
|
29
|
+
let foreign = 0;
|
|
30
|
+
let pos = 0;
|
|
31
|
+
while (pos < html.length) {
|
|
32
|
+
const tagStart = html.indexOf('<', pos);
|
|
33
|
+
if (tagStart < 0) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
const tagEnd = html.indexOf('>', tagStart + 1);
|
|
37
|
+
if (tagEnd < 0) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
// resume right after the first ">", so no tag the parser would see is ever skipped
|
|
41
|
+
pos = tagEnd + 1;
|
|
42
|
+
const closing = html.charAt(tagStart + 1) === '/';
|
|
43
|
+
tagName.lastIndex = tagStart + (closing ? 2 : 1);
|
|
44
|
+
const nameMatch = tagName.exec(html);
|
|
45
|
+
if (!nameMatch) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const name = nameMatch[0].toLowerCase();
|
|
49
|
+
const isForeignRoot = name === 'svg' || name === 'math';
|
|
50
|
+
if (closing) {
|
|
51
|
+
if (!open.get(name)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let popped;
|
|
55
|
+
do {
|
|
56
|
+
// the end tag names an open element, so the stack can not run out before it
|
|
57
|
+
popped = stack.pop();
|
|
58
|
+
open.set(popped, open.get(popped) - 1);
|
|
59
|
+
if (popped === 'svg' || popped === 'math') {
|
|
60
|
+
foreign--;
|
|
61
|
+
}
|
|
62
|
+
} while (popped !== name);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!foreign && NON_NESTING_TAGS.has(name)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// a self-closing flag is ignored on HTML elements, only SVG and MathML honor it
|
|
69
|
+
if ((foreign || isForeignRoot) && html.charAt(tagEnd - 1) === '/') {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (isForeignRoot) {
|
|
73
|
+
foreign++;
|
|
74
|
+
}
|
|
75
|
+
stack.push(name);
|
|
76
|
+
open.set(name, (open.get(name) || 0) + 1);
|
|
77
|
+
if (stack.length > maxDepth) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
// The text of an HTML body for a fallback rendering. The fallback runs when the HTML could not be
|
|
84
|
+
// processed (hostile nesting, a parser or serializer overflow), so it cannot lean on a real HTML
|
|
85
|
+
// parser either: html-to-text gives up on a body nested past its own limit and returned nothing
|
|
86
|
+
// for exactly the input that lands here. One pass over the string: tags become a space, style and
|
|
87
|
+
// script blocks are dropped whole, entities are decoded once
|
|
88
|
+
function fallbackText(html) {
|
|
89
|
+
const lower = html.toLowerCase();
|
|
90
|
+
const parts = [];
|
|
91
|
+
let pos = 0;
|
|
92
|
+
while (pos < html.length) {
|
|
93
|
+
const lt = html.indexOf('<', pos);
|
|
94
|
+
if (lt < 0) {
|
|
95
|
+
parts.push(html.slice(pos));
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
parts.push(html.slice(pos, lt));
|
|
99
|
+
const gt = html.indexOf('>', lt + 1);
|
|
100
|
+
if (gt < 0) {
|
|
101
|
+
// an unterminated tag runs to the end of the input
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
const name = /^(style|script)(?:[\s/]|$)/.exec(lower.slice(lt + 1, gt));
|
|
105
|
+
if (name) {
|
|
106
|
+
const close = lower.indexOf('</' + name[1], gt + 1);
|
|
107
|
+
const closeEnd = close < 0 ? -1 : html.indexOf('>', close);
|
|
108
|
+
if (closeEnd < 0) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
pos = closeEnd + 1;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
pos = gt + 1;
|
|
115
|
+
}
|
|
116
|
+
parts.push(' ');
|
|
117
|
+
}
|
|
118
|
+
return he_1.default.decode(parts.join(''));
|
|
119
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function htmlToText(html: string): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = htmlToText;
|
|
4
|
+
const html_to_text_1 = require("html-to-text");
|
|
5
|
+
// Post-process function to clean up excessive newlines
|
|
6
|
+
function cleanupNewlines(text) {
|
|
7
|
+
// Replace 3 or more consecutive newlines with just 2
|
|
8
|
+
// This handles cases where HTML has many nested empty divs
|
|
9
|
+
return text.replace(/\n{3,}/g, '\n\n');
|
|
10
|
+
}
|
|
11
|
+
const htmlToTextCompiled = (0, html_to_text_1.compile)({
|
|
12
|
+
wordwrap: 160,
|
|
13
|
+
selectors: [
|
|
14
|
+
// Block elements that should preserve line breaks
|
|
15
|
+
{ selector: 'p', options: { leadingLineBreaks: 1, trailingLineBreaks: 1 } },
|
|
16
|
+
// Divs get minimal breaks - many HTML emails wrap each line in a div
|
|
17
|
+
{ selector: 'div', options: { leadingLineBreaks: 1, trailingLineBreaks: 0 } },
|
|
18
|
+
{ selector: 'pre', options: { leadingLineBreaks: 1, trailingLineBreaks: 1 } },
|
|
19
|
+
{ selector: 'blockquote', options: { leadingLineBreaks: 1, trailingLineBreaks: 1, trimEmptyLines: true } },
|
|
20
|
+
// Headers should have spacing
|
|
21
|
+
{ selector: 'h1', options: { leadingLineBreaks: 2, trailingLineBreaks: 1, uppercase: false } },
|
|
22
|
+
{ selector: 'h2', options: { leadingLineBreaks: 2, trailingLineBreaks: 1, uppercase: false } },
|
|
23
|
+
{ selector: 'h3', options: { leadingLineBreaks: 2, trailingLineBreaks: 1, uppercase: false } },
|
|
24
|
+
{ selector: 'h4', options: { leadingLineBreaks: 2, trailingLineBreaks: 1, uppercase: false } },
|
|
25
|
+
{ selector: 'h5', options: { leadingLineBreaks: 2, trailingLineBreaks: 1, uppercase: false } },
|
|
26
|
+
{ selector: 'h6', options: { leadingLineBreaks: 2, trailingLineBreaks: 1, uppercase: false } },
|
|
27
|
+
// Line breaks
|
|
28
|
+
{ selector: 'br', options: { leadingLineBreaks: 1, trailingLineBreaks: 0 } },
|
|
29
|
+
{ selector: 'hr', options: { leadingLineBreaks: 1, trailingLineBreaks: 1 } },
|
|
30
|
+
// Lists should preserve structure
|
|
31
|
+
{ selector: 'ul', options: { leadingLineBreaks: 1, trailingLineBreaks: 1 } },
|
|
32
|
+
{ selector: 'ol', options: { leadingLineBreaks: 1, trailingLineBreaks: 1 } },
|
|
33
|
+
// Tables
|
|
34
|
+
{ selector: 'table', format: 'dataTable' },
|
|
35
|
+
// Ignore images
|
|
36
|
+
{ selector: 'img', format: 'skip' }
|
|
37
|
+
],
|
|
38
|
+
// Preserve whitespace better for content
|
|
39
|
+
preserveNewlines: true,
|
|
40
|
+
// The DOM walk is recursive, so hostile nesting would overflow the stack. Content below this
|
|
41
|
+
// depth is dropped; real email nests a few dozen levels.
|
|
42
|
+
limits: { maxDepth: 500, ellipsis: '' }
|
|
43
|
+
});
|
|
44
|
+
// Wrapper function that applies post-processing
|
|
45
|
+
function htmlToText(html) {
|
|
46
|
+
return cleanupNewlines(htmlToTextCompiled(html));
|
|
47
|
+
}
|
|
48
|
+
module.exports = exports.default;
|
|
49
|
+
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FormatDateOptions } from './format-date.js';
|
|
2
|
+
/** An address of the quoted message */
|
|
3
|
+
export interface MessageAddress {
|
|
4
|
+
name?: string | undefined;
|
|
5
|
+
address?: string | undefined;
|
|
6
|
+
}
|
|
7
|
+
/** The message being replied to or forwarded */
|
|
8
|
+
export interface QuotedMessage {
|
|
9
|
+
from?: MessageAddress | MessageAddress[] | null | undefined;
|
|
10
|
+
to?: MessageAddress | MessageAddress[] | null | undefined;
|
|
11
|
+
cc?: MessageAddress | MessageAddress[] | null | undefined;
|
|
12
|
+
bcc?: MessageAddress | MessageAddress[] | null | undefined;
|
|
13
|
+
subject?: string | null | undefined;
|
|
14
|
+
date?: Date | string | number | null | undefined;
|
|
15
|
+
html?: string | Buffer | null | undefined;
|
|
16
|
+
text?: string | null | undefined;
|
|
17
|
+
}
|
|
18
|
+
export type InlineAction = 'reply' | 'forward';
|
|
19
|
+
export default function inlineHtml(action: InlineAction | string, messageContent: string | Buffer | null | undefined, messageData: QuotedMessage, options?: FormatDateOptions | null): string;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = inlineHtml;
|
|
7
|
+
const node_html_parser_1 = __importDefault(require("node-html-parser"));
|
|
8
|
+
const he_1 = __importDefault(require("he"));
|
|
9
|
+
const format_date_js_1 = __importDefault(require("./format-date.js"));
|
|
10
|
+
const text_to_html_js_1 = __importDefault(require("./text-to-html.js"));
|
|
11
|
+
const html_guard_js_1 = require("./html-guard.js");
|
|
12
|
+
// Escaped text in place of a body that is not worth parsing
|
|
13
|
+
function fallbackBody(html) {
|
|
14
|
+
return `<div style="white-space: pre-wrap;">${he_1.default.encode((0, html_guard_js_1.fallbackText)(html), { useNamedReferences: true })}</div>`;
|
|
15
|
+
}
|
|
16
|
+
function getHtmlBody(input) {
|
|
17
|
+
const html = ((input && input.toString()) || '').trim();
|
|
18
|
+
if (!html) {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
// node-html-parser spends cubic time removing the elements left open at the end of a document
|
|
22
|
+
// (16 KB of unclosed <b> tags took ten seconds), so the depth check that guards mimeHtml()
|
|
23
|
+
// refuses the obvious shapes before parsing. The parse option leaves whatever is still open in
|
|
24
|
+
// place instead of reparenting it, so a shape the check underestimates (it follows the HTML
|
|
25
|
+
// parsing rules, node-html-parser ignores more end tags) costs linear time and at worst a stack
|
|
26
|
+
// overflow in the serializer, caught below
|
|
27
|
+
if ((0, html_guard_js_1.exceedsNestingDepth)(html, html_guard_js_1.MAX_NESTING_DEPTH)) {
|
|
28
|
+
return fallbackBody(html);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const root = node_html_parser_1.default.parse(html, { parseNoneClosedTags: true });
|
|
32
|
+
const body = root.querySelector('body') || root.querySelector('html') || root;
|
|
33
|
+
return (body.innerHTML || '').trim();
|
|
34
|
+
}
|
|
35
|
+
catch (_err) {
|
|
36
|
+
return fallbackBody(html);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function formatAddressHtml(addr) {
|
|
40
|
+
if (!addr || (!addr.name && !addr.address)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
let parts = [];
|
|
44
|
+
if (addr.name) {
|
|
45
|
+
parts.push(he_1.default.encode(addr.name, { useNamedReferences: true }));
|
|
46
|
+
}
|
|
47
|
+
if (addr.address) {
|
|
48
|
+
parts.push(`${he_1.default.encode('<', { useNamedReferences: true })}<a href="mailto:${he_1.default.encode(addr.address, { useNamedReferences: true })}">${he_1.default.encode(addr.address, { useNamedReferences: true })}</a>${he_1.default.encode('>', { useNamedReferences: true })}`);
|
|
49
|
+
}
|
|
50
|
+
return parts.join(' ');
|
|
51
|
+
}
|
|
52
|
+
function formatAddressesHtml(input) {
|
|
53
|
+
const addresses = [].concat(input || []);
|
|
54
|
+
if (!addresses || !addresses.length) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
let list = [];
|
|
58
|
+
for (let address of addresses) {
|
|
59
|
+
let entry = formatAddressHtml(address);
|
|
60
|
+
if (entry) {
|
|
61
|
+
list.push(entry);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!list.length) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return list.join(', ');
|
|
68
|
+
}
|
|
69
|
+
function inlineHtml(action, messageContent, messageData, options) {
|
|
70
|
+
options = options || {};
|
|
71
|
+
messageContent = (messageContent || '').toString();
|
|
72
|
+
let originalBody;
|
|
73
|
+
if (messageData.text && !messageData.html) {
|
|
74
|
+
originalBody = (0, text_to_html_js_1.default)(messageData.text);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
originalBody = getHtmlBody(messageData.html);
|
|
78
|
+
}
|
|
79
|
+
if (!originalBody) {
|
|
80
|
+
return messageContent;
|
|
81
|
+
}
|
|
82
|
+
// remove html headers and stuff
|
|
83
|
+
messageContent = getHtmlBody(messageContent);
|
|
84
|
+
let headerLines = [];
|
|
85
|
+
if (messageData.from) {
|
|
86
|
+
let entry = {
|
|
87
|
+
title: 'From',
|
|
88
|
+
content: formatAddressesHtml(messageData.from) || he_1.default.encode('<>', { useNamedReferences: true })
|
|
89
|
+
};
|
|
90
|
+
headerLines.push(entry);
|
|
91
|
+
}
|
|
92
|
+
if (messageData.subject) {
|
|
93
|
+
let entry = {
|
|
94
|
+
title: 'Subject',
|
|
95
|
+
content: `<b>${he_1.default.encode(messageData.subject, { useNamedReferences: true })}</b>`
|
|
96
|
+
};
|
|
97
|
+
headerLines.push(entry);
|
|
98
|
+
}
|
|
99
|
+
let dateStr = '';
|
|
100
|
+
if (messageData.date) {
|
|
101
|
+
dateStr = (0, format_date_js_1.default)(messageData.date, options);
|
|
102
|
+
let entry = {
|
|
103
|
+
title: 'Date',
|
|
104
|
+
content: he_1.default.encode(dateStr, { useNamedReferences: true })
|
|
105
|
+
};
|
|
106
|
+
headerLines.push(entry);
|
|
107
|
+
}
|
|
108
|
+
for (let key of ['to', 'cc', 'bcc']) {
|
|
109
|
+
if (messageData[key]) {
|
|
110
|
+
let content = formatAddressesHtml(messageData[key]);
|
|
111
|
+
if (content) {
|
|
112
|
+
let entry = {
|
|
113
|
+
title: key.replace(/^./, c => c.toUpperCase()),
|
|
114
|
+
content
|
|
115
|
+
};
|
|
116
|
+
headerLines.push(entry);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
let headers = [];
|
|
121
|
+
switch (action) {
|
|
122
|
+
case 'forward':
|
|
123
|
+
headers.push(`<div>Begin forwarded message:</div><br class="Apple-interchange-newline">`);
|
|
124
|
+
for (let line of headerLines) {
|
|
125
|
+
headers.push(`<div style="margin-top: 0px; margin-right: 0px; margin-bottom: 0px; margin-left: 0px;"><span style="font-family: -webkit-system-font, Helvetica Neue, Helvetica, sans-serif; color:rgba(0, 0, 0, 1.0);"><b>${line.title}: </b></span><span style="font-family: -webkit-system-font, Helvetica Neue, Helvetica, sans-serif;">${line.content}<br></span></div>`);
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
case 'reply':
|
|
129
|
+
headers.push(`<div>On ${he_1.default.encode(dateStr, { useNamedReferences: true })}, ${formatAddressesHtml(messageData.from) || he_1.default.encode('<>', { useNamedReferences: true })} wrote:</div>`);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
return `<html>
|
|
133
|
+
|
|
134
|
+
<head>
|
|
135
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
|
136
|
+
</head>
|
|
137
|
+
|
|
138
|
+
<body style="word-wrap: break-word; -webkit-nbsp-mode: space; line-break: after-white-space;">${messageContent}<br>
|
|
139
|
+
<div><br>
|
|
140
|
+
<blockquote type="cite" class="ee-block-1">
|
|
141
|
+
${headers.join('\n')}<br>
|
|
142
|
+
<div>${originalBody}</div>
|
|
143
|
+
</blockquote>
|
|
144
|
+
</div>
|
|
145
|
+
</body>
|
|
146
|
+
|
|
147
|
+
</html>`;
|
|
148
|
+
}
|
|
149
|
+
module.exports = exports.default;
|
|
150
|
+
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** Base styles mimeHtml() applies to a message that only has a plain text body */
|
|
2
|
+
export declare const INLINE_STYLE_BLOCK = "<style>\n\nbody, td, th, p {\n font-family: sans-serif;\n font-size: 12px;\n}\n\nblockquote {\n border-left-width: 2px;\n border-left-style: solid;\n \n border-left-color: darkblue;\n color: darkblue;\n\n margin: 1rem 0;\n padding-left: 1rem;\n}\n\nblockquote blockquote{\n border-left-color: royalblue;\n color: royalblue;\n}\n\nblockquote blockquote blockquote{\n border-left-color: dodgerblue;\n color: dodgerblue;\n}\n\nblockquote blockquote blockquote blockquote{\n border-left-color: darkblue;\n color: darkblue;\n}\n\n</style>";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The default <style> block mimeHtml() prepends when a message has only a text body, so quoted
|
|
3
|
+
// text converted by textToHtml() gets its quote colouring. A leaf module, so the package entry can
|
|
4
|
+
// export it without loading jsdom. Named export only: lib/ modules with a default export are
|
|
5
|
+
// rewritten to return it from require().
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.INLINE_STYLE_BLOCK = void 0;
|
|
8
|
+
/** Base styles mimeHtml() applies to a message that only has a plain text body */
|
|
9
|
+
exports.INLINE_STYLE_BLOCK = `<style>
|
|
10
|
+
|
|
11
|
+
body, td, th, p {
|
|
12
|
+
font-family: sans-serif;
|
|
13
|
+
font-size: 12px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
blockquote {
|
|
17
|
+
border-left-width: 2px;
|
|
18
|
+
border-left-style: solid;
|
|
19
|
+
|
|
20
|
+
border-left-color: darkblue;
|
|
21
|
+
color: darkblue;
|
|
22
|
+
|
|
23
|
+
margin: 1rem 0;
|
|
24
|
+
padding-left: 1rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
blockquote blockquote{
|
|
28
|
+
border-left-color: royalblue;
|
|
29
|
+
color: royalblue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
blockquote blockquote blockquote{
|
|
33
|
+
border-left-color: dodgerblue;
|
|
34
|
+
color: dodgerblue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
blockquote blockquote blockquote blockquote{
|
|
38
|
+
border-left-color: darkblue;
|
|
39
|
+
color: darkblue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
</style>`;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { FormatDateOptions } from './format-date.js';
|
|
2
|
+
import type { InlineAction, QuotedMessage } from './inline-html.js';
|
|
3
|
+
export default function inlineText(type: InlineAction | string, messageContent: string | Buffer | null | undefined, messageData: QuotedMessage, options?: FormatDateOptions | null): string;
|