@nomadamas/mailcrawl 0.1.5 → 0.1.7

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 CHANGED
@@ -20,6 +20,7 @@ mailcrawl embed --json
20
20
  mailcrawl search --mode hybrid --json "계약 갱신 조건"
21
21
  mailcrawl status --json
22
22
  mailcrawl doctor --json
23
+ mailcrawl repair --all --json
23
24
  ```
24
25
 
25
26
  The CLI owns email synchronization and indexes. Consumers such as AutoRAG,
@@ -49,6 +50,21 @@ before multilingual search; the command re-analyzes existing messages and
49
50
  atomically records the new fingerprint. Embedding model changes are independent
50
51
  and require a new `mailcrawl index` generation.
51
52
 
53
+ ## Sync read concurrency
54
+
55
+ `mailcrawl sync` reads a page of envelopes through a bounded pool of himalaya
56
+ processes — 4 by default, `--concurrency <n>` to change it — instead of
57
+ spawning one process per envelope. Gmail throttles accounts that open too many
58
+ simultaneous IMAP connections, and an unbounded fan-out made one throttled
59
+ read abort the entire sync. `--page-size` still controls only how many
60
+ envelopes the IMAP window returns.
61
+
62
+ A read that fails is retried with exponential backoff (three attempts by
63
+ default). Messages that stay unreadable are reported in the sync JSON as
64
+ `failures[]` with their `providerKey`, `attempts`, and the redacted himalaya
65
+ error, while the messages that could be read are still synced. The command
66
+ exits non-zero only when nothing could be read.
67
+
52
68
  ## Installation
53
69
 
54
70
  For the required Node setup, Kiwi model files, Go installation, Japanese and
@@ -56,6 +72,21 @@ Chinese helper builds, environment variables, smoke tests, and license
56
72
  requirements, follow [`docs/multilingual-installation.md`](docs/multilingual-installation.md)
57
73
  before using multilingual indexing or search.
58
74
 
75
+ ## Shared loopback embedding provider
76
+
77
+ The local in-process model is the default. Opt into a local HTTP runtime with
78
+ `--provider loopback-http`, `--embed-url`, `--embed-model`, and `--embed-dim`
79
+ on `index`, `repair --semantic`, or semantic search. Only HTTP URLs for
80
+ `127.0.0.1`, `localhost`, or `::1` are accepted. Query/passage prefixes and
81
+ timeout are optional and are included in the provider identity. Any provider
82
+ setting change rebuilds vectors, while a failed rebuild preserves the prior
83
+ `CURRENT` generation.
84
+
85
+ The equivalent environment contract is
86
+ `MAILCRAWL_EMBEDDER_PROVIDER`, `MAILCRAWL_EMBED_URL`,
87
+ `MAILCRAWL_EMBED_MODEL`, `MAILCRAWL_EMBED_DIM`, `MAILCRAWL_QUERY_PREFIX`,
88
+ `MAILCRAWL_PASSAGE_PREFIX`, and `MAILCRAWL_EMBED_TIMEOUT`.
89
+
59
90
  ## Releasing
60
91
 
61
92
  GitHub Release `vX.Y.Z` (must match `package.json`) publishes `@nomadamas/mailcrawl` to npm with OIDC trusted publishing. No `NPM_TOKEN` is stored in GitHub.
package/dist/archive.d.ts CHANGED
@@ -1,14 +1,25 @@
1
1
  import Database from "better-sqlite3";
2
- import type { ClassificationPolicy, Chunk, MailMessage, NormalizedMessage, SearchFilters, SearchHit, SyncReport } from "./types.js";
2
+ import type { ClassificationPolicy, Chunk, LoopbackHttpConfig, MailMessage, NormalizedMessage, SearchFilters, SearchHit, SyncReport } from "./types.js";
3
3
  export declare class Archive {
4
4
  readonly db: Database.Database;
5
5
  private embedder?;
6
6
  private lexical?;
7
7
  private operationTail;
8
8
  private lexicalRebuildRequired;
9
- constructor(path?: string);
9
+ private readonly embedderConfig?;
10
+ constructor(path?: string, embedderConfig?: LoopbackHttpConfig);
10
11
  close(): void;
11
12
  sync(messages: MailMessage[], policy?: ClassificationPolicy): Promise<SyncReport>;
13
+ status(): {
14
+ messageCount: number;
15
+ chunkCount: number;
16
+ embeddingBacklog: number;
17
+ archiveRevision: string;
18
+ fts: {
19
+ status: "healthy" | "stale";
20
+ rows: number;
21
+ };
22
+ };
12
23
  private syncUnlocked;
13
24
  searchBm25(query: string, filters?: SearchFilters, limit?: number): Promise<SearchHit[]>;
14
25
  indexSemantic(): Promise<{
@@ -24,6 +35,7 @@ export declare class Archive {
24
35
  }>;
25
36
  private indexSemanticGenerationUnlocked;
26
37
  private cleanupSemanticGenerations;
38
+ private generationTimestamp;
27
39
  semanticGeneration(root: string): {
28
40
  generation: string;
29
41
  archiveRevision: string;
package/dist/archive.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import Database from "better-sqlite3";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
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";
@@ -8,14 +8,16 @@ 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
10
  const RETAINED_SEMANTIC_GENERATIONS = 2;
11
- const SEMANTIC_GENERATION_NAME = /^gen-[0-9a-f]{16}-[0-9]+$/;
11
+ const SEMANTIC_GENERATION_NAME = /^gen-[0-9a-f]{16}-[0-9]+(?:-[0-9a-f]+)?$/;
12
12
  export class Archive {
13
13
  db;
14
14
  embedder;
15
15
  lexical;
16
16
  operationTail = Promise.resolve();
17
17
  lexicalRebuildRequired;
18
- constructor(path = ":memory:") {
18
+ embedderConfig;
19
+ constructor(path = ":memory:", embedderConfig) {
20
+ this.embedderConfig = embedderConfig;
19
21
  this.db = new Database(path);
20
22
  this.db.pragma("journal_mode = WAL");
21
23
  this.db.pragma("foreign_keys = ON");
@@ -38,6 +40,19 @@ export class Archive {
38
40
  async sync(messages, policy = {}) {
39
41
  return this.runExclusive(() => this.syncUnlocked(messages, policy));
40
42
  }
43
+ status() {
44
+ const count = (table) => Number(this.db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count);
45
+ const ftsRows = Number(this.db.prepare("SELECT COUNT(*) AS count FROM chunks_fts").get().count);
46
+ const metadata = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get();
47
+ const chunkCount = count("chunks");
48
+ return {
49
+ messageCount: count("messages"),
50
+ chunkCount,
51
+ embeddingBacklog: Number(this.db.prepare("SELECT COUNT(*) AS count FROM embedding_queue WHERE state = 'pending'").get().count),
52
+ archiveRevision: this.revision(),
53
+ fts: { status: metadata?.version === LEXICAL_ANALYZER_VERSION && ftsRows === chunkCount ? "healthy" : "stale", rows: ftsRows },
54
+ };
55
+ }
41
56
  async syncUnlocked(messages, policy = {}) {
42
57
  for (const message of messages) {
43
58
  if (typeof message.providerKey !== "string" || !message.providerKey.trim())
@@ -160,7 +175,7 @@ export class Archive {
160
175
  const transaction = this.db.transaction(() => {
161
176
  for (const row of rows) {
162
177
  const old = this.db.prepare("SELECT content_hash, model FROM semantic_vectors WHERE chunk_id = ?").get(row.chunk_id);
163
- if (old?.content_hash === row.content_hash && old.model === embeddingModelName()) {
178
+ if (old?.content_hash === row.content_hash && old.model === embeddingModelName(this.embedderConfig)) {
164
179
  completeQueueRow.run(row.chunk_id);
165
180
  reused++;
166
181
  continue;
@@ -175,7 +190,7 @@ export class Archive {
175
190
  const vectors = await embedder.embedDocuments(pending.map((row) => row.text));
176
191
  const write = this.db.transaction(() => {
177
192
  for (const [index, row] of pending.entries()) {
178
- upsert.run(row.chunk_id, row.content_hash, embeddingModelName(), JSON.stringify(vectors[index]));
193
+ upsert.run(row.chunk_id, row.content_hash, embeddingModelName(this.embedderConfig), JSON.stringify(vectors[index]));
179
194
  completeQueueRow.run(row.chunk_id);
180
195
  embedded++;
181
196
  }
@@ -192,20 +207,22 @@ export class Archive {
192
207
  const previousVectors = this.db.prepare("SELECT chunk_id, content_hash, model, vector FROM semantic_vectors ORDER BY chunk_id").all();
193
208
  const previousQueue = this.db.prepare("SELECT chunk_id, content_hash, state, attempts FROM embedding_queue ORDER BY chunk_id").all();
194
209
  mkdirSync(generationRoot, { recursive: true });
195
- const generation = `gen-${this.revision().slice(0, 16)}-${Date.now()}`;
210
+ const generation = `gen-${this.revision().slice(0, 16)}-${Date.now()}-${randomBytes(6).toString("hex")}`;
196
211
  const staging = join(generationRoot, `.${generation}.staging`);
197
212
  const publishedGeneration = join(generationRoot, generation);
213
+ const pointer = join(root, `.CURRENT.${process.pid}`);
198
214
  mkdirSync(staging);
215
+ let published = false;
199
216
  try {
200
217
  const report = await this.indexSemanticUnlocked();
201
218
  const vectors = this.db.prepare(`SELECT v.chunk_id, v.content_hash, v.vector
