@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 +21 -0
- package/README.md +61 -0
- package/dist/archive.d.ts +55 -0
- package/dist/archive.js +388 -0
- package/dist/chunk.d.ts +2 -0
- package/dist/chunk.js +42 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +238 -0
- package/dist/normalize.d.ts +2 -0
- package/dist/normalize.js +56 -0
- package/dist/redact.d.ts +1 -0
- package/dist/redact.js +15 -0
- package/dist/source.d.ts +19 -0
- package/dist/source.js +84 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.js +18 -0
- package/package.json +52 -0
- package/skills/mailcrawl/SKILL.md +124 -0
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
|
+
}
|
package/dist/archive.js
ADDED
|
@@ -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
|
+
}
|
package/dist/chunk.d.ts
ADDED
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,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,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
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/source.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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.
|