@nomadamas/mailcrawl 0.1.3 → 0.1.5
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/archive.d.ts +6 -0
- package/dist/archive.js +122 -18
- package/dist/cli/index.js +2 -1
- package/dist/normalize.js +12 -1
- package/dist/util.d.ts +1 -0
- package/dist/util.js +3 -0
- package/package.json +1 -1
package/dist/archive.d.ts
CHANGED
|
@@ -4,21 +4,26 @@ export declare class Archive {
|
|
|
4
4
|
readonly db: Database.Database;
|
|
5
5
|
private embedder?;
|
|
6
6
|
private lexical?;
|
|
7
|
+
private operationTail;
|
|
7
8
|
private lexicalRebuildRequired;
|
|
8
9
|
constructor(path?: string);
|
|
9
10
|
close(): void;
|
|
10
11
|
sync(messages: MailMessage[], policy?: ClassificationPolicy): Promise<SyncReport>;
|
|
12
|
+
private syncUnlocked;
|
|
11
13
|
searchBm25(query: string, filters?: SearchFilters, limit?: number): Promise<SearchHit[]>;
|
|
12
14
|
indexSemantic(): Promise<{
|
|
13
15
|
embedded: number;
|
|
14
16
|
reused: number;
|
|
15
17
|
archiveRevision: string;
|
|
16
18
|
}>;
|
|
19
|
+
private indexSemanticUnlocked;
|
|
17
20
|
indexSemanticGeneration(root: string): Promise<{
|
|
18
21
|
generation: string;
|
|
19
22
|
embedded: number;
|
|
20
23
|
reused: number;
|
|
21
24
|
}>;
|
|
25
|
+
private indexSemanticGenerationUnlocked;
|
|
26
|
+
private cleanupSemanticGenerations;
|
|
22
27
|
semanticGeneration(root: string): {
|
|
23
28
|
generation: string;
|
|
24
29
|
archiveRevision: string;
|
|
@@ -55,6 +60,7 @@ export declare class Archive {
|
|
|
55
60
|
private removeMessage;
|
|
56
61
|
private replaceMessageChunks;
|
|
57
62
|
private revision;
|
|
63
|
+
private runExclusive;
|
|
58
64
|
private getEmbedder;
|
|
59
65
|
private searchLexicalTable;
|
|
60
66
|
}
|
package/dist/archive.js
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { buildChunks } from "./chunk.js";
|
|
6
6
|
import { normalizeMessage } from "./normalize.js";
|
|
7
|
-
import { snippet } from "./util.js";
|
|
7
|
+
import { scopedId, snippet } from "./util.js";
|
|
8
8
|
import { createEmbedder, embeddingModelName } from "./embedding.js";
|
|
9
9
|
import { createLexicalAnalyzers, languagesForText, lexicalFields, LEXICAL_ANALYZER_VERSION } from "./lexical.js";
|
|
10
|
+
const RETAINED_SEMANTIC_GENERATIONS = 2;
|
|
11
|
+
const SEMANTIC_GENERATION_NAME = /^gen-[0-9a-f]{16}-[0-9]+$/;
|
|
10
12
|
export class Archive {
|
|
11
13
|
db;
|
|
12
14
|
embedder;
|
|
13
15
|
lexical;
|
|
16
|
+
operationTail = Promise.resolve();
|
|
14
17
|
lexicalRebuildRequired;
|
|
15
18
|
constructor(path = ":memory:") {
|
|
16
19
|
this.db = new Database(path);
|
|
17
20
|
this.db.pragma("journal_mode = WAL");
|
|
18
21
|
this.db.pragma("foreign_keys = ON");
|
|
19
22
|
migrate(this.db);
|
|
23
|
+
cleanupOrphanedSemanticRows(this.db);
|
|
20
24
|
this.lexicalRebuildRequired = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get() === undefined;
|
|
21
25
|
if (!this.lexicalRebuildRequired) {
|
|
22
26
|
const row = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get();
|
|
@@ -32,14 +36,23 @@ export class Archive {
|
|
|
32
36
|
this.db.close();
|
|
33
37
|
}
|
|
34
38
|
async sync(messages, policy = {}) {
|
|
39
|
+
return this.runExclusive(() => this.syncUnlocked(messages, policy));
|
|
40
|
+
}
|
|
41
|
+
async syncUnlocked(messages, policy = {}) {
|
|
42
|
+
for (const message of messages) {
|
|
43
|
+
if (typeof message.providerKey !== "string" || !message.providerKey.trim())
|
|
44
|
+
throw new Error("message provider identity is required");
|
|
45
|
+
}
|
|
35
46
|
const excludedCategories = new Set((policy.excludedCategories ?? ["spam", "promotions"]).map(normalizeCategory));
|
|
36
47
|
const normalized = (await Promise.all(messages.map(normalizeMessage))).map((message) => ({
|
|
37
48
|
...message,
|
|
38
|
-
messageId:
|
|
39
|
-
threadId:
|
|
40
|
-
providerKey:
|
|
41
|
-
inReplyTo: message.inReplyTo ?
|
|
49
|
+
messageId: scopedId(message.accountId, message.mailbox, message.messageId),
|
|
50
|
+
threadId: scopedId(message.accountId, message.mailbox, message.threadId),
|
|
51
|
+
providerKey: scopedId(message.accountId, message.mailbox, message.providerKey),
|
|
52
|
+
inReplyTo: message.inReplyTo ? scopedId(message.accountId, message.mailbox, message.inReplyTo) : undefined,
|
|
42
53
|
}));
|
|
54
|
+
validateIdentities(normalized);
|
|
55
|
+
validateStoredIdentities(this.db, normalized);
|
|
43
56
|
const excluded = normalized.filter((message) => message.categories.some((category) => excludedCategories.has(category)));
|
|
44
57
|
const included = normalized.filter((message) => !message.categories.some((category) => excludedCategories.has(category)));
|
|
45
58
|
const existing = this.db.prepare("SELECT provider_key, normalized_hash FROM messages").all();
|
|
@@ -126,18 +139,29 @@ export class Archive {
|
|
|
126
139
|
.slice(0, limit).map(({ hit, score }) => ({ ...hit, score }));
|
|
127
140
|
}
|
|
128
141
|
async indexSemantic() {
|
|
142
|
+
return this.runExclusive(() => this.indexSemanticUnlocked());
|
|
143
|
+
}
|
|
144
|
+
async indexSemanticUnlocked() {
|
|
145
|
+
const completeQueueRow = this.db.prepare("UPDATE embedding_queue SET state = 'complete' WHERE chunk_id = ?");
|
|
146
|
+
const reconcile = this.db.transaction(() => {
|
|
147
|
+
this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id NOT IN (SELECT chunk_id FROM chunks)").run();
|
|
148
|
+
this.db.prepare(`INSERT INTO embedding_queue(chunk_id, content_hash, state, attempts)
|
|
149
|
+
SELECT chunk_id, content_hash, 'pending', 0 FROM chunks
|
|
150
|
+
WHERE chunk_id NOT IN (SELECT chunk_id FROM embedding_queue)`).run();
|
|
151
|
+
});
|
|
152
|
+
reconcile();
|
|
129
153
|
const rows = this.db.prepare("SELECT chunk_id, text, content_hash FROM chunks ORDER BY chunk_id").all();
|
|
130
154
|
let embedded = 0;
|
|
131
155
|
let reused = 0;
|
|
132
156
|
const upsert = this.db.prepare(`INSERT INTO semantic_vectors
|
|
133
157
|
(chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)
|
|
134
158
|
ON CONFLICT(chunk_id) DO UPDATE SET content_hash=excluded.content_hash, model=excluded.model, vector=excluded.vector`);
|
|
135
|
-
const embedder = await this.getEmbedder();
|
|
136
159
|
const pending = [];
|
|
137
160
|
const transaction = this.db.transaction(() => {
|
|
138
161
|
for (const row of rows) {
|
|
139
162
|
const old = this.db.prepare("SELECT content_hash, model FROM semantic_vectors WHERE chunk_id = ?").get(row.chunk_id);
|
|
140
163
|
if (old?.content_hash === row.content_hash && old.model === embeddingModelName()) {
|
|
164
|
+
completeQueueRow.run(row.chunk_id);
|
|
141
165
|
reused++;
|
|
142
166
|
continue;
|
|
143
167
|
}
|
|
@@ -145,10 +169,14 @@ export class Archive {
|
|
|
145
169
|
}
|
|
146
170
|
});
|
|
147
171
|
transaction();
|
|
172
|
+
if (!pending.length)
|
|
173
|
+
return { embedded, reused, archiveRevision: this.revision() };
|
|
174
|
+
const embedder = await this.getEmbedder();
|
|
148
175
|
const vectors = await embedder.embedDocuments(pending.map((row) => row.text));
|
|
149
176
|
const write = this.db.transaction(() => {
|
|
150
177
|
for (const [index, row] of pending.entries()) {
|
|
151
178
|
upsert.run(row.chunk_id, row.content_hash, embeddingModelName(), JSON.stringify(vectors[index]));
|
|
179
|
+
completeQueueRow.run(row.chunk_id);
|
|
152
180
|
embedded++;
|
|
153
181
|
}
|
|
154
182
|
});
|
|
@@ -156,29 +184,65 @@ export class Archive {
|
|
|
156
184
|
return { embedded, reused, archiveRevision: this.revision() };
|
|
157
185
|
}
|
|
158
186
|
async indexSemanticGeneration(root) {
|
|
187
|
+
return this.runExclusive(() => this.indexSemanticGenerationUnlocked(root));
|
|
188
|
+
}
|
|
189
|
+
async indexSemanticGenerationUnlocked(root) {
|
|
159
190
|
const currentPath = join(root, "CURRENT");
|
|
160
191
|
const generationRoot = join(root, "generations");
|
|
192
|
+
const previousVectors = this.db.prepare("SELECT chunk_id, content_hash, model, vector FROM semantic_vectors ORDER BY chunk_id").all();
|
|
193
|
+
const previousQueue = this.db.prepare("SELECT chunk_id, content_hash, state, attempts FROM embedding_queue ORDER BY chunk_id").all();
|
|
161
194
|
mkdirSync(generationRoot, { recursive: true });
|
|
162
195
|
const generation = `gen-${this.revision().slice(0, 16)}-${Date.now()}`;
|
|
163
196
|
const staging = join(generationRoot, `.${generation}.staging`);
|
|
197
|
+
const publishedGeneration = join(generationRoot, generation);
|
|
164
198
|
mkdirSync(staging);
|
|
165
199
|
try {
|
|
166
|
-
const report = await this.
|
|
167
|
-
const vectors = this.db.prepare(
|
|
200
|
+
const report = await this.indexSemanticUnlocked();
|
|
201
|
+
const vectors = this.db.prepare(`SELECT v.chunk_id, v.content_hash, v.vector
|
|
202
|
+
FROM semantic_vectors v JOIN chunks c ON c.chunk_id = v.chunk_id
|
|
203
|
+
ORDER BY v.chunk_id`).all();
|
|
168
204
|
writeFileSync(join(staging, "manifest.json"), JSON.stringify({
|
|
169
205
|
archiveRevision: this.revision(), vectors, model: embeddingModelName(),
|
|
170
206
|
}));
|
|
171
|
-
renameSync(staging,
|
|
207
|
+
renameSync(staging, publishedGeneration);
|
|
172
208
|
const pointer = join(root, `.CURRENT.${process.pid}`);
|
|
173
209
|
writeFileSync(pointer, `${generation}\n`);
|
|
174
210
|
renameSync(pointer, currentPath);
|
|
211
|
+
this.cleanupSemanticGenerations(generationRoot, generation);
|
|
175
212
|
return { generation, embedded: report.embedded, reused: report.reused };
|
|
176
213
|
}
|
|
177
214
|
catch (error) {
|
|
178
215
|
rmSync(staging, { recursive: true, force: true });
|
|
216
|
+
rmSync(publishedGeneration, { recursive: true, force: true });
|
|
217
|
+
const restore = this.db.transaction(() => {
|
|
218
|
+
this.db.exec("DELETE FROM semantic_vectors; DELETE FROM embedding_queue;");
|
|
219
|
+
const restoreVector = this.db.prepare("INSERT INTO semantic_vectors(chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)");
|
|
220
|
+
for (const row of previousVectors)
|
|
221
|
+
restoreVector.run(row.chunk_id, row.content_hash, row.model, row.vector);
|
|
222
|
+
const restoreQueue = this.db.prepare("INSERT INTO embedding_queue(chunk_id, content_hash, state, attempts) VALUES (?, ?, ?, ?)");
|
|
223
|
+
for (const row of previousQueue)
|
|
224
|
+
restoreQueue.run(row.chunk_id, row.content_hash, row.state, row.attempts);
|
|
225
|
+
});
|
|
226
|
+
restore();
|
|
179
227
|
throw error;
|
|
180
228
|
}
|
|
181
229
|
}
|
|
230
|
+
cleanupSemanticGenerations(generationRoot, activeGeneration) {
|
|
231
|
+
const generations = readdirSync(generationRoot, { withFileTypes: true })
|
|
232
|
+
.filter((entry) => entry.isDirectory() && SEMANTIC_GENERATION_NAME.test(entry.name))
|
|
233
|
+
.map((entry) => entry.name)
|
|
234
|
+
.sort((left, right) => {
|
|
235
|
+
const leftTimestamp = Number(left.slice(left.lastIndexOf("-") + 1));
|
|
236
|
+
const rightTimestamp = Number(right.slice(right.lastIndexOf("-") + 1));
|
|
237
|
+
return rightTimestamp - leftTimestamp || right.localeCompare(left);
|
|
238
|
+
});
|
|
239
|
+
const retained = new Set(generations.slice(0, RETAINED_SEMANTIC_GENERATIONS));
|
|
240
|
+
retained.add(activeGeneration);
|
|
241
|
+
for (const generation of generations) {
|
|
242
|
+
if (!retained.has(generation))
|
|
243
|
+
rmSync(join(generationRoot, generation), { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
182
246
|
semanticGeneration(root) {
|
|
183
247
|
const generation = readFileSync(join(root, "CURRENT"), "utf8").trim();
|
|
184
248
|
const manifest = JSON.parse(readFileSync(join(root, "generations", generation, "manifest.json"), "utf8"));
|
|
@@ -313,17 +377,14 @@ export class Archive {
|
|
|
313
377
|
}
|
|
314
378
|
}
|
|
315
379
|
removeMessage(message) {
|
|
316
|
-
const
|
|
317
|
-
if (!stored)
|
|
318
|
-
return;
|
|
319
|
-
const rows = this.db.prepare("SELECT rowid FROM chunks WHERE message_id = ?").all(stored.message_id);
|
|
380
|
+
const rows = this.db.prepare("SELECT rowid FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?)").all(message.providerKey);
|
|
320
381
|
for (const row of rows)
|
|
321
382
|
this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
|
|
322
383
|
for (const language of lexicalFields())
|
|
323
|
-
this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)`).run(
|
|
324
|
-
this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(
|
|
325
|
-
this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(
|
|
326
|
-
this.db.prepare("DELETE FROM messages WHERE
|
|
384
|
+
this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))`).run(message.providerKey);
|
|
385
|
+
this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))").run(message.providerKey);
|
|
386
|
+
this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))").run(message.providerKey);
|
|
387
|
+
this.db.prepare("DELETE FROM messages WHERE provider_key = ?").run(message.providerKey);
|
|
327
388
|
}
|
|
328
389
|
replaceMessageChunks(message, analyzedChunks) {
|
|
329
390
|
const old = this.db.prepare("SELECT rowid, chunk_id FROM chunks WHERE message_id = ?").all(message.messageId);
|
|
@@ -331,6 +392,8 @@ export class Archive {
|
|
|
331
392
|
this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
|
|
332
393
|
for (const language of lexicalFields())
|
|
333
394
|
this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)`).run(message.messageId);
|
|
395
|
+
this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(message.messageId);
|
|
396
|
+
this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(message.messageId);
|
|
334
397
|
this.db.prepare("DELETE FROM chunks WHERE message_id = ?").run(message.messageId);
|
|
335
398
|
for (const chunk of buildChunks(message)) {
|
|
336
399
|
const result = this.db.prepare(`INSERT INTO chunks
|
|
@@ -351,6 +414,19 @@ export class Archive {
|
|
|
351
414
|
const rows = this.db.prepare("SELECT chunk_id, content_hash FROM chunks ORDER BY chunk_id").all();
|
|
352
415
|
return createHash("sha256").update(rows.map((row) => `${row.chunk_id}\0${row.content_hash}`).join("\0")).digest("hex");
|
|
353
416
|
}
|
|
417
|
+
async runExclusive(operation) {
|
|
418
|
+
const previous = this.operationTail;
|
|
419
|
+
let release = () => { };
|
|
420
|
+
const turn = new Promise((resolve) => { release = resolve; });
|
|
421
|
+
this.operationTail = previous.then(() => turn);
|
|
422
|
+
await previous;
|
|
423
|
+
try {
|
|
424
|
+
return await operation();
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
release();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
354
430
|
async getEmbedder() {
|
|
355
431
|
this.embedder ??= await createEmbedder();
|
|
356
432
|
return this.embedder;
|
|
@@ -424,6 +500,12 @@ function migrate(db) {
|
|
|
424
500
|
db.exec(`ALTER TABLE messages ADD COLUMN ${column} TEXT NOT NULL DEFAULT '[]'`);
|
|
425
501
|
}
|
|
426
502
|
}
|
|
503
|
+
function cleanupOrphanedSemanticRows(db) {
|
|
504
|
+
db.prepare(`DELETE FROM embedding_queue
|
|
505
|
+
WHERE NOT EXISTS (SELECT 1 FROM chunks WHERE chunks.chunk_id = embedding_queue.chunk_id)`).run();
|
|
506
|
+
db.prepare(`DELETE FROM semantic_vectors
|
|
507
|
+
WHERE NOT EXISTS (SELECT 1 FROM chunks WHERE chunks.chunk_id = semantic_vectors.chunk_id)`).run();
|
|
508
|
+
}
|
|
427
509
|
function literalFtsQuery(query) {
|
|
428
510
|
return query.trim().split(/\s+/).map((term) => `"${term.replaceAll('"', '""')}"`).join(" ");
|
|
429
511
|
}
|
|
@@ -460,6 +542,28 @@ function addFilters(clauses, params, filters) {
|
|
|
460
542
|
function normalizeCategory(value) {
|
|
461
543
|
return value.trim().toLocaleLowerCase().replace(/^category[_-]/, "").replace(/^label[_-]/, "");
|
|
462
544
|
}
|
|
545
|
+
function validateIdentities(messages) {
|
|
546
|
+
const providerKeys = new Set();
|
|
547
|
+
const messageIds = new Set();
|
|
548
|
+
for (const message of messages) {
|
|
549
|
+
if (!message.providerKey)
|
|
550
|
+
throw new Error("message provider identity is required");
|
|
551
|
+
if (providerKeys.has(message.providerKey))
|
|
552
|
+
throw new Error("duplicate message provider identity");
|
|
553
|
+
providerKeys.add(message.providerKey);
|
|
554
|
+
if (messageIds.has(message.messageId))
|
|
555
|
+
throw new Error("duplicate message identity");
|
|
556
|
+
messageIds.add(message.messageId);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function validateStoredIdentities(db, messages) {
|
|
560
|
+
const byProviderKey = db.prepare("SELECT message_id FROM messages WHERE provider_key = ?");
|
|
561
|
+
for (const message of messages) {
|
|
562
|
+
const storedProvider = byProviderKey.get(message.providerKey);
|
|
563
|
+
if (storedProvider && storedProvider.message_id !== message.messageId)
|
|
564
|
+
throw new Error("provider identity already belongs to another message identity");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
463
567
|
function countExcluded(messages, excluded) {
|
|
464
568
|
const counts = {};
|
|
465
569
|
for (const message of messages) {
|
package/dist/cli/index.js
CHANGED
|
@@ -177,13 +177,14 @@ program
|
|
|
177
177
|
catch (error) {
|
|
178
178
|
semantic = redactDiagnostic({ status: "stale", error: error instanceof Error ? error.message : String(error) });
|
|
179
179
|
}
|
|
180
|
+
const semanticCommitted = typeof semantic === "object" && semantic !== null && "generation" in semantic;
|
|
180
181
|
output({
|
|
181
182
|
name: "mailcrawl",
|
|
182
183
|
archive: `${dataDir}/archive.sqlite`,
|
|
183
184
|
archivePresent: existsSync(`${dataDir}/archive.sqlite`),
|
|
184
185
|
fts: "available",
|
|
185
186
|
semantic,
|
|
186
|
-
recommendation: semantic
|
|
187
|
+
recommendation: semanticCommitted ? "semantic index is committed" : "run sync, then index before semantic search",
|
|
187
188
|
}, options.json);
|
|
188
189
|
}
|
|
189
190
|
finally {
|
package/dist/normalize.js
CHANGED
|
@@ -26,7 +26,18 @@ export async function normalizeMessage(input) {
|
|
|
26
26
|
const subject = input.subject.trim();
|
|
27
27
|
const messageId = input.messageId || input.providerKey;
|
|
28
28
|
const threadId = input.threadId || makeId(input.accountId, normalizeSubject(subject));
|
|
29
|
-
const
|
|
29
|
+
const attachmentInputs = (attachments || []).map((attachment) => [
|
|
30
|
+
attachment.name,
|
|
31
|
+
attachment.mimeType,
|
|
32
|
+
attachment.size ?? null,
|
|
33
|
+
attachment.text ?? null,
|
|
34
|
+
attachment.contentHash ?? null,
|
|
35
|
+
]);
|
|
36
|
+
const normalizedHash = hash(JSON.stringify([
|
|
37
|
+
subject, input.from, input.to, input.cc, latest, quoted,
|
|
38
|
+
input.labels, input.flags, input.classifications,
|
|
39
|
+
...(attachmentInputs.length ? [attachmentInputs] : []),
|
|
40
|
+
]));
|
|
30
41
|
const categories = [...new Set([
|
|
31
42
|
...(input.classifications || []),
|
|
32
43
|
...(input.labels || []),
|
package/dist/util.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare function hash(value: string): string;
|
|
2
2
|
export declare function normalizeSubject(subject: string): string;
|
|
3
3
|
export declare function makeId(...parts: string[]): string;
|
|
4
|
+
export declare function scopedId(accountId: string, mailbox: string, value: string): string;
|
|
4
5
|
export declare function snippet(text: string, query: string, maxChars?: number): string;
|
package/dist/util.js
CHANGED
|
@@ -8,6 +8,9 @@ export function normalizeSubject(subject) {
|
|
|
8
8
|
export function makeId(...parts) {
|
|
9
9
|
return hash(parts.join("\u0000")).slice(0, 24);
|
|
10
10
|
}
|
|
11
|
+
export function scopedId(accountId, mailbox, value) {
|
|
12
|
+
return makeId(accountId, mailbox, value);
|
|
13
|
+
}
|
|
11
14
|
export function snippet(text, query, maxChars = 240) {
|
|
12
15
|
const clean = text.trim();
|
|
13
16
|
if (clean.length <= maxChars)
|