202
219
  FROM semantic_vectors v JOIN chunks c ON c.chunk_id = v.chunk_id
203
220
  ORDER BY v.chunk_id`).all();
204
221
  writeFileSync(join(staging, "manifest.json"), JSON.stringify({
205
- archiveRevision: this.revision(), vectors, model: embeddingModelName(),
222
+ archiveRevision: this.revision(), vectors, model: embeddingModelName(this.embedderConfig),
206
223
  }));
207
224
  renameSync(staging, publishedGeneration);
208
- const pointer = join(root, `.CURRENT.${process.pid}`);
225
+ published = true;
209
226
  writeFileSync(pointer, `${generation}\n`);
210
227
  renameSync(pointer, currentPath);
211
228
  this.cleanupSemanticGenerations(generationRoot, generation);
@@ -213,7 +230,9 @@ export class Archive {
213
230
  }
214
231
  catch (error) {
215
232
  rmSync(staging, { recursive: true, force: true });
216
- rmSync(publishedGeneration, { recursive: true, force: true });
233
+ rmSync(pointer, { force: true });
234
+ if (!published)
235
+ rmSync(publishedGeneration, { recursive: true, force: true });
217
236
  const restore = this.db.transaction(() => {
218
237
  this.db.exec("DELETE FROM semantic_vectors; DELETE FROM embedding_queue;");
219
238
  const restoreVector = this.db.prepare("INSERT INTO semantic_vectors(chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)");
@@ -232,8 +251,8 @@ export class Archive {
232
251
  .filter((entry) => entry.isDirectory() && SEMANTIC_GENERATION_NAME.test(entry.name))
233
252
  .map((entry) => entry.name)
234
253
  .sort((left, right) => {
235
- const leftTimestamp = Number(left.slice(left.lastIndexOf("-") + 1));
236
- const rightTimestamp = Number(right.slice(right.lastIndexOf("-") + 1));
254
+ const leftTimestamp = this.generationTimestamp(left);
255
+ const rightTimestamp = this.generationTimestamp(right);
237
256
  return rightTimestamp - leftTimestamp || right.localeCompare(left);
238
257
  });
239
258
  const retained = new Set(generations.slice(0, RETAINED_SEMANTIC_GENERATIONS));
@@ -243,8 +262,14 @@ export class Archive {
243
262
  rmSync(join(generationRoot, generation), { recursive: true, force: true });
244
263
  }
245
264
  }
265
+ generationTimestamp(name) {
266
+ const match = /^gen-[0-9a-f]{16}-([0-9]+)(?:-[0-9a-f]+)?$/.exec(name);
267
+ return match ? Number(match[1]) : 0;
268
+ }
246
269
  semanticGeneration(root) {
247
270
  const generation = readFileSync(join(root, "CURRENT"), "utf8").trim();
271
+ if (!SEMANTIC_GENERATION_NAME.test(generation))
272
+ throw new Error("invalid semantic generation pointer");
248
273
  const manifest = JSON.parse(readFileSync(join(root, "generations", generation, "manifest.json"), "utf8"));
249
274
  return { generation, archiveRevision: manifest.archiveRevision, vectorCount: manifest.vectors.length };
250
275
  }
@@ -334,8 +359,6 @@ export class Archive {
334
359
  const rows = this.db.prepare("SELECT rowid, chunk_id, text, message_id FROM chunks").all();
335
360
  const rebuild = this.db.transaction(() => {
336
361
  this.db.exec("DELETE FROM chunks_fts");
337
- for (const language of lexicalFields())
338
- this.db.exec(`DELETE FROM chunks_fts_${language}`);
339
362
  for (const row of rows) {
340
363
  const message = this.db.prepare("SELECT * FROM messages WHERE message_id = ?").get(row.message_id);
341
364
  this.db.prepare(`INSERT INTO chunks_fts(rowid, subject, from_address, to_addresses, thread_subject,
@@ -344,8 +367,6 @@ export class Archive {
344
367
  }
345
368
  });
