@gmickel/gno 1.27.0 → 1.28.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/README.md +2 -2
- package/assets/skill/SKILL.md +26 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/spec/cli.md +41 -4
- package/spec/db/schema.sql +12 -0
- package/spec/mcp.md +5 -0
- package/spec/output-schemas/ask.schema.json +125 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
- package/spec/output-schemas/get.schema.json +94 -0
- package/spec/output-schemas/mcp-job-status.schema.json +28 -0
- package/spec/output-schemas/multi-get.schema.json +128 -0
- package/spec/output-schemas/record-import.schema.json +193 -0
- package/spec/output-schemas/search-result.schema.json +94 -0
- package/spec/output-schemas/search-results.schema.json +125 -0
- package/spec/project-profile.schema.json +66 -0
- package/src/app/context-format.ts +1 -1
- package/src/cli/commands/get.ts +12 -2
- package/src/cli/commands/index-cmd.ts +13 -0
- package/src/cli/commands/multi-get.ts +9 -2
- package/src/cli/commands/shared.ts +28 -0
- package/src/cli/commands/update.ts +5 -0
- package/src/cli/program.ts +4 -0
- package/src/config/project-profile.ts +2 -0
- package/src/config/types.ts +16 -0
- package/src/converters/adapters/browser-export/adapter.ts +199 -0
- package/src/converters/adapters/browser-export/formats.ts +358 -0
- package/src/converters/adapters/email/adapter.ts +429 -0
- package/src/converters/adapters/email/html.ts +162 -0
- package/src/converters/adapters/email/mime.ts +454 -0
- package/src/converters/adapters/email/parameters.ts +77 -0
- package/src/converters/adapters/ical/adapter.ts +475 -0
- package/src/converters/adapters/ical/recurrence.ts +186 -0
- package/src/converters/adapters/jsonl/adapter.ts +238 -0
- package/src/converters/adapters/jsonl/config.ts +105 -0
- package/src/converters/adapters/shared/html-text.ts +165 -0
- package/src/converters/adapters/shared/record-utils.ts +79 -0
- package/src/converters/adapters/shared/utf8-lines.ts +141 -0
- package/src/converters/adapters/transcript/adapter.ts +295 -0
- package/src/converters/adapters/transcript/json.ts +184 -0
- package/src/converters/adapters/transcript/model.ts +171 -0
- package/src/converters/adapters/transcript/text.ts +58 -0
- package/src/converters/adapters/transcript/timed.ts +152 -0
- package/src/converters/index.ts +11 -1
- package/src/converters/mime.ts +8 -0
- package/src/converters/pipeline.ts +15 -1
- package/src/converters/registry.ts +37 -1
- package/src/converters/types.ts +136 -0
- package/src/core/context-capsule-schema.ts +87 -0
- package/src/core/context-capsule.ts +20 -0
- package/src/core/context-evidence.ts +7 -1
- package/src/core/document-capabilities.ts +12 -0
- package/src/core/project-profile-apply-state.ts +5 -0
- package/src/core/project-profile.ts +4 -0
- package/src/core/record-metadata.ts +49 -0
- package/src/ingestion/record-adapter-canonical.ts +433 -0
- package/src/ingestion/record-adapter.ts +437 -0
- package/src/ingestion/record-container.ts +688 -0
- package/src/ingestion/record-path.ts +20 -0
- package/src/ingestion/record-sync.ts +70 -0
- package/src/ingestion/sync.ts +228 -36
- package/src/ingestion/types.ts +69 -1
- package/src/ingestion/walker.ts +31 -11
- package/src/mcp/tools/get.ts +10 -2
- package/src/mcp/tools/multi-get.ts +9 -2
- package/src/mcp/tools/workspace-write.ts +2 -0
- package/src/pipeline/filters.ts +1 -1
- package/src/pipeline/graph-retrieval.ts +7 -4
- package/src/pipeline/hybrid.ts +7 -4
- package/src/pipeline/result-context.ts +12 -4
- package/src/pipeline/search.ts +11 -4
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +26 -8
- package/src/sdk/documents.ts +13 -5
- package/src/serve/browse-tree.ts +4 -2
- package/src/serve/routes/api.ts +63 -65
- package/src/store/migrations/022-record-export-lineage.ts +42 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +114 -7
- package/src/store/types.ts +40 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +0 -1
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RecordAttachmentInventoryItem,
|
|
3
|
+
RecordMetadata,
|
|
4
|
+
} from "../../types";
|
|
5
|
+
|
|
6
|
+
import { RECORD_METADATA_LIMITS } from "../../types";
|
|
7
|
+
import { sanitizeHtmlToText } from "./html";
|
|
8
|
+
import { parseParameterizedHeader } from "./parameters";
|
|
9
|
+
|
|
10
|
+
const MAX_MIME_DEPTH = 12;
|
|
11
|
+
const MAX_MIME_PARTS = 256;
|
|
12
|
+
const MAX_HEADER_CHARS = 256 * 1024;
|
|
13
|
+
const MAX_HEADER_LINES = 2_048;
|
|
14
|
+
const CONTROL_CHAR_PATTERN = new RegExp(
|
|
15
|
+
`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`,
|
|
16
|
+
"g"
|
|
17
|
+
);
|
|
18
|
+
const MESSAGE_ID_PATTERN = /<([^<>\s]+)>/g;
|
|
19
|
+
|
|
20
|
+
export type MailParseErrorKind = "limit" | "malformed";
|
|
21
|
+
|
|
22
|
+
export class MailParseError extends Error {
|
|
23
|
+
constructor(
|
|
24
|
+
message: string,
|
|
25
|
+
readonly kind: MailParseErrorKind = "malformed"
|
|
26
|
+
) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "MailParseError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ParsedAttachment extends RecordAttachmentInventoryItem {
|
|
33
|
+
sha256: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ParsedEmail {
|
|
37
|
+
subject?: string;
|
|
38
|
+
author?: string;
|
|
39
|
+
participants: string[];
|
|
40
|
+
sentAt?: string;
|
|
41
|
+
messageId?: string;
|
|
42
|
+
inReplyTo?: string;
|
|
43
|
+
references: string[];
|
|
44
|
+
body: string;
|
|
45
|
+
attachments: ParsedAttachment[];
|
|
46
|
+
metadata: RecordMetadata;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ParseEmailLimits {
|
|
50
|
+
maxBodyChars: number;
|
|
51
|
+
maxMetadataChars: number;
|
|
52
|
+
maxAttachmentBytes: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface MimeState {
|
|
56
|
+
plainBodies: string[];
|
|
57
|
+
htmlBodies: string[];
|
|
58
|
+
attachments: ParsedAttachment[];
|
|
59
|
+
partCount: number;
|
|
60
|
+
limits: ParseEmailLimits;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const binaryToBytes = (value: string): Uint8Array => {
|
|
64
|
+
const bytes = new Uint8Array(value.length);
|
|
65
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
66
|
+
bytes[index] = value.charCodeAt(index) & 0xff;
|
|
67
|
+
}
|
|
68
|
+
return bytes;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const decodeBytes = (bytes: Uint8Array, charset = "utf-8"): string => {
|
|
72
|
+
const normalized = charset.trim().toLowerCase().replaceAll("_", "-");
|
|
73
|
+
const labels: Record<string, string> = {
|
|
74
|
+
ascii: "windows-1252",
|
|
75
|
+
"iso-8859-1": "windows-1252",
|
|
76
|
+
latin1: "windows-1252",
|
|
77
|
+
"us-ascii": "windows-1252",
|
|
78
|
+
utf8: "utf-8",
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
return new TextDecoder(labels[normalized] ?? normalized, {
|
|
82
|
+
fatal: false,
|
|
83
|
+
}).decode(bytes);
|
|
84
|
+
} catch {
|
|
85
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const decodeQuotedPrintable = (value: string): Uint8Array => {
|
|
90
|
+
const unfolded = value.replace(/=\r?\n/g, "");
|
|
91
|
+
const output: number[] = [];
|
|
92
|
+
for (let index = 0; index < unfolded.length; index += 1) {
|
|
93
|
+
if (
|
|
94
|
+
unfolded[index] === "=" &&
|
|
95
|
+
/^[\da-f]{2}$/i.test(unfolded.slice(index + 1, index + 3))
|
|
96
|
+
) {
|
|
97
|
+
output.push(Number.parseInt(unfolded.slice(index + 1, index + 3), 16));
|
|
98
|
+
index += 2;
|
|
99
|
+
} else {
|
|
100
|
+
output.push(unfolded.charCodeAt(index) & 0xff);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return Uint8Array.from(output);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const decodeBase64 = (value: string): Uint8Array => {
|
|
107
|
+
const compact = value.replace(/\s/g, "");
|
|
108
|
+
if (
|
|
109
|
+
compact.length === 0 ||
|
|
110
|
+
compact.length % 4 === 1 ||
|
|
111
|
+
!/^[a-z\d+/]*={0,2}$/i.test(compact)
|
|
112
|
+
) {
|
|
113
|
+
throw new MailParseError("Malformed base64 MIME body.");
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return Uint8Array.fromBase64(compact);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new MailParseError("Malformed base64 MIME body.");
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const decodeTransfer = (
|
|
123
|
+
value: string,
|
|
124
|
+
encoding: string | undefined,
|
|
125
|
+
maxBytes: number
|
|
126
|
+
): Uint8Array => {
|
|
127
|
+
const normalized = encoding?.trim().toLowerCase();
|
|
128
|
+
let bytes: Uint8Array;
|
|
129
|
+
if (normalized === "base64") {
|
|
130
|
+
if (value.replace(/\s/g, "").length > maxBytes * 2) {
|
|
131
|
+
throw new MailParseError(
|
|
132
|
+
"MIME body exceeds its decoded byte limit.",
|
|
133
|
+
"limit"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
bytes = decodeBase64(value);
|
|
137
|
+
} else if (normalized === "quoted-printable") {
|
|
138
|
+
if (value.length > maxBytes * 3) {
|
|
139
|
+
throw new MailParseError(
|
|
140
|
+
"MIME body exceeds its decoded byte limit.",
|
|
141
|
+
"limit"
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
bytes = decodeQuotedPrintable(value);
|
|
145
|
+
} else {
|
|
146
|
+
bytes = binaryToBytes(value);
|
|
147
|
+
}
|
|
148
|
+
if (bytes.byteLength > maxBytes) {
|
|
149
|
+
throw new MailParseError(
|
|
150
|
+
"MIME body exceeds its decoded byte limit.",
|
|
151
|
+
"limit"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return bytes;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const decodeHeaderWords = (value: string): string => {
|
|
158
|
+
const decoded = decodeBytes(binaryToBytes(value)).replace(
|
|
159
|
+
/(\?=)\s+(=\?)/g,
|
|
160
|
+
"$1$2"
|
|
161
|
+
);
|
|
162
|
+
return decoded
|
|
163
|
+
.replace(
|
|
164
|
+
/=\?([^?]+)\?([bq])\?([^?]*)\?=/gi,
|
|
165
|
+
(_match, charset: string, encoding: string, payload: string) => {
|
|
166
|
+
try {
|
|
167
|
+
const bytes =
|
|
168
|
+
encoding.toLowerCase() === "b"
|
|
169
|
+
? decodeBase64(payload)
|
|
170
|
+
: decodeQuotedPrintable(payload.replaceAll("_", " "));
|
|
171
|
+
return decodeBytes(bytes, charset);
|
|
172
|
+
} catch {
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
.replace(/\s+/g, " ")
|
|
178
|
+
.trim();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const splitHeaderBody = (raw: string): [string, string] => {
|
|
182
|
+
const match = /\r?\n\r?\n/.exec(raw);
|
|
183
|
+
if (!match || match.index === undefined) {
|
|
184
|
+
return [raw, ""];
|
|
185
|
+
}
|
|
186
|
+
return [raw.slice(0, match.index), raw.slice(match.index + match[0].length)];
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const parseHeaders = (raw: string): Map<string, string> => {
|
|
190
|
+
if (raw.length > MAX_HEADER_CHARS) {
|
|
191
|
+
throw new MailParseError(
|
|
192
|
+
"Mail headers exceed their character limit.",
|
|
193
|
+
"limit"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const unfolded = raw.replace(/\r?\n[ \t]+/g, " ");
|
|
197
|
+
const lines = unfolded.split(/\r?\n/);
|
|
198
|
+
if (lines.length > MAX_HEADER_LINES) {
|
|
199
|
+
throw new MailParseError("Mail headers exceed their line limit.", "limit");
|
|
200
|
+
}
|
|
201
|
+
const headers = new Map<string, string>();
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
if (!line) continue;
|
|
204
|
+
const separator = line.indexOf(":");
|
|
205
|
+
if (separator <= 0) continue;
|
|
206
|
+
const name = line.slice(0, separator).trim().toLowerCase();
|
|
207
|
+
const value = line.slice(separator + 1).trim();
|
|
208
|
+
if (!/^[a-z\d-]+$/.test(name)) continue;
|
|
209
|
+
const previous = headers.get(name);
|
|
210
|
+
headers.set(name, previous ? `${previous}, ${value}` : value);
|
|
211
|
+
}
|
|
212
|
+
return headers;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const splitMultipart = (body: string, boundary: string): string[] => {
|
|
216
|
+
if (!boundary || boundary.length > 200 || /[\r\n]/.test(boundary)) {
|
|
217
|
+
throw new MailParseError("Invalid MIME multipart boundary.");
|
|
218
|
+
}
|
|
219
|
+
const marker = `--${boundary}`;
|
|
220
|
+
const closing = `${marker}--`;
|
|
221
|
+
const parts: string[] = [];
|
|
222
|
+
let current: string[] | undefined;
|
|
223
|
+
for (const line of body.split("\n")) {
|
|
224
|
+
const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
225
|
+
const boundaryLine = normalized.replace(/[ \t]+$/, "");
|
|
226
|
+
if (boundaryLine === marker || boundaryLine === closing) {
|
|
227
|
+
if (current) parts.push(current.join("\n"));
|
|
228
|
+
current = boundaryLine === closing ? undefined : [];
|
|
229
|
+
if (boundaryLine === closing) break;
|
|
230
|
+
} else if (current) {
|
|
231
|
+
current.push(line);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (parts.length === 0) {
|
|
235
|
+
throw new MailParseError("MIME multipart body has no bounded parts.");
|
|
236
|
+
}
|
|
237
|
+
return parts;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const hashBytes = (bytes: Uint8Array): string =>
|
|
241
|
+
new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
242
|
+
|
|
243
|
+
const safeFilename = (value: string | undefined, index: number): string => {
|
|
244
|
+
const normalized = value
|
|
245
|
+
?.normalize("NFC")
|
|
246
|
+
.replace(CONTROL_CHAR_PATTERN, "")
|
|
247
|
+
.replaceAll("\\", "/")
|
|
248
|
+
.split("/")
|
|
249
|
+
.at(-1)
|
|
250
|
+
?.trim();
|
|
251
|
+
return (normalized || `attachment-${index}`).slice(0, 240);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const walkMimePart = (raw: string, state: MimeState, depth: number): void => {
|
|
255
|
+
if (depth > MAX_MIME_DEPTH) {
|
|
256
|
+
throw new MailParseError("MIME nesting exceeds its depth limit.", "limit");
|
|
257
|
+
}
|
|
258
|
+
state.partCount += 1;
|
|
259
|
+
if (state.partCount > MAX_MIME_PARTS) {
|
|
260
|
+
throw new MailParseError("MIME message exceeds its part limit.", "limit");
|
|
261
|
+
}
|
|
262
|
+
const [rawHeaders, body] = splitHeaderBody(raw);
|
|
263
|
+
const headers = parseHeaders(rawHeaders);
|
|
264
|
+
const contentType = parseParameterizedHeader(
|
|
265
|
+
headers.get("content-type"),
|
|
266
|
+
decodeHeaderWords
|
|
267
|
+
);
|
|
268
|
+
const disposition = parseParameterizedHeader(
|
|
269
|
+
headers.get("content-disposition"),
|
|
270
|
+
decodeHeaderWords
|
|
271
|
+
);
|
|
272
|
+
const mime = contentType.value || "text/plain";
|
|
273
|
+
if (mime.startsWith("multipart/")) {
|
|
274
|
+
const boundary = contentType.params.boundary;
|
|
275
|
+
if (!boundary) {
|
|
276
|
+
throw new MailParseError("MIME multipart boundary is missing.");
|
|
277
|
+
}
|
|
278
|
+
for (const part of splitMultipart(body, boundary)) {
|
|
279
|
+
walkMimePart(part, state, depth + 1);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const filename =
|
|
285
|
+
disposition.params.filename ?? contentType.params.name ?? undefined;
|
|
286
|
+
const isAttachment =
|
|
287
|
+
disposition.value === "attachment" ||
|
|
288
|
+
Boolean(filename) ||
|
|
289
|
+
(!mime.startsWith("text/") && mime !== "message/rfc822");
|
|
290
|
+
const bytes = decodeTransfer(
|
|
291
|
+
body,
|
|
292
|
+
headers.get("content-transfer-encoding"),
|
|
293
|
+
isAttachment
|
|
294
|
+
? state.limits.maxAttachmentBytes
|
|
295
|
+
: state.limits.maxBodyChars * 4
|
|
296
|
+
);
|
|
297
|
+
if (isAttachment || mime === "message/rfc822") {
|
|
298
|
+
state.attachments.push({
|
|
299
|
+
name: safeFilename(filename, state.attachments.length + 1),
|
|
300
|
+
mime,
|
|
301
|
+
bytes: bytes.byteLength,
|
|
302
|
+
disposition: disposition.value === "inline" ? "inline" : "attachment",
|
|
303
|
+
sha256: hashBytes(bytes),
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const charset = contentType.params.charset ?? "utf-8";
|
|
308
|
+
const decoded = decodeBytes(bytes, charset);
|
|
309
|
+
if (mime === "text/html") {
|
|
310
|
+
state.htmlBodies.push(sanitizeHtmlToText(decoded));
|
|
311
|
+
} else if (mime === "text/plain") {
|
|
312
|
+
state.plainBodies.push(decoded.trim());
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const normalizeMessageId = (value: string | undefined): string | undefined => {
|
|
317
|
+
if (!value) return undefined;
|
|
318
|
+
const match = /<([^<>\s]+)>/.exec(value);
|
|
319
|
+
const normalized = (match?.[1] ?? value)
|
|
320
|
+
.normalize("NFC")
|
|
321
|
+
.replace(CONTROL_CHAR_PATTERN, "")
|
|
322
|
+
.trim();
|
|
323
|
+
return normalized
|
|
324
|
+
? normalized.slice(0, RECORD_METADATA_LIMITS.maxIdentifierChars)
|
|
325
|
+
: undefined;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const extractMessageIds = (value: string | undefined): string[] => {
|
|
329
|
+
if (!value) return [];
|
|
330
|
+
const ids: string[] = [];
|
|
331
|
+
for (const match of value.matchAll(MESSAGE_ID_PATTERN)) {
|
|
332
|
+
if (match[1]) {
|
|
333
|
+
ids.push(
|
|
334
|
+
match[1]
|
|
335
|
+
.normalize("NFC")
|
|
336
|
+
.slice(0, RECORD_METADATA_LIMITS.maxIdentifierChars)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return ids;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const normalizeDate = (value: string | undefined): string | undefined => {
|
|
344
|
+
if (!value) return undefined;
|
|
345
|
+
const trimmed = value.trim();
|
|
346
|
+
const namedZones: Record<string, string> = {
|
|
347
|
+
CDT: "-0500",
|
|
348
|
+
CST: "-0600",
|
|
349
|
+
EDT: "-0400",
|
|
350
|
+
EST: "-0500",
|
|
351
|
+
GMT: "+0000",
|
|
352
|
+
MDT: "-0600",
|
|
353
|
+
MST: "-0700",
|
|
354
|
+
PDT: "-0700",
|
|
355
|
+
PST: "-0800",
|
|
356
|
+
UT: "+0000",
|
|
357
|
+
UTC: "+0000",
|
|
358
|
+
};
|
|
359
|
+
const namedMatch = /\b([A-Z]{2,3})\s*$/.exec(trimmed);
|
|
360
|
+
const numericZone = /[+-]\d{4}\s*$/.test(trimmed);
|
|
361
|
+
const replacement = namedMatch ? namedZones[namedMatch[1] ?? ""] : undefined;
|
|
362
|
+
if (!(numericZone || replacement)) return undefined;
|
|
363
|
+
const normalized = replacement
|
|
364
|
+
? trimmed.slice(0, namedMatch?.index).trimEnd() + ` ${replacement}`
|
|
365
|
+
: trimmed;
|
|
366
|
+
const parsed = new Date(normalized);
|
|
367
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const normalizedHeader = (
|
|
371
|
+
headers: Map<string, string>,
|
|
372
|
+
name: string
|
|
373
|
+
): string | undefined => {
|
|
374
|
+
const value = headers.get(name);
|
|
375
|
+
if (!value) return undefined;
|
|
376
|
+
const decoded = decodeHeaderWords(value)
|
|
377
|
+
.replace(CONTROL_CHAR_PATTERN, " ")
|
|
378
|
+
.replace(/\s+/g, " ")
|
|
379
|
+
.trim();
|
|
380
|
+
return decoded ? decoded.slice(0, 8_192) : undefined;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
export const parseEmail = (
|
|
384
|
+
raw: string,
|
|
385
|
+
limits: ParseEmailLimits
|
|
386
|
+
): ParsedEmail => {
|
|
387
|
+
const [rawHeaders] = splitHeaderBody(raw);
|
|
388
|
+
const headers = parseHeaders(rawHeaders);
|
|
389
|
+
const state: MimeState = {
|
|
390
|
+
plainBodies: [],
|
|
391
|
+
htmlBodies: [],
|
|
392
|
+
attachments: [],
|
|
393
|
+
partCount: 0,
|
|
394
|
+
limits,
|
|
395
|
+
};
|
|
396
|
+
walkMimePart(raw, state, 0);
|
|
397
|
+
const bodyParts = state.plainBodies.some(Boolean)
|
|
398
|
+
? state.plainBodies
|
|
399
|
+
: state.htmlBodies;
|
|
400
|
+
const body = bodyParts.filter(Boolean).join("\n\n").trim();
|
|
401
|
+
if (
|
|
402
|
+
headers.size === 0 &&
|
|
403
|
+
body.length === 0 &&
|
|
404
|
+
state.attachments.length === 0
|
|
405
|
+
) {
|
|
406
|
+
throw new MailParseError("Mail message has no parseable content.");
|
|
407
|
+
}
|
|
408
|
+
if (body.length > limits.maxBodyChars) {
|
|
409
|
+
throw new MailParseError(
|
|
410
|
+
"Decoded mail body exceeds its character limit.",
|
|
411
|
+
"limit"
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const author = normalizedHeader(headers, "from");
|
|
416
|
+
const participantHeaders = ["from", "to", "cc", "bcc"]
|
|
417
|
+
.map((name) => normalizedHeader(headers, name))
|
|
418
|
+
.filter((value): value is string => Boolean(value));
|
|
419
|
+
const participants = [...new Set(participantHeaders)];
|
|
420
|
+
const messageId = normalizeMessageId(headers.get("message-id"));
|
|
421
|
+
const inReplyTo = normalizeMessageId(headers.get("in-reply-to"));
|
|
422
|
+
const references = extractMessageIds(headers.get("references"));
|
|
423
|
+
const sentAt = normalizeDate(headers.get("date"));
|
|
424
|
+
const threadId = references[0] ?? inReplyTo ?? messageId;
|
|
425
|
+
const metadata: RecordMetadata = {
|
|
426
|
+
author,
|
|
427
|
+
participants,
|
|
428
|
+
categories: ["email"],
|
|
429
|
+
dateFields: sentAt ? { sentAt } : undefined,
|
|
430
|
+
messageId,
|
|
431
|
+
inReplyTo,
|
|
432
|
+
references,
|
|
433
|
+
threadId,
|
|
434
|
+
attachments: state.attachments,
|
|
435
|
+
};
|
|
436
|
+
if (JSON.stringify(metadata).length > limits.maxMetadataChars) {
|
|
437
|
+
throw new MailParseError(
|
|
438
|
+
"Mail metadata exceeds its character limit.",
|
|
439
|
+
"limit"
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
subject: normalizedHeader(headers, "subject"),
|
|
444
|
+
author,
|
|
445
|
+
participants,
|
|
446
|
+
sentAt,
|
|
447
|
+
messageId,
|
|
448
|
+
inReplyTo,
|
|
449
|
+
references,
|
|
450
|
+
body,
|
|
451
|
+
attachments: state.attachments,
|
|
452
|
+
metadata,
|
|
453
|
+
};
|
|
454
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
interface HeaderValue {
|
|
2
|
+
value: string;
|
|
3
|
+
params: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type DecodeHeaderWords = (value: string) => string;
|
|
7
|
+
|
|
8
|
+
const decodeExtendedParameter = (value: string): string => {
|
|
9
|
+
const encoded = /^[^']*'[^']*'(.*)$/.exec(value)?.[1] ?? value;
|
|
10
|
+
try {
|
|
11
|
+
return decodeURIComponent(encoded);
|
|
12
|
+
} catch {
|
|
13
|
+
return encoded;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const parseParameterizedHeader = (
|
|
18
|
+
raw: string | undefined,
|
|
19
|
+
decodeHeaderWords: DecodeHeaderWords
|
|
20
|
+
): HeaderValue => {
|
|
21
|
+
if (!raw) return { value: "", params: {} };
|
|
22
|
+
const pieces = raw.match(/(?:[^;"']+|"[^"]*"|'[^']*')+/g) ?? [raw];
|
|
23
|
+
const value = pieces[0]?.trim().toLowerCase() ?? "";
|
|
24
|
+
const params: Record<string, string> = {};
|
|
25
|
+
const continuations = new Map<
|
|
26
|
+
string,
|
|
27
|
+
Array<{ encoded: boolean; index: number; value: string }>
|
|
28
|
+
>();
|
|
29
|
+
for (const piece of pieces.slice(1)) {
|
|
30
|
+
const separator = piece.indexOf("=");
|
|
31
|
+
if (separator <= 0) continue;
|
|
32
|
+
const key = piece.slice(0, separator).trim().toLowerCase();
|
|
33
|
+
let parameter = piece.slice(separator + 1).trim();
|
|
34
|
+
if (
|
|
35
|
+
(parameter.startsWith('"') && parameter.endsWith('"')) ||
|
|
36
|
+
(parameter.startsWith("'") && parameter.endsWith("'"))
|
|
37
|
+
) {
|
|
38
|
+
parameter = parameter.slice(1, -1);
|
|
39
|
+
}
|
|
40
|
+
const continuation = /^(.+)\*(\d+)(\*)?$/.exec(key);
|
|
41
|
+
if (continuation) {
|
|
42
|
+
const base = continuation[1];
|
|
43
|
+
const index = Number.parseInt(continuation[2] ?? "", 10);
|
|
44
|
+
if (base && Number.isSafeInteger(index)) {
|
|
45
|
+
const segments = continuations.get(base) ?? [];
|
|
46
|
+
segments.push({
|
|
47
|
+
encoded: Boolean(continuation[3]),
|
|
48
|
+
index,
|
|
49
|
+
value: parameter,
|
|
50
|
+
});
|
|
51
|
+
continuations.set(base, segments);
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const decoded = key.endsWith("*")
|
|
56
|
+
? decodeExtendedParameter(parameter)
|
|
57
|
+
: parameter;
|
|
58
|
+
params[key.replace(/\*$/, "")] = decodeHeaderWords(decoded);
|
|
59
|
+
}
|
|
60
|
+
for (const [key, unsorted] of continuations) {
|
|
61
|
+
const segments = unsorted.toSorted(
|
|
62
|
+
(left, right) => left.index - right.index
|
|
63
|
+
);
|
|
64
|
+
if (
|
|
65
|
+
segments[0]?.index !== 0 ||
|
|
66
|
+
segments.some((segment, index) => segment.index !== index)
|
|
67
|
+
) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const joined = segments.map((segment) => segment.value).join("");
|
|
71
|
+
const decoded = segments.some((segment) => segment.encoded)
|
|
72
|
+
? decodeExtendedParameter(joined)
|
|
73
|
+
: joined;
|
|
74
|
+
params[key] = decodeHeaderWords(decoded);
|
|
75
|
+
}
|
|
76
|
+
return { value, params };
|
|
77
|
+
};
|