@nomadamas/mailcrawl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NomaDamas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # mailcrawl
2
+
3
+ Local, privacy-first email indexing CLI for AI agents and humans.
4
+
5
+ mailcrawl uses the configured [Himalaya](https://github.com/pimalaya/himalaya)
6
+ account as its mail transport, then maintains a local normalized archive with
7
+ incremental synchronization, email-aware chunking, full-text search (FTS5),
8
+ semantic vector search, and hybrid retrieval.
9
+
10
+ > Status: concept scaffold. The architecture is documented before the first
11
+ > implementation increment.
12
+
13
+ ## Product goal
14
+
15
+ Make one local command useful to any client:
16
+
17
+ ```bash
18
+ mailcrawl sync --json
19
+ mailcrawl embed --json
20
+ mailcrawl search --mode hybrid --json "계약 갱신 조건"
21
+ mailcrawl status --json
22
+ mailcrawl doctor --json
23
+ ```
24
+
25
+ The CLI owns email synchronization and indexes. Consumers such as AutoRAG,
26
+ OpenClaw, MCP servers, Raycast, and custom scripts consume its stable JSON
27
+ surface instead of opening the archive database.
28
+
29
+ ## Planned capabilities
30
+
31
+ - Himalaya-backed IMAP/JMAP/Gmail/Microsoft Graph/Maildir access
32
+ - Stable account/mailbox/message identity and cursor state
33
+ - MIME normalization, HTML-to-text conversion, and quoted-reply handling
34
+ - Email-aware, thread-aware chunking
35
+ - Incremental archive, FTS5, and embedding updates
36
+ - LanceDB vector storage with configurable local embedding providers
37
+ - FTS, semantic, and hybrid search modes
38
+ - JSON output, bounded diagnostics, `status`, `doctor`, and `repair`
39
+ - No credential values in logs, diagnostics, or indexed metadata by default
40
+
41
+ See [`docs/architecture.md`](docs/architecture.md) for the proposed design,
42
+ data model, CLI contract, and implementation milestones.
43
+
44
+ ## Releasing
45
+
46
+ 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.
47
+
48
+ One-time setup:
49
+
50
+ 1. Create a GitHub Environment named `release` on this repository.
51
+ 2. On [npm trusted publishers](https://docs.npmjs.com/trusted-publishers) for `@nomadamas/mailcrawl`, add GitHub Actions:
52
+ - Organization: `NomaDamas`
53
+ - Repository: `mailcrawl`
54
+ - Workflow filename: `release.yml`
55
+ - Environment: `release`
56
+
57
+ Then bump the version, push `main`, and publish a GitHub Release whose tag is `v<version>`. The workflow tests, builds, publishes with provenance, and attaches the tarball to the release.
58
+
59
+ ## License
60
+
61
+ MIT. This project is not yet a production release.
@@ -0,0 +1,55 @@
1
+ import Database from "better-sqlite3";
2
+ import type { ClassificationPolicy, Chunk, MailMessage, NormalizedMessage, SearchFilters, SearchHit, SyncReport } from "./types.js";
3
+ export declare class Archive {
4
+ readonly db: Database.Database;
5
+ constructor(path?: string);
6
+ close(): void;
7
+ sync(messages: MailMessage[], policy?: ClassificationPolicy): Promise<SyncReport>;
8
+ searchBm25(query: string, filters?: SearchFilters, limit?: number): SearchHit[];
9
+ indexSemantic(): {
10
+ embedded: number;
11
+ reused: number;
12
+ archiveRevision: string;
13
+ };
14
+ indexSemanticGeneration(root: string): {
15
+ generation: string;
16
+ embedded: number;
17
+ reused: number;
18
+ };
19
+ semanticGeneration(root: string): {
20
+ generation: string;
21
+ archiveRevision: string;
22
+ vectorCount: number;
23
+ };
24
+ searchSemantic(query: string, filters?: SearchFilters, limit?: number): SearchHit[];
25
+ searchHybrid(query: string, filters?: SearchFilters, limit?: number): SearchHit[];
26
+ getMessage(messageId: string): NormalizedMessage | undefined;
27
+ listAttachments(messageId?: string): Array<{
28
+ attachmentId: string;
29
+ messageId: string;
30
+ name: string;
31
+ mimeType: string;
32
+ size: number | null;
33
+ contentHash: string | null;
34
+ extractedText: string | null;
35
+ }>;
36
+ getThread(threadId: string, filters?: SearchFilters): NormalizedMessage[];
37
+ getThreadContext(threadId: string, messageId?: string): {
38
+ previous: NormalizedMessage[];
39
+ current?: NormalizedMessage;
40
+ next: NormalizedMessage[];
41
+ };
42
+ getChunkContext(chunkId: string): {
43
+ previous?: Chunk;
44
+ current?: Chunk;
45
+ next?: Chunk;
46
+ };
47
+ repairFts(): {
48
+ rows: number;
49
+ status: "repaired";
50
+ };
51
+ private upsertMessage;
52
+ private removeMessage;
53
+ private replaceMessageChunks;
54
+ private revision;
55
+ }
@@ -0,0 +1,388 @@
1
+ import Database from "better-sqlite3";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { buildChunks } from "./chunk.js";
6
+ import { normalizeMessage } from "./normalize.js";
7
+ import { snippet } from "./util.js";
8
+ export class Archive {
9
+ db;
10
+ constructor(path = ":memory:") {
11
+ this.db = new Database(path);
12
+ this.db.pragma("journal_mode = WAL");
13
+ this.db.pragma("foreign_keys = ON");
14
+ migrate(this.db);
15
+ }
16
+ close() {
17
+ this.db.close();
18
+ }
19
+ async sync(messages, policy = {}) {
20
+ const excludedCategories = new Set((policy.excludedCategories ?? ["spam", "promotions"]).map(normalizeCategory));
21
+ const normalized = (await Promise.all(messages.map(normalizeMessage))).map((message) => ({
22
+ ...message,
23
+ messageId: `${message.accountId}:${message.messageId}`,
24
+ threadId: `${message.accountId}:${message.threadId}`,
25
+ providerKey: `${message.accountId}:${message.providerKey}`,
26
+ inReplyTo: message.inReplyTo ? `${message.accountId}:${message.inReplyTo}` : undefined,
27
+ }));
28
+ const excluded = normalized.filter((message) => message.categories.some((category) => excludedCategories.has(category)));
29
+ const included = normalized.filter((message) => !message.categories.some((category) => excludedCategories.has(category)));
30
+ const existing = this.db.prepare("SELECT provider_key, normalized_hash FROM messages").all();
31
+ const previous = new Map(existing.map((row) => [row.provider_key, row.normalized_hash]));
32
+ let added = 0;
33
+ let updated = 0;
34
+ let unchanged = 0;
35
+ const touched = new Set();
36
+ const transaction = this.db.transaction((items) => {
37
+ for (const message of items) {
38
+ const oldHash = previous.get(message.providerKey);
39
+ if (!oldHash)
40
+ added++;
41
+ else if (oldHash !== message.normalizedHash)
42
+ updated++;
43
+ else
44
+ unchanged++;
45
+ if (oldHash !== message.normalizedHash)
46
+ touched.add(message.threadId);
47
+ this.upsertMessage(message);
48
+ if (oldHash !== message.normalizedHash)
49
+ this.replaceMessageChunks(message);
50
+ }
51
+ });
52
+ transaction(included);
53
+ for (const message of excluded)
54
+ this.removeMessage(message);
55
+ const chunks = Number(this.db.prepare("SELECT COUNT(*) AS count FROM chunks").get().count);
56
+ const backlog = Number(this.db.prepare("SELECT COUNT(*) AS count FROM embedding_queue WHERE state = 'pending'").get().count);
57
+ return {
58
+ added, updated, deleted: 0, unchanged, touchedThreads: touched.size,
59
+ rebuiltThreads: touched.size, chunksAdded: chunks, chunksDeleted: 0,
60
+ embeddingBacklog: backlog, archiveRevision: this.revision(),
61
+ excluded: excluded.length,
62
+ excludedByReason: countExcluded(excluded, excludedCategories),
63
+ };
64
+ }
65
+ searchBm25(query, filters = {}, limit = 10) {
66
+ if (!query.trim())
67
+ throw new Error("empty query");
68
+ const clauses = ["chunks_fts MATCH ?"];
69
+ const params = [literalFtsQuery(query)];
70
+ addFilters(clauses, params, filters);
71
+ const sql = `SELECT c.chunk_id, c.message_id, c.thread_id, c.account_id, c.mailbox,
72
+ m.subject, m.from_address, m.to_addresses, m.date, snippet(chunks_fts, 0, '[', ']', '…', 32) AS snippet,
73
+ bm25(chunks_fts, 8.0, 5.0, 2.0, 1.0, 1.0, 1.0, 0.5) AS score
74
+ FROM chunks_fts JOIN chunks c ON c.rowid = chunks_fts.rowid
75
+ JOIN messages m ON m.message_id = c.message_id
76
+ WHERE ${clauses.join(" AND ")} ORDER BY score LIMIT ?`;
77
+ params.push(limit);
78
+ return this.db.prepare(sql).all(...params).map((row) => hydrate(row, "bm25", query));
79
+ }
80
+ indexSemantic() {
81
+ const rows = this.db.prepare("SELECT chunk_id, text, content_hash FROM chunks ORDER BY chunk_id").all();
82
+ let embedded = 0;
83
+ let reused = 0;
84
+ const upsert = this.db.prepare(`INSERT INTO semantic_vectors
85
+ (chunk_id, content_hash, vector) VALUES (?, ?, ?)
86
+ ON CONFLICT(chunk_id) DO UPDATE SET content_hash=excluded.content_hash, vector=excluded.vector`);
87
+ const transaction = this.db.transaction(() => {
88
+ for (const row of rows) {
89
+ const old = this.db.prepare("SELECT content_hash FROM semantic_vectors WHERE chunk_id = ?").get(row.chunk_id);
90
+ if (old?.content_hash === row.content_hash) {
91
+ reused++;
92
+ continue;
93
+ }
94
+ upsert.run(row.chunk_id, row.content_hash, JSON.stringify(embed(row.text)));
95
+ embedded++;
96
+ }
97
+ });
98
+ transaction();
99
+ return { embedded, reused, archiveRevision: this.revision() };
100
+ }
101
+ indexSemanticGeneration(root) {
102
+ const currentPath = join(root, "CURRENT");
103
+ const generationRoot = join(root, "generations");
104
+ mkdirSync(generationRoot, { recursive: true });
105
+ const generation = `gen-${this.revision().slice(0, 16)}-${Date.now()}`;
106
+ const staging = join(generationRoot, `.${generation}.staging`);
107
+ mkdirSync(staging);
108
+ try {
109
+ const report = this.indexSemantic();
110
+ const vectors = this.db.prepare("SELECT chunk_id, content_hash, vector FROM semantic_vectors ORDER BY chunk_id").all();
111
+ writeFileSync(join(staging, "manifest.json"), JSON.stringify({
112
+ archiveRevision: this.revision(), vectors, model: "local-hash-v1",
113
+ }));
114
+ renameSync(staging, join(generationRoot, generation));
115
+ const pointer = join(root, `.CURRENT.${process.pid}`);
116
+ writeFileSync(pointer, `${generation}\n`);
117
+ renameSync(pointer, currentPath);
118
+ return { generation, embedded: report.embedded, reused: report.reused };
119
+ }
120
+ catch (error) {
121
+ rmSync(staging, { recursive: true, force: true });
122
+ throw error;
123
+ }
124
+ }
125
+ semanticGeneration(root) {
126
+ const generation = readFileSync(join(root, "CURRENT"), "utf8").trim();
127
+ const manifest = JSON.parse(readFileSync(join(root, "generations", generation, "manifest.json"), "utf8"));
128
+ return { generation, archiveRevision: manifest.archiveRevision, vectorCount: manifest.vectors.length };
129
+ }
130
+ searchSemantic(query, filters = {}, limit = 10) {
131
+ if (!query.trim())
132
+ throw new Error("empty query");
133
+ const queryVector = embed(query);
134
+ const clauses = ["1 = 1"];
135
+ const params = [];
136
+ addFilters(clauses, params, filters);
137
+ const rows = this.db.prepare(`SELECT v.vector, c.chunk_id, c.message_id, c.thread_id, c.account_id, c.mailbox,
138
+ m.subject, m.from_address, m.to_addresses, m.date, c.text
139
+ FROM semantic_vectors v JOIN chunks c ON c.chunk_id = v.chunk_id JOIN messages m ON m.message_id = c.message_id
140
+ WHERE ${clauses.join(" AND ")}`).all(...params);
141
+ return rows.map((row) => ({ row, score: dot(queryVector, JSON.parse(row.vector)) }))
142
+ .sort((a, b) => b.score - a.score || a.row.chunk_id.localeCompare(b.row.chunk_id))
143
+ .slice(0, limit)
144
+ .map(({ row, score }) => ({
145
+ chunkId: row.chunk_id, messageId: row.message_id, threadId: row.thread_id,
146
+ accountId: row.account_id, mailbox: row.mailbox, subject: row.subject,
147
+ from: row.from_address, to: JSON.parse(row.to_addresses), date: row.date,
148
+ snippet: snippet(row.text, query), score, mode: "semantic",
149
+ }));
150
+ }
151
+ searchHybrid(query, filters = {}, limit = 10) {
152
+ const lexical = this.searchBm25(query, filters, limit * 2);
153
+ const semantic = this.searchSemantic(query, filters, limit * 2);
154
+ const merged = new Map();
155
+ for (const [index, hit] of lexical.entries())
156
+ merged.set(hit.chunkId, { ...hit, score: 0.5 * (1 - index / Math.max(1, lexical.length)) });
157
+ for (const [index, hit] of semantic.entries()) {
158
+ const prior = merged.get(hit.chunkId);
159
+ const score = 0.5 * (1 - index / Math.max(1, semantic.length));
160
+ merged.set(hit.chunkId, prior ? { ...prior, score: prior.score + score, mode: "hybrid" } : { ...hit, score, mode: "hybrid" });
161
+ }
162
+ return [...merged.values()].sort((a, b) => b.score - a.score || a.chunkId.localeCompare(b.chunkId)).slice(0, limit);
163
+ }
164
+ getMessage(messageId) {
165
+ const row = this.db.prepare("SELECT * FROM messages WHERE message_id = ?").get(messageId);
166
+ return row && messageRow(row);
167
+ }
168
+ listAttachments(messageId) {
169
+ const rows = (messageId
170
+ ? this.db.prepare("SELECT * FROM attachments WHERE message_id = ? ORDER BY attachment_id").all(messageId)
171
+ : this.db.prepare("SELECT * FROM attachments ORDER BY message_id, attachment_id").all());
172
+ return rows.map((row) => ({
173
+ attachmentId: row.attachment_id,
174
+ messageId: row.message_id,
175
+ name: row.name,
176
+ mimeType: row.mime_type,
177
+ size: row.size,
178
+ contentHash: row.content_hash,
179
+ extractedText: row.extracted_text,
180
+ }));
181
+ }
182
+ getThread(threadId, filters = {}) {
183
+ const clauses = ["thread_id = ?"];
184
+ const params = [threadId];
185
+ addFilters(clauses, params, filters);
186
+ return this.db.prepare(`SELECT * FROM messages WHERE ${clauses.join(" AND ")} ORDER BY date, message_id`).all(...params).map(messageRow);
187
+ }
188
+ getThreadContext(threadId, messageId) {
189
+ const messages = this.getThread(threadId);
190
+ const index = messageId ? messages.findIndex((message) => message.messageId === messageId) : 0;
191
+ const current = index >= 0 ? messages[index] : undefined;
192
+ return {
193
+ previous: index > 0 ? messages.slice(0, index) : [],
194
+ current,
195
+ next: index >= 0 ? messages.slice(index + 1) : messages,
196
+ };
197
+ }
198
+ getChunkContext(chunkId) {
199
+ const current = this.db.prepare("SELECT * FROM chunks WHERE chunk_id = ?").get(chunkId);
200
+ if (!current)
201
+ return {};
202
+ const base = this.db.prepare("SELECT * FROM chunks WHERE thread_id = ? AND (started_at < ? OR (started_at = ? AND rowid < ?)) ORDER BY started_at DESC, rowid DESC LIMIT 1").get(current.thread_id, current.started_at, current.started_at, current.rowid);
203
+ const next = this.db.prepare("SELECT * FROM chunks WHERE thread_id = ? AND (started_at > ? OR (started_at = ? AND rowid > ?)) ORDER BY started_at, rowid LIMIT 1").get(current.thread_id, current.started_at, current.started_at, current.rowid);
204
+ return { previous: base && chunkRow(base), current: chunkRow(current), next: next && chunkRow(next) };
205
+ }
206
+ repairFts() {
207
+ const rows = this.db.prepare("SELECT rowid, chunk_id, text, message_id FROM chunks").all();
208
+ const rebuild = this.db.transaction(() => {
209
+ this.db.exec("DELETE FROM chunks_fts");
210
+ for (const row of rows) {
211
+ const message = this.db.prepare("SELECT * FROM messages WHERE message_id = ?").get(row.message_id);
212
+ this.db.prepare(`INSERT INTO chunks_fts(rowid, subject, from_address, to_addresses, thread_subject,
213
+ body_latest, body_quoted, forwarded_text, attachment_text)
214
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.rowid, message.subject, message.from_address, message.to_addresses, message.subject, row.text, "", "", message.attachment_text || "");
215
+ }
216
+ });
217
+ rebuild();
218
+ return { rows: rows.length, status: "repaired" };
219
+ }
220
+ upsertMessage(message) {
221
+ this.db.prepare(`INSERT INTO messages
222
+ (message_id, account_id, mailbox, provider_key, thread_id, in_reply_to, subject, from_address,
223
+ to_addresses, cc_addresses, date, latest_text, quoted_text, attachment_text, normalized_hash,
224
+ labels, flags, classifications)
225
+ VALUES (@messageId, @accountId, @mailbox, @providerKey, @threadId, @inReplyTo, @subject, @from,
226
+ @to, @cc, @date, @latestText, @quotedText, @attachmentText, @normalizedHash,
227
+ @labels, @flags, @classifications)
228
+ ON CONFLICT(message_id) DO UPDATE SET account_id=excluded.account_id, mailbox=excluded.mailbox,
229
+ provider_key=excluded.provider_key, thread_id=excluded.thread_id, in_reply_to=excluded.in_reply_to,
230
+ subject=excluded.subject, from_address=excluded.from_address, to_addresses=excluded.to_addresses,
231
+ cc_addresses=excluded.cc_addresses, date=excluded.date, latest_text=excluded.latest_text,
232
+ quoted_text=excluded.quoted_text, attachment_text=excluded.attachment_text,
233
+ normalized_hash=excluded.normalized_hash, labels=excluded.labels, flags=excluded.flags,
234
+ classifications=excluded.classifications`).run({
235
+ ...message, inReplyTo: message.inReplyTo ?? null,
236
+ to: JSON.stringify(message.to), cc: JSON.stringify(message.cc),
237
+ labels: JSON.stringify(message.labels ?? []), flags: JSON.stringify(message.flags ?? []),
238
+ classifications: JSON.stringify(message.classifications ?? []),
239
+ attachmentText: attachmentText(message),
240
+ });
241
+ this.db.prepare("DELETE FROM attachments WHERE message_id = ?").run(message.messageId);
242
+ for (const [index, attachment] of (message.attachments || []).entries()) {
243
+ this.db.prepare(`INSERT INTO attachments
244
+ (attachment_id, message_id, name, mime_type, size, content_hash, extracted_text)
245
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(`${message.messageId}:${index}`, message.messageId, attachment.name, attachment.mimeType, attachment.size ?? null, attachment.contentHash ?? null, attachment.text ?? null);
246
+ }
247
+ }
248
+ removeMessage(message) {
249
+ const stored = this.db.prepare("SELECT message_id FROM messages WHERE provider_key = ?").get(message.providerKey);
250
+ if (!stored)
251
+ return;
252
+ const rows = this.db.prepare("SELECT rowid FROM chunks WHERE message_id = ?").all(stored.message_id);
253
+ for (const row of rows)
254
+ this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
255
+ this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(stored.message_id);
256
+ this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(stored.message_id);
257
+ this.db.prepare("DELETE FROM messages WHERE message_id = ?").run(stored.message_id);
258
+ }
259
+ replaceMessageChunks(message) {
260
+ const old = this.db.prepare("SELECT rowid, chunk_id FROM chunks WHERE message_id = ?").all(message.messageId);
261
+ for (const row of old)
262
+ this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
263
+ this.db.prepare("DELETE FROM chunks WHERE message_id = ?").run(message.messageId);
264
+ for (const chunk of buildChunks(message)) {
265
+ const result = this.db.prepare(`INSERT INTO chunks
266
+ (chunk_id, account_id, mailbox, message_id, thread_id, section, ordinal, text, started_at, ended_at, content_hash)
267
+ VALUES (@chunkId, @accountId, @mailbox, @messageId, @threadId, @section, @ordinal, @text, @startedAt, @endedAt, @contentHash)`).run(chunk);
268
+ this.db.prepare(`INSERT INTO chunks_fts(rowid, subject, from_address, to_addresses, thread_subject,
269
+ body_latest, body_quoted, forwarded_text, attachment_text)
270
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(result.lastInsertRowid, message.subject, message.from, message.to.join(" "), message.normalizedSubject, chunk.section === "latest" ? chunk.text : "", chunk.section === "quoted" ? chunk.text : "", "", chunk.section === "attachment" ? chunk.text : "");
271
+ this.db.prepare(`INSERT INTO embedding_queue(chunk_id, content_hash, state, attempts)
272
+ VALUES (?, ?, 'pending', 0) ON CONFLICT(chunk_id) DO UPDATE SET content_hash=excluded.content_hash, state='pending'`).run(chunk.chunkId, chunk.contentHash);
273
+ }
274
+ }
275
+ revision() {
276
+ const rows = this.db.prepare("SELECT chunk_id, content_hash FROM chunks ORDER BY chunk_id").all();
277
+ return createHash("sha256").update(rows.map((row) => `${row.chunk_id}\0${row.content_hash}`).join("\0")).digest("hex");
278
+ }
279
+ }
280
+ function migrate(db) {
281
+ db.exec(`CREATE TABLE IF NOT EXISTS messages (
282
+ message_id TEXT PRIMARY KEY, account_id TEXT NOT NULL, mailbox TEXT NOT NULL,
283
+ provider_key TEXT NOT NULL UNIQUE, thread_id TEXT NOT NULL, in_reply_to TEXT,
284
+ subject TEXT NOT NULL, from_address TEXT NOT NULL, to_addresses TEXT NOT NULL,
285
+ cc_addresses TEXT NOT NULL, date TEXT NOT NULL, latest_text TEXT NOT NULL,
286
+ quoted_text TEXT NOT NULL, attachment_text TEXT NOT NULL DEFAULT '', normalized_hash TEXT NOT NULL,
287
+ labels TEXT NOT NULL DEFAULT '[]', flags TEXT NOT NULL DEFAULT '[]',
288
+ classifications TEXT NOT NULL DEFAULT '[]'
289
+ );
290
+ CREATE TABLE IF NOT EXISTS chunks (
291
+ chunk_id TEXT PRIMARY KEY, account_id TEXT NOT NULL, mailbox TEXT NOT NULL,
292
+ message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE,
293
+ thread_id TEXT NOT NULL, section TEXT NOT NULL, ordinal INTEGER NOT NULL,
294
+ text TEXT NOT NULL, started_at TEXT NOT NULL, ended_at TEXT NOT NULL, content_hash TEXT NOT NULL
295
+ );
296
+ CREATE INDEX IF NOT EXISTS idx_chunks_thread_time ON chunks(thread_id, started_at);
297
+ CREATE TABLE IF NOT EXISTS embedding_queue (
298
+ chunk_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL
299
+ );
300
+ CREATE TABLE IF NOT EXISTS semantic_vectors (
301
+ chunk_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL, vector TEXT NOT NULL
302
+ );
303
+ CREATE TABLE IF NOT EXISTS attachments (
304
+ attachment_id TEXT PRIMARY KEY, message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE,
305
+ name TEXT NOT NULL, mime_type TEXT NOT NULL, size INTEGER, content_hash TEXT, extracted_text TEXT
306
+ );
307
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
308
+ subject, from_address, to_addresses, thread_subject, body_latest,
309
+ body_quoted, forwarded_text, attachment_text
310
+ );`);
311
+ const columns = db.prepare("PRAGMA table_info(messages)").all();
312
+ const existingColumns = new Set(columns.map((column) => column.name));
313
+ for (const column of ["labels", "flags", "classifications"]) {
314
+ if (!existingColumns.has(column))
315
+ db.exec(`ALTER TABLE messages ADD COLUMN ${column} TEXT NOT NULL DEFAULT '[]'`);
316
+ }
317
+ }
318
+ function literalFtsQuery(query) {
319
+ return query.trim().split(/\s+/).map((term) => `"${term.replaceAll('"', '""')}"`).join(" ");
320
+ }
321
+ function addFilters(clauses, params, filters) {
322
+ if (filters.accountId) {
323
+ clauses.push("c.account_id = ?");
324
+ params.push(filters.accountId);
325
+ }
326
+ if (filters.mailbox) {
327
+ clauses.push("c.mailbox = ?");
328
+ params.push(filters.mailbox);
329
+ }
330
+ if (filters.from) {
331
+ clauses.push("lower(m.from_address) = lower(?)");
332
+ params.push(filters.from);
333
+ }
334
+ if (filters.to) {
335
+ clauses.push("EXISTS (SELECT 1 FROM json_each(m.to_addresses) WHERE lower(value) = lower(?))");
336
+ params.push(filters.to);
337
+ }
338
+ if (filters.threadId) {
339
+ clauses.push("c.thread_id = ?");
340
+ params.push(filters.threadId);
341
+ }
342
+ if (filters.after) {
343
+ clauses.push("m.date >= ?");
344
+ params.push(filters.after);
345
+ }
346
+ if (filters.before) {
347
+ clauses.push("m.date <= ?");
348
+ params.push(filters.before);
349
+ }
350
+ }
351
+ function normalizeCategory(value) {
352
+ return value.trim().toLocaleLowerCase().replace(/^category[_-]/, "").replace(/^label[_-]/, "");
353
+ }
354
+ function countExcluded(messages, excluded) {
355
+ const counts = {};
356
+ for (const message of messages) {
357
+ for (const category of message.categories) {
358
+ if (excluded.has(category))
359
+ counts[category] = (counts[category] ?? 0) + 1;
360
+ }
361
+ }
362
+ return counts;
363
+ }
364
+ function hydrate(row, mode, query) {
365
+ return { chunkId: row.chunk_id, messageId: row.message_id, threadId: row.thread_id, accountId: row.account_id, mailbox: row.mailbox, subject: row.subject, from: row.from_address, to: JSON.parse(row.to_addresses), date: row.date, snippet: row.snippet || snippet(row.subject, query), score: row.score, mode };
366
+ }
367
+ function messageRow(row) {
368
+ return { accountId: row.account_id, mailbox: row.mailbox, providerKey: row.provider_key, messageId: row.message_id, threadId: row.thread_id, inReplyTo: row.in_reply_to || undefined, subject: row.subject, from: row.from_address, to: JSON.parse(row.to_addresses), cc: JSON.parse(row.cc_addresses), date: row.date, text: row.latest_text, latestText: row.latest_text, quotedText: row.quoted_text, normalizedSubject: row.subject.toLocaleLowerCase(), normalizedHash: row.normalized_hash, labels: JSON.parse(row.labels || "[]"), flags: JSON.parse(row.flags || "[]"), classifications: JSON.parse(row.classifications || "[]"), categories: [...new Set([...JSON.parse(row.labels || "[]"), ...JSON.parse(row.flags || "[]"), ...JSON.parse(row.classifications || "[]")].map(normalizeCategory))] };
369
+ }
370
+ function chunkRow(row) {
371
+ return { chunkId: row.chunk_id, accountId: row.account_id, mailbox: row.mailbox, messageId: row.message_id, threadId: row.thread_id, section: row.section, ordinal: row.ordinal, text: row.text, startedAt: row.started_at, endedAt: row.ended_at, contentHash: row.content_hash };
372
+ }
373
+ function attachmentText(message) {
374
+ return (message.attachments || []).map((attachment) => attachment.text || "").filter(Boolean).join("\n");
375
+ }
376
+ function embed(text) {
377
+ const vector = new Array(128).fill(0);
378
+ const normalized = text.toLocaleLowerCase().normalize("NFKC");
379
+ for (let index = 0; index < normalized.length; index++) {
380
+ const code = normalized.codePointAt(index) ?? 0;
381
+ vector[(code + index * 31) % vector.length] += 1;
382
+ }
383
+ const magnitude = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1;
384
+ return vector.map((value) => value / magnitude);
385
+ }
386
+ function dot(left, right) {
387
+ return left.reduce((sum, value, index) => sum + value * (right[index] ?? 0), 0);
388
+ }
@@ -0,0 +1,2 @@
1
+ import type { Chunk, NormalizedMessage } from "./types.js";
2
+ export declare function buildChunks(message: NormalizedMessage): Chunk[];
package/dist/chunk.js ADDED
@@ -0,0 +1,42 @@
1
+ import { hash, makeId } from "./util.js";
2
+ const MAX_CHARS = 2400;
3
+ export function buildChunks(message) {
4
+ const sections = [
5
+ ["latest", message.latestText],
6
+ ["quoted", message.quotedText],
7
+ ["attachment", (message.attachments || []).map((a) => a.text || "").filter(Boolean).join("\n")],
8
+ ];
9
+ const chunks = [];
10
+ for (const [section, text] of sections) {
11
+ if (!text.trim())
12
+ continue;
13
+ const parts = splitText(text);
14
+ parts.forEach((part, ordinal) => {
15
+ const chunkId = makeId(message.accountId, message.mailbox, message.messageId, section, String(ordinal), part);
16
+ chunks.push({
17
+ chunkId,
18
+ accountId: message.accountId,
19
+ mailbox: message.mailbox,
20
+ messageId: message.messageId,
21
+ threadId: message.threadId,
22
+ section,
23
+ ordinal,
24
+ text: part,
25
+ startedAt: message.date,
26
+ endedAt: message.date,
27
+ contentHash: hash(part),
28
+ });
29
+ });
30
+ }
31
+ return chunks;
32
+ }
33
+ function splitText(text) {
34
+ const paragraphs = text.split(/\n\s*\n/).map((part) => part.trim()).filter(Boolean);
35
+ const output = [];
36
+ for (const paragraph of paragraphs.length ? paragraphs : [text.trim()]) {
37
+ for (let start = 0; start < paragraph.length; start += MAX_CHARS) {
38
+ output.push(paragraph.slice(start, start + MAX_CHARS).trim());
39
+ }
40
+ }
41
+ return output.filter(Boolean);
42
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { Archive } from "../archive.js";
6
+ import { FixtureSource, HimalayaSource } from "../source.js";
7
+ import { redactDiagnostic } from "../redact.js";
8
+ const program = new Command();
9
+ program.name("mailcrawl").description("Local privacy-first email indexing CLI");
10
+ program.option("--data-dir <path>", "archive directory", process.env.MAILCRAWL_DATA_DIR || ".mailcrawl");
11
+ program
12
+ .command("sync")
13
+ .option("--source <source>", "fixture or himalaya", "himalaya")
14
+ .option("--fixture <path>")
15
+ .option("--account <name>")
16
+ .option("--mailbox <name>", "mailbox name", "INBOX")
17
+ .option("--backend <name>")
18
+ .option("--page-size <n>", "envelopes per page", "1000")
19
+ .option("--himalaya-config <path>")
20
+ .option("--include-category <name>", "include a normally excluded category", collect, [])
21
+ .option("--exclude-category <name>", "exclude a classification category", collect, [])
22
+ .option("--json")
23
+ .action(async (options, command) => {
24
+ const dataDir = command.parent.opts().dataDir;
25
+ await mkdir(dataDir, { recursive: true });
26
+ const archive = new Archive(`${dataDir}/archive.sqlite`);
27
+ try {
28
+ const source = options.source === "fixture"
29
+ ? new FixtureSource(options.fixture)
30
+ : new HimalayaSource(options.account, options.mailbox, options.backend, Number(options.pageSize), options.himalayaConfig);
31
+ const excludedCategories = (options.excludeCategory.length ? options.excludeCategory : ["spam", "promotions"])
32
+ .filter((category) => !options.includeCategory.includes(category));
33
+ output(await archive.sync(await source.list(), { excludedCategories }), options.json);
34
+ }
35
+ finally {
36
+ archive.close();
37
+ }
38
+ });
39
+ program
40
+ .command("index")
41
+ .option("--json")
42
+ .action(async (options, command) => {
43
+ const dataDir = command.parent.opts().dataDir;
44
+ const archive = new Archive(`${dataDir}/archive.sqlite`);
45
+ try {
46
+ output({ ...archive.indexSemanticGeneration(`${dataDir}/semantic`), embedder: "local-hash-v1" }, options.json);
47
+ }
48
+ finally {
49
+ archive.close();
50
+ }
51
+ });
52
+ for (const mode of ["bm25", "keyword", "semantic", "hybrid"]) {
53
+ program
54
+ .command(`search:${mode}`)
55
+ .argument("<query>")
56
+ .option("--account <id>")
57
+ .option("--mailbox <name>")
58
+ .option("--from <address>")
59
+ .option("--to <address>")
60
+ .option("--thread <id>")
61
+ .option("--after <date>")
62
+ .option("--before <date>")
63
+ .option("--limit <n>", "result limit", "10")
64
+ .option("--json")
65
+ .action(async (query, options, command) => {
66
+ const dataDir = command.parent.opts().dataDir;
67
+ const archive = new Archive(`${dataDir}/archive.sqlite`);
68
+ try {
69
+ const result = mode === "bm25"
70
+ ? archive.searchBm25(query, filters(options), Number(options.limit))
71
+ : mode === "hybrid"
72
+ ? archive.searchHybrid(query, filters(options), Number(options.limit))
73
+ : archive.searchSemantic(query, filters(options), Number(options.limit));
74
+ output(result, options.json);
75
+ }
76
+ finally {
77
+ archive.close();
78
+ }
79
+ });
80
+ }
81
+ program
82
+ .command("search")
83
+ .argument("<query>")
84
+ .option("--mode <mode>", "keyword, bm25, semantic, hybrid", "bm25")
85
+ .option("--account <id>")
86
+ .option("--mailbox <name>")
87
+ .option("--from <address>")
88
+ .option("--to <address>")
89
+ .option("--thread <id>")
90
+ .option("--after <date>")
91
+ .option("--before <date>")
92
+ .option("--limit <n>", "result limit", "10")
93
+ .option("--json")
94
+ .action(async (query, options, command) => {
95
+ const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
96
+ try {
97
+ const filter = filters(options);
98
+ const limit = Number(options.limit);
99
+ const result = options.mode === "bm25" ? archive.searchBm25(query, filter, limit)
100
+ : options.mode === "semantic" ? archive.searchSemantic(query, filter, limit)
101
+ : options.mode === "hybrid" ? archive.searchHybrid(query, filter, limit)
102
+ : (() => { throw new Error(`unsupported search mode: ${options.mode}`); })();
103
+ output(result, options.json);
104
+ }
105
+ finally {
106
+ archive.close();
107
+ }
108
+ });
109
+ program
110
+ .command("message")
111
+ .command("get <messageId>")
112
+ .option("--json")
113
+ .action(async (messageId, options, command) => {
114
+ const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
115
+ try {
116
+ output(archive.getMessage(messageId) ?? null, options.json);
117
+ }
118
+ finally {
119
+ archive.close();
120
+ }
121
+ });
122
+ program
123
+ .command("chunk-context")
124
+ .argument("<chunkId>")
125
+ .option("--json")
126
+ .action(async (chunkId, options, command) => {
127
+ const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
128
+ try {
129
+ output(archive.getChunkContext(chunkId), options.json);
130
+ }
131
+ finally {
132
+ archive.close();
133
+ }
134
+ });
135
+ program
136
+ .command("thread")
137
+ .command("get <threadId>")
138
+ .option("--from <address>")
139
+ .option("--to <address>")
140
+ .option("--after <date>")
141
+ .option("--before <date>")
142
+ .option("--json")
143
+ .action(async (threadId, options, command) => {
144
+ const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
145
+ try {
146
+ output(archive.getThread(threadId, filters(options)), options.json);
147
+ }
148
+ finally {
149
+ archive.close();
150
+ }
151
+ });
152
+ program
153
+ .command("thread-context")
154
+ .argument("<threadId>")
155
+ .option("--message <messageId>")
156
+ .option("--json")
157
+ .action(async (threadId, options, command) => {
158
+ const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
159
+ try {
160
+ output(archive.getThreadContext(threadId, options.message), options.json);
161
+ }
162
+ finally {
163
+ archive.close();
164
+ }
165
+ });
166
+ program
167
+ .command("doctor")
168
+ .option("--json")
169
+ .action(async (options, command) => {
170
+ const dataDir = command.parent.opts().dataDir;
171
+ const archive = new Archive(`${dataDir}/archive.sqlite`);
172
+ try {
173
+ let semantic = "missing";
174
+ try {
175
+ semantic = archive.semanticGeneration(`${dataDir}/semantic`);
176
+ }
177
+ catch (error) {
178
+ semantic = redactDiagnostic({ status: "stale", error: error instanceof Error ? error.message : String(error) });
179
+ }
180
+ output({
181
+ name: "mailcrawl",
182
+ archive: `${dataDir}/archive.sqlite`,
183
+ archivePresent: existsSync(`${dataDir}/archive.sqlite`),
184
+ fts: "available",
185
+ semantic,
186
+ recommendation: semantic === "missing" ? "run sync, then index before semantic search" : "semantic index is committed",
187
+ }, options.json);
188
+ }
189
+ finally {
190
+ archive.close();
191
+ }
192
+ });
193
+ program
194
+ .command("repair")
195
+ .option("--fts")
196
+ .option("--json")
197
+ .action(async (options, command) => {
198
+ const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
199
+ try {
200
+ if (!options.fts)
201
+ throw new Error("pass --fts");
202
+ output(archive.repairFts(), options.json);
203
+ }
204
+ finally {
205
+ archive.close();
206
+ }
207
+ });
208
+ const attachments = program.command("attachments");
209
+ attachments
210
+ .command("list")
211
+ .option("--message <messageId>")
212
+ .option("--json")
213
+ .action(async (options, command) => {
214
+ const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
215
+ try {
216
+ output(archive.listAttachments(options.message), options.json);
217
+ }
218
+ finally {
219
+ archive.close();
220
+ }
221
+ });
222
+ program.parseAsync().catch((error) => {
223
+ const message = error instanceof Error ? error.message : String(error);
224
+ console.error(JSON.stringify({ error: message }));
225
+ process.exitCode = 1;
226
+ });
227
+ function filters(options) {
228
+ return { accountId: options.account, mailbox: options.mailbox, from: options.from, to: options.to, threadId: options.thread, after: options.after, before: options.before };
229
+ }
230
+ function output(value, json) {
231
+ if (json)
232
+ console.log(JSON.stringify(value));
233
+ else
234
+ console.log(JSON.stringify(value, null, 2));
235
+ }
236
+ function collect(value, previous) {
237
+ return previous.concat(value.toLocaleLowerCase());
238
+ }
@@ -0,0 +1,2 @@
1
+ import type { MailMessage, NormalizedMessage } from "./types.js";
2
+ export declare function normalizeMessage(input: MailMessage): Promise<NormalizedMessage>;
@@ -0,0 +1,56 @@
1
+ import { simpleParser } from "mailparser";
2
+ import { hash, makeId, normalizeSubject } from "./util.js";
3
+ export async function normalizeMessage(input) {
4
+ let text = input.text || "";
5
+ let html = input.html;
6
+ let attachments = input.attachments;
7
+ if (input.rawMime) {
8
+ const parsed = await simpleParser(input.rawMime);
9
+ text = parsed.text || "";
10
+ html = typeof parsed.html === "string" ? parsed.html : undefined;
11
+ attachments = (parsed.attachments || []).map((attachment) => {
12
+ const mimeType = attachment.contentType || "application/octet-stream";
13
+ const content = attachment.content || Buffer.alloc(0);
14
+ const textLike = mimeType.startsWith("text/") && content.length <= 1_000_000;
15
+ return {
16
+ name: attachment.filename || "unnamed",
17
+ mimeType,
18
+ size: attachment.size ?? content.length,
19
+ contentHash: hash(content.toString("base64")),
20
+ text: textLike ? content.toString("utf8") : undefined,
21
+ };
22
+ });
23
+ }
24
+ const quoted = extractQuoted(text);
25
+ const latest = text.slice(0, text.length - quoted.length).trim();
26
+ const subject = input.subject.trim();
27
+ const messageId = input.messageId || input.providerKey;
28
+ const threadId = input.threadId || makeId(input.accountId, normalizeSubject(subject));
29
+ const normalizedHash = hash(JSON.stringify([subject, input.from, input.to, input.cc, latest, quoted, input.labels, input.flags, input.classifications]));
30
+ const categories = [...new Set([
31
+ ...(input.classifications || []),
32
+ ...(input.labels || []),
33
+ ...(input.flags || []),
34
+ ].map(normalizeCategory).filter(Boolean))];
35
+ return {
36
+ ...input,
37
+ messageId,
38
+ threadId,
39
+ html,
40
+ attachments,
41
+ normalizedSubject: normalizeSubject(subject),
42
+ latestText: latest,
43
+ quotedText: quoted,
44
+ text: latest,
45
+ normalizedHash,
46
+ categories,
47
+ };
48
+ }
49
+ function normalizeCategory(value) {
50
+ return value.trim().toLocaleLowerCase().replace(/^category[_-]/, "").replace(/^label[_-]/, "");
51
+ }
52
+ function extractQuoted(text) {
53
+ const lines = text.split(/\r?\n/);
54
+ const marker = lines.findIndex((line) => /^(?:On .+ wrote:|>{1,}|[- ]*Original Message[- ]*$)/iu.test(line.trim()));
55
+ return marker < 0 ? "" : lines.slice(marker).join("\n").trim();
56
+ }
@@ -0,0 +1 @@
1
+ export declare function redactDiagnostic(value: unknown): unknown;
package/dist/redact.js ADDED
@@ -0,0 +1,15 @@
1
+ export function redactDiagnostic(value) {
2
+ if (typeof value === "string") {
3
+ return value
4
+ .replace(/([?&](?:token|password|secret|key)=)[^&\s]+/giu, "$1[REDACTED]")
5
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, "[EMAIL]");
6
+ }
7
+ if (Array.isArray(value))
8
+ return value.map(redactDiagnostic);
9
+ if (value && typeof value === "object") {
10
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
11
+ key, /password|secret|token|credential/iu.test(key) ? "[REDACTED]" : redactDiagnostic(entry),
12
+ ]));
13
+ }
14
+ return value;
15
+ }
@@ -0,0 +1,19 @@
1
+ import type { MailMessage } from "./types.js";
2
+ export interface MailSource {
3
+ list(): Promise<MailMessage[]>;
4
+ }
5
+ export declare class FixtureSource implements MailSource {
6
+ private readonly path;
7
+ constructor(path: string);
8
+ list(): Promise<MailMessage[]>;
9
+ }
10
+ export declare class HimalayaSource implements MailSource {
11
+ private readonly account;
12
+ private readonly mailbox;
13
+ private readonly backend?;
14
+ private readonly pageSize;
15
+ private readonly config?;
16
+ constructor(account: string, mailbox?: string, backend?: string | undefined, pageSize?: number, config?: string | undefined);
17
+ list(): Promise<MailMessage[]>;
18
+ private read;
19
+ }
package/dist/source.js ADDED
@@ -0,0 +1,84 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { readFile } from "node:fs/promises";
4
+ const execFileAsync = promisify(execFile);
5
+ export class FixtureSource {
6
+ path;
7
+ constructor(path) {
8
+ this.path = path;
9
+ }
10
+ async list() {
11
+ const raw = await readFile(this.path, "utf8");
12
+ return JSON.parse(raw);
13
+ }
14
+ }
15
+ export class HimalayaSource {
16
+ account;
17
+ mailbox;
18
+ backend;
19
+ pageSize;
20
+ config;
21
+ constructor(account, mailbox = "INBOX", backend, pageSize = 1000, config) {
22
+ this.account = account;
23
+ this.mailbox = mailbox;
24
+ this.backend = backend;
25
+ this.pageSize = pageSize;
26
+ this.config = config;
27
+ }
28
+ 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);
32
+ args.push("envelope", "list", "--mailbox", this.mailbox, "--page-size", String(this.pageSize), "--json");
33
+ const { stdout } = await execFileAsync("himalaya", args, { maxBuffer: 16 * 1024 * 1024 });
34
+ 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
+ }));
57
+ }
58
+ async read(id) {
59
+ const args = this.config ? ["-c", this.config, "-a", this.account] : ["-a", this.account];
60
+ if (this.backend)
61
+ args.push("-b", this.backend);
62
+ args.push("--json", "message", "read", id, "--raw");
63
+ const { stdout } = await execFileAsync("himalaya", args, { maxBuffer: 32 * 1024 * 1024 });
64
+ const payload = JSON.parse(stdout);
65
+ return payload.message ?? stdout;
66
+ }
67
+ }
68
+ function address(value) {
69
+ if (typeof value === "string")
70
+ return value;
71
+ if (Array.isArray(value))
72
+ return value.length ? address(value[0]) : "";
73
+ if (value && typeof value === "object" && "email" in value)
74
+ return String(value.email);
75
+ return "";
76
+ }
77
+ function addresses(value) {
78
+ if (!Array.isArray(value))
79
+ return value ? [address(value)] : [];
80
+ return value.map(address).filter(Boolean);
81
+ }
82
+ function strings(value) {
83
+ return Array.isArray(value) ? value.map(String) : [];
84
+ }
@@ -0,0 +1,90 @@
1
+ export type SearchMode = "keyword" | "bm25" | "semantic" | "hybrid";
2
+ export interface MailMessage {
3
+ accountId: string;
4
+ mailbox: string;
5
+ providerKey: string;
6
+ messageId?: string;
7
+ threadId?: string;
8
+ inReplyTo?: string;
9
+ subject: string;
10
+ from: string;
11
+ to: string[];
12
+ cc: string[];
13
+ date: string;
14
+ text: string;
15
+ html?: string;
16
+ rawMime?: string;
17
+ attachments?: AttachmentInput[];
18
+ labels?: string[];
19
+ flags?: string[];
20
+ classifications?: string[];
21
+ }
22
+ export interface AttachmentInput {
23
+ name: string;
24
+ mimeType: string;
25
+ size?: number;
26
+ text?: string;
27
+ contentHash?: string;
28
+ }
29
+ export interface NormalizedMessage extends MailMessage {
30
+ messageId: string;
31
+ threadId: string;
32
+ normalizedSubject: string;
33
+ latestText: string;
34
+ quotedText: string;
35
+ normalizedHash: string;
36
+ categories: string[];
37
+ }
38
+ export interface Chunk {
39
+ chunkId: string;
40
+ accountId: string;
41
+ mailbox: string;
42
+ messageId: string;
43
+ threadId: string;
44
+ section: string;
45
+ ordinal: number;
46
+ text: string;
47
+ startedAt: string;
48
+ endedAt: string;
49
+ contentHash: string;
50
+ }
51
+ export interface SearchFilters {
52
+ accountId?: string;
53
+ mailbox?: string;
54
+ from?: string;
55
+ to?: string;
56
+ threadId?: string;
57
+ after?: string;
58
+ before?: string;
59
+ }
60
+ export interface SearchHit {
61
+ chunkId: string;
62
+ messageId: string;
63
+ threadId: string;
64
+ accountId: string;
65
+ mailbox: string;
66
+ subject: string;
67
+ from: string;
68
+ to: string[];
69
+ date: string;
70
+ snippet: string;
71
+ score: number;
72
+ mode: SearchMode;
73
+ }
74
+ export interface SyncReport {
75
+ added: number;
76
+ updated: number;
77
+ deleted: number;
78
+ unchanged: number;
79
+ touchedThreads: number;
80
+ rebuiltThreads: number;
81
+ chunksAdded: number;
82
+ chunksDeleted: number;
83
+ embeddingBacklog: number;
84
+ archiveRevision: string;
85
+ excluded: number;
86
+ excludedByReason: Record<string, number>;
87
+ }
88
+ export interface ClassificationPolicy {
89
+ excludedCategories?: string[];
90
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare function hash(value: string): string;
2
+ export declare function normalizeSubject(subject: string): string;
3
+ export declare function makeId(...parts: string[]): string;
4
+ export declare function snippet(text: string, query: string, maxChars?: number): string;
package/dist/util.js ADDED
@@ -0,0 +1,18 @@
1
+ import { createHash } from "node:crypto";
2
+ export function hash(value) {
3
+ return createHash("sha256").update(value).digest("hex");
4
+ }
5
+ export function normalizeSubject(subject) {
6
+ return subject.replace(/^(?:(?:re|fw|fwd)\s*:\s*)+/giu, "").trim().toLocaleLowerCase();
7
+ }
8
+ export function makeId(...parts) {
9
+ return hash(parts.join("\u0000")).slice(0, 24);
10
+ }
11
+ export function snippet(text, query, maxChars = 240) {
12
+ const clean = text.trim();
13
+ if (clean.length <= maxChars)
14
+ return clean;
15
+ const index = clean.toLocaleLowerCase().indexOf(query.trim().toLocaleLowerCase());
16
+ const start = Math.max(0, index < 0 ? 0 : index - 60);
17
+ return clean.slice(start, start + maxChars);
18
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@nomadamas/mailcrawl",
3
+ "version": "0.1.0",
4
+ "description": "Local Himalaya-backed incremental email indexing and hybrid search CLI",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "mailcrawl": "dist/cli/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "skills",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/NomaDamas/mailcrawl.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/NomaDamas/mailcrawl/issues"
22
+ },
23
+ "homepage": "https://github.com/NomaDamas/mailcrawl#readme",
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "validate:skill": "node scripts/validate-skill.mjs",
34
+ "cli": "node dist/cli/index.js",
35
+ "commit:check": "git diff --check"
36
+ },
37
+ "dependencies": {
38
+ "better-sqlite3": "^12.4.1",
39
+ "commander": "^14.0.0",
40
+ "mailparser": "^3.9.0",
41
+ "mime-types": "^3.0.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/better-sqlite3": "^7.6.13",
45
+ "@types/node": "^24.3.0",
46
+ "tsx": "^4.20.5",
47
+ "vitest": "^3.2.4"
48
+ },
49
+ "engines": {
50
+ "node": ">=24"
51
+ }
52
+ }
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: mailcrawl
3
+ description: Search and maintain a local, privacy-first email archive through the mailcrawl CLI.
4
+ ---
5
+
6
+ # mailcrawl
7
+
8
+ Use `mailcrawl` as a local, read-oriented email retrieval tool. It indexes
9
+ messages obtained through the configured Himalaya account and returns bounded
10
+ JSON suitable for an agent.
11
+
12
+ ## Installation and activation
13
+
14
+ Install the package and build the CLI from the repository:
15
+
16
+ ```bash
17
+ npm install
18
+ npm run build
19
+ ```
20
+
21
+ Expose `dist/cli/index.js` as `mailcrawl` on `PATH`, or invoke it with
22
+ `node dist/cli/index.js`. Agent skill runners should discover this file through
23
+ the `skills/mailcrawl/SKILL.md` directory and activate it only when the user
24
+ requests local email synchronization or search.
25
+
26
+ Required runtime dependencies are Node.js 24+, the package dependencies, and
27
+ the Himalaya CLI for live synchronization. Fixture synchronization needs no
28
+ mail account.
29
+
30
+ ## Data and credential boundaries
31
+
32
+ The archive lives under `.mailcrawl` by default. Set `--data-dir` or
33
+ `MAILCRAWL_DATA_DIR` to choose another local directory. The archive contains
34
+ sensitive message metadata and content; keep its permissions private and do
35
+ not commit it.
36
+
37
+ Himalaya resolves credentials. Pass only account, backend, mailbox, and
38
+ configuration path options to mailcrawl. Never place passwords, tokens, raw
39
+ provider responses, or message bodies in logs, prompts, issue comments, or
40
+ diagnostics.
41
+
42
+ ## Workflow
43
+
44
+ Synchronization is explicit. mailcrawl does not run a background daemon:
45
+
46
+ ```bash
47
+ mailcrawl sync --account personal --mailbox INBOX --json
48
+ mailcrawl index --json
49
+ mailcrawl search --mode hybrid --json "contract renewal"
50
+ ```
51
+
52
+ For scheduled operation, configure an external scheduler with an explicit
53
+ command, such as a user-level cron or systemd timer. Run `sync` first and
54
+ `index` afterward; inspect JSON exit status before handing results to an
55
+ agent.
56
+
57
+ Use a fixture for deterministic development:
58
+
59
+ ```bash
60
+ mailcrawl sync --source fixture --fixture ./messages.json --json
61
+ ```
62
+
63
+ ## Safe read and maintenance commands
64
+
65
+ Search modes are `bm25`, `keyword`, `semantic`, and `hybrid`. Use metadata
66
+ filters such as `--mailbox`, `--from`, `--to`, `--thread`, `--after`, and
67
+ `--before`. Empty queries and unsupported modes fail with a non-zero exit.
68
+
69
+ Navigate from a hit without dumping the entire archive:
70
+
71
+ ```bash
72
+ mailcrawl message get MESSAGE_ID --json
73
+ mailcrawl thread get THREAD_ID --json
74
+ mailcrawl thread-context THREAD_ID --message MESSAGE_ID --json
75
+ mailcrawl chunk-context CHUNK_ID --json
76
+ mailcrawl attachments list --message MESSAGE_ID --json
77
+ ```
78
+
79
+ Check health and rebuild lexical data when needed:
80
+
81
+ ```bash
82
+ mailcrawl doctor --json
83
+ mailcrawl repair --fts --json
84
+ ```
85
+
86
+ `doctor` reports archive, FTS, and semantic-generation state. `repair` is a
87
+ local maintenance operation and should be run only when diagnostics indicate
88
+ that the corresponding index is inconsistent.
89
+
90
+ ## JSON contract
91
+
92
+ Use `--json` for machine-readable output. Search results contain stable
93
+ `chunkId`, `messageId`, `threadId`, `accountId`, `mailbox`, `subject`, `from`,
94
+ `to`, `date`, `snippet`, `score`, and `mode` fields. Sync returns counts,
95
+ `archiveRevision`, and classification exclusion diagnostics. Do not assume
96
+ that an omitted result means a remote deletion.
97
+
98
+ Treat snippets and identifiers as sensitive. Fetch message or thread content
99
+ only when it is required to answer the user, and return the smallest useful
100
+ excerpt.
101
+
102
+ ## Safety boundary
103
+
104
+ This skill supports synchronization, indexing, search, inspection, and local
105
+ repair only. It does not send, delete, move, or mutate remote email. Never
106
+ invoke an external mail-sending command through this skill. If a user asks to
107
+ send mail, obtain explicit approval and hand off to a separate, user-visible
108
+ mail client workflow.
109
+
110
+ ## Verification
111
+
112
+ From a checkout, verify installation and invocation with:
113
+
114
+ ```bash
115
+ npm install
116
+ npm test
117
+ npm run typecheck
118
+ npm run build
119
+ node dist/cli/index.js --help
120
+ node dist/cli/index.js doctor --json
121
+ ```
122
+
123
+ The help output proves the CLI is callable. `doctor --json` proves that the
124
+ agent can inspect a local data directory without opening a remote account.