@pithy-sh/support 0.1.2 → 0.1.4
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/dist/ai/classify.d.ts +92 -0
- package/dist/ai/classify.d.ts.map +1 -0
- package/dist/ai/classify.js +153 -0
- package/dist/attachment/store.d.ts +55 -0
- package/dist/attachment/store.d.ts.map +1 -0
- package/dist/attachment/store.js +69 -0
- package/dist/audit/actions.d.ts +70 -0
- package/dist/audit/actions.d.ts.map +1 -0
- package/dist/audit/actions.js +70 -0
- package/dist/capability.d.ts +95 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +189 -0
- package/dist/client/projection.d.ts +58 -0
- package/dist/client/projection.d.ts.map +1 -0
- package/dist/client/projection.js +2 -0
- package/dist/config/config.d.ts +217 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +171 -0
- package/dist/data/attachment.d.ts +32 -0
- package/dist/data/attachment.d.ts.map +1 -0
- package/dist/data/attachment.js +34 -0
- package/dist/data/billingScope.d.ts +32 -0
- package/dist/data/billingScope.d.ts.map +1 -0
- package/dist/data/billingScope.js +34 -0
- package/dist/data/categories.d.ts +56 -0
- package/dist/data/categories.d.ts.map +1 -0
- package/dist/data/categories.js +99 -0
- package/dist/data/classification.d.ts +36 -0
- package/dist/data/classification.d.ts.map +1 -0
- package/dist/data/classification.js +30 -0
- package/dist/data/enums.d.ts +84 -0
- package/dist/data/enums.d.ts.map +1 -0
- package/dist/data/enums.js +72 -0
- package/dist/data/flag.d.ts +27 -0
- package/dist/data/flag.d.ts.map +1 -0
- package/dist/data/flag.js +29 -0
- package/dist/data/message.d.ts +83 -0
- package/dist/data/message.d.ts.map +1 -0
- package/dist/data/message.js +86 -0
- package/dist/data/tables.d.ts +162 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +46 -0
- package/dist/data/thread.d.ts +79 -0
- package/dist/data/thread.d.ts.map +1 -0
- package/dist/data/thread.js +67 -0
- package/dist/error/errors.d.ts +66 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +84 -0
- package/dist/http/guards.d.ts +46 -0
- package/dist/http/guards.d.ts.map +1 -0
- package/dist/http/guards.js +55 -0
- package/dist/http/handlers.d.ts +91 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +250 -0
- package/dist/http/resolve.d.ts +46 -0
- package/dist/http/resolve.d.ts.map +1 -0
- package/dist/http/resolve.js +74 -0
- package/dist/http/responses.d.ts +831 -0
- package/dist/http/responses.d.ts.map +1 -0
- package/dist/http/responses.js +305 -0
- package/dist/http/routes.d.ts +111 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +64 -0
- package/dist/http/schemas.d.ts +123 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +91 -0
- package/dist/http/scopes.d.ts +58 -0
- package/dist/http/scopes.d.ts.map +1 -0
- package/dist/http/scopes.js +109 -0
- package/dist/http/views.d.ts +38 -0
- package/dist/http/views.d.ts.map +1 -0
- package/dist/http/views.js +146 -0
- package/dist/inbound/authenticity.d.ts +83 -0
- package/dist/inbound/authenticity.d.ts.map +1 -0
- package/dist/inbound/authenticity.js +63 -0
- package/dist/inbound/guard.d.ts +76 -0
- package/dist/inbound/guard.d.ts.map +1 -0
- package/dist/inbound/guard.js +80 -0
- package/dist/inbound/handler.d.ts +30 -0
- package/dist/inbound/handler.d.ts.map +1 -0
- package/dist/inbound/handler.js +84 -0
- package/dist/inbound/ingest.d.ts +106 -0
- package/dist/inbound/ingest.d.ts.map +1 -0
- package/dist/inbound/ingest.js +328 -0
- package/dist/inbound/recipient.d.ts +18 -0
- package/dist/inbound/recipient.d.ts.map +1 -0
- package/dist/inbound/recipient.js +48 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/link/sender.d.ts +162 -0
- package/dist/link/sender.d.ts.map +1 -0
- package/dist/link/sender.js +202 -0
- package/dist/migrations/0001_threads.d.ts +28 -0
- package/dist/migrations/0001_threads.d.ts.map +1 -0
- package/dist/migrations/0001_threads.js +105 -0
- package/dist/mime/address.d.ts +24 -0
- package/dist/mime/address.d.ts.map +1 -0
- package/dist/mime/address.js +30 -0
- package/dist/mime/parse.d.ts +98 -0
- package/dist/mime/parse.d.ts.map +1 -0
- package/dist/mime/parse.js +148 -0
- package/dist/mime/sanitize.d.ts +27 -0
- package/dist/mime/sanitize.d.ts.map +1 -0
- package/dist/mime/sanitize.js +214 -0
- package/dist/mime/threading.d.ts +81 -0
- package/dist/mime/threading.d.ts.map +1 -0
- package/dist/mime/threading.js +113 -0
- package/dist/mime/truncate.d.ts +19 -0
- package/dist/mime/truncate.d.ts.map +1 -0
- package/dist/mime/truncate.js +49 -0
- package/dist/provision/provisionSupport.d.ts +160 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -0
- package/dist/provision/provisionSupport.js +112 -0
- package/dist/provision/resolveSupportConfig.d.ts +47 -0
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
- package/dist/provision/resolveSupportConfig.js +24 -0
- package/dist/reply/send.d.ts +125 -0
- package/dist/reply/send.d.ts.map +1 -0
- package/dist/reply/send.js +155 -0
- package/dist/reply/snippets.d.ts +81 -0
- package/dist/reply/snippets.d.ts.map +1 -0
- package/dist/reply/snippets.js +137 -0
- package/dist/secret/registry.d.ts +20 -0
- package/dist/secret/registry.d.ts.map +1 -0
- package/dist/secret/registry.js +23 -0
- package/dist/seeds/example.d.ts +5 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +352 -0
- package/dist/store/paging.d.ts +20 -0
- package/dist/store/paging.d.ts.map +1 -0
- package/dist/store/paging.js +22 -0
- package/dist/store/search.d.ts +69 -0
- package/dist/store/search.d.ts.map +1 -0
- package/dist/store/search.js +162 -0
- package/dist/store/searchIndex.d.ts +55 -0
- package/dist/store/searchIndex.d.ts.map +1 -0
- package/dist/store/searchIndex.js +68 -0
- package/dist/store/threads.d.ts +149 -0
- package/dist/store/threads.d.ts.map +1 -0
- package/dist/store/threads.js +199 -0
- package/dist/submission/encoding.d.ts +23 -0
- package/dist/submission/encoding.d.ts.map +1 -0
- package/dist/submission/encoding.js +57 -0
- package/dist/submission/guard.d.ts +73 -0
- package/dist/submission/guard.d.ts.map +1 -0
- package/dist/submission/guard.js +48 -0
- package/dist/submission/submit.d.ts +154 -0
- package/dist/submission/submit.d.ts.map +1 -0
- package/dist/submission/submit.js +321 -0
- package/dist/version.generated.d.ts +7 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +9 -0
- package/dist/workflows/classify.d.ts +65 -0
- package/dist/workflows/classify.d.ts.map +1 -0
- package/dist/workflows/classify.js +70 -0
- package/dist/workflows/retryPolicy.d.ts +41 -0
- package/dist/workflows/retryPolicy.d.ts.map +1 -0
- package/dist/workflows/retryPolicy.js +45 -0
- package/dist/workflows/specs.d.ts +45 -0
- package/dist/workflows/specs.d.ts.map +1 -0
- package/dist/workflows/specs.js +48 -0
- package/dist/workflows/worker.d.ts +50 -0
- package/dist/workflows/worker.d.ts.map +1 -0
- package/dist/workflows/worker.js +39 -0
- package/package.json +24 -15
- package/src/http/handlers.ts +1 -1
- package/src/submission/encoding.ts +1 -1
- package/src/submission/submit.ts +1 -1
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/** The largest sanitized body kept, **in bytes**. A dashboard rendering 5 MB of markup has already lost. */
|
|
4
|
+
export declare const MAX_HTML_BYTES: number;
|
|
5
|
+
/** Whether an `href` value is safe to keep. Relative URLs are fine; a bare scheme we do not know is not. */
|
|
6
|
+
export declare function isSafeHref(value: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Sanitize an HTML body for storage and later rendering.
|
|
9
|
+
*
|
|
10
|
+
* **Remote images are stripped, not merely sandboxed.** An `<img src="https://…">` in a support
|
|
11
|
+
* message is a read receipt: it tells the sender the exact moment somebody opened their mail and
|
|
12
|
+
* from which IP, and against a support inbox that is a reconnaissance tool for whoever is probing
|
|
13
|
+
* you. Every mail client blocks them by default and so does this. The `alt` text survives, so a
|
|
14
|
+
* screenshot that mattered leaves a visible trace rather than vanishing silently. The original is
|
|
15
|
+
* still in R2 if an operator genuinely needs to see it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sanitizeHtml(html: string): Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Reduce HTML to readable plain text — the fallback when a message carried no text part.
|
|
20
|
+
*
|
|
21
|
+
* The classifier reads text, never markup: markup is tokens the adopter pays for that carry no
|
|
22
|
+
* meaning, and it is also an injection surface, since `<!-- ignore your instructions -->` reads
|
|
23
|
+
* exactly like prose to a model. Block-level tags become newlines so a paragraph structure survives
|
|
24
|
+
* the trip.
|
|
25
|
+
*/
|
|
26
|
+
export declare function htmlToText(html: string): Promise<string>;
|
|
27
|
+
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../../src/mime/sanitize.ts"],"names":[],"mappings":"AAuEA,4GAA4G;AAC5G,eAAO,MAAM,cAAc,QAAa,CAAC;AAsCzC,4GAA4G;AAC5G,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAmBjD;AAED;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4ChE;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAwD9D"}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { truncateToBytes } from "./truncate.js";
|
|
4
|
+
//#region src/mime/sanitize.ts
|
|
5
|
+
/**
|
|
6
|
+
* HTML sanitization for inbound mail, through the runtime's own streaming parser.
|
|
7
|
+
*
|
|
8
|
+
* This capability ingests attacker-controlled content and a dashboard renders it. That is the
|
|
9
|
+
* highest-risk sentence in the package, so the sanitizer is built on `HTMLRewriter` — workerd's real
|
|
10
|
+
* HTML parser — rather than on regular expressions. A regex sanitizer is a list of the tricks its
|
|
11
|
+
* author thought of; a parser-based one strips what the browser will actually see, which is the only
|
|
12
|
+
* question that matters. It is also why this file has no dependencies: the safest HTML parser
|
|
13
|
+
* available here already ships in the runtime.
|
|
14
|
+
*
|
|
15
|
+
* The policy is an **allowlist on both axes** — a small set of tags survives and a small set of
|
|
16
|
+
* attributes survives — because a denylist is wrong the day a new attribute is invented, and a
|
|
17
|
+
* support inbox will still be running then.
|
|
18
|
+
*
|
|
19
|
+
* `HTMLRewriter` is a Workers global, so it is touched only inside these functions. The module
|
|
20
|
+
* imports cleanly under node (which the `.describe()` meta-test requires); its tests are
|
|
21
|
+
* `.workers.test.ts`, running against the real parser rather than a stand-in for it.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Tags removed together with everything inside them.
|
|
25
|
+
*
|
|
26
|
+
* Two groups. The obvious executables (`script`, `iframe`, `object`, `embed`, `svg` — SVG is a
|
|
27
|
+
* scripting context, not an image format), and the quiet ones: `style` because CSS can position an
|
|
28
|
+
* invisible overlay over a real control, `link`/`base`/`meta` because they retarget or redirect the
|
|
29
|
+
* whole document, `form` and its inputs because a phishing form rendered inside a trusted dashboard
|
|
30
|
+
* is the most convincing phishing form there is, and `noscript`/`template` because their contents
|
|
31
|
+
* are inert to the parser here and live to a browser later.
|
|
32
|
+
*/
|
|
33
|
+
const DROPPED_TAGS = /* @__PURE__ */ new Set([
|
|
34
|
+
"script",
|
|
35
|
+
"style",
|
|
36
|
+
"iframe",
|
|
37
|
+
"frame",
|
|
38
|
+
"frameset",
|
|
39
|
+
"object",
|
|
40
|
+
"embed",
|
|
41
|
+
"applet",
|
|
42
|
+
"link",
|
|
43
|
+
"meta",
|
|
44
|
+
"base",
|
|
45
|
+
"form",
|
|
46
|
+
"input",
|
|
47
|
+
"button",
|
|
48
|
+
"select",
|
|
49
|
+
"option",
|
|
50
|
+
"textarea",
|
|
51
|
+
"svg",
|
|
52
|
+
"math",
|
|
53
|
+
"noscript",
|
|
54
|
+
"template",
|
|
55
|
+
"title"
|
|
56
|
+
]);
|
|
57
|
+
/**
|
|
58
|
+
* Attributes that survive, on any tag.
|
|
59
|
+
*
|
|
60
|
+
* Note what is absent and deliberate: `style` (see above), every `on*` handler, `srcset`/`ping`/
|
|
61
|
+
* `formaction`, and every `data-*`. Nothing needs an allowlist entry to be dropped — anything not on
|
|
62
|
+
* this list goes, which is what makes the policy hold against attributes that do not exist yet.
|
|
63
|
+
*/
|
|
64
|
+
const ALLOWED_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
65
|
+
"href",
|
|
66
|
+
"alt",
|
|
67
|
+
"title",
|
|
68
|
+
"colspan",
|
|
69
|
+
"rowspan",
|
|
70
|
+
"start",
|
|
71
|
+
"type",
|
|
72
|
+
"dir",
|
|
73
|
+
"lang"
|
|
74
|
+
]);
|
|
75
|
+
/** URL schemes a surviving `href` may use. Everything else — `javascript:`, `data:`, `vbscript:` — is dropped. */
|
|
76
|
+
const ALLOWED_SCHEMES = [
|
|
77
|
+
"http:",
|
|
78
|
+
"https:",
|
|
79
|
+
"mailto:",
|
|
80
|
+
"tel:"
|
|
81
|
+
];
|
|
82
|
+
/** The largest sanitized body kept, **in bytes**. A dashboard rendering 5 MB of markup has already lost. */
|
|
83
|
+
const MAX_HTML_BYTES = 524288;
|
|
84
|
+
/**
|
|
85
|
+
* Decode the character references a browser would decode before it resolves a scheme.
|
|
86
|
+
*
|
|
87
|
+
* `href="java script:alert(1)"` is a `javascript:` URL to every browser: the parser decodes the
|
|
88
|
+
* entity into a newline while reading the attribute, and the URL parser then strips it. A check that
|
|
89
|
+
* reads the raw attribute sees `java script:...`, whose first `#` looks like a fragment marker,
|
|
90
|
+
* and waves it through as relative — which is exactly the bypass this function exists to remove.
|
|
91
|
+
*
|
|
92
|
+
* Numeric, hex, and the three named references that can hide a scheme. Deliberately not a general
|
|
93
|
+
* entity decoder: this value is about to be compared against a scheme allowlist, and the only
|
|
94
|
+
* question worth answering is whether decoding could turn it into something that *is* a scheme.
|
|
95
|
+
*/
|
|
96
|
+
function decodeReferences(value) {
|
|
97
|
+
return value.replace(/&#x([0-9a-f]+);?/gi, (_, hex) => codePoint(Number.parseInt(hex, 16))).replace(/&#(\d+);?/g, (_, dec) => codePoint(Number.parseInt(dec, 10))).replace(/:?/gi, ":").replace(/&newline;?/gi, "\n").replace(/&tab;?/gi, " ");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* One decoded character, or the replacement character when the reference names nothing.
|
|
101
|
+
*
|
|
102
|
+
* `String.fromCodePoint` throws a `RangeError` above U+10FFFF, and `�` in an `href` is a
|
|
103
|
+
* string an attacker chooses. Unguarded it throws out of the attribute walk, rejects the whole
|
|
104
|
+
* `sanitizeHtml` promise, and fails ingest for that message — a mailable denial of service in the
|
|
105
|
+
* decoder that exists to make ingest safe. The HTML spec's own answer is U+FFFD, so this returns what
|
|
106
|
+
* a browser would have shown: a character that names no scheme and so decides nothing.
|
|
107
|
+
*/
|
|
108
|
+
function codePoint(value) {
|
|
109
|
+
if (!Number.isInteger(value) || value < 0 || value > 1114111) return "�";
|
|
110
|
+
return value >= 55296 && value <= 57343 ? "�" : String.fromCodePoint(value);
|
|
111
|
+
}
|
|
112
|
+
/** Whether an `href` value is safe to keep. Relative URLs are fine; a bare scheme we do not know is not. */
|
|
113
|
+
function isSafeHref(value) {
|
|
114
|
+
const trimmed = decodeReferences(value).replace(/[\u0000-\u0020]+/g, "").toLowerCase();
|
|
115
|
+
if (trimmed.length === 0) return false;
|
|
116
|
+
const colon = trimmed.indexOf(":");
|
|
117
|
+
if (colon === -1) return true;
|
|
118
|
+
if (colon > Math.min(...[
|
|
119
|
+
"/",
|
|
120
|
+
"?",
|
|
121
|
+
"#"
|
|
122
|
+
].map((char) => {
|
|
123
|
+
const index = trimmed.indexOf(char);
|
|
124
|
+
return index === -1 ? Number.POSITIVE_INFINITY : index;
|
|
125
|
+
}))) return true;
|
|
126
|
+
return ALLOWED_SCHEMES.includes(trimmed.slice(0, colon + 1));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Sanitize an HTML body for storage and later rendering.
|
|
130
|
+
*
|
|
131
|
+
* **Remote images are stripped, not merely sandboxed.** An `<img src="https://…">` in a support
|
|
132
|
+
* message is a read receipt: it tells the sender the exact moment somebody opened their mail and
|
|
133
|
+
* from which IP, and against a support inbox that is a reconnaissance tool for whoever is probing
|
|
134
|
+
* you. Every mail client blocks them by default and so does this. The `alt` text survives, so a
|
|
135
|
+
* screenshot that mattered leaves a visible trace rather than vanishing silently. The original is
|
|
136
|
+
* still in R2 if an operator genuinely needs to see it.
|
|
137
|
+
*/
|
|
138
|
+
async function sanitizeHtml(html) {
|
|
139
|
+
const sanitized = await new HTMLRewriter().onDocument({ comments(comment) {
|
|
140
|
+
comment.remove();
|
|
141
|
+
} }).on("*", { element(element) {
|
|
142
|
+
const tag = element.tagName.toLowerCase();
|
|
143
|
+
if (DROPPED_TAGS.has(tag)) {
|
|
144
|
+
element.remove();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const pair of [...element.attributes]) {
|
|
148
|
+
const name = pair[0] ?? "";
|
|
149
|
+
const value = pair[1] ?? "";
|
|
150
|
+
const attribute = name.toLowerCase();
|
|
151
|
+
if (!ALLOWED_ATTRIBUTES.has(attribute)) {
|
|
152
|
+
element.removeAttribute(name);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (attribute === "href" && !isSafeHref(value)) element.removeAttribute(name);
|
|
156
|
+
}
|
|
157
|
+
if (tag === "a" && element.getAttribute("href")) {
|
|
158
|
+
element.setAttribute("rel", "noopener noreferrer nofollow");
|
|
159
|
+
element.setAttribute("target", "_blank");
|
|
160
|
+
}
|
|
161
|
+
} }).transform(new Response(html)).text();
|
|
162
|
+
return truncateToBytes(sanitized, MAX_HTML_BYTES);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Reduce HTML to readable plain text — the fallback when a message carried no text part.
|
|
166
|
+
*
|
|
167
|
+
* The classifier reads text, never markup: markup is tokens the adopter pays for that carry no
|
|
168
|
+
* meaning, and it is also an injection surface, since `<!-- ignore your instructions -->` reads
|
|
169
|
+
* exactly like prose to a model. Block-level tags become newlines so a paragraph structure survives
|
|
170
|
+
* the trip.
|
|
171
|
+
*/
|
|
172
|
+
async function htmlToText(html) {
|
|
173
|
+
const parts = [];
|
|
174
|
+
let suppressed = 0;
|
|
175
|
+
await new HTMLRewriter().onDocument({
|
|
176
|
+
comments(comment) {
|
|
177
|
+
comment.remove();
|
|
178
|
+
},
|
|
179
|
+
text(chunk) {
|
|
180
|
+
if (suppressed === 0) parts.push(chunk.text);
|
|
181
|
+
}
|
|
182
|
+
}).on("*", { element(element) {
|
|
183
|
+
const tag = element.tagName.toLowerCase();
|
|
184
|
+
if (DROPPED_TAGS.has(tag)) {
|
|
185
|
+
element.remove();
|
|
186
|
+
suppressed += 1;
|
|
187
|
+
try {
|
|
188
|
+
element.onEndTag(() => {
|
|
189
|
+
suppressed -= 1;
|
|
190
|
+
});
|
|
191
|
+
} catch {
|
|
192
|
+
suppressed -= 1;
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if ([
|
|
197
|
+
"p",
|
|
198
|
+
"div",
|
|
199
|
+
"br",
|
|
200
|
+
"tr",
|
|
201
|
+
"li",
|
|
202
|
+
"h1",
|
|
203
|
+
"h2",
|
|
204
|
+
"h3",
|
|
205
|
+
"h4",
|
|
206
|
+
"h5",
|
|
207
|
+
"h6",
|
|
208
|
+
"blockquote"
|
|
209
|
+
].includes(tag)) parts.push("\n");
|
|
210
|
+
} }).transform(new Response(html)).text();
|
|
211
|
+
return parts.join("").replace(/[\t\r ]+/g, " ").replace(/ ?\n ?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
export { MAX_HTML_BYTES, htmlToText, isSafeHref, sanitizeHtml };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* RFC 5322 threading — turning `Message-ID`, `In-Reply-To`, and `References` into a conversation.
|
|
5
|
+
*
|
|
6
|
+
* **Threading is done on ids, never on the subject.** Subject matching is what makes two unrelated
|
|
7
|
+
* people who both wrote "Refund" land in one thread, and it is what splits a real conversation the
|
|
8
|
+
* moment somebody's client localizes `Re:` to `Aw:` or `Antw:`. The headers exist precisely so this
|
|
9
|
+
* does not have to be guessed, and every mail client sets them.
|
|
10
|
+
*
|
|
11
|
+
* The chain is tried in the order that goes from most precise to least: `In-Reply-To` names exactly
|
|
12
|
+
* one message, so it is asked first; `References` is the whole ancestry, so it is the fallback for a
|
|
13
|
+
* client that dropped `In-Reply-To` or a reply to a message that reached us out of order. Reading
|
|
14
|
+
* `References` **newest-first** matters — the last entry is the nearest ancestor, and starting at the
|
|
15
|
+
* root would attach a reply to the top of a long thread rather than to the message it answers.
|
|
16
|
+
*/
|
|
17
|
+
/** The longest `References` chain kept. Bounded because the header is attacker-controlled and unbounded by spec. */
|
|
18
|
+
export declare const MAX_REFERENCES = 50;
|
|
19
|
+
/**
|
|
20
|
+
* Strip the angle brackets from a message id and bound it.
|
|
21
|
+
*
|
|
22
|
+
* Stored without brackets so a lookup is plain equality: the brackets are transport syntax, and one
|
|
23
|
+
* path storing `<a@b>` while another queries `a@b` is a threading bug that only shows up on replies.
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeMessageId(value: string | null | undefined): string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Parse a `References` (or `In-Reply-To`) header into ids, oldest first, deduplicated and bounded.
|
|
28
|
+
*
|
|
29
|
+
* `postal-mime` hands `references` back as the raw header string, so the splitting is ours. Angle
|
|
30
|
+
* brackets are the delimiter when they are present and whitespace is the fallback when they are not,
|
|
31
|
+
* because a malformed header from a hand-rolled sender is a normal input here.
|
|
32
|
+
*
|
|
33
|
+
* **When the chain is over the cap, the OLDEST entries are dropped, not the newest.** That direction
|
|
34
|
+
* is the whole point: `parentCandidates` reads this list from the newest end backwards, because the
|
|
35
|
+
* nearest ancestor is the message a reply actually answers. Capping from the other end would discard
|
|
36
|
+
* exactly the ids the lookup wants first and keep the ones it would reach last — so a long thread
|
|
37
|
+
* would silently stop threading on `References` and fall back to whatever `In-Reply-To` happened to
|
|
38
|
+
* survive. Same rule `buildReferencesHeader` follows on the way out, and RFC 5322 §3.6.4 says to trim
|
|
39
|
+
* this end for the same reason.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseReferences(value: string | null | undefined): string[];
|
|
42
|
+
/**
|
|
43
|
+
* The ids to look a parent up by, most precise first.
|
|
44
|
+
*
|
|
45
|
+
* The order is the whole content of this function: `In-Reply-To`, then `References` from the newest
|
|
46
|
+
* entry backwards. A caller resolves the first one it recognizes and stops.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parentCandidates(inReplyTo: string | undefined, references: readonly string[]): string[];
|
|
49
|
+
/**
|
|
50
|
+
* Build the `References` header value for an outgoing reply.
|
|
51
|
+
*
|
|
52
|
+
* RFC 5322 §3.6.4: a reply's `References` is the parent's `References` followed by the parent's
|
|
53
|
+
* `Message-ID`. Getting this wrong is not cosmetic — it is the difference between the customer
|
|
54
|
+
* seeing one conversation and seeing a new mail every time somebody answers them, which is the
|
|
55
|
+
* single most visible way a support inbox looks broken from the outside. It is also the reason
|
|
56
|
+
* replying belongs in the Worker rather than in a dashboard: only the Worker holds the chain.
|
|
57
|
+
*
|
|
58
|
+
* The result is bracketed and space-separated, ready to be a header value, and bounded from the
|
|
59
|
+
* *front* — RFC 5322 says to drop the oldest entries when a chain has to be trimmed, because the
|
|
60
|
+
* recent ones are what a client threads on.
|
|
61
|
+
*/
|
|
62
|
+
export declare function buildReferencesHeader(parentReferences: readonly string[], parentMessageId?: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Mint a `Message-ID` for an outgoing reply.
|
|
65
|
+
*
|
|
66
|
+
* Ours to generate, because a message we send has to be nameable by the customer's own reply — their
|
|
67
|
+
* client will put this value in its `In-Reply-To`, and that is how the answer comes back to the same
|
|
68
|
+
* thread. The domain half is taken from the address it is sent as, so the id is plausible to a
|
|
69
|
+
* receiving spam filter rather than pointing at a domain we do not control.
|
|
70
|
+
*/
|
|
71
|
+
export declare function mintMessageId(fromAddress: string, uuid: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* The subject line for a reply — `Re: ` prefixed exactly once.
|
|
74
|
+
*
|
|
75
|
+
* Match `Re:` case-insensitively and allow the bracketed count some clients add (`Re[2]:`), so
|
|
76
|
+
* answering a long thread does not build `Re: Re: Re: Re:`. Non-English prefixes are deliberately
|
|
77
|
+
* left alone: they are unbounded in practice, and a stray `Aw: Re:` is cosmetic, while stripping a
|
|
78
|
+
* word that happened to look like a prefix would change what the customer wrote.
|
|
79
|
+
*/
|
|
80
|
+
export declare function replySubject(subject: string): string;
|
|
81
|
+
//# sourceMappingURL=threading.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"threading.d.ts","sourceRoot":"","sources":["../../src/mime/threading.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AAEH,oHAAoH;AACpH,eAAO,MAAM,cAAc,KAAK,CAAC;AAKjC;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAMvF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,CAW1E;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAGvG;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,MAAM,CAU3G;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAGvE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGpD"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/mime/threading.ts
|
|
4
|
+
/**
|
|
5
|
+
* RFC 5322 threading — turning `Message-ID`, `In-Reply-To`, and `References` into a conversation.
|
|
6
|
+
*
|
|
7
|
+
* **Threading is done on ids, never on the subject.** Subject matching is what makes two unrelated
|
|
8
|
+
* people who both wrote "Refund" land in one thread, and it is what splits a real conversation the
|
|
9
|
+
* moment somebody's client localizes `Re:` to `Aw:` or `Antw:`. The headers exist precisely so this
|
|
10
|
+
* does not have to be guessed, and every mail client sets them.
|
|
11
|
+
*
|
|
12
|
+
* The chain is tried in the order that goes from most precise to least: `In-Reply-To` names exactly
|
|
13
|
+
* one message, so it is asked first; `References` is the whole ancestry, so it is the fallback for a
|
|
14
|
+
* client that dropped `In-Reply-To` or a reply to a message that reached us out of order. Reading
|
|
15
|
+
* `References` **newest-first** matters — the last entry is the nearest ancestor, and starting at the
|
|
16
|
+
* root would attach a reply to the top of a long thread rather than to the message it answers.
|
|
17
|
+
*/
|
|
18
|
+
/** The longest `References` chain kept. Bounded because the header is attacker-controlled and unbounded by spec. */
|
|
19
|
+
const MAX_REFERENCES = 50;
|
|
20
|
+
/** The longest single message id kept, after unwrapping. Anything longer is not an id a client produced. */
|
|
21
|
+
const MAX_ID_LENGTH = 512;
|
|
22
|
+
/**
|
|
23
|
+
* Strip the angle brackets from a message id and bound it.
|
|
24
|
+
*
|
|
25
|
+
* Stored without brackets so a lookup is plain equality: the brackets are transport syntax, and one
|
|
26
|
+
* path storing `<a@b>` while another queries `a@b` is a threading bug that only shows up on replies.
|
|
27
|
+
*/
|
|
28
|
+
function normalizeMessageId(value) {
|
|
29
|
+
if (typeof value !== "string") return void 0;
|
|
30
|
+
const trimmed = value.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
|
|
31
|
+
if (trimmed.length === 0 || trimmed.length > MAX_ID_LENGTH) return void 0;
|
|
32
|
+
return /\s/.test(trimmed) ? void 0 : trimmed;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse a `References` (or `In-Reply-To`) header into ids, oldest first, deduplicated and bounded.
|
|
36
|
+
*
|
|
37
|
+
* `postal-mime` hands `references` back as the raw header string, so the splitting is ours. Angle
|
|
38
|
+
* brackets are the delimiter when they are present and whitespace is the fallback when they are not,
|
|
39
|
+
* because a malformed header from a hand-rolled sender is a normal input here.
|
|
40
|
+
*
|
|
41
|
+
* **When the chain is over the cap, the OLDEST entries are dropped, not the newest.** That direction
|
|
42
|
+
* is the whole point: `parentCandidates` reads this list from the newest end backwards, because the
|
|
43
|
+
* nearest ancestor is the message a reply actually answers. Capping from the other end would discard
|
|
44
|
+
* exactly the ids the lookup wants first and keep the ones it would reach last — so a long thread
|
|
45
|
+
* would silently stop threading on `References` and fall back to whatever `In-Reply-To` happened to
|
|
46
|
+
* survive. Same rule `buildReferencesHeader` follows on the way out, and RFC 5322 §3.6.4 says to trim
|
|
47
|
+
* this end for the same reason.
|
|
48
|
+
*/
|
|
49
|
+
function parseReferences(value) {
|
|
50
|
+
if (typeof value !== "string") return [];
|
|
51
|
+
const bracketed = [...value.matchAll(/<([^<>]+)>/g)].map((match) => match[1] ?? "");
|
|
52
|
+
const candidates = bracketed.length > 0 ? bracketed : value.split(/\s+/);
|
|
53
|
+
const seen = /* @__PURE__ */ new Set();
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
const id = normalizeMessageId(candidate);
|
|
56
|
+
if (id) seen.add(id);
|
|
57
|
+
}
|
|
58
|
+
const ids = [...seen];
|
|
59
|
+
return ids.length > 50 ? ids.slice(ids.length - 50) : ids;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The ids to look a parent up by, most precise first.
|
|
63
|
+
*
|
|
64
|
+
* The order is the whole content of this function: `In-Reply-To`, then `References` from the newest
|
|
65
|
+
* entry backwards. A caller resolves the first one it recognizes and stops.
|
|
66
|
+
*/
|
|
67
|
+
function parentCandidates(inReplyTo, references) {
|
|
68
|
+
const ordered = [...inReplyTo ? [inReplyTo] : [], ...[...references].reverse()];
|
|
69
|
+
return [...new Set(ordered)];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build the `References` header value for an outgoing reply.
|
|
73
|
+
*
|
|
74
|
+
* RFC 5322 §3.6.4: a reply's `References` is the parent's `References` followed by the parent's
|
|
75
|
+
* `Message-ID`. Getting this wrong is not cosmetic — it is the difference between the customer
|
|
76
|
+
* seeing one conversation and seeing a new mail every time somebody answers them, which is the
|
|
77
|
+
* single most visible way a support inbox looks broken from the outside. It is also the reason
|
|
78
|
+
* replying belongs in the Worker rather than in a dashboard: only the Worker holds the chain.
|
|
79
|
+
*
|
|
80
|
+
* The result is bracketed and space-separated, ready to be a header value, and bounded from the
|
|
81
|
+
* *front* — RFC 5322 says to drop the oldest entries when a chain has to be trimmed, because the
|
|
82
|
+
* recent ones are what a client threads on.
|
|
83
|
+
*/
|
|
84
|
+
function buildReferencesHeader(parentReferences, parentMessageId) {
|
|
85
|
+
const chain = [...parentReferences.filter((id) => id !== parentMessageId), ...parentMessageId ? [parentMessageId] : []];
|
|
86
|
+
const deduped = [...new Set(chain)];
|
|
87
|
+
return (deduped.length > 50 ? deduped.slice(deduped.length - 50) : deduped).map((id) => `<${id}>`).join(" ");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Mint a `Message-ID` for an outgoing reply.
|
|
91
|
+
*
|
|
92
|
+
* Ours to generate, because a message we send has to be nameable by the customer's own reply — their
|
|
93
|
+
* client will put this value in its `In-Reply-To`, and that is how the answer comes back to the same
|
|
94
|
+
* thread. The domain half is taken from the address it is sent as, so the id is plausible to a
|
|
95
|
+
* receiving spam filter rather than pointing at a domain we do not control.
|
|
96
|
+
*/
|
|
97
|
+
function mintMessageId(fromAddress, uuid) {
|
|
98
|
+
return `${uuid}@${fromAddress.slice(fromAddress.lastIndexOf("@") + 1) || "localhost"}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The subject line for a reply — `Re: ` prefixed exactly once.
|
|
102
|
+
*
|
|
103
|
+
* Match `Re:` case-insensitively and allow the bracketed count some clients add (`Re[2]:`), so
|
|
104
|
+
* answering a long thread does not build `Re: Re: Re: Re:`. Non-English prefixes are deliberately
|
|
105
|
+
* left alone: they are unbounded in practice, and a stray `Aw: Re:` is cosmetic, while stripping a
|
|
106
|
+
* word that happened to look like a prefix would change what the customer wrote.
|
|
107
|
+
*/
|
|
108
|
+
function replySubject(subject) {
|
|
109
|
+
const trimmed = subject.trim();
|
|
110
|
+
return /^re(\[\d+\])?:/i.test(trimmed) ? trimmed : `Re: ${trimmed}`;
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
export { MAX_REFERENCES, buildReferencesHeader, mintMessageId, normalizeMessageId, parentCandidates, parseReferences, replySubject };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/** How many bytes this string occupies as UTF-8. */
|
|
4
|
+
export declare function byteLength(value: string): number;
|
|
5
|
+
/**
|
|
6
|
+
* `value`, cut to at most `maxBytes` UTF-8 bytes, never mid-code-point.
|
|
7
|
+
*
|
|
8
|
+
* **Binary search, because the obvious loop is quadratic.** Walking down one code unit at a time and
|
|
9
|
+
* re-encoding the prefix each step is O(n) encodes of O(n) bytes, and the starting point makes it
|
|
10
|
+
* worse: `maxBytes` *code units* can be four times `maxBytes` *bytes*, so multi-byte text starts far
|
|
11
|
+
* above the answer and decrements toward it. Measured on the real constants, a 400k-character CJK
|
|
12
|
+
* body took 174,763 iterations and about three minutes of CPU — inside an `email()` handler, which
|
|
13
|
+
* the runtime kills long before that, so the message was never stored and every redelivery hit the
|
|
14
|
+
* same wall. A size bound that loses the message is worse than no bound.
|
|
15
|
+
*
|
|
16
|
+
* The search runs over `[0, min(length, maxBytes)]`: `maxBytes` code units is an upper bound because
|
|
17
|
+
* every code unit is at least one byte, and the predicate is monotone, so O(log n) encodes settle it.
|
|
18
|
+
*/ export declare function truncateToBytes(value: string, maxBytes: number): string;
|
|
19
|
+
//# sourceMappingURL=truncate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"truncate.d.ts","sourceRoot":"","sources":["../../src/mime/truncate.ts"],"names":[],"mappings":"AAmBA,oDAAoD;AACpD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;;;;;;;;;;;GAaG,CAAC,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAiB3E"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/mime/truncate.ts
|
|
4
|
+
/**
|
|
5
|
+
* Truncation that counts **bytes**, because the limits it serves are byte limits.
|
|
6
|
+
*
|
|
7
|
+
* `String.prototype.slice` counts UTF-16 code units, so a bound written as "256 KB" admits 256 K
|
|
8
|
+
* *characters* — three times that in bytes for CJK, four for most emoji. The bounds in this package
|
|
9
|
+
* exist to keep a row under D1's 2,000,000-byte ceiling, and a character-counted bound does not do
|
|
10
|
+
* that: a large CJK message sails through every check and then fails the insert, at which point the
|
|
11
|
+
* inbound handler swallows the error and the customer's mail is gone. Counting the thing the limit is
|
|
12
|
+
* about is the whole fix.
|
|
13
|
+
*
|
|
14
|
+
* Truncation lands on a code-point boundary, so the result is never a lone surrogate — which would be
|
|
15
|
+
* invalid text in the database and could break a JSON encode downstream.
|
|
16
|
+
*/
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
/** How many bytes this string occupies as UTF-8. */
|
|
19
|
+
function byteLength(value) {
|
|
20
|
+
return encoder.encode(value).byteLength;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `value`, cut to at most `maxBytes` UTF-8 bytes, never mid-code-point.
|
|
24
|
+
*
|
|
25
|
+
* **Binary search, because the obvious loop is quadratic.** Walking down one code unit at a time and
|
|
26
|
+
* re-encoding the prefix each step is O(n) encodes of O(n) bytes, and the starting point makes it
|
|
27
|
+
* worse: `maxBytes` *code units* can be four times `maxBytes` *bytes*, so multi-byte text starts far
|
|
28
|
+
* above the answer and decrements toward it. Measured on the real constants, a 400k-character CJK
|
|
29
|
+
* body took 174,763 iterations and about three minutes of CPU — inside an `email()` handler, which
|
|
30
|
+
* the runtime kills long before that, so the message was never stored and every redelivery hit the
|
|
31
|
+
* same wall. A size bound that loses the message is worse than no bound.
|
|
32
|
+
*
|
|
33
|
+
* The search runs over `[0, min(length, maxBytes)]`: `maxBytes` code units is an upper bound because
|
|
34
|
+
* every code unit is at least one byte, and the predicate is monotone, so O(log n) encodes settle it.
|
|
35
|
+
*/ function truncateToBytes(value, maxBytes) {
|
|
36
|
+
if (maxBytes <= 0) return "";
|
|
37
|
+
if (byteLength(value) <= maxBytes) return value;
|
|
38
|
+
let low = 0;
|
|
39
|
+
let high = Math.min(value.length, maxBytes);
|
|
40
|
+
while (low < high) {
|
|
41
|
+
const mid = Math.ceil((low + high) / 2);
|
|
42
|
+
if (byteLength(value.slice(0, mid)) <= maxBytes) low = mid;
|
|
43
|
+
else high = mid - 1;
|
|
44
|
+
}
|
|
45
|
+
const cut = value.slice(0, low);
|
|
46
|
+
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
export { byteLength, truncateToBytes };
|