@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 +15 -0
- package/dist/cli/index.js +18 -2
- package/dist/source.d.ts +36 -2
- package/dist/source.js +134 -32
- package/dist/types.d.ts +9 -0
- package/docs/architecture.md +13 -2
- package/package.json +1 -1
- package/skills/mailcrawl/SKILL.md +7 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
31
|
-
if (
|
|
32
|
-
|
|
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
|
|
90
|
+
const { stdout } = await this.run(args, 16 * 1024 * 1024, "envelope list");
|
|
35
91
|
const payload = JSON.parse(stdout);
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
@@ -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
|