@nomadamas/mailcrawl 0.1.6 → 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
@@ -50,6 +50,21 @@ before multilingual search; the command re-analyzes existing messages and
50
50
  atomically records the new fingerprint. Embedding model changes are independent
51
51
  and require a new `mailcrawl index` generation.
52
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
+
53
68
  ## Installation
54
69
 
55
70
  For the required Node setup, Kiwi model files, Go installation, Japanese and
package/dist/cli/index.js CHANGED
@@ -18,6 +18,7 @@ program
18
18
  .option("--mailbox <name>", "mailbox name", "INBOX")
19
19
  .option("--backend <name>")
20
20
  .option("--page-size <n>", "envelopes per page", "1000")
21
+ .option("--concurrency <n>", "simultaneous message reads (default 4)")
21
22
  .option("--himalaya-config <path>")
22
23
  .option("--include-category <name>", "include a normally excluded category", collect, [])
23
24
  .option("--exclude-category <name>", "exclude a classification category", collect, [])
@@ -38,7 +39,11 @@ program
38
39
  : himalayaSource(options);
39
40
  const excludedCategories = (options.excludeCategory.length ? options.excludeCategory : ["spam", "promotions"])
40
41
  .filter((category) => !options.includeCategory.includes(category));
41
- 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);
42
47
  }
43
48
  finally {
44
49
  archive.close();
@@ -325,7 +330,18 @@ function fixtureSource(options) {
325
330
  function himalayaSource(options) {
326
331
  if (!options.account)
327
332
  throw new Error("--account is required for selected source");
328
- return new HimalayaSource(options.account, options.mailbox, options.backend, Number(options.pageSize), options.himalayaConfig);
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;
329
345
  }
330
346
  function semanticStatus(archive, semantic) {
331
347
  return semantic.archiveRevision === archive.status().archiveRevision ? { ...semantic, status: "healthy" } : { ...semantic, status: "stale" };
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
@@ -3,11 +3,20 @@ import { promisify } from "node:util";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { redactDiagnostic } from "./redact.js";
5
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;
6
12
  export class FixtureSource {
7
13
  path;
8
14
  constructor(path) {
9
15
  this.path = path;
10
16
  }
17
+ async collect() {
18
+ return { messages: await this.list(), failures: [] };
19
+ }
11
20
  async list() {
12
21
  const raw = await readFile(this.path, "utf8");
13
22
  return JSON.parse(raw);
@@ -19,62 +28,155 @@ export class HimalayaSource {
19
28
  backend;
20
29
  pageSize;
21
30
  config;
22
- constructor(account, mailbox = "INBOX", backend, pageSize = 1000, config) {
31
+ readOptions;
32
+ constructor(account, mailbox = "INBOX", backend, pageSize = 1000, config, readOptions = {}) {
23
33
  this.account = account;
24
34
  this.mailbox = mailbox;
25
35
  this.backend = backend;
26
36
  this.pageSize = pageSize;
27
37
  this.config = config;
38
+ this.readOptions = readOptions;
39
+ }
40
+ async collect() {
41
+ return this.readPage(await this.envelopes());
28
42
  }
29
43
  async list() {
30
- const args = this.config ? ["-c", this.config, "-a", this.account] : ["-a", this.account];
31
- if (this.backend)
32
- 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();
33
89
  args.push("envelope", "list", "--mailbox", this.mailbox, "--page-size", String(this.pageSize), "--json");
34
- const { stdout } = await runHimalaya(args, 16 * 1024 * 1024, "envelope list");
90
+ const { stdout } = await this.run(args, 16 * 1024 * 1024, "envelope list");
35
91
  const payload = JSON.parse(stdout);
36
- const envelopes = payload.envelopes ?? (Array.isArray(payload) ? payload : []);
37
- return Promise.all(envelopes.map(async (envelope) => {
38
- const providerKey = String(envelope.id ?? envelope.uid ?? envelope["message-id"]);
39
- const rawMime = await this.read(providerKey);
40
- return {
41
- accountId: this.account,
42
- mailbox: this.mailbox,
43
- providerKey,
44
- messageId: envelope["message-id"],
45
- inReplyTo: envelope["in-reply-to"]?.[0],
46
- subject: envelope.subject ?? "",
47
- from: address(envelope.from),
48
- to: addresses(envelope.to),
49
- cc: addresses(envelope.cc),
50
- date: envelope.date ?? new Date(0).toISOString(),
51
- text: envelope.body ?? envelope.snippet ?? "",
52
- labels: strings(envelope.labels),
53
- flags: strings(envelope.flags),
54
- classifications: strings(envelope.classifications),
55
- rawMime,
56
- };
57
- }));
92
+ return payload.envelopes ?? (Array.isArray(payload) ? payload : []);
58
93
  }
59
- async read(id) {
94
+ baseArgs() {
60
95
  const args = this.config ? ["-c", this.config, "-a", this.account] : ["-a", this.account];
61
96
  if (this.backend)
62
97
  args.push("-b", this.backend);
98
+ return args;
99
+ }
100
+ async read(id) {
101
+ const args = this.baseArgs();
63
102
  args.push("--json", "message", "read", id, "--raw");
64
- const { stdout } = await runHimalaya(args, 32 * 1024 * 1024, "message read");
103
+ const { stdout } = await this.run(args, 32 * 1024 * 1024, "message read");
65
104
  const payload = JSON.parse(stdout);
66
105
  return payload.message ?? stdout;
67
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;
68
134
  }
69
- async function runHimalaya(args, maxBuffer, operation) {
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) {
70
161
  try {
71
- return await execFileAsync("himalaya", args, { maxBuffer });
162
+ return await (exec ?? defaultExec)(args, maxBuffer);
72
163
  }
73
164
  catch (error) {
74
165
  const detail = error instanceof Error ? error.message : String(error);
75
- throw new Error(`himalaya ${operation} failed: ${redactDiagnostic(detail)}`);
166
+ throw new Error(`himalaya ${operation} failed: ${redactDiagnostic(detail)}${stderrDetail(error)}`);
76
167
  }
77
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))}`;
178
+ }
179
+ const defaultExec = (args, maxBuffer) => execFileAsync("himalaya", args, { maxBuffer });
78
180
  function address(value) {
79
181
  if (typeof value === "string")
80
182
  return value;
package/dist/types.d.ts CHANGED
@@ -96,6 +96,15 @@ export interface SyncReport {
96
96
  excluded: number;
97
97
  excludedByReason: Record<string, number>;
98
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
+ }
99
108
  export interface ClassificationPolicy {
100
109
  excludedCategories?: string[];
101
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
161
+ mailcrawl sync [--page-size N] [--concurrency N]
162
162
  mailcrawl embed
163
163
  mailcrawl search --mode fts|bm25|keyword|semantic|hybrid [--limit N] [--mailbox NAME] QUERY
164
164
  mailcrawl message get MESSAGE_ID
165
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.6",
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",
@@ -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