346
369
  rebuild();
347
- this.db.prepare("DELETE FROM lexical_index_meta WHERE id = 1").run();
348
- this.lexicalRebuildRequired = true;
349
370
  return { rows: rows.length, status: "repaired" };
350
371
  }
351
372
  upsertMessage(message) {
@@ -428,7 +449,7 @@ export class Archive {
428
449
  }
429
450
  }
430
451
  async getEmbedder() {
431
- this.embedder ??= await createEmbedder();
452
+ this.embedder ??= await createEmbedder(this.embedderConfig);
432
453
  return this.embedder;
433
454
  }
434
455
  searchLexicalTable(table, query, filters, limit) {
package/dist/cli/index.js CHANGED
@@ -2,9 +2,11 @@
2
2
  import { Command } from "commander";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
5
6
  import { Archive } from "../archive.js";
6
7
  import { FixtureSource, HimalayaSource } from "../source.js";
7
8
  import { redactDiagnostic } from "../redact.js";
9
+ import { embeddingModelName, loopbackConfigFromEnvironment } from "../embedding.js";
8
10
  const program = new Command();
9
11
  program.name("mailcrawl").description("Local privacy-first email indexing CLI");
10
12
  program.option("--data-dir <path>", "archive directory", process.env.MAILCRAWL_DATA_DIR || ".mailcrawl");
@@ -16,21 +18,32 @@ program
16
18
  .option("--mailbox <name>", "mailbox name", "INBOX")
17
19
  .option("--backend <name>")
18
20
  .option("--page-size <n>", "envelopes per page", "1000")
21
+ .option("--concurrency <n>", "simultaneous message reads (default 4)")
19
22
  .option("--himalaya-config <path>")
20
23
  .option("--include-category <name>", "include a normally excluded category", collect, [])
21
24
  .option("--exclude-category <name>", "exclude a classification category", collect, [])
22
25
  .option("--json")
23
26
  .action(async (options, command) => {
24
27
  const dataDir = command.parent.opts().dataDir;
28
+ if (options.source !== "fixture" && options.source !== "himalaya")
29
+ throw new Error(`unsupported source: ${options.source}`);
30
+ if (options.source === "fixture" && !options.fixture)
31
+ throw new Error("--fixture is required for fixture source");
32
+ if (options.source === "himalaya" && !options.account)
33
+ throw new Error("--account is required for himalaya source");
25
34
  await mkdir(dataDir, { recursive: true });
26
- const archive = new Archive(`${dataDir}/archive.sqlite`);
35
+ const archive = new Archive(join(dataDir, "archive.sqlite"));
27
36
  try {
28
37
  const source = options.source === "fixture"
29
- ? new FixtureSource(options.fixture)
30
- : new HimalayaSource(options.account, options.mailbox, options.backend, Number(options.pageSize), options.himalayaConfig);
38
+ ? fixtureSource(options)
39
+ : himalayaSource(options);
31
40
  const excludedCategories = (options.excludeCategory.length ? options.excludeCategory : ["spam", "promotions"])
32
41
  .filter((category) => !options.includeCategory.includes(category));
33
- output(await archive.sync(await source.list(), { excludedCategories }), options.json);
42
+ const { messages, failures } = await source.collect();
43
+ if (messages.length === 0 && failures.length > 0) {
44
+ throw new Error(`source read failed for all ${failures.length} message(s); no messages could be read: ${failures.slice(0, 3).map((failure) => failure.providerKey).join(", ")}`);
45
+ }
46
+ output({ ...await archive.sync(messages, { excludedCategories }), failures }, options.json);
34
47
  }
35
48
  finally {
36
49
  archive.close();
@@ -38,12 +51,21 @@ program
38
51
  });
39
52
  program
40
53
  .command("index")
54
+ .alias("embed")
55
+ .option("--provider <provider>", "embedding provider", "local")
56
+ .option("--embed-url <url>")
57
+ .option("--embed-model <model>")
58
+ .option("--embed-dim <n>")
59
+ .option("--query-prefix <prefix>")
60
+ .option("--passage-prefix <prefix>")
61
+ .option("--embed-timeout <ms>")
41
62
  .option("--json")
42
63
  .action(async (options, command) => {
43
64
  const dataDir = command.parent.opts().dataDir;
44
- const archive = new Archive(`${dataDir}/archive.sqlite`);
65
+ const config = embedderConfig(options);
66
+ const archive = new Archive(join(dataDir, "archive.sqlite"), config);
45
67
  try {
46
- output({ ...await archive.indexSemanticGeneration(`${dataDir}/semantic`), embedder: "onnx-community/embeddinggemma-300m-ONNX" }, options.json);
68
+ output({ ...await archive.indexSemanticGeneration(join(dataDir, "semantic")), embedder: embeddingModelName(config) }, options.json);
47
69
  }
48
70
  finally {
49
71
  archive.close();
@@ -61,12 +83,19 @@ for (const mode of ["bm25", "keyword", "semantic", "hybrid"]) {
61
83
  .option("--after <date>")
62
84
  .option("--before <date>")
63
85
  .option("--limit <n>", "result limit", "10")
86
+ .option("--provider <provider>", "embedding provider", "local")
87
+ .option("--embed-url <url>")
88
+ .option("--embed-model <model>")
89
+ .option("--embed-dim <n>")
90
+ .option("--query-prefix <prefix>")
91
+ .option("--passage-prefix <prefix>")
92
+ .option("--embed-timeout <ms>")
64
93
  .option("--json")
65
94
  .action(async (query, options, command) => {
66
95
  const dataDir = command.parent.opts().dataDir;
67
- const archive = new Archive(`${dataDir}/archive.sqlite`);
96
+ const archive = new Archive(join(dataDir, "archive.sqlite"), embedderConfig(options));
68
97
  try {
69
- const result = mode === "bm25"
98
+ const result = mode === "bm25" || mode === "keyword"
70
99
  ? await archive.searchBm25(query, filters(options), Number(options.limit))
71
100
  : mode === "hybrid"
72
101
  ? await archive.searchHybrid(query, filters(options), Number(options.limit))
@@ -81,7 +110,7 @@ for (const mode of ["bm25", "keyword", "semantic", "hybrid"]) {
81
110
  program
82
111
  .command("search")
83
112
  .argument("<query>")
84
- .option("--mode <mode>", "keyword, bm25, semantic, hybrid", "bm25")
113
+ .option("--mode <mode>", "fts, keyword, bm25, semantic, hybrid", "bm25")
85
114
  .option("--account <id>")
86
115
  .option("--mailbox <name>")
87
116
  .option("--from <address>")
@@ -90,13 +119,20 @@ program
90
119
  .option("--after <date>")
91
120
  .option("--before <date>")
92
121
  .option("--limit <n>", "result limit", "10")
122
+ .option("--provider <provider>", "embedding provider", "local")
123
+ .option("--embed-url <url>")
124
+ .option("--embed-model <model>")
125
+ .option("--embed-dim <n>")
126
+ .option("--query-prefix <prefix>")
127
+ .option("--passage-prefix <prefix>")
128
+ .option("--embed-timeout <ms>")
93
129
  .option("--json")
94
130
  .action(async (query, options, command) => {
95
- const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
131
+ const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"), embedderConfig(options));
96
132
  try {
97
133
  const filter = filters(options);
98
134
  const limit = Number(options.limit);
99
- const result = options.mode === "bm25" ? await archive.searchBm25(query, filter, limit)
135
+ const result = options.mode === "bm25" || options.mode === "fts" || options.mode === "keyword" ? await archive.searchBm25(query, filter, limit)
100
136
  : options.mode === "semantic" ? await archive.searchSemantic(query, filter, limit)
101
137
  : options.mode === "hybrid" ? await archive.searchHybrid(query, filter, limit)
102
138
  : (() => { throw new Error(`unsupported search mode: ${options.mode}`); })();
@@ -111,7 +147,7 @@ program
111
147
  .command("get <messageId>")
112
148
  .option("--json")
113
149
  .action(async (messageId, options, command) => {
114
- const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
150
+ const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
115
151
  try {
116
152
  output(archive.getMessage(messageId) ?? null, options.json);
117
153
  }
@@ -124,7 +160,7 @@ program
124
160
  .argument("<chunkId>")
125
161
  .option("--json")
126
162
  .action(async (chunkId, options, command) => {
127
- const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
163
+ const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"));
128
164
  try {
129
165
  output(archive.getChunkContext(chunkId), options.json);
130
166
  }
@@ -141,7 +177,7 @@ program
141
177
  .option("--before <date>")
142
178
  .option("--json")
143
179
  .action(async (threadId, options, command) => {
144
- const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
180
+ const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
145
181
  try {
146
182
  output(archive.getThread(threadId, filters(options)), options.json);
147
183
  }
@@ -155,7 +191,7 @@ program
155
191
  .option("--message <messageId>")
156
192
  .option("--json")
157
193
  .action(async (threadId, options, command) => {
158
- const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
194
+ const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"));
159
195
  try {
160
196
  output(archive.getThreadContext(threadId, options.message), options.json);
161
197
  }
@@ -168,21 +204,21 @@ program
168
204
  .option("--json")
169
205
  .action(async (options, command) => {
170
206
  const dataDir = command.parent.opts().dataDir;
171
- const archive = new Archive(`${dataDir}/archive.sqlite`);
207
+ const archive = new Archive(join(dataDir, "archive.sqlite"));
172
208
  try {
173
209
  let semantic = "missing";
174
210
  try {
175
- semantic = archive.semanticGeneration(`${dataDir}/semantic`);
211
+ semantic = semanticStatus(archive, archive.semanticGeneration(join(dataDir, "semantic")));
176
212
  }
177
213
  catch (error) {
178
- semantic = redactDiagnostic({ status: "stale", error: error instanceof Error ? error.message : String(error) });
214
+ semantic = redactDiagnostic({ status: semanticErrorStatus(error), error: error instanceof Error ? error.message : String(error) });
179
215
  }
180
216
  const semanticCommitted = typeof semantic === "object" && semantic !== null && "generation" in semantic;
181
217
  output({
182
218
  name: "mailcrawl",
183
- archive: `${dataDir}/archive.sqlite`,
184
- archivePresent: existsSync(`${dataDir}/archive.sqlite`),
185
- fts: "available",
219
+ archive: join(dataDir, "archive.sqlite"),
220
+ archivePresent: existsSync(join(dataDir, "archive.sqlite")),
221
+ fts: archive.status().fts,
186
222
  semantic,
187
223
  recommendation: semanticCommitted ? "semantic index is committed" : "run sync, then index before semantic search",
188
224
  }, options.json);
@@ -191,16 +227,65 @@ program
191
227
  archive.close();
192
228
  }
193
229
  });
230
+ program
231
+ .command("status")
232
+ .option("--json")
233
+ .action(async (options, command) => {
234
+ const dataDir = command.parent.opts().dataDir;
235
+ const archivePath = join(dataDir, "archive.sqlite");
236
+ if (!existsSync(archivePath)) {
237
+ output({ name: "mailcrawl", archive: archivePath, archivePresent: false, messageCount: 0, chunkCount: 0, embeddingBacklog: 0, fts: { status: "missing", rows: 0 }, semantic: { status: "missing" } }, options.json);
238
+ return;
239
+ }
240
+ const archive = new Archive(archivePath);
241
+ try {
242
+ let semantic = "missing";
243
+ try {
244
+ semantic = semanticStatus(archive, archive.semanticGeneration(join(dataDir, "semantic")));
245
+ }
246
+ catch (error) {
247
+ semantic = redactDiagnostic({ status: semanticErrorStatus(error), error: error instanceof Error ? error.message : String(error) });
248
+ }
249
+ output({ name: "mailcrawl", archive: archivePath, archivePresent: true, ...archive.status(), semantic }, options.json);
250
+ }
251
+ finally {
252
+ archive.close();
253
+ }
254
+ });
194
255
  program
195
256
  .command("repair")
196
257
  .option("--fts")
258
+ .option("--semantic")
259
+ .option("--all")
260
+ .option("--provider <provider>", "embedding provider", "local")
261
+ .option("--embed-url <url>")
262
+ .option("--embed-model <model>")
263
+ .option("--embed-dim <n>")
264
+ .option("--query-prefix <prefix>")
265
+ .option("--passage-prefix <prefix>")
266
+ .option("--embed-timeout <ms>")
197
267
  .option("--json")
198
268
  .action(async (options, command) => {
199
- const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
269
+ const dataDir = command.parent.opts().dataDir;
270
+ const archive = new Archive(join(dataDir, "archive.sqlite"));
200
271
  try {
201
- if (!options.fts)
202
- throw new Error("pass --fts");
203
- output(archive.repairFts(), options.json);
272
+ if (!options.fts && !options.semantic && !options.all)
273
+ throw new Error("pass --fts, --semantic, or --all");
274
+ const result = {};
275
+ if (options.semantic || options.all) {
276
+ const config = embedderConfig(options);
277
+ const semanticArchive = config ? new Archive(join(dataDir, "archive.sqlite"), config) : archive;
278
+ try {
279
+ result.semantic = await semanticArchive.indexSemanticGeneration(join(dataDir, "semantic"));
280
+ }
281
+ finally {
282
+ if (semanticArchive !== archive)
283
+ semanticArchive.close();
284
+ }
285
+ }
286
+ if (options.fts || options.all)
287
+ result.fts = archive.repairFts();
288
+ output(Object.keys(result).length === 1 ? Object.values(result)[0] : result, options.json);
204
289
  }
205
290
  finally {
206
291
  archive.close();
@@ -212,7 +297,7 @@ attachments
212
297
  .option("--message <messageId>")
213
298
  .option("--json")
214
299
  .action(async (options, command) => {
215
- const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
300
+ const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
216
301
  try {
217
302
  output(archive.listAttachments(options.message), options.json);
218
303
  }
@@ -222,12 +307,48 @@ attachments
222
307
  });
223
308
  program.parseAsync().catch((error) => {
224
309
  const message = error instanceof Error ? error.message : String(error);
225
- console.error(JSON.stringify({ error: message }));
310
+ console.error(JSON.stringify(redactDiagnostic({ error: message })));
226
311
  process.exitCode = 1;
227
312
  });
228
313
  function filters(options) {
229
314
  return { accountId: options.account, mailbox: options.mailbox, from: options.from, to: options.to, threadId: options.thread, after: options.after, before: options.before };
230
315
  }
316
+ function embedderConfig(options) {
317
+ if (options.provider === "local")
318
+ return loopbackConfigFromEnvironment();
319
+ if (options.provider !== "loopback-http")
320
+ throw new Error(`unsupported embedding provider: ${options.provider}`);
321
+ if (!options.embedUrl || !options.embedModel || !options.embedDim)
322
+ throw new Error("--embed-url, --embed-model, and --embed-dim are required for loopback-http");
323
+ return { provider: "loopback-http", url: options.embedUrl, model: options.embedModel, dimension: Number(options.embedDim), queryPrefix: options.queryPrefix, passagePrefix: options.passagePrefix, timeoutMs: options.embedTimeout ? Number(options.embedTimeout) : undefined };
324
+ }
325
+ function fixtureSource(options) {
326
+ if (!options.fixture)
327
+ throw new Error("--fixture is required for selected source");
328
+ return new FixtureSource(options.fixture);
329
+ }
330
+ function himalayaSource(options) {
331
+ if (!options.account)
332
+ throw new Error("--account is required for selected source");
333
+ return new HimalayaSource(options.account, options.mailbox, options.backend, Number(options.pageSize), options.himalayaConfig, {
334
+ concurrency: readConcurrency(options),
335
+ });
336
+ }
337
+ function readConcurrency(options) {
338
+ if (options.concurrency === undefined)
339
+ return undefined;
340
+ const concurrency = Number(options.concurrency);
341
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
342
+ throw new Error(`--concurrency must be a positive integer: ${options.concurrency}`);
343
+ }
344
+ return concurrency;
345
+ }
346
+ function semanticStatus(archive, semantic) {
347
+ return semantic.archiveRevision === archive.status().archiveRevision ? { ...semantic, status: "healthy" } : { ...semantic, status: "stale" };
348
+ }
349
+ function semanticErrorStatus(error) {
350
+ return error instanceof Error && "code" in error && error.code === "ENOENT" ? "missing" : "corrupt";
351
+ }
231
352
  function output(value, json) {
232
353
  if (json)
233
354
  console.log(JSON.stringify(value));
@@ -1,6 +1,8 @@
1
+ import type { LoopbackHttpConfig } from "./types.js";
1
2
  export interface Embedder {
2
3
  embedDocuments(texts: string[]): Promise<number[][]>;
3
4
  embedQuery(query: string): Promise<number[]>;
4
5
  }
5
- export declare function createEmbedder(): Promise<Embedder>;
6
- export declare function embeddingModelName(): string;
6
+ export declare function createEmbedder(config?: LoopbackHttpConfig): Promise<Embedder>;
7
+ export declare function embeddingModelName(config?: LoopbackHttpConfig): string;
8
+ export declare function loopbackConfigFromEnvironment(): LoopbackHttpConfig | undefined;
package/dist/embedding.js CHANGED
@@ -33,13 +33,76 @@ class TestEmbedder {
33
33
  return hashVector(query);
34
34
  }
35
35
  }
36
- export async function createEmbedder() {
36
+ class LoopbackHttpEmbedder {
37
+ config;
38
+ constructor(config) {
39
+ this.config = config;
40
+ const url = new URL(config.url);
41
+ const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
42
+ if (url.protocol !== "http:" || !["127.0.0.1", "localhost", "::1"].includes(hostname)) {
43
+ throw new Error("loopback HTTP embedding URL is required");
44
+ }
45
+ if (!Number.isInteger(config.dimension) || config.dimension <= 0)
46
+ throw new Error("embedding dimension must be positive");
47
+ }
48
+ async embedDocuments(texts) {
49
+ return this.request(texts.map((text) => (this.config.passagePrefix ?? "") + text));
50
+ }
51
+ async embedQuery(query) {
52
+ return (await this.request([(this.config.queryPrefix ?? "") + query.trim()]))[0];
53
+ }
54
+ async request(texts) {
55
+ const controller = new AbortController();
56
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 30_000);
57
+ try {
58
+ const response = await fetch(this.config.url, {
59
+ method: "POST",
60
+ headers: { "content-type": "application/json" },
61
+ body: JSON.stringify({ model: this.config.model, texts }),
62
+ signal: controller.signal,
63
+ });
64
+ if (!response.ok)
65
+ throw new Error(`embedding provider returned HTTP ${response.status}`);
66
+ const payload = await response.json();
67
+ if (!Array.isArray(payload.embeddings) || payload.embeddings.length !== texts.length)
68
+ throw new Error("embedding provider returned invalid embeddings");
69
+ const vectors = payload.embeddings;
70
+ if (vectors.some((vector) => !Array.isArray(vector) || vector.length !== this.config.dimension || vector.some((value) => typeof value !== "number"))) {
71
+ throw new Error("embedding provider returned invalid vector dimensions");
72
+ }
73
+ return vectors;
74
+ }
75
+ finally {
76
+ clearTimeout(timeout);
77
+ }
78
+ }
79
+ }
80
+ export async function createEmbedder(config) {
81
+ if (config?.provider === "loopback-http")
82
+ return new LoopbackHttpEmbedder(config);
37
83
  if (process.env.MAILCRAWL_EMBEDDER === "mock" || process.env.NODE_ENV === "test")
38
84
  return new TestEmbedder();
39
85
  return EmbeddingGemma.create();
40
86
  }
41
- export function embeddingModelName() {
42
- return EMBEDDING_MODEL;
87
+ export function embeddingModelName(config) {
88
+ return config
89
+ ? `loopback-http:${config.model}:${config.dimension}:${config.url}:${config.queryPrefix ?? ""}:${config.passagePrefix ?? ""}:${config.timeoutMs ?? 30_000}`
90
+ : EMBEDDING_MODEL;
91
+ }
92
+ export function loopbackConfigFromEnvironment() {
93
+ if (process.env.MAILCRAWL_EMBEDDER_PROVIDER !== "loopback-http")
94
+ return undefined;
95
+ const url = process.env.MAILCRAWL_EMBED_URL;
96
+ const model = process.env.MAILCRAWL_EMBED_MODEL;
97
+ const dimension = Number(process.env.MAILCRAWL_EMBED_DIM);
98
+ if (!url || !model || !Number.isInteger(dimension))
99
+ throw new Error("MAILCRAWL_EMBED_URL, MAILCRAWL_EMBED_MODEL, and MAILCRAWL_EMBED_DIM are required");
100
+ return {
101
+ provider: "loopback-http", url, model, dimension,
102
+ queryPrefix: process.env.MAILCRAWL_QUERY_PREFIX,
103
+ passagePrefix: process.env.MAILCRAWL_PASSAGE_PREFIX,
104
+ timeoutMs: process.env.MAILCRAWL_EMBED_TIMEOUT ? Number(process.env.MAILCRAWL_EMBED_TIMEOUT) : undefined,
105
+ };
43
106
  }
44
107
  function hashVector(text) {
45
108
  const vector = new Array(128).fill(0);
package/dist/redact.js CHANGED
@@ -2,6 +2,7 @@ export function redactDiagnostic(value) {
2
2
  if (typeof value === "string") {
3
3
  return value
4
4
  .replace(/([?&](?:token|password|secret|key)=)[^&\s]+/giu, "$1[REDACTED]")
5
+ .replace(/(?<![?&])\b(?:token|password|secret|key|credential)\s*[=:]\s*[^,\s]+/giu, "[REDACTED]")
5
6
  .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, "[EMAIL]");
6
7
  }
7
8
  if (Array.isArray(value))
package/dist/source.d.ts CHANGED
@@ -1,10 +1,34 @@
1
- import type { MailMessage } from "./types.js";
1
+ import type { MailMessage, SourceReadResult } from "./types.js";
2
+ /** Default number of simultaneous `himalaya message read` processes. */
3
+ export declare const DEFAULT_READ_CONCURRENCY = 4;
4
+ /** Default number of attempts per message read, including the first one. */
5
+ export declare const DEFAULT_RETRY_ATTEMPTS = 3;
6
+ /** Default backoff before the second read attempt; doubles per round. */
7
+ export declare const DEFAULT_RETRY_BASE_DELAY_MS = 250;
8
+ /** Executes one himalaya invocation; replaced by tests. */
9
+ export type HimalayaExec = (args: string[], maxBuffer: number) => Promise<{
10
+ stdout: string;
11
+ stderr?: string;
12
+ }>;
13
+ export interface HimalayaReadOptions {
14
+ /** Simultaneous message reads; defaults to DEFAULT_READ_CONCURRENCY. */
15
+ concurrency?: number;
16
+ /** Attempts per message read, including the first one; defaults to DEFAULT_RETRY_ATTEMPTS. */
17
+ retryAttempts?: number;
18
+ /** Backoff before the second attempt, doubling per round; defaults to DEFAULT_RETRY_BASE_DELAY_MS. */
19
+ retryBaseDelayMs?: number;
20
+ exec?: HimalayaExec;
21
+ }
2
22
  export interface MailSource {
23
+ /** Messages that could be read, plus one record per message that could not. */
24
+ collect(): Promise<SourceReadResult>;
25
+ /** Strict variant of `collect()`: rejects when any message could not be read. */
3
26
  list(): Promise<MailMessage[]>;
4
27
  }
5
28
  export declare class FixtureSource implements MailSource {
6
29
  private readonly path;
7
30
  constructor(path: string);
31
+ collect(): Promise<SourceReadResult>;
8
32
  list(): Promise<MailMessage[]>;
9
33
  }
10
34
  export declare class HimalayaSource implements MailSource {
@@ -13,7 +37,17 @@ export declare class HimalayaSource implements MailSource {
13
37
  private readonly backend?;
14
38
  private readonly pageSize;
15
39
  private readonly config?;
16
- constructor(account: string, mailbox?: string, backend?: string | undefined, pageSize?: number, config?: string | undefined);
40
+ private readonly readOptions;
41
+ constructor(account: string, mailbox?: string, backend?: string | undefined, pageSize?: number, config?: string | undefined, readOptions?: HimalayaReadOptions);
42
+ collect(): Promise<SourceReadResult>;
17
43
  list(): Promise<MailMessage[]>;
44
+ /** Reads a page through a bounded pool, retrying failed reads in later rounds. */
45
+ private readPage;
46
+ private get concurrency();
47
+ private envelopes;
48
+ private baseArgs;
18
49
  private read;
50
+ private run;
19
51
  }
52
+ /** Runs `worker` over `items` with at most `limit` workers in flight, preserving input order. */
53
+ export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]>;
package/dist/source.js CHANGED
@@ -1,12 +1,22 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { readFile } from "node:fs/promises";
4
+ import { redactDiagnostic } from "./redact.js";
4
5
  const execFileAsync = promisify(execFile);
6
+ /** Default number of simultaneous `himalaya message read` processes. */
7
+ export const DEFAULT_READ_CONCURRENCY = 4;
8
+ /** Default number of attempts per message read, including the first one. */
9
+ export const DEFAULT_RETRY_ATTEMPTS = 3;
10
+ /** Default backoff before the second read attempt; doubles per round. */
11
+ export const DEFAULT_RETRY_BASE_DELAY_MS = 250;
5
12
  export class FixtureSource {
6
13
  path;
7
14
  constructor(path) {
8
15
  this.path = path;
9
16
  }
17
+ async collect() {
18
+ return { messages: await this.list(), failures: [] };
19
+ }
10
20
  async list() {
11
21
  const raw = await readFile(this.path, "utf8");
12
22
  return JSON.parse(raw);
@@ -18,53 +28,155 @@ export class HimalayaSource {
18
28
  backend;
19
29
  pageSize;
20
30
  config;
21
- constructor(account, mailbox = "INBOX", backend, pageSize = 1000, config) {
31
+ readOptions;
32
+ constructor(account, mailbox = "INBOX", backend, pageSize = 1000, config, readOptions = {}) {
22
33
  this.account = account;
23
34
  this.mailbox = mailbox;
24
35
  this.backend = backend;
25
36
  this.pageSize = pageSize;
26
37
  this.config = config;
38
+ this.readOptions = readOptions;
39
+ }
40
+ async collect() {
41
+ return this.readPage(await this.envelopes());
27
42
  }
28
43
  async list() {
29
- const args = this.config ? ["-c", this.config, "-a", this.account] : ["-a", this.account];
30
- if (this.backend)
31
- args.push("-b", this.backend);
44
+ const { messages, failures } = await this.collect();
45
+ if (failures.length > 0)
46
+ throw new Error(failures[0].error);
47
+ return messages;
48
+ }
49
+ /** Reads a page through a bounded pool, retrying failed reads in later rounds. */
50
+ async readPage(envelopes) {
51
+ const keys = envelopes.map(envelopeKey);
52
+ const rawMime = new Map();
53
+ const errors = new Map();
54
+ const attempts = Math.max(1, Math.floor(this.readOptions.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS));
55
+ const baseDelayMs = Math.max(0, this.readOptions.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS);
56
+ let pending = envelopes.map((_, index) => index);
57
+ for (let attempt = 1; attempt <= attempts && pending.length > 0; attempt += 1) {
58
+ if (attempt > 1)
59
+ await delay(baseDelayMs * 2 ** (attempt - 2));
60
+ const failed = [];
61
+ await mapWithConcurrency(pending, this.concurrency, async (index) => {
62
+ try {
63
+ rawMime.set(index, await this.read(keys[index]));
64
+ }
65
+ catch (error) {
66
+ errors.set(index, error instanceof Error ? error : new Error(String(error)));
67
+ failed.push(index);
68
+ }
69
+ });
70
+ pending = failed;
71
+ }
72
+ const messages = [];
73
+ const failures = [];
74
+ envelopes.forEach((envelope, index) => {
75
+ const raw = rawMime.get(index);
76
+ if (raw !== undefined) {
77
+ messages.push(envelopeMessage(this.account, this.mailbox, envelope, keys[index], raw));
78
+ return;
79
+ }
80
+ failures.push({ providerKey: keys[index], attempts, error: (errors.get(index) ?? new Error("message read failed")).message });
81
+ });
82
+ return { messages, failures };
83
+ }
84
+ get concurrency() {
85
+ return Math.max(1, Math.floor(this.readOptions.concurrency ?? DEFAULT_READ_CONCURRENCY));
86
+ }
87
+ async envelopes() {
88
+ const args = this.baseArgs();
32
89
  args.push("envelope", "list", "--mailbox", this.mailbox, "--page-size", String(this.pageSize), "--json");
33
- const { stdout } = await execFileAsync("himalaya", args, { maxBuffer: 16 * 1024 * 1024 });
90
+ const { stdout } = await this.run(args, 16 * 1024 * 1024, "envelope list");
34
91
  const payload = JSON.parse(stdout);
35
- const envelopes = payload.envelopes ?? (Array.isArray(payload) ? payload : []);
36
- return Promise.all(envelopes.map(async (envelope) => {
37
- const providerKey = String(envelope.id ?? envelope.uid ?? envelope["message-id"]);
38
- const rawMime = await this.read(providerKey);
39
- return {
40
- accountId: this.account,
41
- mailbox: this.mailbox,
42
- providerKey,
43
- messageId: envelope["message-id"],
44
- inReplyTo: envelope["in-reply-to"]?.[0],
45
- subject: envelope.subject ?? "",
46
- from: address(envelope.from),
47
- to: addresses(envelope.to),
48
- cc: addresses(envelope.cc),
49
- date: envelope.date ?? new Date(0).toISOString(),
50
- text: envelope.body ?? envelope.snippet ?? "",
51
- labels: strings(envelope.labels),
52
- flags: strings(envelope.flags),
53
- classifications: strings(envelope.classifications),
54
- rawMime,
55
- };
56
- }));
92
+ return payload.envelopes ?? (Array.isArray(payload) ? payload : []);
57
93
  }
58
- async read(id) {
94
+ baseArgs() {
59
95
  const args = this.config ? ["-c", this.config, "-a", this.account] : ["-a", this.account];
60
96
  if (this.backend)
61
97
  args.push("-b", this.backend);
98
+ return args;
99
+ }
100
+ async read(id) {
101
+ const args = this.baseArgs();
62
102
  args.push("--json", "message", "read", id, "--raw");
63
- const { stdout } = await execFileAsync("himalaya", args, { maxBuffer: 32 * 1024 * 1024 });
103
+ const { stdout } = await this.run(args, 32 * 1024 * 1024, "message read");
64
104
  const payload = JSON.parse(stdout);
65
105
  return payload.message ?? stdout;
66
106
  }
107
+ run(args, maxBuffer, operation) {
108
+ return runHimalaya(args, maxBuffer, operation, this.readOptions.exec);
109
+ }
110
+ }
111
+ /** Runs `worker` over `items` with at most `limit` workers in flight, preserving input order. */
112
+ export async function mapWithConcurrency(items, limit, worker) {
113
+ const results = new Array(items.length);
114
+ let cursor = 0;
115
+ let stopped = false;
116
+ const width = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
117
+ await Promise.all(Array.from({ length: width }, async () => {
118
+ while (!stopped) {
119
+ const index = cursor++;
120
+ if (index >= items.length)
121
+ return;
122
+ try {
123
+ results[index] = await worker(items[index]);
124
+ }
125
+ catch (error) {
126
+ // A failed page must not keep spawning provider processes for the
127
+ // items that were never read; only the in-flight workers finish.
128
+ stopped = true;
129
+ throw error;
130
+ }
131
+ }
132
+ }));
133
+ return results;
134
+ }
135
+ function envelopeKey(envelope) {
136
+ return String(envelope.id ?? envelope.uid ?? envelope["message-id"]);
137
+ }
138
+ function delay(ms) {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+ function envelopeMessage(account, mailbox, envelope, providerKey, rawMime) {
142
+ return {
143
+ accountId: account,
144
+ mailbox,
145
+ providerKey,
146
+ messageId: envelope["message-id"],
147
+ inReplyTo: envelope["in-reply-to"]?.[0],
148
+ subject: envelope.subject ?? "",
149
+ from: address(envelope.from),
150
+ to: addresses(envelope.to),
151
+ cc: addresses(envelope.cc),
152
+ date: envelope.date ?? new Date(0).toISOString(),
153
+ text: envelope.body ?? envelope.snippet ?? "",
154
+ labels: strings(envelope.labels),
155
+ flags: strings(envelope.flags),
156
+ classifications: strings(envelope.classifications),
157
+ rawMime,
158
+ };
159
+ }
160
+ async function runHimalaya(args, maxBuffer, operation, exec) {
161
+ try {
162
+ return await (exec ?? defaultExec)(args, maxBuffer);
163
+ }
164
+ catch (error) {
165
+ const detail = error instanceof Error ? error.message : String(error);
166
+ throw new Error(`himalaya ${operation} failed: ${redactDiagnostic(detail)}${stderrDetail(error)}`);
167
+ }
168
+ }
169
+ /** Keeps himalaya's own stderr in the surfaced error so throttling is distinguishable from a malformed message. */
170
+ function stderrDetail(error) {
171
+ const stderr = error?.stderr;
172
+ if (typeof stderr !== "string")
173
+ return "";
174
+ const text = stderr.trim();
175
+ if (!text)
176
+ return "";
177
+ return `: ${String(redactDiagnostic(text.length > 2_000 ? `${text.slice(0, 2_000)} [truncated]` : text))}`;
67
178
  }
179
+ const defaultExec = (args, maxBuffer) => execFileAsync("himalaya", args, { maxBuffer });
68
180
  function address(value) {
69
181
  if (typeof value === "string")
70
182
  return value;
package/dist/types.d.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  export type SearchMode = "keyword" | "bm25" | "semantic" | "hybrid";
2
+ export type EmbedderProvider = "local" | "loopback-http";
3
+ export interface LoopbackHttpConfig {
4
+ provider: "loopback-http";
5
+ url: string;
6
+ model: string;
7
+ dimension: number;
8
+ queryPrefix?: string;
9
+ passagePrefix?: string;
10
+ timeoutMs?: number;
11
+ }
2
12
  export interface MailMessage {
3
13
  accountId: string;
4
14
  mailbox: string;
@@ -86,6 +96,15 @@ export interface SyncReport {
86
96
  excluded: number;
87
97
  excludedByReason: Record<string, number>;
88
98
  }
99
+ export interface SourceReadFailure {
100
+ providerKey: string;
101
+ attempts: number;
102
+ error: string;
103
+ }
104
+ export interface SourceReadResult {
105
+ messages: MailMessage[];
106
+ failures: SourceReadFailure[];
107
+ }
89
108
  export interface ClassificationPolicy {
90
109
  excludedCategories?: string[];
91
110
  }
@@ -158,13 +158,19 @@ All commands support `--json` where machine-readable output is useful.
158
158
  ```text
159
159
  mailcrawl doctor
160
160
  mailcrawl status
161
- mailcrawl sync
162
- mailcrawl embed [--limit N]
163
- mailcrawl search --mode fts|semantic|hybrid [--limit N] [--mailbox NAME] QUERY
161
+ mailcrawl sync [--page-size N] [--concurrency N]
162
+ mailcrawl embed
163
+ mailcrawl search --mode fts|bm25|keyword|semantic|hybrid [--limit N] [--mailbox NAME] QUERY
164
164
  mailcrawl message get MESSAGE_ID
165
- mailcrawl repair [--fts|--vectors|--all]
165
+ mailcrawl repair [--fts|--semantic|--all]
166
166
  ```
167
167
 
168
+ `--page-size` is the IMAP envelope window and `--concurrency` is the number of
169
+ simultaneous message reads (default 4). Reads run through that bounded pool,
170
+ failures are retried with backoff, and the messages that could be read are
171
+ synced even when some reads keep failing, so a throttled provider no longer
172
+ aborts a whole page.
173
+
168
174
  Example sync response:
169
175
 
170
176
  ```json
@@ -175,10 +181,15 @@ Example sync response:
175
181
  "unchanged": 4821,
176
182
  "chunksAdded": 21,
177
183
  "chunksDeleted": 6,
178
- "embeddingBacklog": 21
184
+ "embeddingBacklog": 21,
185
+ "failures": []
179
186
  }
180
187
  ```
181
188
 
189
+ Each entry of `failures` carries the `providerKey`, the `attempts` spent on it,
190
+ and the redacted himalaya error (including himalaya's own stderr). The command
191
+ exits non-zero only when nothing could be read.
192
+
182
193
  Example search hit:
183
194
 
184
195
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/mailcrawl",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Local Himalaya-backed incremental email indexing and hybrid search CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,6 +48,7 @@
48
48
  "@types/better-sqlite3": "^7.6.13",
49
49
  "@types/node": "^24.3.0",
50
50
  "tsx": "^4.20.5",
51
+ "typescript": "^5.9.3",
51
52
  "vitest": "^3.2.4"
52
53
  },
53
54
  "engines": {
@@ -63,6 +63,13 @@ command, such as a user-level cron or systemd timer. Run `sync` first and
63
63
  `index` afterward; inspect JSON exit status before handing results to an
64
64
  agent.
65
65
 
66
+ Message reads use a bounded pool of himalaya processes (4 by default; change
67
+ it with `--concurrency <n>`) because Gmail throttles accounts that open too
68
+ many simultaneous IMAP connections. `--page-size` only sets the envelope
69
+ window. A read that fails is retried with backoff, and messages that stay
70
+ unreadable appear in the sync JSON `failures[]` while the readable messages
71
+ are still synced; the command exits non-zero only when nothing could be read.
72
+
66
73
  Use a fixture for deterministic development:
67
74
 
68
75
  ```bash
@@ -71,7 +78,8 @@ mailcrawl sync --source fixture --fixture ./messages.json --json
71
78
 
72
79
  ## Safe read and maintenance commands
73
80
 
74
- Search modes are `bm25`, `keyword`, `semantic`, and `hybrid`. Use metadata
81
+ Search modes are `fts`, `bm25`, `keyword`, `semantic`, and `hybrid`; `fts` and
82
+ `keyword` are aliases for BM25 lexical search. Use metadata
75
83
  filters such as `--mailbox`, `--from`, `--to`, `--thread`, `--after`, and
76
84
  `--before`. Empty queries and unsupported modes fail with a non-zero exit.
77
85
 
@@ -89,7 +97,11 @@ Check health and rebuild lexical data when needed:
89
97
 
90
98
  ```bash
91
99
  mailcrawl doctor --json
100
+ mailcrawl status --json
101
+ mailcrawl embed --json
92
102
  mailcrawl repair --fts --json
103
+ mailcrawl repair --semantic --json
104
+ mailcrawl repair --all --json
93
105
  ```
94
106
 
95
107
  `doctor` reports archive, FTS, and semantic-generation state. `repair` is a