inboxctl 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/.env.example +14 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/dist/chunk-EY6VV43S.js +4744 -0
- package/dist/chunk-EY6VV43S.js.map +1 -0
- package/dist/cli.js +4668 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp.js +16 -0
- package/dist/mcp.js.map +1 -0
- package/package.json +83 -0
- package/rules/example.yaml +29 -0
|
@@ -0,0 +1,4744 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __export = (target, all) => {
|
|
4
|
+
for (var name in all)
|
|
5
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// src/mcp/server.ts
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z as z2 } from "zod";
|
|
12
|
+
|
|
13
|
+
// src/core/actions/audit.ts
|
|
14
|
+
import { randomUUID } from "crypto";
|
|
15
|
+
|
|
16
|
+
// src/config.ts
|
|
17
|
+
import { config as dotenvConfig } from "dotenv";
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync } from "fs";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
import { dirname, isAbsolute, join, resolve } from "path";
|
|
21
|
+
dotenvConfig();
|
|
22
|
+
var DEFAULT_GOOGLE_REDIRECT_URI = "http://127.0.0.1:3456/callback";
|
|
23
|
+
function resolveHome(filepath) {
|
|
24
|
+
if (filepath.startsWith("~")) {
|
|
25
|
+
return join(homedir(), filepath.slice(1));
|
|
26
|
+
}
|
|
27
|
+
return filepath;
|
|
28
|
+
}
|
|
29
|
+
function ensureDir(dir) {
|
|
30
|
+
if (!existsSync(dir)) {
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function readJsonConfig(configPath) {
|
|
35
|
+
if (!existsSync(configPath)) {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
const raw = readFileSync(configPath, "utf8");
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!parsed || typeof parsed !== "object") {
|
|
41
|
+
throw new Error(`Invalid config file at ${configPath}: expected JSON object`);
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
function parseNumber(value) {
|
|
46
|
+
if (!value) {
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
const parsed = Number(value);
|
|
50
|
+
if (Number.isNaN(parsed)) {
|
|
51
|
+
throw new Error(`Invalid numeric configuration value: ${value}`);
|
|
52
|
+
}
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
function resolvePath(value, baseDir) {
|
|
56
|
+
if (!value) {
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
const expanded = resolveHome(value);
|
|
60
|
+
return isAbsolute(expanded) ? expanded : resolve(baseDir, expanded);
|
|
61
|
+
}
|
|
62
|
+
function getDefaultDataDir() {
|
|
63
|
+
return resolveHome(process.env.INBOXCTL_DATA_DIR || "~/.config/inboxctl");
|
|
64
|
+
}
|
|
65
|
+
function getConfigFilePath(dataDir = getDefaultDataDir()) {
|
|
66
|
+
return join(dataDir, "config.json");
|
|
67
|
+
}
|
|
68
|
+
function getGoogleCredentialStatus(config) {
|
|
69
|
+
const missing = [];
|
|
70
|
+
if (!config.google.clientId) {
|
|
71
|
+
missing.push("GOOGLE_CLIENT_ID");
|
|
72
|
+
}
|
|
73
|
+
if (!config.google.clientSecret) {
|
|
74
|
+
missing.push("GOOGLE_CLIENT_SECRET");
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
configured: missing.length === 0,
|
|
78
|
+
missing
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function requireGoogleCredentials(config) {
|
|
82
|
+
const status = getGoogleCredentialStatus(config);
|
|
83
|
+
if (!status.configured) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Missing Google OAuth credentials: ${status.missing.join(", ")}. Set them in the environment or in config.json before live Gmail operations.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
clientId: config.google.clientId,
|
|
90
|
+
clientSecret: config.google.clientSecret,
|
|
91
|
+
redirectUri: config.google.redirectUri
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function loadConfig() {
|
|
95
|
+
const dataDir = getDefaultDataDir();
|
|
96
|
+
ensureDir(dataDir);
|
|
97
|
+
const fileConfig = readJsonConfig(getConfigFilePath(dataDir));
|
|
98
|
+
const configBaseDir = dirname(getConfigFilePath(dataDir));
|
|
99
|
+
const dbPath = resolvePath(process.env.INBOXCTL_DB_PATH, configBaseDir) || resolvePath(fileConfig.dbPath, configBaseDir) || join(dataDir, "emails.db");
|
|
100
|
+
const tokensPath = resolvePath(process.env.INBOXCTL_TOKENS_PATH, configBaseDir) || resolvePath(fileConfig.tokensPath, configBaseDir) || join(dataDir, "tokens.json");
|
|
101
|
+
const rulesDir = resolvePath(process.env.INBOXCTL_RULES_DIR, configBaseDir) || resolvePath(fileConfig.rulesDir, configBaseDir) || resolve("./rules");
|
|
102
|
+
ensureDir(dirname(dbPath));
|
|
103
|
+
ensureDir(dirname(tokensPath));
|
|
104
|
+
ensureDir(rulesDir);
|
|
105
|
+
return {
|
|
106
|
+
dataDir,
|
|
107
|
+
dbPath,
|
|
108
|
+
rulesDir,
|
|
109
|
+
tokensPath,
|
|
110
|
+
google: {
|
|
111
|
+
clientId: process.env.GOOGLE_CLIENT_ID || fileConfig.google?.clientId || null,
|
|
112
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET || fileConfig.google?.clientSecret || null,
|
|
113
|
+
redirectUri: process.env.GOOGLE_REDIRECT_URI || fileConfig.google?.redirectUri || DEFAULT_GOOGLE_REDIRECT_URI
|
|
114
|
+
},
|
|
115
|
+
sync: {
|
|
116
|
+
pageSize: parseNumber(process.env.INBOXCTL_SYNC_PAGE_SIZE) || fileConfig.sync?.pageSize || 500,
|
|
117
|
+
maxMessages: parseNumber(process.env.INBOXCTL_SYNC_MAX_MESSAGES) ?? fileConfig.sync?.maxMessages ?? null
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/core/db/client.ts
|
|
123
|
+
import Database from "better-sqlite3";
|
|
124
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
125
|
+
import { dirname as dirname2, resolve as resolve2 } from "path";
|
|
126
|
+
|
|
127
|
+
// src/core/db/schema.ts
|
|
128
|
+
var schema_exports = {};
|
|
129
|
+
__export(schema_exports, {
|
|
130
|
+
emails: () => emails,
|
|
131
|
+
executionItems: () => executionItems,
|
|
132
|
+
executionRuns: () => executionRuns,
|
|
133
|
+
newsletterSenders: () => newsletterSenders,
|
|
134
|
+
rules: () => rules,
|
|
135
|
+
syncState: () => syncState
|
|
136
|
+
});
|
|
137
|
+
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
|
138
|
+
var emails = sqliteTable(
|
|
139
|
+
"emails",
|
|
140
|
+
{
|
|
141
|
+
id: text("id").primaryKey(),
|
|
142
|
+
// Gmail message ID
|
|
143
|
+
threadId: text("thread_id"),
|
|
144
|
+
fromAddress: text("from_address"),
|
|
145
|
+
fromName: text("from_name"),
|
|
146
|
+
toAddresses: text("to_addresses"),
|
|
147
|
+
// JSON array
|
|
148
|
+
subject: text("subject"),
|
|
149
|
+
snippet: text("snippet"),
|
|
150
|
+
date: integer("date"),
|
|
151
|
+
// Unix timestamp
|
|
152
|
+
isRead: integer("is_read"),
|
|
153
|
+
// 0/1
|
|
154
|
+
isStarred: integer("is_starred"),
|
|
155
|
+
// 0/1
|
|
156
|
+
labelIds: text("label_ids"),
|
|
157
|
+
// JSON array
|
|
158
|
+
sizeEstimate: integer("size_estimate"),
|
|
159
|
+
hasAttachments: integer("has_attachments"),
|
|
160
|
+
// 0/1
|
|
161
|
+
listUnsubscribe: text("list_unsubscribe"),
|
|
162
|
+
// List-Unsubscribe header
|
|
163
|
+
syncedAt: integer("synced_at")
|
|
164
|
+
// Unix timestamp
|
|
165
|
+
},
|
|
166
|
+
(table) => [
|
|
167
|
+
index("idx_emails_from_address").on(table.fromAddress),
|
|
168
|
+
index("idx_emails_date").on(table.date),
|
|
169
|
+
index("idx_emails_thread_id").on(table.threadId),
|
|
170
|
+
index("idx_emails_is_read").on(table.isRead)
|
|
171
|
+
]
|
|
172
|
+
);
|
|
173
|
+
var rules = sqliteTable("rules", {
|
|
174
|
+
id: text("id").primaryKey(),
|
|
175
|
+
// UUID
|
|
176
|
+
name: text("name").unique().notNull(),
|
|
177
|
+
description: text("description"),
|
|
178
|
+
enabled: integer("enabled").default(1),
|
|
179
|
+
// 0/1
|
|
180
|
+
yamlHash: text("yaml_hash"),
|
|
181
|
+
// SHA-256 of source YAML
|
|
182
|
+
conditions: text("conditions").notNull(),
|
|
183
|
+
// JSON
|
|
184
|
+
actions: text("actions").notNull(),
|
|
185
|
+
// JSON
|
|
186
|
+
priority: integer("priority").default(50),
|
|
187
|
+
deployedAt: integer("deployed_at"),
|
|
188
|
+
createdAt: integer("created_at")
|
|
189
|
+
});
|
|
190
|
+
var executionRuns = sqliteTable(
|
|
191
|
+
"execution_runs",
|
|
192
|
+
{
|
|
193
|
+
id: text("id").primaryKey(),
|
|
194
|
+
// UUID
|
|
195
|
+
sourceType: text("source_type").notNull(),
|
|
196
|
+
// manual | rule
|
|
197
|
+
ruleId: text("rule_id"),
|
|
198
|
+
// FK to rules.id (null for manual actions)
|
|
199
|
+
dryRun: integer("dry_run").default(0),
|
|
200
|
+
// 0/1
|
|
201
|
+
requestedActions: text("requested_actions").notNull(),
|
|
202
|
+
// JSON
|
|
203
|
+
query: text("query"),
|
|
204
|
+
status: text("status").notNull(),
|
|
205
|
+
// planned | applied | partial | error | undone
|
|
206
|
+
createdAt: integer("created_at"),
|
|
207
|
+
undoneAt: integer("undone_at")
|
|
208
|
+
},
|
|
209
|
+
(table) => [
|
|
210
|
+
index("idx_execution_runs_rule_id").on(table.ruleId),
|
|
211
|
+
index("idx_execution_runs_created_at").on(table.createdAt)
|
|
212
|
+
]
|
|
213
|
+
);
|
|
214
|
+
var executionItems = sqliteTable(
|
|
215
|
+
"execution_items",
|
|
216
|
+
{
|
|
217
|
+
id: text("id").primaryKey(),
|
|
218
|
+
// UUID
|
|
219
|
+
runId: text("run_id").notNull(),
|
|
220
|
+
// FK to execution_runs.id
|
|
221
|
+
emailId: text("email_id").notNull(),
|
|
222
|
+
// Gmail message ID
|
|
223
|
+
status: text("status").notNull(),
|
|
224
|
+
// planned | applied | warning | error | undone
|
|
225
|
+
appliedActions: text("applied_actions").notNull(),
|
|
226
|
+
// JSON
|
|
227
|
+
beforeLabelIds: text("before_label_ids").notNull(),
|
|
228
|
+
// JSON array
|
|
229
|
+
afterLabelIds: text("after_label_ids").notNull(),
|
|
230
|
+
// JSON array
|
|
231
|
+
errorMessage: text("error_message"),
|
|
232
|
+
executedAt: integer("executed_at"),
|
|
233
|
+
undoneAt: integer("undone_at")
|
|
234
|
+
},
|
|
235
|
+
(table) => [
|
|
236
|
+
index("idx_execution_items_run_id").on(table.runId),
|
|
237
|
+
index("idx_execution_items_email_id").on(table.emailId),
|
|
238
|
+
index("idx_execution_items_executed_at").on(table.executedAt)
|
|
239
|
+
]
|
|
240
|
+
);
|
|
241
|
+
var syncState = sqliteTable("sync_state", {
|
|
242
|
+
id: integer("id").primaryKey(),
|
|
243
|
+
// Always 1
|
|
244
|
+
accountEmail: text("account_email"),
|
|
245
|
+
historyId: text("history_id"),
|
|
246
|
+
lastFullSync: integer("last_full_sync"),
|
|
247
|
+
lastIncrementalSync: integer("last_incremental_sync"),
|
|
248
|
+
totalMessages: integer("total_messages"),
|
|
249
|
+
fullSyncCursor: text("full_sync_cursor"),
|
|
250
|
+
fullSyncProcessed: integer("full_sync_processed"),
|
|
251
|
+
fullSyncTotal: integer("full_sync_total")
|
|
252
|
+
});
|
|
253
|
+
var newsletterSenders = sqliteTable(
|
|
254
|
+
"newsletter_senders",
|
|
255
|
+
{
|
|
256
|
+
id: text("id").primaryKey(),
|
|
257
|
+
// UUID
|
|
258
|
+
email: text("email").unique().notNull(),
|
|
259
|
+
name: text("name"),
|
|
260
|
+
messageCount: integer("message_count").default(0),
|
|
261
|
+
unreadCount: integer("unread_count").default(0),
|
|
262
|
+
status: text("status").default("active"),
|
|
263
|
+
// active | unsubscribed | archived
|
|
264
|
+
unsubscribeLink: text("unsubscribe_link"),
|
|
265
|
+
detectionReason: text("detection_reason"),
|
|
266
|
+
firstSeen: integer("first_seen"),
|
|
267
|
+
lastSeen: integer("last_seen")
|
|
268
|
+
},
|
|
269
|
+
(table) => [index("idx_newsletter_senders_email").on(table.email)]
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// src/core/db/client.ts
|
|
273
|
+
var dbCache = /* @__PURE__ */ new Map();
|
|
274
|
+
var sqliteCache = /* @__PURE__ */ new Map();
|
|
275
|
+
var SCHEMA_SQL = `
|
|
276
|
+
CREATE TABLE IF NOT EXISTS emails (
|
|
277
|
+
id TEXT PRIMARY KEY,
|
|
278
|
+
thread_id TEXT,
|
|
279
|
+
from_address TEXT,
|
|
280
|
+
from_name TEXT,
|
|
281
|
+
to_addresses TEXT,
|
|
282
|
+
subject TEXT,
|
|
283
|
+
snippet TEXT,
|
|
284
|
+
date INTEGER,
|
|
285
|
+
is_read INTEGER,
|
|
286
|
+
is_starred INTEGER,
|
|
287
|
+
label_ids TEXT,
|
|
288
|
+
size_estimate INTEGER,
|
|
289
|
+
has_attachments INTEGER,
|
|
290
|
+
list_unsubscribe TEXT,
|
|
291
|
+
synced_at INTEGER
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
CREATE INDEX IF NOT EXISTS idx_emails_from_address ON emails(from_address);
|
|
295
|
+
CREATE INDEX IF NOT EXISTS idx_emails_date ON emails(date);
|
|
296
|
+
CREATE INDEX IF NOT EXISTS idx_emails_thread_id ON emails(thread_id);
|
|
297
|
+
CREATE INDEX IF NOT EXISTS idx_emails_is_read ON emails(is_read);
|
|
298
|
+
|
|
299
|
+
CREATE TABLE IF NOT EXISTS rules (
|
|
300
|
+
id TEXT PRIMARY KEY,
|
|
301
|
+
name TEXT NOT NULL UNIQUE,
|
|
302
|
+
description TEXT,
|
|
303
|
+
enabled INTEGER DEFAULT 1,
|
|
304
|
+
yaml_hash TEXT,
|
|
305
|
+
conditions TEXT NOT NULL,
|
|
306
|
+
actions TEXT NOT NULL,
|
|
307
|
+
priority INTEGER DEFAULT 50,
|
|
308
|
+
deployed_at INTEGER,
|
|
309
|
+
created_at INTEGER
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
CREATE TABLE IF NOT EXISTS execution_runs (
|
|
313
|
+
id TEXT PRIMARY KEY,
|
|
314
|
+
source_type TEXT NOT NULL,
|
|
315
|
+
rule_id TEXT,
|
|
316
|
+
dry_run INTEGER DEFAULT 0,
|
|
317
|
+
requested_actions TEXT NOT NULL,
|
|
318
|
+
query TEXT,
|
|
319
|
+
status TEXT NOT NULL,
|
|
320
|
+
created_at INTEGER,
|
|
321
|
+
undone_at INTEGER
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
CREATE INDEX IF NOT EXISTS idx_execution_runs_rule_id ON execution_runs(rule_id);
|
|
325
|
+
CREATE INDEX IF NOT EXISTS idx_execution_runs_created_at ON execution_runs(created_at);
|
|
326
|
+
|
|
327
|
+
CREATE TABLE IF NOT EXISTS execution_items (
|
|
328
|
+
id TEXT PRIMARY KEY,
|
|
329
|
+
run_id TEXT NOT NULL,
|
|
330
|
+
email_id TEXT NOT NULL,
|
|
331
|
+
status TEXT NOT NULL,
|
|
332
|
+
applied_actions TEXT NOT NULL,
|
|
333
|
+
before_label_ids TEXT NOT NULL,
|
|
334
|
+
after_label_ids TEXT NOT NULL,
|
|
335
|
+
error_message TEXT,
|
|
336
|
+
executed_at INTEGER,
|
|
337
|
+
undone_at INTEGER
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
CREATE INDEX IF NOT EXISTS idx_execution_items_run_id ON execution_items(run_id);
|
|
341
|
+
CREATE INDEX IF NOT EXISTS idx_execution_items_email_id ON execution_items(email_id);
|
|
342
|
+
CREATE INDEX IF NOT EXISTS idx_execution_items_executed_at ON execution_items(executed_at);
|
|
343
|
+
|
|
344
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
345
|
+
id INTEGER PRIMARY KEY,
|
|
346
|
+
account_email TEXT,
|
|
347
|
+
history_id TEXT,
|
|
348
|
+
last_full_sync INTEGER,
|
|
349
|
+
last_incremental_sync INTEGER,
|
|
350
|
+
total_messages INTEGER,
|
|
351
|
+
full_sync_cursor TEXT,
|
|
352
|
+
full_sync_processed INTEGER,
|
|
353
|
+
full_sync_total INTEGER
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
CREATE TABLE IF NOT EXISTS newsletter_senders (
|
|
357
|
+
id TEXT PRIMARY KEY,
|
|
358
|
+
email TEXT NOT NULL UNIQUE,
|
|
359
|
+
name TEXT,
|
|
360
|
+
message_count INTEGER DEFAULT 0,
|
|
361
|
+
unread_count INTEGER DEFAULT 0,
|
|
362
|
+
status TEXT DEFAULT 'active',
|
|
363
|
+
unsubscribe_link TEXT,
|
|
364
|
+
detection_reason TEXT,
|
|
365
|
+
first_seen INTEGER,
|
|
366
|
+
last_seen INTEGER
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
CREATE INDEX IF NOT EXISTS idx_newsletter_senders_email ON newsletter_senders(email);
|
|
370
|
+
|
|
371
|
+
INSERT OR IGNORE INTO sync_state (id, history_id, last_full_sync, last_incremental_sync, total_messages)
|
|
372
|
+
VALUES (1, NULL, NULL, NULL, 0);
|
|
373
|
+
`;
|
|
374
|
+
function ensureSyncStateColumns(sqlite) {
|
|
375
|
+
const columns = sqlite.prepare("PRAGMA table_info(sync_state)").all();
|
|
376
|
+
const columnNames = new Set(columns.map((column) => column.name));
|
|
377
|
+
if (!columnNames.has("account_email")) {
|
|
378
|
+
sqlite.exec("ALTER TABLE sync_state ADD COLUMN account_email TEXT");
|
|
379
|
+
}
|
|
380
|
+
if (!columnNames.has("full_sync_cursor")) {
|
|
381
|
+
sqlite.exec("ALTER TABLE sync_state ADD COLUMN full_sync_cursor TEXT");
|
|
382
|
+
}
|
|
383
|
+
if (!columnNames.has("full_sync_processed")) {
|
|
384
|
+
sqlite.exec("ALTER TABLE sync_state ADD COLUMN full_sync_processed INTEGER");
|
|
385
|
+
}
|
|
386
|
+
if (!columnNames.has("full_sync_total")) {
|
|
387
|
+
sqlite.exec("ALTER TABLE sync_state ADD COLUMN full_sync_total INTEGER");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function getResolvedPath(dbPath) {
|
|
391
|
+
return resolve2(dbPath);
|
|
392
|
+
}
|
|
393
|
+
function getSqlite(dbPath) {
|
|
394
|
+
const resolvedPath = getResolvedPath(dbPath);
|
|
395
|
+
const cached = sqliteCache.get(resolvedPath);
|
|
396
|
+
if (cached) {
|
|
397
|
+
return cached;
|
|
398
|
+
}
|
|
399
|
+
ensureDir(dirname2(resolvedPath));
|
|
400
|
+
const sqlite = new Database(resolvedPath);
|
|
401
|
+
sqlite.pragma("journal_mode = WAL");
|
|
402
|
+
sqlite.pragma("foreign_keys = ON");
|
|
403
|
+
sqlite.pragma("busy_timeout = 5000");
|
|
404
|
+
sqlite.exec(SCHEMA_SQL);
|
|
405
|
+
ensureSyncStateColumns(sqlite);
|
|
406
|
+
sqliteCache.set(resolvedPath, sqlite);
|
|
407
|
+
return sqlite;
|
|
408
|
+
}
|
|
409
|
+
function getDb(dbPath) {
|
|
410
|
+
const resolvedPath = getResolvedPath(dbPath);
|
|
411
|
+
const cached = dbCache.get(resolvedPath);
|
|
412
|
+
if (cached) {
|
|
413
|
+
return cached;
|
|
414
|
+
}
|
|
415
|
+
const sqlite = getSqlite(resolvedPath);
|
|
416
|
+
const db = drizzle(sqlite, { schema: schema_exports });
|
|
417
|
+
dbCache.set(resolvedPath, db);
|
|
418
|
+
return db;
|
|
419
|
+
}
|
|
420
|
+
function initializeDb(dbPath) {
|
|
421
|
+
return getDb(dbPath);
|
|
422
|
+
}
|
|
423
|
+
function closeDb(dbPath) {
|
|
424
|
+
const resolvedPath = getResolvedPath(dbPath);
|
|
425
|
+
const sqlite = sqliteCache.get(resolvedPath);
|
|
426
|
+
if (sqlite) {
|
|
427
|
+
sqlite.close();
|
|
428
|
+
sqliteCache.delete(resolvedPath);
|
|
429
|
+
}
|
|
430
|
+
dbCache.delete(resolvedPath);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/core/actions/audit.ts
|
|
434
|
+
function getDatabase() {
|
|
435
|
+
const config = loadConfig();
|
|
436
|
+
return getSqlite(config.dbPath);
|
|
437
|
+
}
|
|
438
|
+
function ensureValidSourceType(sourceType) {
|
|
439
|
+
if (sourceType !== "manual" && sourceType !== "rule") {
|
|
440
|
+
throw new Error(`Invalid execution source type: ${sourceType}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function ensureValidRunStatus(status) {
|
|
444
|
+
if (status !== "planned" && status !== "applied" && status !== "partial" && status !== "error" && status !== "undone") {
|
|
445
|
+
throw new Error(`Invalid execution run status: ${status}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function ensureValidItemStatus(status) {
|
|
449
|
+
if (status !== "planned" && status !== "applied" && status !== "warning" && status !== "error" && status !== "undone") {
|
|
450
|
+
throw new Error(`Invalid execution item status: ${status}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function parseJsonArray(raw, fallback) {
|
|
454
|
+
if (!raw) {
|
|
455
|
+
return fallback;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
const parsed = JSON.parse(raw);
|
|
459
|
+
return Array.isArray(parsed) ? parsed : fallback;
|
|
460
|
+
} catch {
|
|
461
|
+
return fallback;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function parseJsonObjectArray(raw) {
|
|
465
|
+
return parseJsonArray(raw, []);
|
|
466
|
+
}
|
|
467
|
+
function serializeJson(value) {
|
|
468
|
+
return JSON.stringify(value ?? []);
|
|
469
|
+
}
|
|
470
|
+
function rowToExecutionRun(row) {
|
|
471
|
+
ensureValidSourceType(row.sourceType);
|
|
472
|
+
ensureValidRunStatus(row.status);
|
|
473
|
+
return {
|
|
474
|
+
id: row.id,
|
|
475
|
+
sourceType: row.sourceType,
|
|
476
|
+
ruleId: row.ruleId,
|
|
477
|
+
dryRun: row.dryRun === 1,
|
|
478
|
+
requestedActions: parseJsonObjectArray(row.requestedActions),
|
|
479
|
+
query: row.query,
|
|
480
|
+
status: row.status,
|
|
481
|
+
createdAt: row.createdAt ?? 0,
|
|
482
|
+
undoneAt: row.undoneAt ?? null,
|
|
483
|
+
itemCount: row.itemCount,
|
|
484
|
+
plannedItemCount: row.plannedItemCount,
|
|
485
|
+
appliedItemCount: row.appliedItemCount,
|
|
486
|
+
warningItemCount: row.warningItemCount,
|
|
487
|
+
errorItemCount: row.errorItemCount,
|
|
488
|
+
undoneItemCount: row.undoneItemCount
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function rowToExecutionItem(row) {
|
|
492
|
+
ensureValidItemStatus(row.status);
|
|
493
|
+
return {
|
|
494
|
+
id: row.id,
|
|
495
|
+
runId: row.runId,
|
|
496
|
+
emailId: row.emailId,
|
|
497
|
+
status: row.status,
|
|
498
|
+
appliedActions: parseJsonObjectArray(row.appliedActions),
|
|
499
|
+
beforeLabelIds: parseJsonArray(row.beforeLabelIds, []),
|
|
500
|
+
afterLabelIds: parseJsonArray(row.afterLabelIds, []),
|
|
501
|
+
errorMessage: row.errorMessage,
|
|
502
|
+
executedAt: row.executedAt ?? 0,
|
|
503
|
+
undoneAt: row.undoneAt ?? null
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function queryRuns(whereClause = "", params = [], limit) {
|
|
507
|
+
const sqlite = getDatabase();
|
|
508
|
+
const sql = `
|
|
509
|
+
SELECT
|
|
510
|
+
r.id AS id,
|
|
511
|
+
r.source_type AS sourceType,
|
|
512
|
+
r.rule_id AS ruleId,
|
|
513
|
+
r.dry_run AS dryRun,
|
|
514
|
+
r.requested_actions AS requestedActions,
|
|
515
|
+
r.query AS query,
|
|
516
|
+
r.status AS status,
|
|
517
|
+
r.created_at AS createdAt,
|
|
518
|
+
r.undone_at AS undoneAt,
|
|
519
|
+
COUNT(i.id) AS itemCount,
|
|
520
|
+
COALESCE(SUM(CASE WHEN i.status = 'planned' THEN 1 ELSE 0 END), 0) AS plannedItemCount,
|
|
521
|
+
COALESCE(SUM(CASE WHEN i.status = 'applied' THEN 1 ELSE 0 END), 0) AS appliedItemCount,
|
|
522
|
+
COALESCE(SUM(CASE WHEN i.status = 'warning' THEN 1 ELSE 0 END), 0) AS warningItemCount,
|
|
523
|
+
COALESCE(SUM(CASE WHEN i.status = 'error' THEN 1 ELSE 0 END), 0) AS errorItemCount,
|
|
524
|
+
COALESCE(SUM(CASE WHEN i.status = 'undone' THEN 1 ELSE 0 END), 0) AS undoneItemCount
|
|
525
|
+
FROM execution_runs r
|
|
526
|
+
LEFT JOIN execution_items i ON i.run_id = r.id
|
|
527
|
+
${whereClause}
|
|
528
|
+
GROUP BY r.id
|
|
529
|
+
ORDER BY COALESCE(r.created_at, 0) DESC, r.id DESC
|
|
530
|
+
${limit ? "LIMIT ?" : ""}
|
|
531
|
+
`;
|
|
532
|
+
const rows = limit === void 0 ? sqlite.prepare(sql).all(...params) : sqlite.prepare(sql).all(...params, limit);
|
|
533
|
+
return rows.map(rowToExecutionRun);
|
|
534
|
+
}
|
|
535
|
+
async function createExecutionRun(input) {
|
|
536
|
+
ensureValidSourceType(input.sourceType);
|
|
537
|
+
const sqlite = getDatabase();
|
|
538
|
+
const now2 = input.createdAt ?? Date.now();
|
|
539
|
+
const id = input.id ?? randomUUID();
|
|
540
|
+
const status = input.status ?? "planned";
|
|
541
|
+
ensureValidRunStatus(status);
|
|
542
|
+
sqlite.prepare(
|
|
543
|
+
`
|
|
544
|
+
INSERT INTO execution_runs (
|
|
545
|
+
id, source_type, rule_id, dry_run, requested_actions, query, status, created_at, undone_at
|
|
546
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
547
|
+
`
|
|
548
|
+
).run(
|
|
549
|
+
id,
|
|
550
|
+
input.sourceType,
|
|
551
|
+
input.ruleId ?? null,
|
|
552
|
+
input.dryRun ? 1 : 0,
|
|
553
|
+
serializeJson(input.requestedActions ?? []),
|
|
554
|
+
input.query ?? null,
|
|
555
|
+
status,
|
|
556
|
+
now2,
|
|
557
|
+
input.undoneAt ?? null
|
|
558
|
+
);
|
|
559
|
+
return await getRun(id);
|
|
560
|
+
}
|
|
561
|
+
async function appendExecutionItem(runId, input) {
|
|
562
|
+
const sqlite = getDatabase();
|
|
563
|
+
const runExists = sqlite.prepare(`SELECT id FROM execution_runs WHERE id = ?`).get(runId);
|
|
564
|
+
if (!runExists) {
|
|
565
|
+
throw new Error(`Execution run not found: ${runId}`);
|
|
566
|
+
}
|
|
567
|
+
ensureValidItemStatus(input.status);
|
|
568
|
+
const id = input.id ?? randomUUID();
|
|
569
|
+
const executedAt = input.executedAt ?? Date.now();
|
|
570
|
+
sqlite.prepare(
|
|
571
|
+
`
|
|
572
|
+
INSERT INTO execution_items (
|
|
573
|
+
id, run_id, email_id, status, applied_actions, before_label_ids,
|
|
574
|
+
after_label_ids, error_message, executed_at, undone_at
|
|
575
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
576
|
+
`
|
|
577
|
+
).run(
|
|
578
|
+
id,
|
|
579
|
+
runId,
|
|
580
|
+
input.emailId,
|
|
581
|
+
input.status,
|
|
582
|
+
serializeJson(input.appliedActions ?? []),
|
|
583
|
+
serializeJson(input.beforeLabelIds),
|
|
584
|
+
serializeJson(input.afterLabelIds),
|
|
585
|
+
input.errorMessage ?? null,
|
|
586
|
+
executedAt,
|
|
587
|
+
input.undoneAt ?? null
|
|
588
|
+
);
|
|
589
|
+
const item = sqlite.prepare(
|
|
590
|
+
`
|
|
591
|
+
SELECT
|
|
592
|
+
id,
|
|
593
|
+
run_id AS runId,
|
|
594
|
+
email_id AS emailId,
|
|
595
|
+
status,
|
|
596
|
+
applied_actions AS appliedActions,
|
|
597
|
+
before_label_ids AS beforeLabelIds,
|
|
598
|
+
after_label_ids AS afterLabelIds,
|
|
599
|
+
error_message AS errorMessage,
|
|
600
|
+
executed_at AS executedAt,
|
|
601
|
+
undone_at AS undoneAt
|
|
602
|
+
FROM execution_items
|
|
603
|
+
WHERE id = ?
|
|
604
|
+
`
|
|
605
|
+
).get(id);
|
|
606
|
+
if (!item) {
|
|
607
|
+
throw new Error(`Failed to load inserted execution item: ${id}`);
|
|
608
|
+
}
|
|
609
|
+
return rowToExecutionItem(item);
|
|
610
|
+
}
|
|
611
|
+
async function addExecutionItems(runId, items) {
|
|
612
|
+
const inserted = [];
|
|
613
|
+
for (const item of items) {
|
|
614
|
+
inserted.push(await appendExecutionItem(runId, item));
|
|
615
|
+
}
|
|
616
|
+
return inserted;
|
|
617
|
+
}
|
|
618
|
+
async function getRecentRuns(limit = 20) {
|
|
619
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
620
|
+
throw new Error(`Invalid limit: ${limit}`);
|
|
621
|
+
}
|
|
622
|
+
return queryRuns("", [], limit);
|
|
623
|
+
}
|
|
624
|
+
async function getRun(runId) {
|
|
625
|
+
const runs = queryRuns("WHERE r.id = ?", [runId], 1);
|
|
626
|
+
return runs[0] ?? null;
|
|
627
|
+
}
|
|
628
|
+
async function getRunItems(runId) {
|
|
629
|
+
const sqlite = getDatabase();
|
|
630
|
+
const rows = sqlite.prepare(
|
|
631
|
+
`
|
|
632
|
+
SELECT
|
|
633
|
+
id,
|
|
634
|
+
run_id AS runId,
|
|
635
|
+
email_id AS emailId,
|
|
636
|
+
status,
|
|
637
|
+
applied_actions AS appliedActions,
|
|
638
|
+
before_label_ids AS beforeLabelIds,
|
|
639
|
+
after_label_ids AS afterLabelIds,
|
|
640
|
+
error_message AS errorMessage,
|
|
641
|
+
executed_at AS executedAt,
|
|
642
|
+
undone_at AS undoneAt
|
|
643
|
+
FROM execution_items
|
|
644
|
+
WHERE run_id = ?
|
|
645
|
+
ORDER BY COALESCE(executed_at, 0) ASC, id ASC
|
|
646
|
+
`
|
|
647
|
+
).all(runId);
|
|
648
|
+
return rows.map(rowToExecutionItem);
|
|
649
|
+
}
|
|
650
|
+
async function getRunsByEmail(emailId) {
|
|
651
|
+
return queryRuns(
|
|
652
|
+
"WHERE EXISTS (SELECT 1 FROM execution_items i2 WHERE i2.run_id = r.id AND i2.email_id = ?)",
|
|
653
|
+
[emailId]
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
async function getRunsByRule(ruleId) {
|
|
657
|
+
return queryRuns("WHERE r.rule_id = ?", [ruleId]);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/core/gmail/transport_google_api.ts
|
|
661
|
+
import { gmail } from "@googleapis/gmail";
|
|
662
|
+
|
|
663
|
+
// src/core/auth/tokens.ts
|
|
664
|
+
import { OAuth2Client } from "google-auth-library";
|
|
665
|
+
import { existsSync as existsSync2 } from "fs";
|
|
666
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
667
|
+
import { dirname as dirname3 } from "path";
|
|
668
|
+
async function saveTokens(tokensPath, tokens) {
|
|
669
|
+
await mkdir(dirname3(tokensPath), { recursive: true });
|
|
670
|
+
await writeFile(tokensPath, `${JSON.stringify(tokens, null, 2)}
|
|
671
|
+
`, "utf8");
|
|
672
|
+
}
|
|
673
|
+
async function loadTokens(tokensPath) {
|
|
674
|
+
if (!existsSync2(tokensPath)) {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
const raw = await readFile(tokensPath, "utf8");
|
|
678
|
+
const parsed = JSON.parse(raw);
|
|
679
|
+
if (typeof parsed.accessToken !== "string" || typeof parsed.refreshToken !== "string" || typeof parsed.expiryDate !== "number" || typeof parsed.email !== "string") {
|
|
680
|
+
throw new Error(`Invalid token file at ${tokensPath}`);
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
accessToken: parsed.accessToken,
|
|
684
|
+
refreshToken: parsed.refreshToken,
|
|
685
|
+
expiryDate: parsed.expiryDate,
|
|
686
|
+
email: parsed.email,
|
|
687
|
+
scope: parsed.scope,
|
|
688
|
+
tokenType: parsed.tokenType
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function isTokenExpired(tokens, skewMs = 6e4) {
|
|
692
|
+
return Date.now() >= tokens.expiryDate - skewMs;
|
|
693
|
+
}
|
|
694
|
+
async function refreshAccessToken(tokens, clientId, clientSecret) {
|
|
695
|
+
const client = new OAuth2Client({
|
|
696
|
+
clientId,
|
|
697
|
+
clientSecret
|
|
698
|
+
});
|
|
699
|
+
client.setCredentials({
|
|
700
|
+
access_token: tokens.accessToken,
|
|
701
|
+
refresh_token: tokens.refreshToken,
|
|
702
|
+
expiry_date: tokens.expiryDate
|
|
703
|
+
});
|
|
704
|
+
const { credentials } = await client.refreshAccessToken();
|
|
705
|
+
if (!credentials.access_token || !credentials.expiry_date) {
|
|
706
|
+
throw new Error("Google token refresh did not return a new access token");
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
...tokens,
|
|
710
|
+
accessToken: credentials.access_token,
|
|
711
|
+
refreshToken: credentials.refresh_token || tokens.refreshToken,
|
|
712
|
+
expiryDate: credentials.expiry_date,
|
|
713
|
+
scope: credentials.scope || tokens.scope,
|
|
714
|
+
tokenType: credentials.token_type || tokens.tokenType
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/core/auth/oauth.ts
|
|
719
|
+
import { OAuth2Client as OAuth2Client2 } from "google-auth-library";
|
|
720
|
+
import { createServer } from "http";
|
|
721
|
+
import { URL } from "url";
|
|
722
|
+
import open from "open";
|
|
723
|
+
var GMAIL_SCOPES = [
|
|
724
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
|
725
|
+
"https://www.googleapis.com/auth/gmail.labels",
|
|
726
|
+
"https://www.googleapis.com/auth/gmail.settings.basic",
|
|
727
|
+
"https://www.googleapis.com/auth/userinfo.email"
|
|
728
|
+
];
|
|
729
|
+
function getOAuthReadiness(config) {
|
|
730
|
+
const status = getGoogleCredentialStatus(config);
|
|
731
|
+
return {
|
|
732
|
+
ready: status.configured,
|
|
733
|
+
missing: status.missing
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
function createOAuthClient(config, redirectUri) {
|
|
737
|
+
const credentials = requireGoogleCredentials(config);
|
|
738
|
+
return new OAuth2Client2({
|
|
739
|
+
clientId: credentials.clientId,
|
|
740
|
+
clientSecret: credentials.clientSecret,
|
|
741
|
+
redirectUri: redirectUri || credentials.redirectUri
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function waitForAuthorizationCode(server) {
|
|
745
|
+
return new Promise((resolve3, reject) => {
|
|
746
|
+
server.on("request", (request, response) => {
|
|
747
|
+
if (!request.url) {
|
|
748
|
+
response.statusCode = 400;
|
|
749
|
+
response.end("Missing callback URL.");
|
|
750
|
+
reject(new Error("Missing callback URL."));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
754
|
+
const error = url.searchParams.get("error");
|
|
755
|
+
const code = url.searchParams.get("code");
|
|
756
|
+
if (error) {
|
|
757
|
+
if (error === "access_denied") {
|
|
758
|
+
const guidance = [
|
|
759
|
+
"Google blocked the sign-in. Common causes:",
|
|
760
|
+
"",
|
|
761
|
+
"- Your Gmail address is not listed as a test user",
|
|
762
|
+
" Go to Google Auth Platform > Audience in Cloud Console",
|
|
763
|
+
" and add your email under Test Users.",
|
|
764
|
+
"",
|
|
765
|
+
"- You selected Internal but are using a personal Gmail account",
|
|
766
|
+
" Go to Audience and switch User Type to External.",
|
|
767
|
+
"",
|
|
768
|
+
"- You clicked Cancel on the Google consent page",
|
|
769
|
+
" Just retry: inboxctl auth login"
|
|
770
|
+
].join("\n");
|
|
771
|
+
response.statusCode = 403;
|
|
772
|
+
response.end(`Access denied.
|
|
773
|
+
|
|
774
|
+
${guidance}`);
|
|
775
|
+
reject(new Error(`OAuth access denied.
|
|
776
|
+
|
|
777
|
+
${guidance}`));
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
response.statusCode = 400;
|
|
781
|
+
response.end(`OAuth failed: ${error}`);
|
|
782
|
+
reject(new Error(`OAuth failed: ${error}`));
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (!code) {
|
|
786
|
+
response.statusCode = 400;
|
|
787
|
+
response.end("Missing OAuth code.");
|
|
788
|
+
reject(new Error("Missing OAuth code."));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
response.statusCode = 200;
|
|
792
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
793
|
+
response.end("Authentication complete. You can close this tab and return to inboxctl.");
|
|
794
|
+
resolve3(code);
|
|
795
|
+
});
|
|
796
|
+
server.on("error", reject);
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
async function listen(server, port) {
|
|
800
|
+
return new Promise((resolve3, reject) => {
|
|
801
|
+
server.listen(port, "127.0.0.1", () => {
|
|
802
|
+
resolve3(server.address());
|
|
803
|
+
});
|
|
804
|
+
server.on("error", reject);
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
async function getAuthenticatedEmail(accessToken) {
|
|
808
|
+
const response = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", {
|
|
809
|
+
headers: {
|
|
810
|
+
Authorization: `Bearer ${accessToken}`
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
if (!response.ok) {
|
|
814
|
+
throw new Error(await response.text());
|
|
815
|
+
}
|
|
816
|
+
const profile = await response.json();
|
|
817
|
+
return profile.emailAddress || "unknown";
|
|
818
|
+
}
|
|
819
|
+
async function startOAuthFlow(config) {
|
|
820
|
+
const readiness = getOAuthReadiness(config);
|
|
821
|
+
if (!readiness.ready) {
|
|
822
|
+
throw new Error(
|
|
823
|
+
`Google OAuth credentials are not configured yet. Missing: ${readiness.missing.join(", ")}.`
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
const requestedRedirectUri = config.google.redirectUri || DEFAULT_GOOGLE_REDIRECT_URI;
|
|
827
|
+
const server = createServer();
|
|
828
|
+
const redirectUrl = new URL(requestedRedirectUri);
|
|
829
|
+
const address = await listen(server, Number(redirectUrl.port) || 80);
|
|
830
|
+
const redirectUri = `${redirectUrl.protocol}//${redirectUrl.hostname}:${address.port}${redirectUrl.pathname}`;
|
|
831
|
+
const client = createOAuthClient(config, redirectUri);
|
|
832
|
+
const codePromise = waitForAuthorizationCode(server);
|
|
833
|
+
try {
|
|
834
|
+
const authUrl = client.generateAuthUrl({
|
|
835
|
+
access_type: "offline",
|
|
836
|
+
prompt: "consent",
|
|
837
|
+
scope: GMAIL_SCOPES
|
|
838
|
+
});
|
|
839
|
+
console.log(`Open this URL if your browser does not launch automatically:
|
|
840
|
+
${authUrl}
|
|
841
|
+
`);
|
|
842
|
+
await open(authUrl);
|
|
843
|
+
const code = await codePromise;
|
|
844
|
+
const { tokens } = await client.getToken(code);
|
|
845
|
+
if (!tokens.access_token || !tokens.refresh_token || !tokens.expiry_date) {
|
|
846
|
+
throw new Error(
|
|
847
|
+
"Google OAuth did not return the access token, refresh token, and expiry date we need."
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
await saveTokens(config.tokensPath, {
|
|
851
|
+
accessToken: tokens.access_token,
|
|
852
|
+
refreshToken: tokens.refresh_token,
|
|
853
|
+
expiryDate: tokens.expiry_date,
|
|
854
|
+
email: "unknown",
|
|
855
|
+
scope: tokens.scope,
|
|
856
|
+
tokenType: tokens.token_type ?? void 0
|
|
857
|
+
});
|
|
858
|
+
let email = "unknown";
|
|
859
|
+
try {
|
|
860
|
+
email = await getAuthenticatedEmail(tokens.access_token);
|
|
861
|
+
await saveTokens(config.tokensPath, {
|
|
862
|
+
accessToken: tokens.access_token,
|
|
863
|
+
refreshToken: tokens.refresh_token,
|
|
864
|
+
expiryDate: tokens.expiry_date,
|
|
865
|
+
email,
|
|
866
|
+
scope: tokens.scope,
|
|
867
|
+
tokenType: tokens.token_type ?? void 0
|
|
868
|
+
});
|
|
869
|
+
} catch (error) {
|
|
870
|
+
console.warn(
|
|
871
|
+
`OAuth completed but fetching the Gmail profile failed: ${error instanceof Error ? error.message : String(error)}`
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
email,
|
|
876
|
+
redirectUri
|
|
877
|
+
};
|
|
878
|
+
} finally {
|
|
879
|
+
await new Promise((resolve3) => server.close(() => resolve3()));
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/core/gmail/client.ts
|
|
884
|
+
var GMAIL_API_BASE_URL = "https://gmail.googleapis.com/gmail/v1/users/me";
|
|
885
|
+
var MAX_GMAIL_RETRIES = 5;
|
|
886
|
+
function getGmailReadiness(config, tokens) {
|
|
887
|
+
const missing = [];
|
|
888
|
+
try {
|
|
889
|
+
requireGoogleCredentials(config);
|
|
890
|
+
} catch {
|
|
891
|
+
missing.push("google_credentials");
|
|
892
|
+
}
|
|
893
|
+
if (!tokens) {
|
|
894
|
+
missing.push("tokens");
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
ready: missing.length === 0,
|
|
898
|
+
missing
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
async function getAuthenticatedGmailClient(config) {
|
|
902
|
+
const tokens = await getAuthenticatedTokens(config);
|
|
903
|
+
return {
|
|
904
|
+
accessToken: tokens.accessToken,
|
|
905
|
+
tokens
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
async function getAuthenticatedTokens(config) {
|
|
909
|
+
let tokens = await loadTokens(config.tokensPath);
|
|
910
|
+
if (!tokens) {
|
|
911
|
+
throw new Error("No Gmail tokens found. Run `inboxctl auth login` first.");
|
|
912
|
+
}
|
|
913
|
+
if (isTokenExpired(tokens)) {
|
|
914
|
+
const credentials = requireGoogleCredentials(config);
|
|
915
|
+
tokens = await refreshAccessToken(
|
|
916
|
+
tokens,
|
|
917
|
+
credentials.clientId,
|
|
918
|
+
credentials.clientSecret
|
|
919
|
+
);
|
|
920
|
+
await saveTokens(config.tokensPath, tokens);
|
|
921
|
+
}
|
|
922
|
+
return tokens;
|
|
923
|
+
}
|
|
924
|
+
async function getAuthenticatedOAuthClient(config) {
|
|
925
|
+
const tokens = await getAuthenticatedTokens(config);
|
|
926
|
+
const auth = createOAuthClient(config);
|
|
927
|
+
auth.setCredentials({
|
|
928
|
+
access_token: tokens.accessToken,
|
|
929
|
+
refresh_token: tokens.refreshToken,
|
|
930
|
+
expiry_date: tokens.expiryDate,
|
|
931
|
+
token_type: tokens.tokenType,
|
|
932
|
+
scope: tokens.scope
|
|
933
|
+
});
|
|
934
|
+
return auth;
|
|
935
|
+
}
|
|
936
|
+
async function gmailApiRequest(config, path, init) {
|
|
937
|
+
let attempt = 0;
|
|
938
|
+
while (true) {
|
|
939
|
+
attempt += 1;
|
|
940
|
+
const { accessToken } = await getAuthenticatedGmailClient(config);
|
|
941
|
+
const response = await fetch(`${GMAIL_API_BASE_URL}${path}`, {
|
|
942
|
+
...init,
|
|
943
|
+
headers: {
|
|
944
|
+
Authorization: `Bearer ${accessToken}`,
|
|
945
|
+
...init?.headers || {}
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
if (response.ok) {
|
|
949
|
+
if (response.status === 204) {
|
|
950
|
+
return void 0;
|
|
951
|
+
}
|
|
952
|
+
const text3 = await response.text();
|
|
953
|
+
if (!text3.trim()) {
|
|
954
|
+
return void 0;
|
|
955
|
+
}
|
|
956
|
+
return JSON.parse(text3);
|
|
957
|
+
}
|
|
958
|
+
const text2 = await response.text();
|
|
959
|
+
const retryable = response.status === 429 || response.status === 500 || response.status === 502 || response.status === 503 || response.status === 504;
|
|
960
|
+
if (retryable && attempt < MAX_GMAIL_RETRIES) {
|
|
961
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
962
|
+
const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : Number.NaN;
|
|
963
|
+
const delayMs = Number.isNaN(retryAfterSeconds) ? Math.min(1e3 * 2 ** (attempt - 1), 1e4) : retryAfterSeconds * 1e3;
|
|
964
|
+
await new Promise((resolve3) => setTimeout(resolve3, delayMs));
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const error = new Error(
|
|
968
|
+
`Gmail API request failed: ${response.status} ${response.statusText} ${text2}`
|
|
969
|
+
);
|
|
970
|
+
error.code = response.status;
|
|
971
|
+
error.status = response.status;
|
|
972
|
+
throw error;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/core/gmail/transport_google_api.ts
|
|
977
|
+
function createGoogleApiTransport(config) {
|
|
978
|
+
async function getClient() {
|
|
979
|
+
const auth = await getAuthenticatedOAuthClient(config);
|
|
980
|
+
return gmail({
|
|
981
|
+
version: "v1",
|
|
982
|
+
auth
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
kind: "google-api",
|
|
987
|
+
async getProfile() {
|
|
988
|
+
const client = await getClient();
|
|
989
|
+
const response = await client.users.getProfile({ userId: "me" });
|
|
990
|
+
return response.data;
|
|
991
|
+
},
|
|
992
|
+
async listLabels() {
|
|
993
|
+
const client = await getClient();
|
|
994
|
+
const response = await client.users.labels.list({ userId: "me" });
|
|
995
|
+
return response.data;
|
|
996
|
+
},
|
|
997
|
+
async getLabel(id) {
|
|
998
|
+
const client = await getClient();
|
|
999
|
+
const response = await client.users.labels.get({ userId: "me", id });
|
|
1000
|
+
return response.data;
|
|
1001
|
+
},
|
|
1002
|
+
async createLabel(input) {
|
|
1003
|
+
const client = await getClient();
|
|
1004
|
+
const response = await client.users.labels.create({
|
|
1005
|
+
userId: "me",
|
|
1006
|
+
requestBody: {
|
|
1007
|
+
name: input.name,
|
|
1008
|
+
color: input.color,
|
|
1009
|
+
type: "user"
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
return response.data;
|
|
1013
|
+
},
|
|
1014
|
+
async batchModifyMessages(input) {
|
|
1015
|
+
const client = await getClient();
|
|
1016
|
+
await client.users.messages.batchModify({
|
|
1017
|
+
userId: "me",
|
|
1018
|
+
requestBody: {
|
|
1019
|
+
ids: input.ids,
|
|
1020
|
+
addLabelIds: input.addLabelIds,
|
|
1021
|
+
removeLabelIds: input.removeLabelIds
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
},
|
|
1025
|
+
async sendMessage(raw) {
|
|
1026
|
+
const client = await getClient();
|
|
1027
|
+
const response = await client.users.messages.send({
|
|
1028
|
+
userId: "me",
|
|
1029
|
+
requestBody: {
|
|
1030
|
+
raw
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
return response.data;
|
|
1034
|
+
},
|
|
1035
|
+
async listMessages(options) {
|
|
1036
|
+
const client = await getClient();
|
|
1037
|
+
const response = await client.users.messages.list({
|
|
1038
|
+
userId: "me",
|
|
1039
|
+
q: options.query,
|
|
1040
|
+
maxResults: options.maxResults,
|
|
1041
|
+
pageToken: options.pageToken
|
|
1042
|
+
});
|
|
1043
|
+
return response.data;
|
|
1044
|
+
},
|
|
1045
|
+
async getMessage(options) {
|
|
1046
|
+
const client = await getClient();
|
|
1047
|
+
const response = await client.users.messages.get({
|
|
1048
|
+
userId: "me",
|
|
1049
|
+
id: options.id,
|
|
1050
|
+
format: options.format,
|
|
1051
|
+
metadataHeaders: options.metadataHeaders
|
|
1052
|
+
});
|
|
1053
|
+
return response.data;
|
|
1054
|
+
},
|
|
1055
|
+
async getThread(id) {
|
|
1056
|
+
const client = await getClient();
|
|
1057
|
+
const response = await client.users.threads.get({
|
|
1058
|
+
userId: "me",
|
|
1059
|
+
id,
|
|
1060
|
+
format: "full"
|
|
1061
|
+
});
|
|
1062
|
+
return response.data;
|
|
1063
|
+
},
|
|
1064
|
+
async listHistory(options) {
|
|
1065
|
+
const client = await getClient();
|
|
1066
|
+
const response = await client.users.history.list({
|
|
1067
|
+
userId: "me",
|
|
1068
|
+
startHistoryId: options.startHistoryId,
|
|
1069
|
+
maxResults: options.maxResults,
|
|
1070
|
+
historyTypes: options.historyTypes
|
|
1071
|
+
});
|
|
1072
|
+
return response.data;
|
|
1073
|
+
},
|
|
1074
|
+
async listFilters() {
|
|
1075
|
+
const client = await getClient();
|
|
1076
|
+
const response = await client.users.settings.filters.list({ userId: "me" });
|
|
1077
|
+
return response.data;
|
|
1078
|
+
},
|
|
1079
|
+
async getFilter(id) {
|
|
1080
|
+
const client = await getClient();
|
|
1081
|
+
const response = await client.users.settings.filters.get({ userId: "me", id });
|
|
1082
|
+
return response.data;
|
|
1083
|
+
},
|
|
1084
|
+
async createFilter(filter) {
|
|
1085
|
+
const client = await getClient();
|
|
1086
|
+
const response = await client.users.settings.filters.create({
|
|
1087
|
+
userId: "me",
|
|
1088
|
+
requestBody: filter
|
|
1089
|
+
});
|
|
1090
|
+
return response.data;
|
|
1091
|
+
},
|
|
1092
|
+
async deleteFilter(id) {
|
|
1093
|
+
const client = await getClient();
|
|
1094
|
+
await client.users.settings.filters.delete({ userId: "me", id });
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// src/core/gmail/transport_rest.ts
|
|
1100
|
+
function jsonRequestInit(body) {
|
|
1101
|
+
return {
|
|
1102
|
+
method: "POST",
|
|
1103
|
+
headers: {
|
|
1104
|
+
"content-type": "application/json"
|
|
1105
|
+
},
|
|
1106
|
+
body: JSON.stringify(body)
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
function createRestTransport(config) {
|
|
1110
|
+
return {
|
|
1111
|
+
kind: "rest",
|
|
1112
|
+
getProfile() {
|
|
1113
|
+
return gmailApiRequest(config, "/profile");
|
|
1114
|
+
},
|
|
1115
|
+
listLabels() {
|
|
1116
|
+
return gmailApiRequest(config, "/labels");
|
|
1117
|
+
},
|
|
1118
|
+
getLabel(id) {
|
|
1119
|
+
return gmailApiRequest(config, `/labels/${id}`);
|
|
1120
|
+
},
|
|
1121
|
+
createLabel(input) {
|
|
1122
|
+
return gmailApiRequest(
|
|
1123
|
+
config,
|
|
1124
|
+
"/labels",
|
|
1125
|
+
jsonRequestInit({
|
|
1126
|
+
name: input.name,
|
|
1127
|
+
color: input.color,
|
|
1128
|
+
type: "user"
|
|
1129
|
+
})
|
|
1130
|
+
);
|
|
1131
|
+
},
|
|
1132
|
+
batchModifyMessages(input) {
|
|
1133
|
+
return gmailApiRequest(
|
|
1134
|
+
config,
|
|
1135
|
+
"/messages/batchModify",
|
|
1136
|
+
jsonRequestInit({
|
|
1137
|
+
ids: input.ids,
|
|
1138
|
+
addLabelIds: input.addLabelIds,
|
|
1139
|
+
removeLabelIds: input.removeLabelIds
|
|
1140
|
+
})
|
|
1141
|
+
);
|
|
1142
|
+
},
|
|
1143
|
+
sendMessage(raw) {
|
|
1144
|
+
return gmailApiRequest(
|
|
1145
|
+
config,
|
|
1146
|
+
"/messages/send",
|
|
1147
|
+
jsonRequestInit({
|
|
1148
|
+
raw
|
|
1149
|
+
})
|
|
1150
|
+
);
|
|
1151
|
+
},
|
|
1152
|
+
listMessages(options) {
|
|
1153
|
+
const params = new URLSearchParams();
|
|
1154
|
+
if (options.query) {
|
|
1155
|
+
params.set("q", options.query);
|
|
1156
|
+
}
|
|
1157
|
+
if (options.maxResults) {
|
|
1158
|
+
params.set("maxResults", String(options.maxResults));
|
|
1159
|
+
}
|
|
1160
|
+
if (options.pageToken) {
|
|
1161
|
+
params.set("pageToken", options.pageToken);
|
|
1162
|
+
}
|
|
1163
|
+
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
|
1164
|
+
return gmailApiRequest(config, `/messages${suffix}`);
|
|
1165
|
+
},
|
|
1166
|
+
getMessage(options) {
|
|
1167
|
+
const params = new URLSearchParams();
|
|
1168
|
+
if (options.format) {
|
|
1169
|
+
params.set("format", options.format);
|
|
1170
|
+
}
|
|
1171
|
+
for (const header of options.metadataHeaders || []) {
|
|
1172
|
+
params.append("metadataHeaders", header);
|
|
1173
|
+
}
|
|
1174
|
+
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
|
1175
|
+
return gmailApiRequest(config, `/messages/${options.id}${suffix}`);
|
|
1176
|
+
},
|
|
1177
|
+
getThread(id) {
|
|
1178
|
+
return gmailApiRequest(config, `/threads/${id}?format=full`);
|
|
1179
|
+
},
|
|
1180
|
+
listHistory(options) {
|
|
1181
|
+
const params = new URLSearchParams({
|
|
1182
|
+
startHistoryId: options.startHistoryId,
|
|
1183
|
+
maxResults: String(options.maxResults)
|
|
1184
|
+
});
|
|
1185
|
+
for (const historyType of options.historyTypes) {
|
|
1186
|
+
params.append("historyTypes", historyType);
|
|
1187
|
+
}
|
|
1188
|
+
return gmailApiRequest(config, `/history?${params.toString()}`);
|
|
1189
|
+
},
|
|
1190
|
+
listFilters() {
|
|
1191
|
+
return gmailApiRequest(config, "/settings/filters");
|
|
1192
|
+
},
|
|
1193
|
+
getFilter(id) {
|
|
1194
|
+
return gmailApiRequest(config, `/settings/filters/${id}`);
|
|
1195
|
+
},
|
|
1196
|
+
createFilter(filter) {
|
|
1197
|
+
return gmailApiRequest(config, "/settings/filters", jsonRequestInit(filter));
|
|
1198
|
+
},
|
|
1199
|
+
deleteFilter(id) {
|
|
1200
|
+
return gmailApiRequest(config, `/settings/filters/${id}`, { method: "DELETE" });
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// src/core/gmail/transport.ts
|
|
1206
|
+
var transportKindCache = /* @__PURE__ */ new Map();
|
|
1207
|
+
var transportOverrides = /* @__PURE__ */ new Map();
|
|
1208
|
+
function getConfiguredTransportKind() {
|
|
1209
|
+
const value = process.env.INBOXCTL_GMAIL_TRANSPORT;
|
|
1210
|
+
if (value === "google-api" || value === "rest" || value === "auto") {
|
|
1211
|
+
return value;
|
|
1212
|
+
}
|
|
1213
|
+
return "auto";
|
|
1214
|
+
}
|
|
1215
|
+
function isAuthTransportFailure(error) {
|
|
1216
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1217
|
+
const status = error.code || error.status;
|
|
1218
|
+
return status === 401 || /Login Required/i.test(message) || /UNAUTHENTICATED/i.test(message) || /CREDENTIALS_MISSING/i.test(message);
|
|
1219
|
+
}
|
|
1220
|
+
async function getGmailTransport(config) {
|
|
1221
|
+
const override = transportOverrides.get(config.dataDir);
|
|
1222
|
+
if (override) {
|
|
1223
|
+
return typeof override === "function" ? await override() : override;
|
|
1224
|
+
}
|
|
1225
|
+
const configured = getConfiguredTransportKind();
|
|
1226
|
+
if (configured === "rest") {
|
|
1227
|
+
return createRestTransport(config);
|
|
1228
|
+
}
|
|
1229
|
+
if (configured === "google-api") {
|
|
1230
|
+
return createGoogleApiTransport(config);
|
|
1231
|
+
}
|
|
1232
|
+
const cached = transportKindCache.get(config.dataDir);
|
|
1233
|
+
if (cached === "rest") {
|
|
1234
|
+
return createRestTransport(config);
|
|
1235
|
+
}
|
|
1236
|
+
if (cached === "google-api") {
|
|
1237
|
+
return createGoogleApiTransport(config);
|
|
1238
|
+
}
|
|
1239
|
+
const googleTransport = createGoogleApiTransport(config);
|
|
1240
|
+
try {
|
|
1241
|
+
await googleTransport.getProfile();
|
|
1242
|
+
transportKindCache.set(config.dataDir, "google-api");
|
|
1243
|
+
return googleTransport;
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
if (!isAuthTransportFailure(error)) {
|
|
1246
|
+
throw error;
|
|
1247
|
+
}
|
|
1248
|
+
transportKindCache.set(config.dataDir, "rest");
|
|
1249
|
+
return createRestTransport(config);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
function setGmailTransportOverride(dataDir, transport) {
|
|
1253
|
+
transportOverrides.set(dataDir, transport);
|
|
1254
|
+
transportKindCache.delete(dataDir);
|
|
1255
|
+
}
|
|
1256
|
+
function clearGmailTransportOverride(dataDir) {
|
|
1257
|
+
transportOverrides.delete(dataDir);
|
|
1258
|
+
transportKindCache.delete(dataDir);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// src/core/gmail/messages.ts
|
|
1262
|
+
var MESSAGE_FETCH_CONCURRENCY = 5;
|
|
1263
|
+
function getHeaders(message) {
|
|
1264
|
+
return message.payload?.headers || [];
|
|
1265
|
+
}
|
|
1266
|
+
function getHeader(message, name) {
|
|
1267
|
+
const header = getHeaders(message).find(
|
|
1268
|
+
(entry) => entry.name?.toLowerCase() === name.toLowerCase()
|
|
1269
|
+
);
|
|
1270
|
+
return header?.value || null;
|
|
1271
|
+
}
|
|
1272
|
+
function parseAddressList(value) {
|
|
1273
|
+
if (!value) {
|
|
1274
|
+
return [];
|
|
1275
|
+
}
|
|
1276
|
+
return value.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
1277
|
+
const match = part.match(/<([^>]+)>/);
|
|
1278
|
+
return match?.[1] || part.replace(/^"|"$/g, "");
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
function parseFromHeader(value) {
|
|
1282
|
+
if (!value) {
|
|
1283
|
+
return { fromName: "", fromAddress: "" };
|
|
1284
|
+
}
|
|
1285
|
+
const match = value.match(/^(.*?)(?:\s*<([^>]+)>)?$/);
|
|
1286
|
+
if (!match) {
|
|
1287
|
+
return { fromName: "", fromAddress: value };
|
|
1288
|
+
}
|
|
1289
|
+
const rawName = match[1]?.trim().replace(/^"|"$/g, "") || "";
|
|
1290
|
+
const rawAddress = match[2]?.trim() || rawName;
|
|
1291
|
+
return {
|
|
1292
|
+
fromName: rawAddress === rawName ? "" : rawName,
|
|
1293
|
+
fromAddress: rawAddress
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function decodeBase64Url(value) {
|
|
1297
|
+
if (!value) {
|
|
1298
|
+
return "";
|
|
1299
|
+
}
|
|
1300
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1301
|
+
return Buffer.from(normalized, "base64").toString("utf8");
|
|
1302
|
+
}
|
|
1303
|
+
function findParts(part, mimeType) {
|
|
1304
|
+
if (!part) {
|
|
1305
|
+
return [];
|
|
1306
|
+
}
|
|
1307
|
+
const matches = part.mimeType === mimeType ? [part] : [];
|
|
1308
|
+
const nested = (part.parts || []).flatMap((child) => findParts(child, mimeType));
|
|
1309
|
+
return [...matches, ...nested];
|
|
1310
|
+
}
|
|
1311
|
+
function extractTextBody(message) {
|
|
1312
|
+
const textParts = findParts(message.payload, "text/plain");
|
|
1313
|
+
const text2 = textParts.map((part) => decodeBase64Url(part.body?.data)).join("\n").trim();
|
|
1314
|
+
if (text2) {
|
|
1315
|
+
return text2;
|
|
1316
|
+
}
|
|
1317
|
+
return decodeBase64Url(message.payload?.body?.data).trim();
|
|
1318
|
+
}
|
|
1319
|
+
function extractHtmlBody(message) {
|
|
1320
|
+
const html = findParts(message.payload, "text/html").map((part) => decodeBase64Url(part.body?.data)).join("\n").trim();
|
|
1321
|
+
return html || null;
|
|
1322
|
+
}
|
|
1323
|
+
function hasAttachments(part) {
|
|
1324
|
+
if (!part) {
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
if (part.filename) {
|
|
1328
|
+
return true;
|
|
1329
|
+
}
|
|
1330
|
+
return (part.parts || []).some((child) => hasAttachments(child));
|
|
1331
|
+
}
|
|
1332
|
+
function parseMessage(message) {
|
|
1333
|
+
const { fromName, fromAddress } = parseFromHeader(getHeader(message, "From"));
|
|
1334
|
+
const dateHeader = getHeader(message, "Date");
|
|
1335
|
+
const internalDate = message.internalDate ? Number(message.internalDate) : null;
|
|
1336
|
+
const parsedDate = dateHeader ? Date.parse(dateHeader) : NaN;
|
|
1337
|
+
return {
|
|
1338
|
+
id: message.id || "",
|
|
1339
|
+
threadId: message.threadId || "",
|
|
1340
|
+
fromAddress,
|
|
1341
|
+
fromName,
|
|
1342
|
+
toAddresses: parseAddressList(getHeader(message, "To")),
|
|
1343
|
+
subject: getHeader(message, "Subject") || "",
|
|
1344
|
+
snippet: message.snippet || "",
|
|
1345
|
+
date: internalDate && !Number.isNaN(internalDate) ? internalDate : Number.isNaN(parsedDate) ? Date.now() : parsedDate,
|
|
1346
|
+
isRead: !(message.labelIds || []).includes("UNREAD"),
|
|
1347
|
+
isStarred: (message.labelIds || []).includes("STARRED"),
|
|
1348
|
+
labelIds: message.labelIds || [],
|
|
1349
|
+
sizeEstimate: message.sizeEstimate || 0,
|
|
1350
|
+
hasAttachments: hasAttachments(message.payload),
|
|
1351
|
+
listUnsubscribe: getHeader(message, "List-Unsubscribe")
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
function parseMessageDetail(message) {
|
|
1355
|
+
const base = parseMessage(message);
|
|
1356
|
+
const textPlain = extractTextBody(message);
|
|
1357
|
+
const bodyHtml = extractHtmlBody(message);
|
|
1358
|
+
return {
|
|
1359
|
+
...base,
|
|
1360
|
+
textPlain,
|
|
1361
|
+
body: textPlain || bodyHtml || "",
|
|
1362
|
+
bodyHtml
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
async function listMessages(query, maxResults = 20) {
|
|
1366
|
+
const config = loadConfig();
|
|
1367
|
+
const transport = await getGmailTransport(config);
|
|
1368
|
+
const response = await transport.listMessages({
|
|
1369
|
+
query,
|
|
1370
|
+
maxResults
|
|
1371
|
+
});
|
|
1372
|
+
const ids = (response.messages || []).map((message) => message.id).filter(Boolean);
|
|
1373
|
+
return batchGetMessages(ids);
|
|
1374
|
+
}
|
|
1375
|
+
async function getMessage(id) {
|
|
1376
|
+
const config = loadConfig();
|
|
1377
|
+
const transport = await getGmailTransport(config);
|
|
1378
|
+
const response = await transport.getMessage({
|
|
1379
|
+
id,
|
|
1380
|
+
format: "full"
|
|
1381
|
+
});
|
|
1382
|
+
if (!response.id) {
|
|
1383
|
+
throw new Error(`Gmail message not found: ${id}`);
|
|
1384
|
+
}
|
|
1385
|
+
return parseMessageDetail(response);
|
|
1386
|
+
}
|
|
1387
|
+
async function batchGetMessages(ids, onProgress) {
|
|
1388
|
+
if (ids.length === 0) {
|
|
1389
|
+
return [];
|
|
1390
|
+
}
|
|
1391
|
+
const config = loadConfig();
|
|
1392
|
+
const transport = await getGmailTransport(config);
|
|
1393
|
+
const pending = ids.map((id, index2) => ({ id, index: index2 }));
|
|
1394
|
+
const messages = new Array(ids.length).fill(null);
|
|
1395
|
+
let completed = 0;
|
|
1396
|
+
async function worker() {
|
|
1397
|
+
while (pending.length > 0) {
|
|
1398
|
+
const next = pending.shift();
|
|
1399
|
+
if (!next) {
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const response = await transport.getMessage({
|
|
1403
|
+
id: next.id,
|
|
1404
|
+
format: "metadata",
|
|
1405
|
+
metadataHeaders: ["From", "To", "Subject", "Date", "List-Unsubscribe"]
|
|
1406
|
+
});
|
|
1407
|
+
if (!response.id) {
|
|
1408
|
+
messages[next.index] = null;
|
|
1409
|
+
completed += 1;
|
|
1410
|
+
onProgress?.(completed, ids.length);
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
messages[next.index] = parseMessage(response);
|
|
1414
|
+
completed += 1;
|
|
1415
|
+
onProgress?.(completed, ids.length);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
await Promise.all(
|
|
1419
|
+
Array.from({
|
|
1420
|
+
length: Math.min(MESSAGE_FETCH_CONCURRENCY, ids.length)
|
|
1421
|
+
}, () => worker())
|
|
1422
|
+
);
|
|
1423
|
+
return messages.filter((message) => message !== null);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// src/core/gmail/labels.ts
|
|
1427
|
+
var SYSTEM_LABEL_ALIASES = /* @__PURE__ */ new Map([
|
|
1428
|
+
["INBOX", "INBOX"],
|
|
1429
|
+
["SENT", "SENT"],
|
|
1430
|
+
["DRAFT", "DRAFT"],
|
|
1431
|
+
["TRASH", "TRASH"],
|
|
1432
|
+
["SPAM", "SPAM"],
|
|
1433
|
+
["STARRED", "STARRED"],
|
|
1434
|
+
["IMPORTANT", "IMPORTANT"],
|
|
1435
|
+
["UNREAD", "UNREAD"],
|
|
1436
|
+
["SNOOZED", "SNOOZED"],
|
|
1437
|
+
["ALL_MAIL", "ALL_MAIL"],
|
|
1438
|
+
["CATEGORY_PERSONAL", "CATEGORY_PERSONAL"],
|
|
1439
|
+
["CATEGORY_SOCIAL", "CATEGORY_SOCIAL"],
|
|
1440
|
+
["CATEGORY_PROMOTIONS", "CATEGORY_PROMOTIONS"],
|
|
1441
|
+
["CATEGORY_UPDATES", "CATEGORY_UPDATES"],
|
|
1442
|
+
["CATEGORY_FORUMS", "CATEGORY_FORUMS"],
|
|
1443
|
+
["CHAT", "CHAT"]
|
|
1444
|
+
]);
|
|
1445
|
+
var labelCache = /* @__PURE__ */ new Map();
|
|
1446
|
+
function normalizeKey(value) {
|
|
1447
|
+
return value.trim().toLowerCase();
|
|
1448
|
+
}
|
|
1449
|
+
function getCacheKey(config) {
|
|
1450
|
+
return config.dataDir;
|
|
1451
|
+
}
|
|
1452
|
+
function getCachedLabelName(labelId, config = loadConfig()) {
|
|
1453
|
+
return labelCache.get(getCacheKey(config))?.byId.get(labelId)?.name || null;
|
|
1454
|
+
}
|
|
1455
|
+
function toLabel(raw) {
|
|
1456
|
+
const id = raw.id?.trim() || raw.name?.trim();
|
|
1457
|
+
const name = raw.name?.trim() || raw.id?.trim();
|
|
1458
|
+
if (!id || !name) {
|
|
1459
|
+
return null;
|
|
1460
|
+
}
|
|
1461
|
+
return {
|
|
1462
|
+
id,
|
|
1463
|
+
name,
|
|
1464
|
+
type: raw.type === "system" ? "system" : "user",
|
|
1465
|
+
color: raw.color || null,
|
|
1466
|
+
labelListVisibility: raw.labelListVisibility ?? null,
|
|
1467
|
+
messageListVisibility: raw.messageListVisibility ?? null,
|
|
1468
|
+
messagesTotal: raw.messagesTotal ?? 0,
|
|
1469
|
+
messagesUnread: raw.messagesUnread ?? 0,
|
|
1470
|
+
threadsTotal: raw.threadsTotal ?? 0,
|
|
1471
|
+
threadsUnread: raw.threadsUnread ?? 0
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
function setCache(config, labels) {
|
|
1475
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1476
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1477
|
+
for (const label of labels) {
|
|
1478
|
+
byId.set(label.id, label);
|
|
1479
|
+
byName.set(normalizeKey(label.name), label);
|
|
1480
|
+
byName.set(normalizeKey(label.id), label);
|
|
1481
|
+
}
|
|
1482
|
+
labelCache.set(getCacheKey(config), {
|
|
1483
|
+
labels,
|
|
1484
|
+
byId,
|
|
1485
|
+
byName,
|
|
1486
|
+
loadedAt: Date.now()
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
function updateCacheLabel(config, label) {
|
|
1490
|
+
const key = getCacheKey(config);
|
|
1491
|
+
const existing = labelCache.get(key);
|
|
1492
|
+
if (!existing) {
|
|
1493
|
+
setCache(config, [label]);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const nextLabels = existing.labels.filter((entry) => entry.id !== label.id);
|
|
1497
|
+
nextLabels.push(label);
|
|
1498
|
+
setCache(config, nextLabels);
|
|
1499
|
+
}
|
|
1500
|
+
async function resolveContext(options) {
|
|
1501
|
+
const config = options?.config || loadConfig();
|
|
1502
|
+
const transport = options?.transport || await getGmailTransport(config);
|
|
1503
|
+
return { config, transport };
|
|
1504
|
+
}
|
|
1505
|
+
function resolveSystemLabelId(name) {
|
|
1506
|
+
const normalized = name.trim().replace(/[\s-]+/g, "_").toUpperCase();
|
|
1507
|
+
return SYSTEM_LABEL_ALIASES.get(normalized) || null;
|
|
1508
|
+
}
|
|
1509
|
+
async function refreshLabels(context) {
|
|
1510
|
+
const response = await context.transport.listLabels();
|
|
1511
|
+
const rawLabels = response.labels || [];
|
|
1512
|
+
const detailed = await Promise.all(
|
|
1513
|
+
rawLabels.map(async (raw) => {
|
|
1514
|
+
const id = raw.id?.trim() || raw.name?.trim();
|
|
1515
|
+
if (!id) {
|
|
1516
|
+
return null;
|
|
1517
|
+
}
|
|
1518
|
+
const detailedLabel = await context.transport.getLabel(id);
|
|
1519
|
+
return toLabel(detailedLabel);
|
|
1520
|
+
})
|
|
1521
|
+
);
|
|
1522
|
+
const labels = detailed.filter((label) => label !== null);
|
|
1523
|
+
setCache(context.config, labels);
|
|
1524
|
+
return labels;
|
|
1525
|
+
}
|
|
1526
|
+
async function getCachedLabels(context, forceRefresh) {
|
|
1527
|
+
const cached = labelCache.get(getCacheKey(context.config));
|
|
1528
|
+
if (!forceRefresh && cached) {
|
|
1529
|
+
return cached.labels;
|
|
1530
|
+
}
|
|
1531
|
+
return refreshLabels(context);
|
|
1532
|
+
}
|
|
1533
|
+
async function syncLabels(options) {
|
|
1534
|
+
const context = await resolveContext(options);
|
|
1535
|
+
return getCachedLabels(context, options?.forceRefresh ?? false);
|
|
1536
|
+
}
|
|
1537
|
+
async function listLabels(options) {
|
|
1538
|
+
return syncLabels({ ...options, forceRefresh: true });
|
|
1539
|
+
}
|
|
1540
|
+
async function getLabelId(name, options) {
|
|
1541
|
+
const trimmed = name.trim();
|
|
1542
|
+
if (!trimmed) {
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
const systemLabelId = resolveSystemLabelId(trimmed);
|
|
1546
|
+
if (systemLabelId) {
|
|
1547
|
+
return systemLabelId;
|
|
1548
|
+
}
|
|
1549
|
+
const context = await resolveContext(options);
|
|
1550
|
+
const labels = await getCachedLabels(context, false);
|
|
1551
|
+
const key = normalizeKey(trimmed);
|
|
1552
|
+
for (const label of labels) {
|
|
1553
|
+
if (normalizeKey(label.name) === key || normalizeKey(label.id) === key) {
|
|
1554
|
+
return label.id;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
1559
|
+
async function createLabel(name, color, options) {
|
|
1560
|
+
const trimmed = name.trim();
|
|
1561
|
+
if (!trimmed) {
|
|
1562
|
+
throw new Error("Label name cannot be empty");
|
|
1563
|
+
}
|
|
1564
|
+
const context = await resolveContext(options);
|
|
1565
|
+
const existingId = await getLabelId(trimmed, context);
|
|
1566
|
+
if (existingId) {
|
|
1567
|
+
const refreshed = await context.transport.getLabel(existingId);
|
|
1568
|
+
const label2 = toLabel(refreshed);
|
|
1569
|
+
if (!label2) {
|
|
1570
|
+
throw new Error(`Unable to resolve label details for ${trimmed}`);
|
|
1571
|
+
}
|
|
1572
|
+
updateCacheLabel(context.config, label2);
|
|
1573
|
+
return label2;
|
|
1574
|
+
}
|
|
1575
|
+
const created = toLabel(
|
|
1576
|
+
await context.transport.createLabel({
|
|
1577
|
+
name: trimmed,
|
|
1578
|
+
color
|
|
1579
|
+
})
|
|
1580
|
+
);
|
|
1581
|
+
if (!created) {
|
|
1582
|
+
throw new Error(`Gmail did not return a usable label for ${trimmed}`);
|
|
1583
|
+
}
|
|
1584
|
+
const detailed = await context.transport.getLabel(created.id).catch(() => created);
|
|
1585
|
+
const label = toLabel(detailed) || created;
|
|
1586
|
+
updateCacheLabel(context.config, label);
|
|
1587
|
+
return label;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// src/core/gmail/modify.ts
|
|
1591
|
+
var MESSAGE_FETCH_HEADERS = ["From", "To", "Subject", "Date", "List-Unsubscribe"];
|
|
1592
|
+
function now() {
|
|
1593
|
+
return Date.now();
|
|
1594
|
+
}
|
|
1595
|
+
function normalizeLabelIds(labelIds) {
|
|
1596
|
+
return Array.from(new Set((labelIds || []).filter(Boolean)));
|
|
1597
|
+
}
|
|
1598
|
+
function rowToEmail(row) {
|
|
1599
|
+
return {
|
|
1600
|
+
id: row.id,
|
|
1601
|
+
threadId: row.thread_id || "",
|
|
1602
|
+
fromAddress: row.from_address || "",
|
|
1603
|
+
fromName: row.from_name || "",
|
|
1604
|
+
toAddresses: row.to_addresses ? JSON.parse(row.to_addresses) : [],
|
|
1605
|
+
subject: row.subject || "",
|
|
1606
|
+
snippet: row.snippet || "",
|
|
1607
|
+
date: row.date || 0,
|
|
1608
|
+
isRead: (row.is_read || 0) === 1,
|
|
1609
|
+
isStarred: (row.is_starred || 0) === 1,
|
|
1610
|
+
labelIds: row.label_ids ? JSON.parse(row.label_ids) : [],
|
|
1611
|
+
sizeEstimate: row.size_estimate || 0,
|
|
1612
|
+
hasAttachments: (row.has_attachments || 0) === 1,
|
|
1613
|
+
listUnsubscribe: row.list_unsubscribe
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
function emailToRow(email) {
|
|
1617
|
+
return {
|
|
1618
|
+
id: email.id,
|
|
1619
|
+
thread_id: email.threadId,
|
|
1620
|
+
from_address: email.fromAddress,
|
|
1621
|
+
from_name: email.fromName,
|
|
1622
|
+
to_addresses: JSON.stringify(email.toAddresses),
|
|
1623
|
+
subject: email.subject,
|
|
1624
|
+
snippet: email.snippet,
|
|
1625
|
+
date: email.date,
|
|
1626
|
+
is_read: email.isRead ? 1 : 0,
|
|
1627
|
+
is_starred: email.isStarred ? 1 : 0,
|
|
1628
|
+
label_ids: JSON.stringify(email.labelIds),
|
|
1629
|
+
size_estimate: email.sizeEstimate,
|
|
1630
|
+
has_attachments: email.hasAttachments ? 1 : 0,
|
|
1631
|
+
list_unsubscribe: email.listUnsubscribe,
|
|
1632
|
+
synced_at: now()
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
function applyLabelChange(labelIds, addLabelIds = [], removeLabelIds = []) {
|
|
1636
|
+
const next = [...labelIds];
|
|
1637
|
+
for (const labelId of removeLabelIds) {
|
|
1638
|
+
const index2 = next.indexOf(labelId);
|
|
1639
|
+
if (index2 >= 0) {
|
|
1640
|
+
next.splice(index2, 1);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
for (const labelId of addLabelIds) {
|
|
1644
|
+
if (!next.includes(labelId)) {
|
|
1645
|
+
next.push(labelId);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return next;
|
|
1649
|
+
}
|
|
1650
|
+
function getReadState(labelIds) {
|
|
1651
|
+
return !labelIds.includes("UNREAD");
|
|
1652
|
+
}
|
|
1653
|
+
async function resolveContext2(options) {
|
|
1654
|
+
const config = options?.config || loadConfig();
|
|
1655
|
+
const transport = options?.transport || await getGmailTransport(config);
|
|
1656
|
+
return { config, transport };
|
|
1657
|
+
}
|
|
1658
|
+
function makePlaceholders(values) {
|
|
1659
|
+
return values.map(() => "?").join(", ");
|
|
1660
|
+
}
|
|
1661
|
+
function readSnapshots(config, ids) {
|
|
1662
|
+
const sqlite = getSqlite(config.dbPath);
|
|
1663
|
+
const rows = sqlite.prepare(
|
|
1664
|
+
`
|
|
1665
|
+
SELECT id, thread_id, from_address, from_name, to_addresses, subject, snippet, date,
|
|
1666
|
+
is_read, is_starred, label_ids, size_estimate, has_attachments, list_unsubscribe
|
|
1667
|
+
FROM emails
|
|
1668
|
+
WHERE id IN (${makePlaceholders(ids)})
|
|
1669
|
+
`
|
|
1670
|
+
).all(...ids);
|
|
1671
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
1672
|
+
for (const row of rows) {
|
|
1673
|
+
snapshots.set(row.id, {
|
|
1674
|
+
email: rowToEmail(row)
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
return snapshots;
|
|
1678
|
+
}
|
|
1679
|
+
async function fetchMissingSnapshots(transport, ids, snapshots) {
|
|
1680
|
+
const missing = ids.filter((id) => !snapshots.has(id));
|
|
1681
|
+
const fetched = await Promise.all(
|
|
1682
|
+
missing.map(async (id) => {
|
|
1683
|
+
const response = await transport.getMessage({
|
|
1684
|
+
id,
|
|
1685
|
+
format: "metadata",
|
|
1686
|
+
metadataHeaders: MESSAGE_FETCH_HEADERS
|
|
1687
|
+
});
|
|
1688
|
+
if (!response.id) {
|
|
1689
|
+
throw new Error(`Gmail message not found: ${id}`);
|
|
1690
|
+
}
|
|
1691
|
+
return parseMessage(response);
|
|
1692
|
+
})
|
|
1693
|
+
);
|
|
1694
|
+
for (const email of fetched) {
|
|
1695
|
+
snapshots.set(email.id, { email });
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
function upsertEmails(config, emails2) {
|
|
1699
|
+
if (emails2.length === 0) {
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
const sqlite = getSqlite(config.dbPath);
|
|
1703
|
+
const statement = sqlite.prepare(`
|
|
1704
|
+
INSERT INTO emails (
|
|
1705
|
+
id, thread_id, from_address, from_name, to_addresses, subject, snippet, date,
|
|
1706
|
+
is_read, is_starred, label_ids, size_estimate, has_attachments, list_unsubscribe, synced_at
|
|
1707
|
+
) VALUES (
|
|
1708
|
+
@id, @thread_id, @from_address, @from_name, @to_addresses, @subject, @snippet, @date,
|
|
1709
|
+
@is_read, @is_starred, @label_ids, @size_estimate, @has_attachments, @list_unsubscribe, @synced_at
|
|
1710
|
+
)
|
|
1711
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1712
|
+
thread_id = excluded.thread_id,
|
|
1713
|
+
from_address = excluded.from_address,
|
|
1714
|
+
from_name = excluded.from_name,
|
|
1715
|
+
to_addresses = excluded.to_addresses,
|
|
1716
|
+
subject = excluded.subject,
|
|
1717
|
+
snippet = excluded.snippet,
|
|
1718
|
+
date = excluded.date,
|
|
1719
|
+
is_read = excluded.is_read,
|
|
1720
|
+
is_starred = excluded.is_starred,
|
|
1721
|
+
label_ids = excluded.label_ids,
|
|
1722
|
+
size_estimate = excluded.size_estimate,
|
|
1723
|
+
has_attachments = excluded.has_attachments,
|
|
1724
|
+
list_unsubscribe = excluded.list_unsubscribe,
|
|
1725
|
+
synced_at = excluded.synced_at
|
|
1726
|
+
`);
|
|
1727
|
+
const transaction = sqlite.transaction((rows) => {
|
|
1728
|
+
for (const email of rows) {
|
|
1729
|
+
statement.run(emailToRow(email));
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
transaction(emails2);
|
|
1733
|
+
}
|
|
1734
|
+
function buildResult(action, items, metadata) {
|
|
1735
|
+
return {
|
|
1736
|
+
action,
|
|
1737
|
+
affectedCount: items.length,
|
|
1738
|
+
items,
|
|
1739
|
+
nonReversible: action === "forward",
|
|
1740
|
+
...metadata
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
function buildAppliedActions(action, metadata) {
|
|
1744
|
+
switch (action) {
|
|
1745
|
+
case "archive":
|
|
1746
|
+
return [{ type: "archive" }];
|
|
1747
|
+
case "label":
|
|
1748
|
+
return metadata?.labelName ? [{ type: "label", label: metadata.labelName }] : [];
|
|
1749
|
+
case "mark_read":
|
|
1750
|
+
return [{ type: "mark_read" }];
|
|
1751
|
+
case "mark_spam":
|
|
1752
|
+
return [{ type: "mark_spam" }];
|
|
1753
|
+
case "forward":
|
|
1754
|
+
return metadata?.toAddress ? [{ type: "forward", to: metadata.toAddress }] : [];
|
|
1755
|
+
default:
|
|
1756
|
+
return [];
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
async function performLabelMutation(action, ids, addLabelIds, removeLabelIds, options, metadata) {
|
|
1760
|
+
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
|
|
1761
|
+
if (uniqueIds.length === 0) {
|
|
1762
|
+
return buildResult(action, [], metadata);
|
|
1763
|
+
}
|
|
1764
|
+
const context = await resolveContext2(options);
|
|
1765
|
+
const snapshots = readSnapshots(context.config, uniqueIds);
|
|
1766
|
+
await fetchMissingSnapshots(context.transport, uniqueIds, snapshots);
|
|
1767
|
+
const orderedSnapshots = uniqueIds.map((id) => {
|
|
1768
|
+
const snapshot = snapshots.get(id);
|
|
1769
|
+
if (!snapshot) {
|
|
1770
|
+
throw new Error(`Unable to resolve Gmail message snapshot for ${id}`);
|
|
1771
|
+
}
|
|
1772
|
+
return snapshot;
|
|
1773
|
+
});
|
|
1774
|
+
const batchSize = 1e3;
|
|
1775
|
+
for (let index2 = 0; index2 < uniqueIds.length; index2 += batchSize) {
|
|
1776
|
+
await context.transport.batchModifyMessages({
|
|
1777
|
+
ids: uniqueIds.slice(index2, index2 + batchSize),
|
|
1778
|
+
addLabelIds,
|
|
1779
|
+
removeLabelIds
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
const updatedEmails = orderedSnapshots.map(({ email }) => {
|
|
1783
|
+
const labelIds = applyLabelChange(email.labelIds, addLabelIds, removeLabelIds);
|
|
1784
|
+
return {
|
|
1785
|
+
...email,
|
|
1786
|
+
labelIds,
|
|
1787
|
+
isRead: getReadState(labelIds)
|
|
1788
|
+
};
|
|
1789
|
+
});
|
|
1790
|
+
upsertEmails(context.config, updatedEmails);
|
|
1791
|
+
const items = orderedSnapshots.map(({ email }, index2) => {
|
|
1792
|
+
const afterLabelIds = updatedEmails[index2]?.labelIds || [];
|
|
1793
|
+
return {
|
|
1794
|
+
emailId: email.id,
|
|
1795
|
+
beforeLabelIds: [...email.labelIds],
|
|
1796
|
+
afterLabelIds,
|
|
1797
|
+
status: "applied",
|
|
1798
|
+
appliedActions: buildAppliedActions(action, metadata)
|
|
1799
|
+
};
|
|
1800
|
+
});
|
|
1801
|
+
return buildResult(action, items, metadata);
|
|
1802
|
+
}
|
|
1803
|
+
function encodeBase64Url(value) {
|
|
1804
|
+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
1805
|
+
}
|
|
1806
|
+
function formatAddress(name, address) {
|
|
1807
|
+
if (!name) {
|
|
1808
|
+
return address;
|
|
1809
|
+
}
|
|
1810
|
+
return `"${name.replace(/"/g, '\\"')}" <${address}>`;
|
|
1811
|
+
}
|
|
1812
|
+
function normalizeForwardSubject(subject) {
|
|
1813
|
+
if (/^fwd:/i.test(subject)) {
|
|
1814
|
+
return subject;
|
|
1815
|
+
}
|
|
1816
|
+
return `Fwd: ${subject}`;
|
|
1817
|
+
}
|
|
1818
|
+
function buildForwardRawMessage(message, toAddress) {
|
|
1819
|
+
const detail = parseMessageDetail(message);
|
|
1820
|
+
const introLines = [
|
|
1821
|
+
"---------- Forwarded message ---------",
|
|
1822
|
+
`From: ${formatAddress(detail.fromName, detail.fromAddress)}`,
|
|
1823
|
+
`Date: ${new Date(detail.date).toUTCString()}`,
|
|
1824
|
+
`Subject: ${detail.subject}`,
|
|
1825
|
+
`To: ${detail.toAddresses.join(", ")}`,
|
|
1826
|
+
""
|
|
1827
|
+
];
|
|
1828
|
+
const forwardedBody = detail.body || detail.textPlain || detail.snippet || "";
|
|
1829
|
+
const rawMessage = [
|
|
1830
|
+
`To: ${toAddress}`,
|
|
1831
|
+
`Subject: ${normalizeForwardSubject(detail.subject)}`,
|
|
1832
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
1833
|
+
"",
|
|
1834
|
+
[...introLines, forwardedBody].join("\r\n")
|
|
1835
|
+
].join("\r\n");
|
|
1836
|
+
return {
|
|
1837
|
+
raw: encodeBase64Url(rawMessage),
|
|
1838
|
+
detail
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
async function restoreEmailLabels(emailId, beforeLabelIds) {
|
|
1842
|
+
try {
|
|
1843
|
+
const context = await resolveContext2();
|
|
1844
|
+
const snapshots = readSnapshots(context.config, [emailId]);
|
|
1845
|
+
await fetchMissingSnapshots(context.transport, [emailId], snapshots);
|
|
1846
|
+
const snapshot = snapshots.get(emailId);
|
|
1847
|
+
if (!snapshot) {
|
|
1848
|
+
return {
|
|
1849
|
+
status: "error",
|
|
1850
|
+
errorMessage: `Unable to resolve Gmail message snapshot for ${emailId}`
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
const currentLabelIds = normalizeLabelIds(snapshot.email.labelIds);
|
|
1854
|
+
const targetLabelIds = normalizeLabelIds(beforeLabelIds);
|
|
1855
|
+
const addLabelIds = targetLabelIds.filter((labelId) => !currentLabelIds.includes(labelId));
|
|
1856
|
+
const removeLabelIds = currentLabelIds.filter((labelId) => !targetLabelIds.includes(labelId));
|
|
1857
|
+
if (addLabelIds.length === 0 && removeLabelIds.length === 0) {
|
|
1858
|
+
return { status: "applied" };
|
|
1859
|
+
}
|
|
1860
|
+
await context.transport.batchModifyMessages({
|
|
1861
|
+
ids: [emailId],
|
|
1862
|
+
addLabelIds,
|
|
1863
|
+
removeLabelIds
|
|
1864
|
+
});
|
|
1865
|
+
const restoredLabelIds = applyLabelChange(currentLabelIds, addLabelIds, removeLabelIds);
|
|
1866
|
+
upsertEmails(context.config, [
|
|
1867
|
+
{
|
|
1868
|
+
...snapshot.email,
|
|
1869
|
+
labelIds: restoredLabelIds,
|
|
1870
|
+
isRead: getReadState(restoredLabelIds)
|
|
1871
|
+
}
|
|
1872
|
+
]);
|
|
1873
|
+
return { status: "applied" };
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
return {
|
|
1876
|
+
status: "error",
|
|
1877
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
async function archiveEmails(ids, options) {
|
|
1882
|
+
return performLabelMutation("archive", ids, [], ["INBOX"], options, {
|
|
1883
|
+
labelId: "INBOX",
|
|
1884
|
+
labelName: "INBOX"
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
async function labelEmails(ids, labelName, options) {
|
|
1888
|
+
const context = await resolveContext2(options);
|
|
1889
|
+
const labelId = await getLabelId(labelName, context);
|
|
1890
|
+
if (!labelId) {
|
|
1891
|
+
throw new Error(`Unknown Gmail label: ${labelName}`);
|
|
1892
|
+
}
|
|
1893
|
+
return performLabelMutation("label", ids, [labelId], [], context, {
|
|
1894
|
+
labelId,
|
|
1895
|
+
labelName
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
async function unlabelEmails(ids, labelName, options) {
|
|
1899
|
+
const context = await resolveContext2(options);
|
|
1900
|
+
const labelId = await getLabelId(labelName, context);
|
|
1901
|
+
if (!labelId) {
|
|
1902
|
+
throw new Error(`Unknown Gmail label: ${labelName}`);
|
|
1903
|
+
}
|
|
1904
|
+
return performLabelMutation("unlabel", ids, [], [labelId], context, {
|
|
1905
|
+
labelId,
|
|
1906
|
+
labelName
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
async function markRead(ids, options) {
|
|
1910
|
+
return performLabelMutation("mark_read", ids, [], ["UNREAD"], options, {
|
|
1911
|
+
labelId: "UNREAD",
|
|
1912
|
+
labelName: "UNREAD"
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
async function markUnread(ids, options) {
|
|
1916
|
+
return performLabelMutation("mark_unread", ids, ["UNREAD"], [], options, {
|
|
1917
|
+
labelId: "UNREAD",
|
|
1918
|
+
labelName: "UNREAD"
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
async function markSpam(ids, options) {
|
|
1922
|
+
return performLabelMutation("mark_spam", ids, ["SPAM"], ["INBOX"], options, {
|
|
1923
|
+
labelId: "SPAM",
|
|
1924
|
+
labelName: "SPAM"
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
async function forwardEmail(id, toAddress, options) {
|
|
1928
|
+
const context = await resolveContext2(options);
|
|
1929
|
+
const response = await context.transport.getMessage({
|
|
1930
|
+
id,
|
|
1931
|
+
format: "full"
|
|
1932
|
+
});
|
|
1933
|
+
if (!response.id) {
|
|
1934
|
+
throw new Error(`Gmail message not found: ${id}`);
|
|
1935
|
+
}
|
|
1936
|
+
const { raw, detail } = buildForwardRawMessage(response, toAddress);
|
|
1937
|
+
const sent = await context.transport.sendMessage(raw);
|
|
1938
|
+
const labelIds = normalizeLabelIds(response.labelIds || detail.labelIds);
|
|
1939
|
+
return buildResult(
|
|
1940
|
+
"forward",
|
|
1941
|
+
[
|
|
1942
|
+
{
|
|
1943
|
+
emailId: response.id,
|
|
1944
|
+
beforeLabelIds: labelIds,
|
|
1945
|
+
afterLabelIds: labelIds,
|
|
1946
|
+
status: "applied",
|
|
1947
|
+
appliedActions: buildAppliedActions("forward", { toAddress })
|
|
1948
|
+
}
|
|
1949
|
+
],
|
|
1950
|
+
{
|
|
1951
|
+
toAddress,
|
|
1952
|
+
sentMessageId: sent.id || void 0,
|
|
1953
|
+
sentThreadId: sent.threadId || void 0
|
|
1954
|
+
}
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// src/core/actions/undo.ts
|
|
1959
|
+
function getDatabase2() {
|
|
1960
|
+
const config = loadConfig();
|
|
1961
|
+
return getSqlite(config.dbPath);
|
|
1962
|
+
}
|
|
1963
|
+
function getActionType(action) {
|
|
1964
|
+
return typeof action === "object" && action && "type" in action && typeof action.type === "string" ? action.type.toLowerCase() : null;
|
|
1965
|
+
}
|
|
1966
|
+
function hasNonReversibleAction(item) {
|
|
1967
|
+
return item.appliedActions.some((action) => getActionType(action) === "forward");
|
|
1968
|
+
}
|
|
1969
|
+
function updateItem(sqlite, itemId, status, errorMessage, undoneAt) {
|
|
1970
|
+
sqlite.prepare(
|
|
1971
|
+
`
|
|
1972
|
+
UPDATE execution_items
|
|
1973
|
+
SET status = ?, error_message = ?, undone_at = ?
|
|
1974
|
+
WHERE id = ?
|
|
1975
|
+
`
|
|
1976
|
+
).run(status, errorMessage, undoneAt, itemId);
|
|
1977
|
+
}
|
|
1978
|
+
function updateRun(sqlite, runId, status, undoneAt) {
|
|
1979
|
+
sqlite.prepare(
|
|
1980
|
+
`
|
|
1981
|
+
UPDATE execution_runs
|
|
1982
|
+
SET status = ?, undone_at = ?
|
|
1983
|
+
WHERE id = ?
|
|
1984
|
+
`
|
|
1985
|
+
).run(status, undoneAt, runId);
|
|
1986
|
+
}
|
|
1987
|
+
async function undoRun(runId) {
|
|
1988
|
+
const sqlite = getDatabase2();
|
|
1989
|
+
const run = await getRun(runId);
|
|
1990
|
+
if (!run) {
|
|
1991
|
+
throw new Error(`Execution run not found: ${runId}`);
|
|
1992
|
+
}
|
|
1993
|
+
if (run.status === "undone" || run.undoneAt !== null) {
|
|
1994
|
+
throw new Error(`Execution run is already undone: ${runId}`);
|
|
1995
|
+
}
|
|
1996
|
+
const items = await getRunItems(runId);
|
|
1997
|
+
const warnings = [];
|
|
1998
|
+
let undoneCount = 0;
|
|
1999
|
+
let warningCount = 0;
|
|
2000
|
+
let errorCount = 0;
|
|
2001
|
+
const undoneAt = Date.now();
|
|
2002
|
+
for (const item of items) {
|
|
2003
|
+
const restored = await restoreEmailLabels(item.emailId, item.beforeLabelIds);
|
|
2004
|
+
if (restored.status === "error") {
|
|
2005
|
+
errorCount += 1;
|
|
2006
|
+
updateItem(
|
|
2007
|
+
sqlite,
|
|
2008
|
+
item.id,
|
|
2009
|
+
"error",
|
|
2010
|
+
restored.errorMessage || "Failed to restore Gmail label snapshot.",
|
|
2011
|
+
null
|
|
2012
|
+
);
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
if (hasNonReversibleAction(item)) {
|
|
2016
|
+
const message = `Email ${item.emailId}: label state was restored, but forward actions cannot be undone.`;
|
|
2017
|
+
warnings.push(message);
|
|
2018
|
+
warningCount += 1;
|
|
2019
|
+
updateItem(sqlite, item.id, "warning", message, undoneAt);
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
undoneCount += 1;
|
|
2023
|
+
updateItem(sqlite, item.id, "undone", null, undoneAt);
|
|
2024
|
+
}
|
|
2025
|
+
const status = errorCount > 0 || warningCount > 0 ? "partial" : "undone";
|
|
2026
|
+
updateRun(sqlite, run.id, status, undoneAt);
|
|
2027
|
+
const refreshedRun = await getRun(run.id);
|
|
2028
|
+
if (!refreshedRun) {
|
|
2029
|
+
throw new Error(`Failed to reload execution run after undo: ${run.id}`);
|
|
2030
|
+
}
|
|
2031
|
+
return {
|
|
2032
|
+
runId: run.id,
|
|
2033
|
+
run: refreshedRun,
|
|
2034
|
+
warnings,
|
|
2035
|
+
restoredItemCount: undoneCount + warningCount,
|
|
2036
|
+
itemCount: items.length,
|
|
2037
|
+
undoneCount,
|
|
2038
|
+
warningCount,
|
|
2039
|
+
errorCount,
|
|
2040
|
+
status
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// src/core/gmail/threads.ts
|
|
2045
|
+
async function getThread(id) {
|
|
2046
|
+
const config = loadConfig();
|
|
2047
|
+
const transport = await getGmailTransport(config);
|
|
2048
|
+
const response = await transport.getThread(id);
|
|
2049
|
+
const messages = (response.messages || []).map(
|
|
2050
|
+
(message) => parseMessageDetail(message)
|
|
2051
|
+
);
|
|
2052
|
+
return {
|
|
2053
|
+
id: response.id || id,
|
|
2054
|
+
messages
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// src/core/stats/common.ts
|
|
2059
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2060
|
+
var SYSTEM_LABEL_NAMES = /* @__PURE__ */ new Map([
|
|
2061
|
+
["INBOX", "Inbox"],
|
|
2062
|
+
["UNREAD", "Unread"],
|
|
2063
|
+
["STARRED", "Starred"],
|
|
2064
|
+
["IMPORTANT", "Important"],
|
|
2065
|
+
["SENT", "Sent"],
|
|
2066
|
+
["DRAFT", "Drafts"],
|
|
2067
|
+
["TRASH", "Trash"],
|
|
2068
|
+
["SPAM", "Spam"],
|
|
2069
|
+
["ALL_MAIL", "All Mail"],
|
|
2070
|
+
["SNOOZED", "Snoozed"],
|
|
2071
|
+
["CHAT", "Chat"],
|
|
2072
|
+
["CATEGORY_PERSONAL", "Personal"],
|
|
2073
|
+
["CATEGORY_SOCIAL", "Social"],
|
|
2074
|
+
["CATEGORY_PROMOTIONS", "Promotions"],
|
|
2075
|
+
["CATEGORY_UPDATES", "Updates"],
|
|
2076
|
+
["CATEGORY_FORUMS", "Forums"]
|
|
2077
|
+
]);
|
|
2078
|
+
function getStatsSqlite() {
|
|
2079
|
+
const config = loadConfig();
|
|
2080
|
+
return getSqlite(config.dbPath);
|
|
2081
|
+
}
|
|
2082
|
+
function normalizeLimit(value, fallback) {
|
|
2083
|
+
if (!value || Number.isNaN(value) || value < 1) {
|
|
2084
|
+
return fallback;
|
|
2085
|
+
}
|
|
2086
|
+
return Math.floor(value);
|
|
2087
|
+
}
|
|
2088
|
+
function clampPercentage(value, fallback = 0) {
|
|
2089
|
+
if (value === void 0 || Number.isNaN(value)) {
|
|
2090
|
+
return fallback;
|
|
2091
|
+
}
|
|
2092
|
+
return Math.max(0, Math.min(100, value));
|
|
2093
|
+
}
|
|
2094
|
+
function roundPercent(numerator, denominator) {
|
|
2095
|
+
if (!denominator) {
|
|
2096
|
+
return 0;
|
|
2097
|
+
}
|
|
2098
|
+
return Math.round(numerator / denominator * 1e3) / 10;
|
|
2099
|
+
}
|
|
2100
|
+
function getPeriodStart(period = "all", now2 = Date.now()) {
|
|
2101
|
+
switch (period) {
|
|
2102
|
+
case "day":
|
|
2103
|
+
return now2 - DAY_MS;
|
|
2104
|
+
case "week":
|
|
2105
|
+
return now2 - 7 * DAY_MS;
|
|
2106
|
+
case "month":
|
|
2107
|
+
return now2 - 30 * DAY_MS;
|
|
2108
|
+
case "year":
|
|
2109
|
+
return now2 - 365 * DAY_MS;
|
|
2110
|
+
case "all":
|
|
2111
|
+
return null;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
function resolveLabelName(labelId) {
|
|
2115
|
+
return SYSTEM_LABEL_NAMES.get(labelId) || getCachedLabelName(labelId) || labelId;
|
|
2116
|
+
}
|
|
2117
|
+
function startOfLocalDay(now2 = Date.now()) {
|
|
2118
|
+
const date = new Date(now2);
|
|
2119
|
+
date.setHours(0, 0, 0, 0);
|
|
2120
|
+
return date.getTime();
|
|
2121
|
+
}
|
|
2122
|
+
function startOfLocalWeek(now2 = Date.now()) {
|
|
2123
|
+
const date = new Date(startOfLocalDay(now2));
|
|
2124
|
+
const day = date.getDay();
|
|
2125
|
+
const diff = day === 0 ? 6 : day - 1;
|
|
2126
|
+
date.setDate(date.getDate() - diff);
|
|
2127
|
+
return date.getTime();
|
|
2128
|
+
}
|
|
2129
|
+
function startOfLocalMonth(now2 = Date.now()) {
|
|
2130
|
+
const date = new Date(now2);
|
|
2131
|
+
date.setDate(1);
|
|
2132
|
+
date.setHours(0, 0, 0, 0);
|
|
2133
|
+
return date.getTime();
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// src/core/stats/labels.ts
|
|
2137
|
+
async function getLabelDistribution() {
|
|
2138
|
+
const sqlite = getStatsSqlite();
|
|
2139
|
+
const rows = sqlite.prepare(
|
|
2140
|
+
`
|
|
2141
|
+
SELECT
|
|
2142
|
+
label.value AS labelId,
|
|
2143
|
+
COUNT(*) AS totalMessages,
|
|
2144
|
+
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages
|
|
2145
|
+
FROM emails AS e, json_each(e.label_ids) AS label
|
|
2146
|
+
GROUP BY label.value
|
|
2147
|
+
ORDER BY totalMessages DESC, unreadMessages DESC, label.value ASC
|
|
2148
|
+
`
|
|
2149
|
+
).all();
|
|
2150
|
+
return rows.map((row) => ({
|
|
2151
|
+
labelId: row.labelId,
|
|
2152
|
+
labelName: resolveLabelName(row.labelId),
|
|
2153
|
+
totalMessages: row.totalMessages,
|
|
2154
|
+
unreadMessages: row.unreadMessages
|
|
2155
|
+
}));
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// src/core/stats/newsletters.ts
|
|
2159
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2160
|
+
var KNOWN_NEWSLETTER_LOCAL_PART = /^(newsletter|digest|noreply|no-reply|updates|news)([+._-].*)?$/i;
|
|
2161
|
+
function extractNewsletterReasons(row) {
|
|
2162
|
+
const reasons = [];
|
|
2163
|
+
const localPart = row.email.split("@")[0] || "";
|
|
2164
|
+
const unreadRate = roundPercent(row.unreadCount, row.messageCount);
|
|
2165
|
+
if (row.unsubscribeLink) {
|
|
2166
|
+
reasons.push("list_unsubscribe");
|
|
2167
|
+
}
|
|
2168
|
+
if (row.messageCount > 5 && unreadRate > 50) {
|
|
2169
|
+
reasons.push("high_volume_high_unread");
|
|
2170
|
+
}
|
|
2171
|
+
if (KNOWN_NEWSLETTER_LOCAL_PART.test(localPart)) {
|
|
2172
|
+
reasons.push("known_sender_pattern");
|
|
2173
|
+
}
|
|
2174
|
+
if (row.recipientPatternCount > 1) {
|
|
2175
|
+
reasons.push("bulk_sender_pattern");
|
|
2176
|
+
}
|
|
2177
|
+
return reasons;
|
|
2178
|
+
}
|
|
2179
|
+
function normalizeUnsubscribeLink(value) {
|
|
2180
|
+
if (!value) {
|
|
2181
|
+
return null;
|
|
2182
|
+
}
|
|
2183
|
+
const header = value.trim();
|
|
2184
|
+
const match = header.match(/<([^>]+)>/);
|
|
2185
|
+
return match?.[1]?.trim() || header.split(",")[0]?.trim() || null;
|
|
2186
|
+
}
|
|
2187
|
+
function mapNewsletterRow(row) {
|
|
2188
|
+
return {
|
|
2189
|
+
email: row.email,
|
|
2190
|
+
name: row.name?.trim() || row.email,
|
|
2191
|
+
messageCount: row.messageCount,
|
|
2192
|
+
unreadCount: row.unreadCount,
|
|
2193
|
+
unreadRate: roundPercent(row.unreadCount, row.messageCount),
|
|
2194
|
+
status: row.status,
|
|
2195
|
+
unsubscribeLink: row.unsubscribeLink,
|
|
2196
|
+
firstSeen: new Date(row.firstSeen),
|
|
2197
|
+
lastSeen: new Date(row.lastSeen),
|
|
2198
|
+
detectionReason: row.detectionReason
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
async function detectNewsletters() {
|
|
2202
|
+
const sqlite = getStatsSqlite();
|
|
2203
|
+
const rows = sqlite.prepare(
|
|
2204
|
+
`
|
|
2205
|
+
SELECT
|
|
2206
|
+
from_address AS email,
|
|
2207
|
+
COALESCE(MAX(NULLIF(TRIM(from_name), '')), from_address) AS name,
|
|
2208
|
+
COUNT(*) AS messageCount,
|
|
2209
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
|
|
2210
|
+
MIN(date) AS firstSeen,
|
|
2211
|
+
MAX(date) AS lastSeen,
|
|
2212
|
+
MAX(NULLIF(TRIM(list_unsubscribe), '')) AS unsubscribeLink,
|
|
2213
|
+
COUNT(DISTINCT to_addresses) AS recipientPatternCount
|
|
2214
|
+
FROM emails
|
|
2215
|
+
WHERE from_address IS NOT NULL
|
|
2216
|
+
AND TRIM(from_address) <> ''
|
|
2217
|
+
GROUP BY from_address
|
|
2218
|
+
`
|
|
2219
|
+
).all();
|
|
2220
|
+
const detected = [];
|
|
2221
|
+
for (const row of rows) {
|
|
2222
|
+
const reasons = extractNewsletterReasons(row);
|
|
2223
|
+
if (reasons.length === 0) {
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
detected.push({
|
|
2227
|
+
id: randomUUID2(),
|
|
2228
|
+
email: row.email,
|
|
2229
|
+
name: row.name?.trim() || row.email,
|
|
2230
|
+
messageCount: row.messageCount,
|
|
2231
|
+
unreadCount: row.unreadCount,
|
|
2232
|
+
unsubscribeLink: normalizeUnsubscribeLink(row.unsubscribeLink),
|
|
2233
|
+
detectionReason: reasons.join(", "),
|
|
2234
|
+
firstSeen: row.firstSeen,
|
|
2235
|
+
lastSeen: row.lastSeen
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
const upsert = sqlite.prepare(
|
|
2239
|
+
`
|
|
2240
|
+
INSERT INTO newsletter_senders (
|
|
2241
|
+
id,
|
|
2242
|
+
email,
|
|
2243
|
+
name,
|
|
2244
|
+
message_count,
|
|
2245
|
+
unread_count,
|
|
2246
|
+
status,
|
|
2247
|
+
unsubscribe_link,
|
|
2248
|
+
detection_reason,
|
|
2249
|
+
first_seen,
|
|
2250
|
+
last_seen
|
|
2251
|
+
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
|
|
2252
|
+
ON CONFLICT(email) DO UPDATE SET
|
|
2253
|
+
name = excluded.name,
|
|
2254
|
+
message_count = excluded.message_count,
|
|
2255
|
+
unread_count = excluded.unread_count,
|
|
2256
|
+
unsubscribe_link = excluded.unsubscribe_link,
|
|
2257
|
+
detection_reason = excluded.detection_reason,
|
|
2258
|
+
first_seen = excluded.first_seen,
|
|
2259
|
+
last_seen = excluded.last_seen
|
|
2260
|
+
`
|
|
2261
|
+
);
|
|
2262
|
+
const transaction = sqlite.transaction(
|
|
2263
|
+
(entries) => {
|
|
2264
|
+
for (const entry of entries) {
|
|
2265
|
+
upsert.run(
|
|
2266
|
+
entry.id,
|
|
2267
|
+
entry.email,
|
|
2268
|
+
entry.name,
|
|
2269
|
+
entry.messageCount,
|
|
2270
|
+
entry.unreadCount,
|
|
2271
|
+
entry.unsubscribeLink,
|
|
2272
|
+
entry.detectionReason,
|
|
2273
|
+
entry.firstSeen,
|
|
2274
|
+
entry.lastSeen
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
);
|
|
2279
|
+
transaction(detected);
|
|
2280
|
+
return detected.map((row) => ({
|
|
2281
|
+
email: row.email,
|
|
2282
|
+
name: row.name,
|
|
2283
|
+
messageCount: row.messageCount,
|
|
2284
|
+
unreadCount: row.unreadCount,
|
|
2285
|
+
unreadRate: roundPercent(row.unreadCount, row.messageCount),
|
|
2286
|
+
status: "active",
|
|
2287
|
+
unsubscribeLink: row.unsubscribeLink,
|
|
2288
|
+
firstSeen: new Date(row.firstSeen),
|
|
2289
|
+
lastSeen: new Date(row.lastSeen),
|
|
2290
|
+
detectionReason: row.detectionReason
|
|
2291
|
+
}));
|
|
2292
|
+
}
|
|
2293
|
+
async function getNewsletters(options = {}) {
|
|
2294
|
+
await detectNewsletters();
|
|
2295
|
+
const sqlite = getStatsSqlite();
|
|
2296
|
+
const minMessages = normalizeLimit(options.minMessages, 1);
|
|
2297
|
+
const minUnreadRate = clampPercentage(options.minUnreadRate, 0);
|
|
2298
|
+
const status = options.status || "active";
|
|
2299
|
+
const rows = sqlite.prepare(
|
|
2300
|
+
`
|
|
2301
|
+
SELECT
|
|
2302
|
+
email,
|
|
2303
|
+
name,
|
|
2304
|
+
message_count AS messageCount,
|
|
2305
|
+
unread_count AS unreadCount,
|
|
2306
|
+
status,
|
|
2307
|
+
unsubscribe_link AS unsubscribeLink,
|
|
2308
|
+
first_seen AS firstSeen,
|
|
2309
|
+
last_seen AS lastSeen,
|
|
2310
|
+
detection_reason AS detectionReason
|
|
2311
|
+
FROM newsletter_senders
|
|
2312
|
+
WHERE message_count >= ?
|
|
2313
|
+
AND (100.0 * unread_count / CASE WHEN message_count = 0 THEN 1 ELSE message_count END) >= ?
|
|
2314
|
+
AND (? = 'all' OR status = ?)
|
|
2315
|
+
ORDER BY message_count DESC, unreadCount DESC, lastSeen DESC, email ASC
|
|
2316
|
+
`
|
|
2317
|
+
).all(minMessages, minUnreadRate, status, status);
|
|
2318
|
+
return rows.map(mapNewsletterRow);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// src/core/stats/sender.ts
|
|
2322
|
+
function buildSenderWhereClause(period) {
|
|
2323
|
+
const whereParts = [
|
|
2324
|
+
"from_address IS NOT NULL",
|
|
2325
|
+
"TRIM(from_address) <> ''"
|
|
2326
|
+
];
|
|
2327
|
+
const params = [];
|
|
2328
|
+
const periodStart = getPeriodStart(period);
|
|
2329
|
+
if (periodStart !== null) {
|
|
2330
|
+
whereParts.push("date >= ?");
|
|
2331
|
+
params.push(periodStart);
|
|
2332
|
+
}
|
|
2333
|
+
return {
|
|
2334
|
+
clause: whereParts.join(" AND "),
|
|
2335
|
+
params
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
function mapAggregateRow(sqlite, row, whereClause, params) {
|
|
2339
|
+
return {
|
|
2340
|
+
email: row.email,
|
|
2341
|
+
name: row.name?.trim() || row.email,
|
|
2342
|
+
totalMessages: row.totalMessages,
|
|
2343
|
+
unreadMessages: row.unreadMessages,
|
|
2344
|
+
unreadRate: roundPercent(row.unreadMessages, row.totalMessages),
|
|
2345
|
+
lastEmailDate: row.lastEmailDate,
|
|
2346
|
+
firstEmailDate: row.firstEmailDate,
|
|
2347
|
+
labels: getTopLabels(sqlite, whereClause, params)
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
function getTopLabels(sqlite, whereClause, params) {
|
|
2351
|
+
const rows = sqlite.prepare(
|
|
2352
|
+
`
|
|
2353
|
+
SELECT label.value AS labelId, COUNT(*) AS totalMessages
|
|
2354
|
+
FROM emails AS e, json_each(e.label_ids) AS label
|
|
2355
|
+
WHERE ${whereClause}
|
|
2356
|
+
GROUP BY label.value
|
|
2357
|
+
ORDER BY totalMessages DESC, label.value ASC
|
|
2358
|
+
LIMIT 5
|
|
2359
|
+
`
|
|
2360
|
+
).all(...params);
|
|
2361
|
+
return rows.map((row) => resolveLabelName(row.labelId));
|
|
2362
|
+
}
|
|
2363
|
+
function getRecentEmailsForMatch(sqlite, whereClause, params) {
|
|
2364
|
+
const rows = sqlite.prepare(
|
|
2365
|
+
`
|
|
2366
|
+
SELECT
|
|
2367
|
+
id,
|
|
2368
|
+
from_address AS fromAddress,
|
|
2369
|
+
subject,
|
|
2370
|
+
date,
|
|
2371
|
+
is_read AS isRead
|
|
2372
|
+
FROM emails
|
|
2373
|
+
WHERE ${whereClause}
|
|
2374
|
+
ORDER BY date DESC
|
|
2375
|
+
LIMIT 10
|
|
2376
|
+
`
|
|
2377
|
+
).all(...params);
|
|
2378
|
+
return rows.map((row) => ({
|
|
2379
|
+
id: row.id,
|
|
2380
|
+
fromAddress: row.fromAddress,
|
|
2381
|
+
subject: row.subject,
|
|
2382
|
+
date: row.date,
|
|
2383
|
+
isRead: row.isRead === 1
|
|
2384
|
+
}));
|
|
2385
|
+
}
|
|
2386
|
+
function getMatchingSenders(sqlite, whereClause, params) {
|
|
2387
|
+
const rows = sqlite.prepare(
|
|
2388
|
+
`
|
|
2389
|
+
SELECT from_address AS email, COUNT(*) AS totalMessages, MAX(date) AS lastEmailDate
|
|
2390
|
+
FROM emails
|
|
2391
|
+
WHERE ${whereClause}
|
|
2392
|
+
GROUP BY from_address
|
|
2393
|
+
ORDER BY totalMessages DESC, lastEmailDate DESC, email ASC
|
|
2394
|
+
`
|
|
2395
|
+
).all(...params);
|
|
2396
|
+
return rows.map((row) => row.email);
|
|
2397
|
+
}
|
|
2398
|
+
async function getTopSenders(options = {}) {
|
|
2399
|
+
const sqlite = getStatsSqlite();
|
|
2400
|
+
const limit = normalizeLimit(options.limit, 20);
|
|
2401
|
+
const minMessages = normalizeLimit(options.minMessages, 1);
|
|
2402
|
+
const minUnreadRate = clampPercentage(options.minUnreadRate, 0);
|
|
2403
|
+
const { clause, params } = buildSenderWhereClause(options.period);
|
|
2404
|
+
const rows = sqlite.prepare(
|
|
2405
|
+
`
|
|
2406
|
+
SELECT
|
|
2407
|
+
from_address AS email,
|
|
2408
|
+
COALESCE(MAX(NULLIF(TRIM(from_name), '')), from_address) AS name,
|
|
2409
|
+
COUNT(*) AS totalMessages,
|
|
2410
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages,
|
|
2411
|
+
MAX(date) AS lastEmailDate,
|
|
2412
|
+
MIN(date) AS firstEmailDate
|
|
2413
|
+
FROM emails
|
|
2414
|
+
WHERE ${clause}
|
|
2415
|
+
GROUP BY from_address
|
|
2416
|
+
HAVING COUNT(*) >= ?
|
|
2417
|
+
AND (100.0 * SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) / COUNT(*)) >= ?
|
|
2418
|
+
ORDER BY totalMessages DESC, lastEmailDate DESC, email ASC
|
|
2419
|
+
LIMIT ?
|
|
2420
|
+
`
|
|
2421
|
+
).all(...params, minMessages, minUnreadRate, limit);
|
|
2422
|
+
return rows.map(
|
|
2423
|
+
(row) => mapAggregateRow(
|
|
2424
|
+
sqlite,
|
|
2425
|
+
row,
|
|
2426
|
+
`from_address = ?${clause.includes("date >= ?") ? " AND date >= ?" : ""}`,
|
|
2427
|
+
clause.includes("date >= ?") ? [row.email, ...params] : [row.email]
|
|
2428
|
+
)
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
async function getSenderStats(emailOrDomain) {
|
|
2432
|
+
const sqlite = getStatsSqlite();
|
|
2433
|
+
const query = emailOrDomain.trim().toLowerCase();
|
|
2434
|
+
if (!query) {
|
|
2435
|
+
return null;
|
|
2436
|
+
}
|
|
2437
|
+
const isDomain = query.startsWith("@");
|
|
2438
|
+
const domain = isDomain ? query.slice(1) : "";
|
|
2439
|
+
if (isDomain && !domain) {
|
|
2440
|
+
return null;
|
|
2441
|
+
}
|
|
2442
|
+
const whereClause = isDomain ? "from_address IS NOT NULL AND INSTR(from_address, '@') > 0 AND LOWER(SUBSTR(from_address, INSTR(from_address, '@') + 1)) = ?" : "LOWER(from_address) = ?";
|
|
2443
|
+
const params = [isDomain ? domain : query];
|
|
2444
|
+
const row = sqlite.prepare(
|
|
2445
|
+
`
|
|
2446
|
+
SELECT
|
|
2447
|
+
${isDomain ? "? AS email" : "LOWER(from_address) AS email"},
|
|
2448
|
+
${isDomain ? "? AS name" : "COALESCE(MAX(NULLIF(TRIM(from_name), '')), LOWER(from_address)) AS name"},
|
|
2449
|
+
COUNT(*) AS totalMessages,
|
|
2450
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages,
|
|
2451
|
+
MAX(date) AS lastEmailDate,
|
|
2452
|
+
MIN(date) AS firstEmailDate
|
|
2453
|
+
FROM emails
|
|
2454
|
+
WHERE ${whereClause}
|
|
2455
|
+
`
|
|
2456
|
+
).get(
|
|
2457
|
+
...isDomain ? [`@${domain}`, domain] : [],
|
|
2458
|
+
...params
|
|
2459
|
+
);
|
|
2460
|
+
if (!row || row.totalMessages === 0) {
|
|
2461
|
+
return null;
|
|
2462
|
+
}
|
|
2463
|
+
const displayQuery = isDomain ? `@${domain}` : query;
|
|
2464
|
+
const detail = mapAggregateRow(
|
|
2465
|
+
sqlite,
|
|
2466
|
+
row,
|
|
2467
|
+
whereClause,
|
|
2468
|
+
params
|
|
2469
|
+
);
|
|
2470
|
+
return {
|
|
2471
|
+
...detail,
|
|
2472
|
+
type: isDomain ? "domain" : "sender",
|
|
2473
|
+
query: displayQuery,
|
|
2474
|
+
matchingSenders: getMatchingSenders(sqlite, whereClause, params),
|
|
2475
|
+
recentEmails: getRecentEmailsForMatch(sqlite, whereClause, params)
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// src/core/stats/volume.ts
|
|
2480
|
+
function getBucketExpression(granularity) {
|
|
2481
|
+
switch (granularity) {
|
|
2482
|
+
case "hour":
|
|
2483
|
+
return "strftime('%Y-%m-%d %H:00', date / 1000, 'unixepoch', 'localtime')";
|
|
2484
|
+
case "day":
|
|
2485
|
+
return "strftime('%Y-%m-%d', date / 1000, 'unixepoch', 'localtime')";
|
|
2486
|
+
case "week":
|
|
2487
|
+
return "printf('%s-W%02d', strftime('%Y', date / 1000, 'unixepoch', 'localtime'), CAST(strftime('%W', date / 1000, 'unixepoch', 'localtime') AS INTEGER))";
|
|
2488
|
+
case "month":
|
|
2489
|
+
return "strftime('%Y-%m', date / 1000, 'unixepoch', 'localtime')";
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
async function getVolumeByPeriod(granularity, range) {
|
|
2493
|
+
const sqlite = getStatsSqlite();
|
|
2494
|
+
const whereParts = [];
|
|
2495
|
+
const params = [];
|
|
2496
|
+
if (range?.start !== void 0) {
|
|
2497
|
+
whereParts.push("date >= ?");
|
|
2498
|
+
params.push(range.start);
|
|
2499
|
+
}
|
|
2500
|
+
if (range?.end !== void 0) {
|
|
2501
|
+
whereParts.push("date <= ?");
|
|
2502
|
+
params.push(range.end);
|
|
2503
|
+
}
|
|
2504
|
+
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(" AND ")}` : "";
|
|
2505
|
+
const bucketExpression = getBucketExpression(granularity);
|
|
2506
|
+
const rows = sqlite.prepare(
|
|
2507
|
+
`
|
|
2508
|
+
SELECT
|
|
2509
|
+
${bucketExpression} AS period,
|
|
2510
|
+
COUNT(*) AS received,
|
|
2511
|
+
SUM(CASE WHEN is_read = 1 THEN 1 ELSE 0 END) AS read,
|
|
2512
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unread,
|
|
2513
|
+
SUM(
|
|
2514
|
+
CASE
|
|
2515
|
+
WHEN EXISTS (
|
|
2516
|
+
SELECT 1
|
|
2517
|
+
FROM json_each(emails.label_ids)
|
|
2518
|
+
WHERE json_each.value = 'INBOX'
|
|
2519
|
+
) THEN 0
|
|
2520
|
+
ELSE 1
|
|
2521
|
+
END
|
|
2522
|
+
) AS archived
|
|
2523
|
+
FROM emails
|
|
2524
|
+
${whereClause}
|
|
2525
|
+
GROUP BY period
|
|
2526
|
+
ORDER BY MIN(date) ASC
|
|
2527
|
+
`
|
|
2528
|
+
).all(...params);
|
|
2529
|
+
return rows.map((row) => ({
|
|
2530
|
+
period: row.period,
|
|
2531
|
+
received: row.received,
|
|
2532
|
+
read: row.read,
|
|
2533
|
+
unread: row.unread,
|
|
2534
|
+
archived: row.archived
|
|
2535
|
+
}));
|
|
2536
|
+
}
|
|
2537
|
+
async function getInboxOverview() {
|
|
2538
|
+
const sqlite = getStatsSqlite();
|
|
2539
|
+
const now2 = Date.now();
|
|
2540
|
+
const todayStart = startOfLocalDay(now2);
|
|
2541
|
+
const weekStart = startOfLocalWeek(now2);
|
|
2542
|
+
const monthStart = startOfLocalMonth(now2);
|
|
2543
|
+
const row = sqlite.prepare(
|
|
2544
|
+
`
|
|
2545
|
+
SELECT
|
|
2546
|
+
COUNT(*) AS total,
|
|
2547
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unread,
|
|
2548
|
+
SUM(CASE WHEN is_starred = 1 THEN 1 ELSE 0 END) AS starred,
|
|
2549
|
+
SUM(CASE WHEN date >= ? THEN 1 ELSE 0 END) AS todayReceived,
|
|
2550
|
+
SUM(CASE WHEN date >= ? AND is_read = 0 THEN 1 ELSE 0 END) AS todayUnread,
|
|
2551
|
+
SUM(CASE WHEN date >= ? THEN 1 ELSE 0 END) AS thisWeekReceived,
|
|
2552
|
+
SUM(CASE WHEN date >= ? AND is_read = 0 THEN 1 ELSE 0 END) AS thisWeekUnread,
|
|
2553
|
+
SUM(CASE WHEN date >= ? THEN 1 ELSE 0 END) AS thisMonthReceived,
|
|
2554
|
+
SUM(CASE WHEN date >= ? AND is_read = 0 THEN 1 ELSE 0 END) AS thisMonthUnread,
|
|
2555
|
+
MIN(CASE WHEN is_read = 0 THEN date ELSE NULL END) AS oldestUnread
|
|
2556
|
+
FROM emails
|
|
2557
|
+
`
|
|
2558
|
+
).get(
|
|
2559
|
+
todayStart,
|
|
2560
|
+
todayStart,
|
|
2561
|
+
weekStart,
|
|
2562
|
+
weekStart,
|
|
2563
|
+
monthStart,
|
|
2564
|
+
monthStart
|
|
2565
|
+
);
|
|
2566
|
+
return {
|
|
2567
|
+
total: row?.total || 0,
|
|
2568
|
+
unread: row?.unread || 0,
|
|
2569
|
+
starred: row?.starred || 0,
|
|
2570
|
+
today: {
|
|
2571
|
+
received: row?.todayReceived || 0,
|
|
2572
|
+
unread: row?.todayUnread || 0
|
|
2573
|
+
},
|
|
2574
|
+
thisWeek: {
|
|
2575
|
+
received: row?.thisWeekReceived || 0,
|
|
2576
|
+
unread: row?.thisWeekUnread || 0
|
|
2577
|
+
},
|
|
2578
|
+
thisMonth: {
|
|
2579
|
+
received: row?.thisMonthReceived || 0,
|
|
2580
|
+
unread: row?.thisMonthUnread || 0
|
|
2581
|
+
},
|
|
2582
|
+
oldestUnread: row?.oldestUnread ? new Date(row.oldestUnread) : null
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// src/core/rules/history.ts
|
|
2587
|
+
async function getExecutionHistory(ruleId, limit = 20) {
|
|
2588
|
+
const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(limit);
|
|
2589
|
+
return runs.slice(0, limit);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
// src/core/rules/deploy.ts
|
|
2593
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2594
|
+
|
|
2595
|
+
// src/core/rules/loader.ts
|
|
2596
|
+
import { createHash } from "crypto";
|
|
2597
|
+
import { readdir, readFile as readFile2 } from "fs/promises";
|
|
2598
|
+
import { join as join2 } from "path";
|
|
2599
|
+
import YAML from "yaml";
|
|
2600
|
+
|
|
2601
|
+
// src/core/rules/types.ts
|
|
2602
|
+
import { z } from "zod";
|
|
2603
|
+
var RuleNameSchema = z.string().min(1, "Rule name is required").regex(
|
|
2604
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
2605
|
+
"Rule name must be kebab-case (lowercase letters, numbers, and single hyphens)"
|
|
2606
|
+
);
|
|
2607
|
+
var RuleFieldSchema = z.enum(["from", "to", "subject", "snippet", "labels"]);
|
|
2608
|
+
var RegexStringSchema = z.string().min(1, "Pattern must not be empty").superRefine((value, ctx) => {
|
|
2609
|
+
try {
|
|
2610
|
+
new RegExp(value);
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
ctx.addIssue({
|
|
2613
|
+
code: z.ZodIssueCode.custom,
|
|
2614
|
+
message: `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
});
|
|
2618
|
+
var MatcherSchema = z.object({
|
|
2619
|
+
// `snippet` is the only cached free-text matcher in MVP.
|
|
2620
|
+
field: RuleFieldSchema,
|
|
2621
|
+
pattern: RegexStringSchema.optional(),
|
|
2622
|
+
contains: z.array(z.string().min(1)).min(1).optional(),
|
|
2623
|
+
values: z.array(z.string().min(1)).min(1).optional(),
|
|
2624
|
+
exclude: z.boolean().default(false)
|
|
2625
|
+
}).strict().superRefine((value, ctx) => {
|
|
2626
|
+
if (!value.pattern && !value.contains && !value.values) {
|
|
2627
|
+
ctx.addIssue({
|
|
2628
|
+
code: z.ZodIssueCode.custom,
|
|
2629
|
+
message: "Matcher must provide at least one of pattern, contains, or values",
|
|
2630
|
+
path: ["pattern"]
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
});
|
|
2634
|
+
var ConditionsSchema = z.object({
|
|
2635
|
+
operator: z.enum(["AND", "OR"]),
|
|
2636
|
+
matchers: z.array(MatcherSchema).min(1, "At least one matcher is required")
|
|
2637
|
+
}).strict();
|
|
2638
|
+
var LabelActionSchema = z.object({
|
|
2639
|
+
type: z.literal("label"),
|
|
2640
|
+
label: z.string().min(1, "Label name is required")
|
|
2641
|
+
});
|
|
2642
|
+
var ArchiveActionSchema = z.object({ type: z.literal("archive") });
|
|
2643
|
+
var MarkReadActionSchema = z.object({ type: z.literal("mark_read") });
|
|
2644
|
+
var ForwardActionSchema = z.object({
|
|
2645
|
+
type: z.literal("forward"),
|
|
2646
|
+
to: z.string().email("Forward destination must be a valid email address")
|
|
2647
|
+
});
|
|
2648
|
+
var MarkSpamActionSchema = z.object({ type: z.literal("mark_spam") });
|
|
2649
|
+
var ActionSchema = z.discriminatedUnion("type", [
|
|
2650
|
+
LabelActionSchema,
|
|
2651
|
+
ArchiveActionSchema,
|
|
2652
|
+
MarkReadActionSchema,
|
|
2653
|
+
ForwardActionSchema,
|
|
2654
|
+
MarkSpamActionSchema
|
|
2655
|
+
]);
|
|
2656
|
+
var RuleSchema = z.object({
|
|
2657
|
+
name: RuleNameSchema,
|
|
2658
|
+
description: z.string(),
|
|
2659
|
+
enabled: z.boolean().default(true),
|
|
2660
|
+
priority: z.number().int().min(0).default(50),
|
|
2661
|
+
conditions: ConditionsSchema,
|
|
2662
|
+
actions: z.array(ActionSchema).min(1, "At least one action is required")
|
|
2663
|
+
}).strict();
|
|
2664
|
+
|
|
2665
|
+
// src/core/rules/loader.ts
|
|
2666
|
+
function isRuleFile(filename) {
|
|
2667
|
+
const lower = filename.toLowerCase();
|
|
2668
|
+
return lower.endsWith(".yaml") || lower.endsWith(".yml");
|
|
2669
|
+
}
|
|
2670
|
+
function formatZodError(path, error) {
|
|
2671
|
+
return `${path}: ${error.message}`;
|
|
2672
|
+
}
|
|
2673
|
+
function formatYamlErrors(path, errors) {
|
|
2674
|
+
const messages = errors.map((error) => {
|
|
2675
|
+
if (error instanceof Error) {
|
|
2676
|
+
return error.message;
|
|
2677
|
+
}
|
|
2678
|
+
return String(error);
|
|
2679
|
+
});
|
|
2680
|
+
return new Error(`${path}: invalid YAML - ${messages.join("; ")}`);
|
|
2681
|
+
}
|
|
2682
|
+
function hashRule(yamlContent) {
|
|
2683
|
+
return createHash("sha256").update(yamlContent, "utf8").digest("hex");
|
|
2684
|
+
}
|
|
2685
|
+
function parseRuleYaml(yamlContent, path = "<rule>") {
|
|
2686
|
+
const document = YAML.parseDocument(yamlContent, {
|
|
2687
|
+
prettyErrors: true
|
|
2688
|
+
});
|
|
2689
|
+
if (document.errors.length > 0) {
|
|
2690
|
+
throw formatYamlErrors(path, document.errors);
|
|
2691
|
+
}
|
|
2692
|
+
const parsed = document.toJS({
|
|
2693
|
+
mapAsMap: false,
|
|
2694
|
+
maxAliasCount: 50
|
|
2695
|
+
});
|
|
2696
|
+
const result = RuleSchema.safeParse(parsed);
|
|
2697
|
+
if (!result.success) {
|
|
2698
|
+
const message = result.error.issues.map((issue) => {
|
|
2699
|
+
const issuePath = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
2700
|
+
return `${issuePath}: ${issue.message}`;
|
|
2701
|
+
}).join("; ");
|
|
2702
|
+
throw new Error(formatZodError(path, new Error(message)));
|
|
2703
|
+
}
|
|
2704
|
+
return result.data;
|
|
2705
|
+
}
|
|
2706
|
+
async function loadRuleFile(path) {
|
|
2707
|
+
const yaml = await readFile2(path, "utf8");
|
|
2708
|
+
const rule = parseRuleYaml(yaml, path);
|
|
2709
|
+
return {
|
|
2710
|
+
...rule,
|
|
2711
|
+
path,
|
|
2712
|
+
yaml,
|
|
2713
|
+
yamlHash: hashRule(yaml),
|
|
2714
|
+
rule
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
async function loadAllRules(rulesDir) {
|
|
2718
|
+
const entries = await readdir(rulesDir, { withFileTypes: true });
|
|
2719
|
+
const filePaths = entries.filter((entry) => entry.isFile() && isRuleFile(entry.name)).map((entry) => join2(rulesDir, entry.name)).sort((left, right) => left.localeCompare(right));
|
|
2720
|
+
const loaded = await Promise.all(filePaths.map(async (path) => loadRuleFile(path)));
|
|
2721
|
+
return loaded;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// src/core/rules/deploy.ts
|
|
2725
|
+
function getDatabase3() {
|
|
2726
|
+
const config = loadConfig();
|
|
2727
|
+
return getSqlite(config.dbPath);
|
|
2728
|
+
}
|
|
2729
|
+
function parseJson(value, fallback) {
|
|
2730
|
+
if (!value) {
|
|
2731
|
+
return fallback;
|
|
2732
|
+
}
|
|
2733
|
+
try {
|
|
2734
|
+
return JSON.parse(value);
|
|
2735
|
+
} catch {
|
|
2736
|
+
return fallback;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
function serializeJson2(value) {
|
|
2740
|
+
return JSON.stringify(value);
|
|
2741
|
+
}
|
|
2742
|
+
function rowToRule(row) {
|
|
2743
|
+
return {
|
|
2744
|
+
id: row.id,
|
|
2745
|
+
name: row.name,
|
|
2746
|
+
description: row.description ?? "",
|
|
2747
|
+
enabled: row.enabled !== 0,
|
|
2748
|
+
yamlHash: row.yamlHash,
|
|
2749
|
+
conditions: parseJson(row.conditions, { operator: "OR", matchers: [] }),
|
|
2750
|
+
actions: parseJson(row.actions, []),
|
|
2751
|
+
priority: row.priority ?? 50,
|
|
2752
|
+
deployedAt: row.deployedAt,
|
|
2753
|
+
createdAt: row.createdAt
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
function ruleSelectSql(whereClause = "", limitClause = "") {
|
|
2757
|
+
return `
|
|
2758
|
+
SELECT
|
|
2759
|
+
id,
|
|
2760
|
+
name,
|
|
2761
|
+
description,
|
|
2762
|
+
enabled,
|
|
2763
|
+
yaml_hash AS yamlHash,
|
|
2764
|
+
conditions,
|
|
2765
|
+
actions,
|
|
2766
|
+
priority,
|
|
2767
|
+
deployed_at AS deployedAt,
|
|
2768
|
+
created_at AS createdAt
|
|
2769
|
+
FROM rules
|
|
2770
|
+
${whereClause}
|
|
2771
|
+
ORDER BY COALESCE(priority, 50) ASC, name ASC
|
|
2772
|
+
${limitClause}
|
|
2773
|
+
`;
|
|
2774
|
+
}
|
|
2775
|
+
async function loadRuleRows() {
|
|
2776
|
+
const sqlite = getDatabase3();
|
|
2777
|
+
const rows = sqlite.prepare(ruleSelectSql()).all();
|
|
2778
|
+
return rows.map(rowToRule);
|
|
2779
|
+
}
|
|
2780
|
+
async function getRuleByName(name) {
|
|
2781
|
+
const trimmed = name.trim();
|
|
2782
|
+
if (!trimmed) {
|
|
2783
|
+
return null;
|
|
2784
|
+
}
|
|
2785
|
+
const sqlite = getDatabase3();
|
|
2786
|
+
const row = sqlite.prepare(ruleSelectSql("WHERE name = ? OR id = ?", "LIMIT 1")).get(trimmed, trimmed);
|
|
2787
|
+
return row ? rowToRule(row) : null;
|
|
2788
|
+
}
|
|
2789
|
+
async function getAllRules() {
|
|
2790
|
+
return loadRuleRows();
|
|
2791
|
+
}
|
|
2792
|
+
function upsertRule(rule, yamlHash) {
|
|
2793
|
+
const sqlite = getDatabase3();
|
|
2794
|
+
const existing = sqlite.prepare(ruleSelectSql("WHERE name = ?", "LIMIT 1")).get(rule.name);
|
|
2795
|
+
if (existing && existing.yamlHash === yamlHash) {
|
|
2796
|
+
return {
|
|
2797
|
+
...rowToRule(existing),
|
|
2798
|
+
status: "unchanged"
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
const now2 = Date.now();
|
|
2802
|
+
if (existing) {
|
|
2803
|
+
sqlite.prepare(
|
|
2804
|
+
`
|
|
2805
|
+
UPDATE rules
|
|
2806
|
+
SET description = ?, enabled = ?, yaml_hash = ?, conditions = ?, actions = ?, priority = ?, deployed_at = ?
|
|
2807
|
+
WHERE id = ?
|
|
2808
|
+
`
|
|
2809
|
+
).run(
|
|
2810
|
+
rule.description,
|
|
2811
|
+
rule.enabled ? 1 : 0,
|
|
2812
|
+
yamlHash,
|
|
2813
|
+
serializeJson2(rule.conditions),
|
|
2814
|
+
serializeJson2(rule.actions),
|
|
2815
|
+
rule.priority,
|
|
2816
|
+
now2,
|
|
2817
|
+
existing.id
|
|
2818
|
+
);
|
|
2819
|
+
} else {
|
|
2820
|
+
sqlite.prepare(
|
|
2821
|
+
`
|
|
2822
|
+
INSERT INTO rules (
|
|
2823
|
+
id, name, description, enabled, yaml_hash, conditions, actions, priority, deployed_at, created_at
|
|
2824
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2825
|
+
`
|
|
2826
|
+
).run(
|
|
2827
|
+
randomUUID3(),
|
|
2828
|
+
rule.name,
|
|
2829
|
+
rule.description,
|
|
2830
|
+
rule.enabled ? 1 : 0,
|
|
2831
|
+
yamlHash,
|
|
2832
|
+
serializeJson2(rule.conditions),
|
|
2833
|
+
serializeJson2(rule.actions),
|
|
2834
|
+
rule.priority,
|
|
2835
|
+
now2,
|
|
2836
|
+
now2
|
|
2837
|
+
);
|
|
2838
|
+
}
|
|
2839
|
+
const refreshed = sqlite.prepare(ruleSelectSql("WHERE name = ?", "LIMIT 1")).get(rule.name);
|
|
2840
|
+
if (!refreshed) {
|
|
2841
|
+
throw new Error(`Failed to load deployed rule: ${rule.name}`);
|
|
2842
|
+
}
|
|
2843
|
+
return {
|
|
2844
|
+
...rowToRule(refreshed),
|
|
2845
|
+
status: existing ? "updated" : "created"
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
async function deployRule(rule, yamlHash) {
|
|
2849
|
+
return upsertRule(rule, yamlHash);
|
|
2850
|
+
}
|
|
2851
|
+
async function deployLoadedRule(loaded) {
|
|
2852
|
+
return deployRule(loaded.rule, loaded.yamlHash);
|
|
2853
|
+
}
|
|
2854
|
+
async function deployAllRules(rulesDir) {
|
|
2855
|
+
const loadedRules = await loadAllRules(rulesDir);
|
|
2856
|
+
const deployed = [];
|
|
2857
|
+
for (const entry of loadedRules) {
|
|
2858
|
+
deployed.push(await deployRule(entry.rule, entry.yamlHash));
|
|
2859
|
+
}
|
|
2860
|
+
return deployed;
|
|
2861
|
+
}
|
|
2862
|
+
async function getExecutionStatsByRuleId(ruleId) {
|
|
2863
|
+
const sqlite = getDatabase3();
|
|
2864
|
+
const counts = sqlite.prepare(
|
|
2865
|
+
`
|
|
2866
|
+
SELECT
|
|
2867
|
+
COUNT(*) AS totalRuns,
|
|
2868
|
+
COALESCE(SUM(CASE WHEN status = 'planned' THEN 1 ELSE 0 END), 0) AS plannedRuns,
|
|
2869
|
+
COALESCE(SUM(CASE WHEN status = 'applied' THEN 1 ELSE 0 END), 0) AS appliedRuns,
|
|
2870
|
+
COALESCE(SUM(CASE WHEN status = 'partial' THEN 1 ELSE 0 END), 0) AS partialRuns,
|
|
2871
|
+
COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) AS errorRuns,
|
|
2872
|
+
COALESCE(SUM(CASE WHEN status = 'undone' THEN 1 ELSE 0 END), 0) AS undoneRuns,
|
|
2873
|
+
MAX(created_at) AS lastExecutionAt
|
|
2874
|
+
FROM execution_runs
|
|
2875
|
+
WHERE rule_id = ?
|
|
2876
|
+
`
|
|
2877
|
+
).get(ruleId);
|
|
2878
|
+
const lastRun = sqlite.prepare(
|
|
2879
|
+
`
|
|
2880
|
+
SELECT id, status, created_at AS createdAt
|
|
2881
|
+
FROM execution_runs
|
|
2882
|
+
WHERE rule_id = ?
|
|
2883
|
+
ORDER BY COALESCE(created_at, 0) DESC, id DESC
|
|
2884
|
+
LIMIT 1
|
|
2885
|
+
`
|
|
2886
|
+
).get(ruleId);
|
|
2887
|
+
return {
|
|
2888
|
+
totalRuns: counts?.totalRuns ?? 0,
|
|
2889
|
+
plannedRuns: counts?.plannedRuns ?? 0,
|
|
2890
|
+
appliedRuns: counts?.appliedRuns ?? 0,
|
|
2891
|
+
partialRuns: counts?.partialRuns ?? 0,
|
|
2892
|
+
errorRuns: counts?.errorRuns ?? 0,
|
|
2893
|
+
undoneRuns: counts?.undoneRuns ?? 0,
|
|
2894
|
+
lastExecutionAt: counts?.lastExecutionAt ?? null,
|
|
2895
|
+
lastExecutionStatus: lastRun?.status ?? null,
|
|
2896
|
+
lastRunId: lastRun?.id ?? null
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
async function getRuleStatus(name) {
|
|
2900
|
+
const rule = await getRuleByName(name);
|
|
2901
|
+
if (!rule) {
|
|
2902
|
+
return null;
|
|
2903
|
+
}
|
|
2904
|
+
const stats = await getExecutionStatsByRuleId(rule.id);
|
|
2905
|
+
return {
|
|
2906
|
+
...rule,
|
|
2907
|
+
...stats
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2910
|
+
async function getAllRulesStatus() {
|
|
2911
|
+
const rules2 = await loadRuleRows();
|
|
2912
|
+
const statuses = await Promise.all(
|
|
2913
|
+
rules2.map(async (rule) => ({
|
|
2914
|
+
...rule,
|
|
2915
|
+
...await getExecutionStatsByRuleId(rule.id)
|
|
2916
|
+
}))
|
|
2917
|
+
);
|
|
2918
|
+
return statuses;
|
|
2919
|
+
}
|
|
2920
|
+
async function detectDrift(rulesDir) {
|
|
2921
|
+
const loadedRules = await loadAllRules(rulesDir);
|
|
2922
|
+
const deployedRules = await loadRuleRows();
|
|
2923
|
+
const deployedByName = new Map(deployedRules.map((rule) => [rule.name, rule]));
|
|
2924
|
+
const fileByName = new Map(loadedRules.map((entry) => [entry.rule.name, entry]));
|
|
2925
|
+
const entries = [];
|
|
2926
|
+
for (const entry of loadedRules) {
|
|
2927
|
+
const deployed = deployedByName.get(entry.rule.name);
|
|
2928
|
+
if (!deployed) {
|
|
2929
|
+
entries.push({
|
|
2930
|
+
name: entry.rule.name,
|
|
2931
|
+
filePath: entry.path,
|
|
2932
|
+
fileHash: entry.yamlHash,
|
|
2933
|
+
deployedHash: null,
|
|
2934
|
+
status: "not_deployed"
|
|
2935
|
+
});
|
|
2936
|
+
continue;
|
|
2937
|
+
}
|
|
2938
|
+
entries.push({
|
|
2939
|
+
name: entry.rule.name,
|
|
2940
|
+
filePath: entry.path,
|
|
2941
|
+
fileHash: entry.yamlHash,
|
|
2942
|
+
deployedHash: deployed.yamlHash,
|
|
2943
|
+
status: deployed.yamlHash === entry.yamlHash ? "in_sync" : "changed"
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
for (const deployed of deployedRules) {
|
|
2947
|
+
if (fileByName.has(deployed.name)) {
|
|
2948
|
+
continue;
|
|
2949
|
+
}
|
|
2950
|
+
entries.push({
|
|
2951
|
+
name: deployed.name,
|
|
2952
|
+
deployedHash: deployed.yamlHash,
|
|
2953
|
+
status: "missing_file"
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
return {
|
|
2957
|
+
drifted: entries.some((entry) => entry.status !== "in_sync"),
|
|
2958
|
+
entries
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
async function setRuleEnabled(name, enabled) {
|
|
2962
|
+
const rule = await getRuleByName(name);
|
|
2963
|
+
if (!rule) {
|
|
2964
|
+
throw new Error(`Rule not found: ${name}`);
|
|
2965
|
+
}
|
|
2966
|
+
const sqlite = getDatabase3();
|
|
2967
|
+
sqlite.prepare(
|
|
2968
|
+
`
|
|
2969
|
+
UPDATE rules
|
|
2970
|
+
SET enabled = ?
|
|
2971
|
+
WHERE id = ?
|
|
2972
|
+
`
|
|
2973
|
+
).run(enabled ? 1 : 0, rule.id);
|
|
2974
|
+
const refreshed = await getRuleByName(rule.id);
|
|
2975
|
+
if (!refreshed) {
|
|
2976
|
+
throw new Error(`Failed to refresh rule after update: ${name}`);
|
|
2977
|
+
}
|
|
2978
|
+
return refreshed;
|
|
2979
|
+
}
|
|
2980
|
+
async function enableRule(name) {
|
|
2981
|
+
return setRuleEnabled(name, true);
|
|
2982
|
+
}
|
|
2983
|
+
async function disableRule(name) {
|
|
2984
|
+
return setRuleEnabled(name, false);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// src/core/rules/matcher.ts
|
|
2988
|
+
function getDatabase4() {
|
|
2989
|
+
const config = loadConfig();
|
|
2990
|
+
return getSqlite(config.dbPath);
|
|
2991
|
+
}
|
|
2992
|
+
function parseJsonArray2(value) {
|
|
2993
|
+
if (!value) {
|
|
2994
|
+
return [];
|
|
2995
|
+
}
|
|
2996
|
+
try {
|
|
2997
|
+
const parsed = JSON.parse(value);
|
|
2998
|
+
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
|
|
2999
|
+
} catch {
|
|
3000
|
+
return [];
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
function rowToEmail2(row) {
|
|
3004
|
+
return {
|
|
3005
|
+
id: row.id,
|
|
3006
|
+
threadId: row.thread_id ?? "",
|
|
3007
|
+
fromAddress: row.from_address ?? "",
|
|
3008
|
+
fromName: row.from_name ?? "",
|
|
3009
|
+
toAddresses: parseJsonArray2(row.to_addresses),
|
|
3010
|
+
subject: row.subject ?? "",
|
|
3011
|
+
snippet: row.snippet ?? "",
|
|
3012
|
+
date: row.date ?? 0,
|
|
3013
|
+
isRead: row.is_read === 1,
|
|
3014
|
+
isStarred: row.is_starred === 1,
|
|
3015
|
+
labelIds: parseJsonArray2(row.label_ids),
|
|
3016
|
+
sizeEstimate: row.size_estimate ?? 0,
|
|
3017
|
+
hasAttachments: row.has_attachments === 1,
|
|
3018
|
+
listUnsubscribe: row.list_unsubscribe
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
function getFieldValues(email, matcher) {
|
|
3022
|
+
switch (matcher.field) {
|
|
3023
|
+
case "from":
|
|
3024
|
+
return [email.fromAddress, email.fromName].filter(Boolean);
|
|
3025
|
+
case "to":
|
|
3026
|
+
return email.toAddresses;
|
|
3027
|
+
case "subject":
|
|
3028
|
+
return [email.subject];
|
|
3029
|
+
case "snippet":
|
|
3030
|
+
return [email.snippet];
|
|
3031
|
+
case "labels":
|
|
3032
|
+
return email.labelIds;
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
function normalize(value) {
|
|
3036
|
+
return value.trim().toLowerCase();
|
|
3037
|
+
}
|
|
3038
|
+
function exactMatch(values, candidates) {
|
|
3039
|
+
if (values.length === 0 || candidates.length === 0) {
|
|
3040
|
+
return false;
|
|
3041
|
+
}
|
|
3042
|
+
const normalizedCandidates = new Set(candidates.map(normalize));
|
|
3043
|
+
return values.some((value) => normalizedCandidates.has(normalize(value)));
|
|
3044
|
+
}
|
|
3045
|
+
function containsMatch(values, candidates) {
|
|
3046
|
+
if (values.length === 0 || candidates.length === 0) {
|
|
3047
|
+
return false;
|
|
3048
|
+
}
|
|
3049
|
+
const normalizedCandidates = candidates.map(normalize);
|
|
3050
|
+
return values.some((value) => {
|
|
3051
|
+
const needle = normalize(value);
|
|
3052
|
+
return normalizedCandidates.some((candidate) => candidate.includes(needle));
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
function patternMatch(pattern, candidates) {
|
|
3056
|
+
if (!pattern || candidates.length === 0) {
|
|
3057
|
+
return false;
|
|
3058
|
+
}
|
|
3059
|
+
const regex = new RegExp(pattern);
|
|
3060
|
+
return candidates.some((candidate) => regex.test(candidate));
|
|
3061
|
+
}
|
|
3062
|
+
function matchField(email, matcher) {
|
|
3063
|
+
const candidates = getFieldValues(email, matcher);
|
|
3064
|
+
const matched = patternMatch(matcher.pattern, candidates) || containsMatch(matcher.contains ?? [], candidates) || exactMatch(matcher.values ?? [], candidates);
|
|
3065
|
+
return matcher.exclude ? !matched : matched;
|
|
3066
|
+
}
|
|
3067
|
+
function matchEmail(email, conditions) {
|
|
3068
|
+
const matchedFields = [];
|
|
3069
|
+
if (conditions.operator === "AND") {
|
|
3070
|
+
for (const matcher of conditions.matchers) {
|
|
3071
|
+
const matched = matchField(email, matcher);
|
|
3072
|
+
if (!matched) {
|
|
3073
|
+
return {
|
|
3074
|
+
matches: false,
|
|
3075
|
+
matchedFields: []
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
matchedFields.push(matcher.field);
|
|
3079
|
+
}
|
|
3080
|
+
return {
|
|
3081
|
+
matches: true,
|
|
3082
|
+
matchedFields: Array.from(new Set(matchedFields))
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
for (const matcher of conditions.matchers) {
|
|
3086
|
+
if (matchField(email, matcher)) {
|
|
3087
|
+
matchedFields.push(matcher.field);
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
return {
|
|
3091
|
+
matches: matchedFields.length > 0,
|
|
3092
|
+
matchedFields: Array.from(new Set(matchedFields))
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
async function findMatchingEmails(ruleOrConditions, limit) {
|
|
3096
|
+
const conditions = typeof ruleOrConditions === "string" ? (await getRuleByName(ruleOrConditions))?.conditions : "conditions" in ruleOrConditions ? ruleOrConditions.conditions : ruleOrConditions;
|
|
3097
|
+
if (!conditions) {
|
|
3098
|
+
throw new Error(`Rule not found: ${ruleOrConditions}`);
|
|
3099
|
+
}
|
|
3100
|
+
const sqlite = getDatabase4();
|
|
3101
|
+
const rows = sqlite.prepare(
|
|
3102
|
+
`
|
|
3103
|
+
SELECT
|
|
3104
|
+
id,
|
|
3105
|
+
thread_id,
|
|
3106
|
+
from_address,
|
|
3107
|
+
from_name,
|
|
3108
|
+
to_addresses,
|
|
3109
|
+
subject,
|
|
3110
|
+
snippet,
|
|
3111
|
+
date,
|
|
3112
|
+
is_read,
|
|
3113
|
+
is_starred,
|
|
3114
|
+
label_ids,
|
|
3115
|
+
size_estimate,
|
|
3116
|
+
has_attachments,
|
|
3117
|
+
list_unsubscribe
|
|
3118
|
+
FROM emails
|
|
3119
|
+
ORDER BY COALESCE(date, 0) DESC, id DESC
|
|
3120
|
+
`
|
|
3121
|
+
).all();
|
|
3122
|
+
const matches = [];
|
|
3123
|
+
for (const row of rows) {
|
|
3124
|
+
const email = rowToEmail2(row);
|
|
3125
|
+
const result = matchEmail(email, conditions);
|
|
3126
|
+
if (!result.matches) {
|
|
3127
|
+
continue;
|
|
3128
|
+
}
|
|
3129
|
+
matches.push({
|
|
3130
|
+
email,
|
|
3131
|
+
matchedFields: result.matchedFields
|
|
3132
|
+
});
|
|
3133
|
+
if (limit !== void 0 && matches.length >= limit) {
|
|
3134
|
+
break;
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
return matches;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
// src/core/rules/executor.ts
|
|
3141
|
+
function resolveRunStatus(items, dryRun) {
|
|
3142
|
+
if (dryRun) {
|
|
3143
|
+
return "planned";
|
|
3144
|
+
}
|
|
3145
|
+
if (items.length === 0) {
|
|
3146
|
+
return "applied";
|
|
3147
|
+
}
|
|
3148
|
+
if (items.every((item) => item.status === "applied")) {
|
|
3149
|
+
return "applied";
|
|
3150
|
+
}
|
|
3151
|
+
if (items.some((item) => item.status === "applied" || item.status === "warning")) {
|
|
3152
|
+
return "partial";
|
|
3153
|
+
}
|
|
3154
|
+
return "error";
|
|
3155
|
+
}
|
|
3156
|
+
async function getQueryLimitedIds(query, maxEmails, options) {
|
|
3157
|
+
if (options.transport) {
|
|
3158
|
+
const response = await options.transport.listMessages({
|
|
3159
|
+
query,
|
|
3160
|
+
maxResults: maxEmails
|
|
3161
|
+
});
|
|
3162
|
+
return new Set(
|
|
3163
|
+
(response.messages || []).map((message) => message.id).filter((id) => Boolean(id))
|
|
3164
|
+
);
|
|
3165
|
+
}
|
|
3166
|
+
const emails2 = await listMessages(query, maxEmails);
|
|
3167
|
+
return new Set(emails2.map((email) => email.id));
|
|
3168
|
+
}
|
|
3169
|
+
async function loadMatchedItems(rule, options) {
|
|
3170
|
+
const matches = await findMatchingEmails(rule, options.maxEmails);
|
|
3171
|
+
const allowedIds = options.query ? await getQueryLimitedIds(options.query, Math.max(options.maxEmails, 1), {
|
|
3172
|
+
config: options.config,
|
|
3173
|
+
transport: options.transport
|
|
3174
|
+
}) : null;
|
|
3175
|
+
const filtered = allowedIds ? matches.filter((match) => allowedIds.has(match.email.id)) : matches;
|
|
3176
|
+
return filtered.slice(0, options.maxEmails).map((match) => ({
|
|
3177
|
+
emailId: match.email.id,
|
|
3178
|
+
fromAddress: match.email.fromAddress,
|
|
3179
|
+
subject: match.email.subject,
|
|
3180
|
+
date: match.email.date,
|
|
3181
|
+
matchedFields: match.matchedFields,
|
|
3182
|
+
status: options.dryRun ? "planned" : "applied",
|
|
3183
|
+
appliedActions: options.dryRun ? [] : [...rule.actions],
|
|
3184
|
+
beforeLabelIds: [...match.email.labelIds],
|
|
3185
|
+
afterLabelIds: [...match.email.labelIds],
|
|
3186
|
+
errorMessage: null
|
|
3187
|
+
}));
|
|
3188
|
+
}
|
|
3189
|
+
async function executeAction(emailId, action, options) {
|
|
3190
|
+
switch (action.type) {
|
|
3191
|
+
case "archive":
|
|
3192
|
+
return (await archiveEmails([emailId], options)).items[0];
|
|
3193
|
+
case "label":
|
|
3194
|
+
return (await labelEmails([emailId], action.label, options)).items[0];
|
|
3195
|
+
case "mark_read":
|
|
3196
|
+
return (await markRead([emailId], options)).items[0];
|
|
3197
|
+
case "forward":
|
|
3198
|
+
return (await forwardEmail(emailId, action.to, options)).items[0];
|
|
3199
|
+
case "mark_spam":
|
|
3200
|
+
return (await markSpam([emailId], options)).items[0];
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
async function applyRuleActions(item, actions, options) {
|
|
3204
|
+
let current = {
|
|
3205
|
+
...item,
|
|
3206
|
+
appliedActions: []
|
|
3207
|
+
};
|
|
3208
|
+
for (const action of actions) {
|
|
3209
|
+
try {
|
|
3210
|
+
const result = await executeAction(item.emailId, action, options);
|
|
3211
|
+
current = {
|
|
3212
|
+
...current,
|
|
3213
|
+
status: result.status,
|
|
3214
|
+
appliedActions: [...current.appliedActions, ...result.appliedActions],
|
|
3215
|
+
afterLabelIds: [...result.afterLabelIds],
|
|
3216
|
+
errorMessage: result.errorMessage ?? null
|
|
3217
|
+
};
|
|
3218
|
+
if (result.status === "error") {
|
|
3219
|
+
break;
|
|
3220
|
+
}
|
|
3221
|
+
} catch (error) {
|
|
3222
|
+
current = {
|
|
3223
|
+
...current,
|
|
3224
|
+
status: "error",
|
|
3225
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
3226
|
+
};
|
|
3227
|
+
break;
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
return current;
|
|
3231
|
+
}
|
|
3232
|
+
async function recordRuleRun(rule, options, items) {
|
|
3233
|
+
const status = resolveRunStatus(items, options.dryRun);
|
|
3234
|
+
const run = await createExecutionRun({
|
|
3235
|
+
sourceType: "rule",
|
|
3236
|
+
ruleId: rule.id,
|
|
3237
|
+
dryRun: options.dryRun,
|
|
3238
|
+
requestedActions: rule.actions,
|
|
3239
|
+
query: options.query ?? null,
|
|
3240
|
+
status
|
|
3241
|
+
});
|
|
3242
|
+
await addExecutionItems(
|
|
3243
|
+
run.id,
|
|
3244
|
+
items.map((item) => ({
|
|
3245
|
+
emailId: item.emailId,
|
|
3246
|
+
status: item.status,
|
|
3247
|
+
appliedActions: item.appliedActions,
|
|
3248
|
+
beforeLabelIds: item.beforeLabelIds,
|
|
3249
|
+
afterLabelIds: item.afterLabelIds,
|
|
3250
|
+
errorMessage: item.errorMessage
|
|
3251
|
+
}))
|
|
3252
|
+
);
|
|
3253
|
+
return {
|
|
3254
|
+
runId: run.id,
|
|
3255
|
+
run,
|
|
3256
|
+
status
|
|
3257
|
+
};
|
|
3258
|
+
}
|
|
3259
|
+
async function runRule(name, options) {
|
|
3260
|
+
const dryRun = options.dryRun ?? true;
|
|
3261
|
+
const maxEmails = options.maxEmails ?? 100;
|
|
3262
|
+
const config = options.config ?? loadConfig();
|
|
3263
|
+
const rule = await getRuleByName(name);
|
|
3264
|
+
if (!rule) {
|
|
3265
|
+
throw new Error(`Rule not found: ${name}`);
|
|
3266
|
+
}
|
|
3267
|
+
if (!rule.enabled) {
|
|
3268
|
+
const run = await createExecutionRun({
|
|
3269
|
+
sourceType: "rule",
|
|
3270
|
+
ruleId: rule.id,
|
|
3271
|
+
dryRun,
|
|
3272
|
+
requestedActions: rule.actions,
|
|
3273
|
+
query: options.query ?? null,
|
|
3274
|
+
status: "planned"
|
|
3275
|
+
});
|
|
3276
|
+
return {
|
|
3277
|
+
rule,
|
|
3278
|
+
dryRun,
|
|
3279
|
+
maxEmails,
|
|
3280
|
+
query: options.query ?? null,
|
|
3281
|
+
matchedCount: 0,
|
|
3282
|
+
runId: run.id,
|
|
3283
|
+
run,
|
|
3284
|
+
status: "planned",
|
|
3285
|
+
items: [],
|
|
3286
|
+
skipped: true
|
|
3287
|
+
};
|
|
3288
|
+
}
|
|
3289
|
+
const plannedItems = await loadMatchedItems(rule, {
|
|
3290
|
+
dryRun,
|
|
3291
|
+
maxEmails,
|
|
3292
|
+
query: options.query,
|
|
3293
|
+
config,
|
|
3294
|
+
transport: options.transport
|
|
3295
|
+
});
|
|
3296
|
+
const items = dryRun ? plannedItems : await Promise.all(
|
|
3297
|
+
plannedItems.map(
|
|
3298
|
+
(item) => applyRuleActions(item, rule.actions, {
|
|
3299
|
+
config,
|
|
3300
|
+
transport: options.transport
|
|
3301
|
+
})
|
|
3302
|
+
)
|
|
3303
|
+
);
|
|
3304
|
+
const recorded = await recordRuleRun(
|
|
3305
|
+
rule,
|
|
3306
|
+
{
|
|
3307
|
+
dryRun,
|
|
3308
|
+
maxEmails,
|
|
3309
|
+
query: options.query
|
|
3310
|
+
},
|
|
3311
|
+
items
|
|
3312
|
+
);
|
|
3313
|
+
return {
|
|
3314
|
+
rule,
|
|
3315
|
+
dryRun,
|
|
3316
|
+
maxEmails,
|
|
3317
|
+
query: options.query ?? null,
|
|
3318
|
+
matchedCount: items.length,
|
|
3319
|
+
runId: recorded.runId,
|
|
3320
|
+
run: recorded.run,
|
|
3321
|
+
status: recorded.status,
|
|
3322
|
+
items
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
async function runAllRules(options) {
|
|
3326
|
+
const rules2 = (await getAllRules()).filter((rule) => rule.enabled).sort((left, right) => left.priority - right.priority || left.name.localeCompare(right.name));
|
|
3327
|
+
const results = [];
|
|
3328
|
+
for (const rule of rules2) {
|
|
3329
|
+
results.push(await runRule(rule.name, options));
|
|
3330
|
+
}
|
|
3331
|
+
return {
|
|
3332
|
+
dryRun: options.dryRun ?? true,
|
|
3333
|
+
results
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
// src/core/gmail/filters.ts
|
|
3338
|
+
async function resolveContext3(options) {
|
|
3339
|
+
const config = options?.config ?? loadConfig();
|
|
3340
|
+
const transport = options?.transport ?? await getGmailTransport(config);
|
|
3341
|
+
return { config, transport };
|
|
3342
|
+
}
|
|
3343
|
+
function toFilterCriteria(raw) {
|
|
3344
|
+
return {
|
|
3345
|
+
from: raw?.from ?? null,
|
|
3346
|
+
to: raw?.to ?? null,
|
|
3347
|
+
subject: raw?.subject ?? null,
|
|
3348
|
+
query: raw?.query ?? null,
|
|
3349
|
+
negatedQuery: raw?.negatedQuery ?? null,
|
|
3350
|
+
hasAttachment: raw?.hasAttachment ?? false,
|
|
3351
|
+
excludeChats: raw?.excludeChats ?? false,
|
|
3352
|
+
size: raw?.size ?? null,
|
|
3353
|
+
sizeComparison: raw?.sizeComparison === "larger" || raw?.sizeComparison === "smaller" ? raw.sizeComparison : null
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
function toFilterActions(raw, labelMap) {
|
|
3357
|
+
const addIds = raw?.addLabelIds ?? [];
|
|
3358
|
+
const removeIds = raw?.removeLabelIds ?? [];
|
|
3359
|
+
const addLabelNames = addIds.filter((id) => id !== "STARRED").map((id) => labelMap.get(id)?.name ?? id);
|
|
3360
|
+
const removeLabelNames = removeIds.filter((id) => id !== "INBOX" && id !== "UNREAD").map((id) => labelMap.get(id)?.name ?? id);
|
|
3361
|
+
return {
|
|
3362
|
+
addLabelNames,
|
|
3363
|
+
removeLabelNames,
|
|
3364
|
+
forward: raw?.forward ?? null,
|
|
3365
|
+
archive: removeIds.includes("INBOX"),
|
|
3366
|
+
markRead: removeIds.includes("UNREAD"),
|
|
3367
|
+
star: addIds.includes("STARRED")
|
|
3368
|
+
};
|
|
3369
|
+
}
|
|
3370
|
+
function toGmailFilter(raw, labelMap) {
|
|
3371
|
+
const id = raw.id?.trim();
|
|
3372
|
+
if (!id) return null;
|
|
3373
|
+
return {
|
|
3374
|
+
id,
|
|
3375
|
+
criteria: toFilterCriteria(raw.criteria),
|
|
3376
|
+
actions: toFilterActions(raw.action, labelMap)
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
async function buildLabelMap(context) {
|
|
3380
|
+
const labels = await syncLabels({ config: context.config, transport: context.transport });
|
|
3381
|
+
const map = /* @__PURE__ */ new Map();
|
|
3382
|
+
for (const label of labels) {
|
|
3383
|
+
map.set(label.id, label);
|
|
3384
|
+
}
|
|
3385
|
+
return map;
|
|
3386
|
+
}
|
|
3387
|
+
async function listFilters(options) {
|
|
3388
|
+
const context = await resolveContext3(options);
|
|
3389
|
+
const [response, labelMap] = await Promise.all([
|
|
3390
|
+
context.transport.listFilters(),
|
|
3391
|
+
buildLabelMap(context)
|
|
3392
|
+
]);
|
|
3393
|
+
const raw = response.filter ?? [];
|
|
3394
|
+
return raw.map((f) => toGmailFilter(f, labelMap)).filter((f) => f !== null);
|
|
3395
|
+
}
|
|
3396
|
+
async function getFilter(id, options) {
|
|
3397
|
+
const context = await resolveContext3(options);
|
|
3398
|
+
const [raw, labelMap] = await Promise.all([
|
|
3399
|
+
context.transport.getFilter(id),
|
|
3400
|
+
buildLabelMap(context)
|
|
3401
|
+
]);
|
|
3402
|
+
const filter = toGmailFilter(raw, labelMap);
|
|
3403
|
+
if (!filter) {
|
|
3404
|
+
throw new Error(`Filter ${id} returned an invalid response from Gmail`);
|
|
3405
|
+
}
|
|
3406
|
+
return filter;
|
|
3407
|
+
}
|
|
3408
|
+
async function createFilter(input, options) {
|
|
3409
|
+
const hasCriteria = input.from != null || input.to != null || input.subject != null || input.query != null || input.negatedQuery != null || input.hasAttachment != null || input.excludeChats != null || input.size != null;
|
|
3410
|
+
if (!hasCriteria) {
|
|
3411
|
+
throw new Error(
|
|
3412
|
+
"At least one criteria field is required (from, to, subject, query, negatedQuery, hasAttachment, excludeChats, or size)"
|
|
3413
|
+
);
|
|
3414
|
+
}
|
|
3415
|
+
const hasAction = input.labelName != null || input.archive === true || input.markRead === true || input.star === true || input.forward != null;
|
|
3416
|
+
if (!hasAction) {
|
|
3417
|
+
throw new Error(
|
|
3418
|
+
"At least one action is required (labelName, archive, markRead, star, or forward)"
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
const context = await resolveContext3(options);
|
|
3422
|
+
const addLabelIds = [];
|
|
3423
|
+
if (input.star) {
|
|
3424
|
+
addLabelIds.push("STARRED");
|
|
3425
|
+
}
|
|
3426
|
+
if (input.labelName) {
|
|
3427
|
+
let labelId = await getLabelId(input.labelName, context);
|
|
3428
|
+
if (!labelId) {
|
|
3429
|
+
const created = await createLabel(input.labelName, void 0, context);
|
|
3430
|
+
labelId = created.id;
|
|
3431
|
+
}
|
|
3432
|
+
addLabelIds.push(labelId);
|
|
3433
|
+
}
|
|
3434
|
+
const removeLabelIds = [];
|
|
3435
|
+
if (input.archive) removeLabelIds.push("INBOX");
|
|
3436
|
+
if (input.markRead) removeLabelIds.push("UNREAD");
|
|
3437
|
+
const criteria = {};
|
|
3438
|
+
if (input.from) criteria.from = input.from;
|
|
3439
|
+
if (input.to) criteria.to = input.to;
|
|
3440
|
+
if (input.subject) criteria.subject = input.subject;
|
|
3441
|
+
if (input.query) criteria.query = input.query;
|
|
3442
|
+
if (input.negatedQuery) criteria.negatedQuery = input.negatedQuery;
|
|
3443
|
+
if (input.hasAttachment != null) criteria.hasAttachment = input.hasAttachment;
|
|
3444
|
+
if (input.excludeChats != null) criteria.excludeChats = input.excludeChats;
|
|
3445
|
+
if (input.size != null) criteria.size = input.size;
|
|
3446
|
+
if (input.sizeComparison) criteria.sizeComparison = input.sizeComparison;
|
|
3447
|
+
const action = {};
|
|
3448
|
+
if (addLabelIds.length > 0) action.addLabelIds = addLabelIds;
|
|
3449
|
+
if (removeLabelIds.length > 0) action.removeLabelIds = removeLabelIds;
|
|
3450
|
+
if (input.forward) action.forward = input.forward;
|
|
3451
|
+
const raw = await context.transport.createFilter({ criteria, action });
|
|
3452
|
+
const labelMap = await buildLabelMap(context);
|
|
3453
|
+
const filter = toGmailFilter(raw, labelMap);
|
|
3454
|
+
if (!filter) {
|
|
3455
|
+
throw new Error("Gmail did not return a valid filter after creation");
|
|
3456
|
+
}
|
|
3457
|
+
return filter;
|
|
3458
|
+
}
|
|
3459
|
+
async function deleteFilter(id, options) {
|
|
3460
|
+
const context = await resolveContext3(options);
|
|
3461
|
+
await context.transport.deleteFilter(id);
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
// src/core/sync/cache.ts
|
|
3465
|
+
function mapRow(row) {
|
|
3466
|
+
return {
|
|
3467
|
+
id: row.id,
|
|
3468
|
+
threadId: row.thread_id,
|
|
3469
|
+
fromAddress: row.from_address,
|
|
3470
|
+
fromName: row.from_name,
|
|
3471
|
+
toAddresses: JSON.parse(row.to_addresses || "[]"),
|
|
3472
|
+
subject: row.subject,
|
|
3473
|
+
snippet: row.snippet,
|
|
3474
|
+
date: row.date,
|
|
3475
|
+
isRead: row.is_read === 1,
|
|
3476
|
+
isStarred: row.is_starred === 1,
|
|
3477
|
+
labelIds: JSON.parse(row.label_ids || "[]"),
|
|
3478
|
+
sizeEstimate: row.size_estimate,
|
|
3479
|
+
hasAttachments: row.has_attachments === 1,
|
|
3480
|
+
listUnsubscribe: row.list_unsubscribe
|
|
3481
|
+
};
|
|
3482
|
+
}
|
|
3483
|
+
async function getRecentEmails(limit = 20, offset = 0) {
|
|
3484
|
+
const config = loadConfig();
|
|
3485
|
+
const sqlite = getSqlite(config.dbPath);
|
|
3486
|
+
const rows = sqlite.prepare(
|
|
3487
|
+
`
|
|
3488
|
+
SELECT id, thread_id, from_address, from_name, to_addresses, subject, snippet, date,
|
|
3489
|
+
is_read, is_starred, label_ids, size_estimate, has_attachments, list_unsubscribe
|
|
3490
|
+
FROM emails
|
|
3491
|
+
ORDER BY date DESC
|
|
3492
|
+
LIMIT ? OFFSET ?
|
|
3493
|
+
`
|
|
3494
|
+
).all(limit, offset);
|
|
3495
|
+
return rows.map(mapRow);
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
// src/core/sync/sync.ts
|
|
3499
|
+
function upsertEmails2(dbPath, messages) {
|
|
3500
|
+
if (messages.length === 0) {
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
const sqlite = getSqlite(dbPath);
|
|
3504
|
+
const statement = sqlite.prepare(`
|
|
3505
|
+
INSERT INTO emails (
|
|
3506
|
+
id, thread_id, from_address, from_name, to_addresses, subject, snippet, date,
|
|
3507
|
+
is_read, is_starred, label_ids, size_estimate, has_attachments, list_unsubscribe, synced_at
|
|
3508
|
+
) VALUES (
|
|
3509
|
+
@id, @thread_id, @from_address, @from_name, @to_addresses, @subject, @snippet, @date,
|
|
3510
|
+
@is_read, @is_starred, @label_ids, @size_estimate, @has_attachments, @list_unsubscribe, @synced_at
|
|
3511
|
+
)
|
|
3512
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3513
|
+
thread_id = excluded.thread_id,
|
|
3514
|
+
from_address = excluded.from_address,
|
|
3515
|
+
from_name = excluded.from_name,
|
|
3516
|
+
to_addresses = excluded.to_addresses,
|
|
3517
|
+
subject = excluded.subject,
|
|
3518
|
+
snippet = excluded.snippet,
|
|
3519
|
+
date = excluded.date,
|
|
3520
|
+
is_read = excluded.is_read,
|
|
3521
|
+
is_starred = excluded.is_starred,
|
|
3522
|
+
label_ids = excluded.label_ids,
|
|
3523
|
+
size_estimate = excluded.size_estimate,
|
|
3524
|
+
has_attachments = excluded.has_attachments,
|
|
3525
|
+
list_unsubscribe = excluded.list_unsubscribe,
|
|
3526
|
+
synced_at = excluded.synced_at
|
|
3527
|
+
`);
|
|
3528
|
+
const now2 = Date.now();
|
|
3529
|
+
const transaction = sqlite.transaction((emails2) => {
|
|
3530
|
+
for (const message of emails2) {
|
|
3531
|
+
statement.run({
|
|
3532
|
+
id: message.id,
|
|
3533
|
+
thread_id: message.threadId,
|
|
3534
|
+
from_address: message.fromAddress,
|
|
3535
|
+
from_name: message.fromName,
|
|
3536
|
+
to_addresses: JSON.stringify(message.toAddresses),
|
|
3537
|
+
subject: message.subject,
|
|
3538
|
+
snippet: message.snippet,
|
|
3539
|
+
date: message.date,
|
|
3540
|
+
is_read: message.isRead ? 1 : 0,
|
|
3541
|
+
is_starred: message.isStarred ? 1 : 0,
|
|
3542
|
+
label_ids: JSON.stringify(message.labelIds),
|
|
3543
|
+
size_estimate: message.sizeEstimate,
|
|
3544
|
+
has_attachments: message.hasAttachments ? 1 : 0,
|
|
3545
|
+
list_unsubscribe: message.listUnsubscribe,
|
|
3546
|
+
synced_at: now2
|
|
3547
|
+
});
|
|
3548
|
+
}
|
|
3549
|
+
});
|
|
3550
|
+
transaction(messages);
|
|
3551
|
+
}
|
|
3552
|
+
function deleteEmails(dbPath, ids) {
|
|
3553
|
+
if (ids.length === 0) {
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
const sqlite = getSqlite(dbPath);
|
|
3557
|
+
const statement = sqlite.prepare(`DELETE FROM emails WHERE id = ?`);
|
|
3558
|
+
const transaction = sqlite.transaction((messageIds) => {
|
|
3559
|
+
for (const id of messageIds) {
|
|
3560
|
+
statement.run(id);
|
|
3561
|
+
}
|
|
3562
|
+
});
|
|
3563
|
+
transaction(ids);
|
|
3564
|
+
}
|
|
3565
|
+
function getSyncState(dbPath) {
|
|
3566
|
+
const sqlite = getSqlite(dbPath);
|
|
3567
|
+
const row = sqlite.prepare(
|
|
3568
|
+
`SELECT account_email, history_id, last_full_sync, last_incremental_sync, total_messages, full_sync_cursor, full_sync_processed, full_sync_total FROM sync_state WHERE id = 1`
|
|
3569
|
+
).get();
|
|
3570
|
+
return row || {
|
|
3571
|
+
account_email: null,
|
|
3572
|
+
history_id: null,
|
|
3573
|
+
last_full_sync: null,
|
|
3574
|
+
last_incremental_sync: null,
|
|
3575
|
+
total_messages: 0,
|
|
3576
|
+
full_sync_cursor: null,
|
|
3577
|
+
full_sync_processed: 0,
|
|
3578
|
+
full_sync_total: 0
|
|
3579
|
+
};
|
|
3580
|
+
}
|
|
3581
|
+
function saveSyncState(dbPath, updates) {
|
|
3582
|
+
const current = getSyncState(dbPath);
|
|
3583
|
+
const sqlite = getSqlite(dbPath);
|
|
3584
|
+
const next = {
|
|
3585
|
+
account_email: Object.prototype.hasOwnProperty.call(updates, "account_email") ? updates.account_email ?? null : current.account_email,
|
|
3586
|
+
history_id: Object.prototype.hasOwnProperty.call(updates, "history_id") ? updates.history_id ?? null : current.history_id,
|
|
3587
|
+
last_full_sync: Object.prototype.hasOwnProperty.call(updates, "last_full_sync") ? updates.last_full_sync ?? null : current.last_full_sync,
|
|
3588
|
+
last_incremental_sync: Object.prototype.hasOwnProperty.call(updates, "last_incremental_sync") ? updates.last_incremental_sync ?? null : current.last_incremental_sync,
|
|
3589
|
+
total_messages: Object.prototype.hasOwnProperty.call(updates, "total_messages") ? updates.total_messages ?? 0 : current.total_messages,
|
|
3590
|
+
full_sync_cursor: Object.prototype.hasOwnProperty.call(updates, "full_sync_cursor") ? updates.full_sync_cursor ?? null : current.full_sync_cursor,
|
|
3591
|
+
full_sync_processed: Object.prototype.hasOwnProperty.call(updates, "full_sync_processed") ? updates.full_sync_processed ?? 0 : current.full_sync_processed,
|
|
3592
|
+
full_sync_total: Object.prototype.hasOwnProperty.call(updates, "full_sync_total") ? updates.full_sync_total ?? 0 : current.full_sync_total
|
|
3593
|
+
};
|
|
3594
|
+
sqlite.prepare(
|
|
3595
|
+
`
|
|
3596
|
+
UPDATE sync_state
|
|
3597
|
+
SET account_email = ?,
|
|
3598
|
+
history_id = ?,
|
|
3599
|
+
last_full_sync = ?,
|
|
3600
|
+
last_incremental_sync = ?,
|
|
3601
|
+
total_messages = ?,
|
|
3602
|
+
full_sync_cursor = ?,
|
|
3603
|
+
full_sync_processed = ?,
|
|
3604
|
+
full_sync_total = ?
|
|
3605
|
+
WHERE id = 1
|
|
3606
|
+
`
|
|
3607
|
+
).run(
|
|
3608
|
+
next.account_email,
|
|
3609
|
+
next.history_id,
|
|
3610
|
+
next.last_full_sync,
|
|
3611
|
+
next.last_incremental_sync,
|
|
3612
|
+
next.total_messages,
|
|
3613
|
+
next.full_sync_cursor,
|
|
3614
|
+
next.full_sync_processed,
|
|
3615
|
+
next.full_sync_total
|
|
3616
|
+
);
|
|
3617
|
+
}
|
|
3618
|
+
function clearCachedEmailData(dbPath) {
|
|
3619
|
+
const sqlite = getSqlite(dbPath);
|
|
3620
|
+
sqlite.exec(`
|
|
3621
|
+
DELETE FROM emails;
|
|
3622
|
+
DELETE FROM newsletter_senders;
|
|
3623
|
+
`);
|
|
3624
|
+
}
|
|
3625
|
+
function clearAccountScopedState(dbPath, nextAccountEmail) {
|
|
3626
|
+
const sqlite = getSqlite(dbPath);
|
|
3627
|
+
sqlite.exec(`
|
|
3628
|
+
DELETE FROM emails;
|
|
3629
|
+
DELETE FROM newsletter_senders;
|
|
3630
|
+
DELETE FROM execution_items;
|
|
3631
|
+
DELETE FROM execution_runs;
|
|
3632
|
+
`);
|
|
3633
|
+
sqlite.prepare(
|
|
3634
|
+
`
|
|
3635
|
+
UPDATE sync_state
|
|
3636
|
+
SET account_email = ?,
|
|
3637
|
+
history_id = NULL,
|
|
3638
|
+
last_full_sync = NULL,
|
|
3639
|
+
last_incremental_sync = NULL,
|
|
3640
|
+
total_messages = 0,
|
|
3641
|
+
full_sync_cursor = NULL,
|
|
3642
|
+
full_sync_processed = 0,
|
|
3643
|
+
full_sync_total = 0
|
|
3644
|
+
WHERE id = 1
|
|
3645
|
+
`
|
|
3646
|
+
).run(nextAccountEmail);
|
|
3647
|
+
}
|
|
3648
|
+
function resetFullSyncProgress(dbPath) {
|
|
3649
|
+
saveSyncState(dbPath, {
|
|
3650
|
+
full_sync_cursor: null,
|
|
3651
|
+
full_sync_processed: 0,
|
|
3652
|
+
full_sync_total: 0
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
function reconcileCacheForAuthenticatedAccount(dbPath, authenticatedEmail, options) {
|
|
3656
|
+
const normalizedEmail = authenticatedEmail && authenticatedEmail !== "unknown" ? authenticatedEmail : null;
|
|
3657
|
+
const state = getSyncState(dbPath);
|
|
3658
|
+
const sqlite = getSqlite(dbPath);
|
|
3659
|
+
const cachedEmailCount = sqlite.prepare("SELECT COUNT(*) as count FROM emails").get().count;
|
|
3660
|
+
if (!normalizedEmail) {
|
|
3661
|
+
return {
|
|
3662
|
+
cleared: false,
|
|
3663
|
+
reason: null,
|
|
3664
|
+
previousEmail: state.account_email
|
|
3665
|
+
};
|
|
3666
|
+
}
|
|
3667
|
+
if (state.account_email && state.account_email !== normalizedEmail) {
|
|
3668
|
+
clearAccountScopedState(dbPath, normalizedEmail);
|
|
3669
|
+
return {
|
|
3670
|
+
cleared: true,
|
|
3671
|
+
reason: "account_switched",
|
|
3672
|
+
previousEmail: state.account_email
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3675
|
+
if (!state.account_email && cachedEmailCount > 0 && options?.clearLegacyUnscoped) {
|
|
3676
|
+
clearAccountScopedState(dbPath, normalizedEmail);
|
|
3677
|
+
return {
|
|
3678
|
+
cleared: true,
|
|
3679
|
+
reason: "legacy_unscoped_cache",
|
|
3680
|
+
previousEmail: null
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
if (state.account_email !== normalizedEmail) {
|
|
3684
|
+
saveSyncState(dbPath, { account_email: normalizedEmail });
|
|
3685
|
+
}
|
|
3686
|
+
return {
|
|
3687
|
+
cleared: false,
|
|
3688
|
+
reason: null,
|
|
3689
|
+
previousEmail: state.account_email
|
|
3690
|
+
};
|
|
3691
|
+
}
|
|
3692
|
+
async function fullSync(onProgress, onEvent) {
|
|
3693
|
+
const config = loadConfig();
|
|
3694
|
+
const transport = await getGmailTransport(config);
|
|
3695
|
+
const profile = await transport.getProfile();
|
|
3696
|
+
const accountEmail = profile.emailAddress || null;
|
|
3697
|
+
const priorState = getSyncState(config.dbPath);
|
|
3698
|
+
const accountReconciliation = reconcileCacheForAuthenticatedAccount(
|
|
3699
|
+
config.dbPath,
|
|
3700
|
+
accountEmail,
|
|
3701
|
+
{ clearLegacyUnscoped: true }
|
|
3702
|
+
);
|
|
3703
|
+
const pageSize = Math.min(config.sync.pageSize, 100);
|
|
3704
|
+
const maxMessages = config.sync.maxMessages;
|
|
3705
|
+
const resumableSync = !accountReconciliation.cleared && priorState.account_email === accountEmail && !priorState.history_id && (priorState.full_sync_cursor && priorState.full_sync_cursor.length > 0 || (priorState.full_sync_processed || 0) > 0);
|
|
3706
|
+
let pageToken = resumableSync ? priorState.full_sync_cursor || void 0 : void 0;
|
|
3707
|
+
let processed = resumableSync ? priorState.full_sync_processed || 0 : 0;
|
|
3708
|
+
let added = 0;
|
|
3709
|
+
let updated = 0;
|
|
3710
|
+
let latestHistoryId = profile.historyId || getSyncState(config.dbPath).history_id || "";
|
|
3711
|
+
const knownTotalMessages = profile.messagesTotal ?? priorState.full_sync_total ?? null;
|
|
3712
|
+
if (!resumableSync) {
|
|
3713
|
+
clearCachedEmailData(config.dbPath);
|
|
3714
|
+
resetFullSyncProgress(config.dbPath);
|
|
3715
|
+
}
|
|
3716
|
+
onEvent?.({
|
|
3717
|
+
mode: "full",
|
|
3718
|
+
phase: "starting",
|
|
3719
|
+
synced: processed,
|
|
3720
|
+
total: knownTotalMessages,
|
|
3721
|
+
detail: resumableSync ? `Resuming full mailbox sync\u2026 ${processed}${knownTotalMessages ? ` / ${knownTotalMessages}` : ""}` : accountReconciliation.cleared ? "Starting full mailbox sync after resetting the local cache for this account\u2026" : "Starting full mailbox sync\u2026"
|
|
3722
|
+
});
|
|
3723
|
+
onProgress?.(processed, knownTotalMessages);
|
|
3724
|
+
do {
|
|
3725
|
+
const response = await transport.listMessages({
|
|
3726
|
+
maxResults: pageSize,
|
|
3727
|
+
pageToken
|
|
3728
|
+
});
|
|
3729
|
+
pageToken = response.nextPageToken || void 0;
|
|
3730
|
+
const ids = (response.messages || []).map((message) => message.id).filter(Boolean);
|
|
3731
|
+
if (ids.length === 0) {
|
|
3732
|
+
break;
|
|
3733
|
+
}
|
|
3734
|
+
onEvent?.({
|
|
3735
|
+
mode: "full",
|
|
3736
|
+
phase: "fetching_messages",
|
|
3737
|
+
synced: processed,
|
|
3738
|
+
total: knownTotalMessages,
|
|
3739
|
+
detail: "Fetching message metadata\u2026"
|
|
3740
|
+
});
|
|
3741
|
+
const processedBeforeBatch = processed;
|
|
3742
|
+
const messages = await batchGetMessages(ids, (completedInBatch) => {
|
|
3743
|
+
const synced = processedBeforeBatch + completedInBatch;
|
|
3744
|
+
const total = knownTotalMessages !== null ? Math.max(knownTotalMessages, synced) : null;
|
|
3745
|
+
onProgress?.(synced, total);
|
|
3746
|
+
onEvent?.({
|
|
3747
|
+
mode: "full",
|
|
3748
|
+
phase: "fetching_messages",
|
|
3749
|
+
synced,
|
|
3750
|
+
total,
|
|
3751
|
+
detail: `Fetching mailbox metadata\u2026 ${synced}${total ? ` / ${total}` : ""}`
|
|
3752
|
+
});
|
|
3753
|
+
});
|
|
3754
|
+
upsertEmails2(config.dbPath, messages);
|
|
3755
|
+
processed += messages.length;
|
|
3756
|
+
updated += messages.length;
|
|
3757
|
+
added += messages.length;
|
|
3758
|
+
saveSyncState(config.dbPath, {
|
|
3759
|
+
account_email: accountEmail,
|
|
3760
|
+
total_messages: processed,
|
|
3761
|
+
full_sync_cursor: pageToken || null,
|
|
3762
|
+
full_sync_processed: processed,
|
|
3763
|
+
full_sync_total: knownTotalMessages ?? processed
|
|
3764
|
+
});
|
|
3765
|
+
onProgress?.(
|
|
3766
|
+
processed,
|
|
3767
|
+
knownTotalMessages !== null ? Math.max(knownTotalMessages, processed) : null
|
|
3768
|
+
);
|
|
3769
|
+
if (maxMessages && processed >= maxMessages) {
|
|
3770
|
+
pageToken = void 0;
|
|
3771
|
+
}
|
|
3772
|
+
} while (pageToken);
|
|
3773
|
+
onEvent?.({
|
|
3774
|
+
mode: "full",
|
|
3775
|
+
phase: "finalizing",
|
|
3776
|
+
synced: processed,
|
|
3777
|
+
total: knownTotalMessages !== null ? Math.max(knownTotalMessages, processed) : processed,
|
|
3778
|
+
detail: "Finalizing full sync\u2026"
|
|
3779
|
+
});
|
|
3780
|
+
saveSyncState(config.dbPath, {
|
|
3781
|
+
account_email: accountEmail,
|
|
3782
|
+
history_id: latestHistoryId,
|
|
3783
|
+
last_full_sync: Date.now(),
|
|
3784
|
+
total_messages: processed,
|
|
3785
|
+
full_sync_cursor: null,
|
|
3786
|
+
full_sync_processed: 0,
|
|
3787
|
+
full_sync_total: 0
|
|
3788
|
+
});
|
|
3789
|
+
return {
|
|
3790
|
+
messagesProcessed: processed,
|
|
3791
|
+
messagesAdded: added,
|
|
3792
|
+
messagesUpdated: updated,
|
|
3793
|
+
historyId: latestHistoryId,
|
|
3794
|
+
mode: "full",
|
|
3795
|
+
usedHistoryFallback: false
|
|
3796
|
+
};
|
|
3797
|
+
}
|
|
3798
|
+
function isStaleHistoryError(error) {
|
|
3799
|
+
const status = error.code || error.status;
|
|
3800
|
+
return status === 404;
|
|
3801
|
+
}
|
|
3802
|
+
async function incrementalSync(onProgress, onEvent) {
|
|
3803
|
+
const config = loadConfig();
|
|
3804
|
+
const transport = await getGmailTransport(config);
|
|
3805
|
+
const profile = await transport.getProfile();
|
|
3806
|
+
const accountReconciliation = reconcileCacheForAuthenticatedAccount(
|
|
3807
|
+
config.dbPath,
|
|
3808
|
+
profile.emailAddress || null,
|
|
3809
|
+
{ clearLegacyUnscoped: true }
|
|
3810
|
+
);
|
|
3811
|
+
const state = getSyncState(config.dbPath);
|
|
3812
|
+
if (accountReconciliation.cleared || !state.history_id) {
|
|
3813
|
+
return fullSync(onProgress, onEvent);
|
|
3814
|
+
}
|
|
3815
|
+
try {
|
|
3816
|
+
onEvent?.({
|
|
3817
|
+
mode: "incremental",
|
|
3818
|
+
phase: "checking_history",
|
|
3819
|
+
synced: 0,
|
|
3820
|
+
total: null,
|
|
3821
|
+
detail: "Checking Gmail history for changes\u2026"
|
|
3822
|
+
});
|
|
3823
|
+
const response = await transport.listHistory({
|
|
3824
|
+
startHistoryId: state.history_id,
|
|
3825
|
+
maxResults: config.sync.pageSize,
|
|
3826
|
+
historyTypes: [
|
|
3827
|
+
"messageAdded",
|
|
3828
|
+
"labelAdded",
|
|
3829
|
+
"labelRemoved",
|
|
3830
|
+
"messageDeleted"
|
|
3831
|
+
]
|
|
3832
|
+
});
|
|
3833
|
+
const history = response.history || [];
|
|
3834
|
+
const touchedIds = /* @__PURE__ */ new Set();
|
|
3835
|
+
const deletedIds = /* @__PURE__ */ new Set();
|
|
3836
|
+
for (const entry of history) {
|
|
3837
|
+
for (const item of entry.messagesAdded || []) {
|
|
3838
|
+
if (item.message?.id) {
|
|
3839
|
+
touchedIds.add(item.message.id);
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
for (const item of entry.labelsAdded || []) {
|
|
3843
|
+
if (item.message?.id) {
|
|
3844
|
+
touchedIds.add(item.message.id);
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
for (const item of entry.labelsRemoved || []) {
|
|
3848
|
+
if (item.message?.id) {
|
|
3849
|
+
touchedIds.add(item.message.id);
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
for (const item of entry.messagesDeleted || []) {
|
|
3853
|
+
if (item.message?.id) {
|
|
3854
|
+
deletedIds.add(item.message.id);
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
for (const id of deletedIds) {
|
|
3859
|
+
touchedIds.delete(id);
|
|
3860
|
+
}
|
|
3861
|
+
const totalChanges = touchedIds.size + deletedIds.size;
|
|
3862
|
+
onEvent?.({
|
|
3863
|
+
mode: "incremental",
|
|
3864
|
+
phase: "fetching_messages",
|
|
3865
|
+
synced: 0,
|
|
3866
|
+
total: totalChanges,
|
|
3867
|
+
detail: totalChanges === 0 ? "No changes found." : `Refreshing ${touchedIds.size} changed emails and ${deletedIds.size} deletions\u2026`
|
|
3868
|
+
});
|
|
3869
|
+
const refreshed = await batchGetMessages([...touchedIds], (completed) => {
|
|
3870
|
+
onProgress?.(completed, totalChanges);
|
|
3871
|
+
onEvent?.({
|
|
3872
|
+
mode: "incremental",
|
|
3873
|
+
phase: "fetching_messages",
|
|
3874
|
+
synced: completed,
|
|
3875
|
+
total: totalChanges,
|
|
3876
|
+
detail: `Refreshing changed emails\u2026 ${completed}${totalChanges ? ` / ${totalChanges}` : ""}`
|
|
3877
|
+
});
|
|
3878
|
+
});
|
|
3879
|
+
onEvent?.({
|
|
3880
|
+
mode: "incremental",
|
|
3881
|
+
phase: "applying_changes",
|
|
3882
|
+
synced: refreshed.length,
|
|
3883
|
+
total: totalChanges,
|
|
3884
|
+
detail: "Applying Gmail changes to the local cache\u2026"
|
|
3885
|
+
});
|
|
3886
|
+
upsertEmails2(config.dbPath, refreshed);
|
|
3887
|
+
deleteEmails(config.dbPath, [...deletedIds]);
|
|
3888
|
+
onProgress?.(totalChanges, totalChanges || null);
|
|
3889
|
+
onEvent?.({
|
|
3890
|
+
mode: "incremental",
|
|
3891
|
+
phase: "finalizing",
|
|
3892
|
+
synced: totalChanges,
|
|
3893
|
+
total: totalChanges || null,
|
|
3894
|
+
detail: "Finalizing incremental sync\u2026"
|
|
3895
|
+
});
|
|
3896
|
+
const latestHistoryId = response.historyId || state.history_id;
|
|
3897
|
+
const totalMessagesRow = getSqlite(config.dbPath).prepare(`SELECT COUNT(*) as count FROM emails`).get();
|
|
3898
|
+
saveSyncState(config.dbPath, {
|
|
3899
|
+
account_email: profile.emailAddress || null,
|
|
3900
|
+
history_id: latestHistoryId,
|
|
3901
|
+
last_incremental_sync: Date.now(),
|
|
3902
|
+
total_messages: totalMessagesRow.count,
|
|
3903
|
+
full_sync_cursor: null,
|
|
3904
|
+
full_sync_processed: 0,
|
|
3905
|
+
full_sync_total: 0
|
|
3906
|
+
});
|
|
3907
|
+
return {
|
|
3908
|
+
messagesProcessed: refreshed.length + deletedIds.size,
|
|
3909
|
+
messagesAdded: refreshed.length,
|
|
3910
|
+
messagesUpdated: refreshed.length,
|
|
3911
|
+
historyId: latestHistoryId,
|
|
3912
|
+
mode: "incremental",
|
|
3913
|
+
usedHistoryFallback: false
|
|
3914
|
+
};
|
|
3915
|
+
} catch (error) {
|
|
3916
|
+
if (!isStaleHistoryError(error)) {
|
|
3917
|
+
throw error;
|
|
3918
|
+
}
|
|
3919
|
+
console.warn(
|
|
3920
|
+
`Stored Gmail historyId ${state.history_id} is stale; falling back to full sync.`
|
|
3921
|
+
);
|
|
3922
|
+
onEvent?.({
|
|
3923
|
+
mode: "incremental",
|
|
3924
|
+
phase: "fallback",
|
|
3925
|
+
synced: 0,
|
|
3926
|
+
total: null,
|
|
3927
|
+
detail: "History checkpoint expired. Falling back to a full sync\u2026"
|
|
3928
|
+
});
|
|
3929
|
+
const result = await fullSync(onProgress, onEvent);
|
|
3930
|
+
return {
|
|
3931
|
+
...result,
|
|
3932
|
+
usedHistoryFallback: true
|
|
3933
|
+
};
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
async function getSyncStatus() {
|
|
3937
|
+
const config = loadConfig();
|
|
3938
|
+
const state = getSyncState(config.dbPath);
|
|
3939
|
+
return {
|
|
3940
|
+
accountEmail: state.account_email,
|
|
3941
|
+
historyId: state.history_id,
|
|
3942
|
+
lastFullSync: state.last_full_sync,
|
|
3943
|
+
lastIncrementalSync: state.last_incremental_sync,
|
|
3944
|
+
totalMessages: state.total_messages || 0,
|
|
3945
|
+
fullSyncProcessed: state.full_sync_processed || 0,
|
|
3946
|
+
fullSyncTotal: state.full_sync_total || null,
|
|
3947
|
+
fullSyncResumable: Boolean(state.full_sync_cursor && state.full_sync_cursor.length > 0 || (state.full_sync_processed || 0) > 0)
|
|
3948
|
+
};
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
// src/mcp/server.ts
|
|
3952
|
+
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
3953
|
+
var MCP_VERSION = "0.1.0";
|
|
3954
|
+
var MCP_TOOLS = [
|
|
3955
|
+
"search_emails",
|
|
3956
|
+
"get_email",
|
|
3957
|
+
"get_thread",
|
|
3958
|
+
"sync_inbox",
|
|
3959
|
+
"archive_emails",
|
|
3960
|
+
"label_emails",
|
|
3961
|
+
"mark_read",
|
|
3962
|
+
"forward_email",
|
|
3963
|
+
"undo_run",
|
|
3964
|
+
"get_labels",
|
|
3965
|
+
"create_label",
|
|
3966
|
+
"get_inbox_stats",
|
|
3967
|
+
"get_top_senders",
|
|
3968
|
+
"get_sender_stats",
|
|
3969
|
+
"get_newsletter_senders",
|
|
3970
|
+
"deploy_rule",
|
|
3971
|
+
"list_rules",
|
|
3972
|
+
"run_rule",
|
|
3973
|
+
"enable_rule",
|
|
3974
|
+
"disable_rule",
|
|
3975
|
+
"list_filters",
|
|
3976
|
+
"get_filter",
|
|
3977
|
+
"create_filter",
|
|
3978
|
+
"delete_filter"
|
|
3979
|
+
];
|
|
3980
|
+
var MCP_RESOURCES = [
|
|
3981
|
+
"inbox://recent",
|
|
3982
|
+
"inbox://summary",
|
|
3983
|
+
"rules://deployed",
|
|
3984
|
+
"rules://history",
|
|
3985
|
+
"stats://senders",
|
|
3986
|
+
"stats://overview"
|
|
3987
|
+
];
|
|
3988
|
+
var MCP_PROMPTS = [
|
|
3989
|
+
"summarize-inbox",
|
|
3990
|
+
"review-senders",
|
|
3991
|
+
"find-newsletters",
|
|
3992
|
+
"suggest-rules",
|
|
3993
|
+
"triage-inbox"
|
|
3994
|
+
];
|
|
3995
|
+
function toTextResult(value) {
|
|
3996
|
+
return {
|
|
3997
|
+
content: [
|
|
3998
|
+
{
|
|
3999
|
+
type: "text",
|
|
4000
|
+
text: JSON.stringify(value, null, 2)
|
|
4001
|
+
}
|
|
4002
|
+
],
|
|
4003
|
+
structuredContent: {
|
|
4004
|
+
result: value
|
|
4005
|
+
}
|
|
4006
|
+
};
|
|
4007
|
+
}
|
|
4008
|
+
function toErrorResult(error) {
|
|
4009
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4010
|
+
return {
|
|
4011
|
+
content: [
|
|
4012
|
+
{
|
|
4013
|
+
type: "text",
|
|
4014
|
+
text: message
|
|
4015
|
+
}
|
|
4016
|
+
],
|
|
4017
|
+
structuredContent: {
|
|
4018
|
+
error: {
|
|
4019
|
+
message
|
|
4020
|
+
}
|
|
4021
|
+
},
|
|
4022
|
+
isError: true
|
|
4023
|
+
};
|
|
4024
|
+
}
|
|
4025
|
+
function toolHandler(handler) {
|
|
4026
|
+
return async (args) => {
|
|
4027
|
+
try {
|
|
4028
|
+
return toTextResult(await handler(args));
|
|
4029
|
+
} catch (error) {
|
|
4030
|
+
return toErrorResult(error);
|
|
4031
|
+
}
|
|
4032
|
+
};
|
|
4033
|
+
}
|
|
4034
|
+
function resourceText(uri, value) {
|
|
4035
|
+
return {
|
|
4036
|
+
contents: [
|
|
4037
|
+
{
|
|
4038
|
+
uri,
|
|
4039
|
+
mimeType: "application/json",
|
|
4040
|
+
text: JSON.stringify(value, null, 2)
|
|
4041
|
+
}
|
|
4042
|
+
]
|
|
4043
|
+
};
|
|
4044
|
+
}
|
|
4045
|
+
function promptResult(description, text2) {
|
|
4046
|
+
return {
|
|
4047
|
+
description,
|
|
4048
|
+
messages: [
|
|
4049
|
+
{
|
|
4050
|
+
role: "user",
|
|
4051
|
+
content: {
|
|
4052
|
+
type: "text",
|
|
4053
|
+
text: text2
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
]
|
|
4057
|
+
};
|
|
4058
|
+
}
|
|
4059
|
+
function buildSearchQuery(query, label) {
|
|
4060
|
+
const trimmedQuery = query.trim();
|
|
4061
|
+
const trimmedLabel = label?.trim();
|
|
4062
|
+
if (trimmedLabel) {
|
|
4063
|
+
return trimmedQuery ? `${trimmedQuery} label:${trimmedLabel}` : `label:${trimmedLabel}`;
|
|
4064
|
+
}
|
|
4065
|
+
return trimmedQuery;
|
|
4066
|
+
}
|
|
4067
|
+
function uniqueStrings(values) {
|
|
4068
|
+
return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
|
|
4069
|
+
}
|
|
4070
|
+
function resolveResourceUri(uri, fallback) {
|
|
4071
|
+
return typeof uri === "string" ? uri : fallback;
|
|
4072
|
+
}
|
|
4073
|
+
async function buildStartupWarnings() {
|
|
4074
|
+
const config = loadConfig();
|
|
4075
|
+
initializeDb(config.dbPath);
|
|
4076
|
+
const warnings = [];
|
|
4077
|
+
const tokens = await loadTokens(config.tokensPath);
|
|
4078
|
+
const readiness = getGmailReadiness(config, tokens);
|
|
4079
|
+
const googleStatus = getGoogleCredentialStatus(config);
|
|
4080
|
+
const syncStatus = await getSyncStatus();
|
|
4081
|
+
const latestSync = Math.max(syncStatus.lastIncrementalSync ?? 0, syncStatus.lastFullSync ?? 0);
|
|
4082
|
+
if (!readiness.ready) {
|
|
4083
|
+
const missing = [
|
|
4084
|
+
...googleStatus.missing,
|
|
4085
|
+
...tokens ? [] : ["gmail_tokens"]
|
|
4086
|
+
];
|
|
4087
|
+
warnings.push(
|
|
4088
|
+
`Gmail auth is incomplete (${missing.join(", ")}). Live Gmail MCP tools will fail until \`inboxctl auth login\` is complete.`
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
if (!latestSync) {
|
|
4092
|
+
warnings.push("Inbox cache has not been synced yet. Stats and resources will be empty until `sync_inbox` runs.");
|
|
4093
|
+
} else if (Date.now() - latestSync > DAY_MS2) {
|
|
4094
|
+
warnings.push("Inbox cache appears stale (last sync older than 24 hours). Call `sync_inbox` if freshness matters.");
|
|
4095
|
+
}
|
|
4096
|
+
return warnings;
|
|
4097
|
+
}
|
|
4098
|
+
async function buildStatsOverview() {
|
|
4099
|
+
return {
|
|
4100
|
+
overview: await getInboxOverview(),
|
|
4101
|
+
topSenders: await getTopSenders({ limit: 10 }),
|
|
4102
|
+
labelDistribution: (await getLabelDistribution()).slice(0, 10),
|
|
4103
|
+
dailyVolume: await getVolumeByPeriod("day", {
|
|
4104
|
+
start: Date.now() - 30 * DAY_MS2,
|
|
4105
|
+
end: Date.now()
|
|
4106
|
+
})
|
|
4107
|
+
};
|
|
4108
|
+
}
|
|
4109
|
+
async function buildRuleHistory() {
|
|
4110
|
+
const runs = await getExecutionHistory(void 0, 20);
|
|
4111
|
+
return {
|
|
4112
|
+
runs,
|
|
4113
|
+
recentRuns: await getRecentRuns(20)
|
|
4114
|
+
};
|
|
4115
|
+
}
|
|
4116
|
+
async function createMcpServer() {
|
|
4117
|
+
const warnings = await buildStartupWarnings();
|
|
4118
|
+
const server = new McpServer({
|
|
4119
|
+
name: "inboxctl",
|
|
4120
|
+
version: MCP_VERSION
|
|
4121
|
+
});
|
|
4122
|
+
server.registerTool(
|
|
4123
|
+
"search_emails",
|
|
4124
|
+
{
|
|
4125
|
+
description: "Search Gmail using Gmail query syntax and return matching email metadata.",
|
|
4126
|
+
inputSchema: {
|
|
4127
|
+
query: z2.string().min(1),
|
|
4128
|
+
max_results: z2.number().int().positive().max(100).optional(),
|
|
4129
|
+
label: z2.string().min(1).optional()
|
|
4130
|
+
},
|
|
4131
|
+
annotations: {
|
|
4132
|
+
readOnlyHint: true
|
|
4133
|
+
}
|
|
4134
|
+
},
|
|
4135
|
+
toolHandler(async ({ query, max_results, label }) => {
|
|
4136
|
+
return listMessages(buildSearchQuery(query, label), max_results ?? 20);
|
|
4137
|
+
})
|
|
4138
|
+
);
|
|
4139
|
+
server.registerTool(
|
|
4140
|
+
"get_email",
|
|
4141
|
+
{
|
|
4142
|
+
description: "Fetch a single email with full content by Gmail message ID.",
|
|
4143
|
+
inputSchema: {
|
|
4144
|
+
email_id: z2.string().min(1)
|
|
4145
|
+
},
|
|
4146
|
+
annotations: {
|
|
4147
|
+
readOnlyHint: true
|
|
4148
|
+
}
|
|
4149
|
+
},
|
|
4150
|
+
toolHandler(async ({ email_id }) => getMessage(email_id))
|
|
4151
|
+
);
|
|
4152
|
+
server.registerTool(
|
|
4153
|
+
"get_thread",
|
|
4154
|
+
{
|
|
4155
|
+
description: "Fetch a full Gmail thread by thread ID.",
|
|
4156
|
+
inputSchema: {
|
|
4157
|
+
thread_id: z2.string().min(1)
|
|
4158
|
+
},
|
|
4159
|
+
annotations: {
|
|
4160
|
+
readOnlyHint: true
|
|
4161
|
+
}
|
|
4162
|
+
},
|
|
4163
|
+
toolHandler(async ({ thread_id }) => getThread(thread_id))
|
|
4164
|
+
);
|
|
4165
|
+
server.registerTool(
|
|
4166
|
+
"sync_inbox",
|
|
4167
|
+
{
|
|
4168
|
+
description: "Run inbox sync. Uses incremental sync by default and full sync when requested.",
|
|
4169
|
+
inputSchema: {
|
|
4170
|
+
full: z2.boolean().optional()
|
|
4171
|
+
},
|
|
4172
|
+
annotations: {
|
|
4173
|
+
readOnlyHint: false,
|
|
4174
|
+
idempotentHint: false
|
|
4175
|
+
}
|
|
4176
|
+
},
|
|
4177
|
+
toolHandler(async ({ full }) => full ? fullSync() : incrementalSync())
|
|
4178
|
+
);
|
|
4179
|
+
server.registerTool(
|
|
4180
|
+
"archive_emails",
|
|
4181
|
+
{
|
|
4182
|
+
description: "Archive one or more Gmail messages by removing the INBOX label.",
|
|
4183
|
+
inputSchema: {
|
|
4184
|
+
email_ids: z2.array(z2.string().min(1)).min(1)
|
|
4185
|
+
},
|
|
4186
|
+
annotations: {
|
|
4187
|
+
readOnlyHint: false,
|
|
4188
|
+
destructiveHint: false
|
|
4189
|
+
}
|
|
4190
|
+
},
|
|
4191
|
+
toolHandler(async ({ email_ids }) => archiveEmails(uniqueStrings(email_ids)))
|
|
4192
|
+
);
|
|
4193
|
+
server.registerTool(
|
|
4194
|
+
"label_emails",
|
|
4195
|
+
{
|
|
4196
|
+
description: "Add and/or remove Gmail labels on one or more messages.",
|
|
4197
|
+
inputSchema: {
|
|
4198
|
+
email_ids: z2.array(z2.string().min(1)).min(1),
|
|
4199
|
+
add_labels: z2.array(z2.string().min(1)).optional(),
|
|
4200
|
+
remove_labels: z2.array(z2.string().min(1)).optional()
|
|
4201
|
+
},
|
|
4202
|
+
annotations: {
|
|
4203
|
+
readOnlyHint: false,
|
|
4204
|
+
destructiveHint: false
|
|
4205
|
+
}
|
|
4206
|
+
},
|
|
4207
|
+
toolHandler(async ({ email_ids, add_labels, remove_labels }) => {
|
|
4208
|
+
const ids = uniqueStrings(email_ids);
|
|
4209
|
+
const addLabels = uniqueStrings(add_labels);
|
|
4210
|
+
const removeLabels = uniqueStrings(remove_labels);
|
|
4211
|
+
if (addLabels.length === 0 && removeLabels.length === 0) {
|
|
4212
|
+
throw new Error("Provide at least one label to add or remove.");
|
|
4213
|
+
}
|
|
4214
|
+
const operations = [];
|
|
4215
|
+
for (const label of addLabels) {
|
|
4216
|
+
operations.push(await labelEmails(ids, label));
|
|
4217
|
+
}
|
|
4218
|
+
for (const label of removeLabels) {
|
|
4219
|
+
operations.push(await unlabelEmails(ids, label));
|
|
4220
|
+
}
|
|
4221
|
+
return {
|
|
4222
|
+
emailIds: ids,
|
|
4223
|
+
addLabels,
|
|
4224
|
+
removeLabels,
|
|
4225
|
+
operations
|
|
4226
|
+
};
|
|
4227
|
+
})
|
|
4228
|
+
);
|
|
4229
|
+
server.registerTool(
|
|
4230
|
+
"mark_read",
|
|
4231
|
+
{
|
|
4232
|
+
description: "Mark one or more Gmail messages as read or unread.",
|
|
4233
|
+
inputSchema: {
|
|
4234
|
+
email_ids: z2.array(z2.string().min(1)).min(1),
|
|
4235
|
+
read: z2.boolean()
|
|
4236
|
+
},
|
|
4237
|
+
annotations: {
|
|
4238
|
+
readOnlyHint: false,
|
|
4239
|
+
destructiveHint: false
|
|
4240
|
+
}
|
|
4241
|
+
},
|
|
4242
|
+
toolHandler(async ({ email_ids, read }) => {
|
|
4243
|
+
const ids = uniqueStrings(email_ids);
|
|
4244
|
+
return read ? markRead(ids) : markUnread(ids);
|
|
4245
|
+
})
|
|
4246
|
+
);
|
|
4247
|
+
server.registerTool(
|
|
4248
|
+
"forward_email",
|
|
4249
|
+
{
|
|
4250
|
+
description: "Forward a Gmail message to another address.",
|
|
4251
|
+
inputSchema: {
|
|
4252
|
+
email_id: z2.string().min(1),
|
|
4253
|
+
to: z2.string().email()
|
|
4254
|
+
},
|
|
4255
|
+
annotations: {
|
|
4256
|
+
readOnlyHint: false,
|
|
4257
|
+
destructiveHint: false
|
|
4258
|
+
}
|
|
4259
|
+
},
|
|
4260
|
+
toolHandler(async ({ email_id, to }) => forwardEmail(email_id, to))
|
|
4261
|
+
);
|
|
4262
|
+
server.registerTool(
|
|
4263
|
+
"undo_run",
|
|
4264
|
+
{
|
|
4265
|
+
description: "Undo a prior inboxctl action run when the underlying Gmail mutations are reversible.",
|
|
4266
|
+
inputSchema: {
|
|
4267
|
+
run_id: z2.string().min(1)
|
|
4268
|
+
},
|
|
4269
|
+
annotations: {
|
|
4270
|
+
readOnlyHint: false,
|
|
4271
|
+
destructiveHint: false
|
|
4272
|
+
}
|
|
4273
|
+
},
|
|
4274
|
+
toolHandler(async ({ run_id }) => undoRun(run_id))
|
|
4275
|
+
);
|
|
4276
|
+
server.registerTool(
|
|
4277
|
+
"get_labels",
|
|
4278
|
+
{
|
|
4279
|
+
description: "List Gmail labels with message and unread counts.",
|
|
4280
|
+
annotations: {
|
|
4281
|
+
readOnlyHint: true
|
|
4282
|
+
}
|
|
4283
|
+
},
|
|
4284
|
+
toolHandler(async () => listLabels())
|
|
4285
|
+
);
|
|
4286
|
+
server.registerTool(
|
|
4287
|
+
"create_label",
|
|
4288
|
+
{
|
|
4289
|
+
description: "Create a Gmail label if it does not already exist.",
|
|
4290
|
+
inputSchema: {
|
|
4291
|
+
name: z2.string().min(1),
|
|
4292
|
+
color: z2.string().min(1).optional()
|
|
4293
|
+
},
|
|
4294
|
+
annotations: {
|
|
4295
|
+
readOnlyHint: false,
|
|
4296
|
+
destructiveHint: false
|
|
4297
|
+
}
|
|
4298
|
+
},
|
|
4299
|
+
toolHandler(async ({ name, color }) => {
|
|
4300
|
+
const label = await createLabel(name);
|
|
4301
|
+
return {
|
|
4302
|
+
label,
|
|
4303
|
+
requestedColor: color ?? null,
|
|
4304
|
+
colorApplied: false,
|
|
4305
|
+
note: color ? "Color hints are not applied yet; the label was created with Gmail defaults." : null
|
|
4306
|
+
};
|
|
4307
|
+
})
|
|
4308
|
+
);
|
|
4309
|
+
server.registerTool(
|
|
4310
|
+
"get_inbox_stats",
|
|
4311
|
+
{
|
|
4312
|
+
description: "Return inbox overview counts from the local SQLite cache.",
|
|
4313
|
+
annotations: {
|
|
4314
|
+
readOnlyHint: true
|
|
4315
|
+
}
|
|
4316
|
+
},
|
|
4317
|
+
toolHandler(async () => getInboxOverview())
|
|
4318
|
+
);
|
|
4319
|
+
server.registerTool(
|
|
4320
|
+
"get_top_senders",
|
|
4321
|
+
{
|
|
4322
|
+
description: "Return top senders ranked by cached email volume.",
|
|
4323
|
+
inputSchema: {
|
|
4324
|
+
limit: z2.number().int().positive().max(100).optional(),
|
|
4325
|
+
min_unread_rate: z2.number().min(0).max(100).optional(),
|
|
4326
|
+
period: z2.enum(["day", "week", "month", "year", "all"]).optional()
|
|
4327
|
+
},
|
|
4328
|
+
annotations: {
|
|
4329
|
+
readOnlyHint: true
|
|
4330
|
+
}
|
|
4331
|
+
},
|
|
4332
|
+
toolHandler(async ({ limit, min_unread_rate, period }) => getTopSenders({
|
|
4333
|
+
limit,
|
|
4334
|
+
minUnreadRate: min_unread_rate,
|
|
4335
|
+
period
|
|
4336
|
+
}))
|
|
4337
|
+
);
|
|
4338
|
+
server.registerTool(
|
|
4339
|
+
"get_sender_stats",
|
|
4340
|
+
{
|
|
4341
|
+
description: "Return detailed stats for a sender email address or an @domain aggregate.",
|
|
4342
|
+
inputSchema: {
|
|
4343
|
+
email_or_domain: z2.string().min(1)
|
|
4344
|
+
},
|
|
4345
|
+
annotations: {
|
|
4346
|
+
readOnlyHint: true
|
|
4347
|
+
}
|
|
4348
|
+
},
|
|
4349
|
+
toolHandler(async ({ email_or_domain }) => {
|
|
4350
|
+
const result = await getSenderStats(email_or_domain);
|
|
4351
|
+
return {
|
|
4352
|
+
query: email_or_domain,
|
|
4353
|
+
found: result !== null,
|
|
4354
|
+
result
|
|
4355
|
+
};
|
|
4356
|
+
})
|
|
4357
|
+
);
|
|
4358
|
+
server.registerTool(
|
|
4359
|
+
"get_newsletter_senders",
|
|
4360
|
+
{
|
|
4361
|
+
description: "Return senders that look like newsletters or mailing lists based on cached heuristics.",
|
|
4362
|
+
inputSchema: {
|
|
4363
|
+
min_messages: z2.number().int().positive().optional(),
|
|
4364
|
+
min_unread_rate: z2.number().min(0).max(100).optional()
|
|
4365
|
+
},
|
|
4366
|
+
annotations: {
|
|
4367
|
+
readOnlyHint: true
|
|
4368
|
+
}
|
|
4369
|
+
},
|
|
4370
|
+
toolHandler(async ({ min_messages, min_unread_rate }) => getNewsletters({
|
|
4371
|
+
minMessages: min_messages,
|
|
4372
|
+
minUnreadRate: min_unread_rate
|
|
4373
|
+
}))
|
|
4374
|
+
);
|
|
4375
|
+
server.registerTool(
|
|
4376
|
+
"deploy_rule",
|
|
4377
|
+
{
|
|
4378
|
+
description: "Validate and deploy a rule directly from YAML content.",
|
|
4379
|
+
inputSchema: {
|
|
4380
|
+
yaml_content: z2.string().min(1)
|
|
4381
|
+
},
|
|
4382
|
+
annotations: {
|
|
4383
|
+
readOnlyHint: false,
|
|
4384
|
+
destructiveHint: false
|
|
4385
|
+
}
|
|
4386
|
+
},
|
|
4387
|
+
toolHandler(async ({ yaml_content }) => {
|
|
4388
|
+
const rule = parseRuleYaml(yaml_content, "<mcp:deploy_rule>");
|
|
4389
|
+
return deployRule(rule, hashRule(yaml_content));
|
|
4390
|
+
})
|
|
4391
|
+
);
|
|
4392
|
+
server.registerTool(
|
|
4393
|
+
"list_rules",
|
|
4394
|
+
{
|
|
4395
|
+
description: "List deployed inboxctl rules and their execution status.",
|
|
4396
|
+
inputSchema: {
|
|
4397
|
+
enabled_only: z2.boolean().optional()
|
|
4398
|
+
},
|
|
4399
|
+
annotations: {
|
|
4400
|
+
readOnlyHint: true
|
|
4401
|
+
}
|
|
4402
|
+
},
|
|
4403
|
+
toolHandler(async ({ enabled_only }) => {
|
|
4404
|
+
const rules2 = await getAllRulesStatus();
|
|
4405
|
+
return enabled_only ? rules2.filter((rule) => rule.enabled) : rules2;
|
|
4406
|
+
})
|
|
4407
|
+
);
|
|
4408
|
+
server.registerTool(
|
|
4409
|
+
"run_rule",
|
|
4410
|
+
{
|
|
4411
|
+
description: "Run a deployed rule in dry-run mode by default, or apply it when dry_run is false.",
|
|
4412
|
+
inputSchema: {
|
|
4413
|
+
rule_name: z2.string().min(1),
|
|
4414
|
+
dry_run: z2.boolean().optional(),
|
|
4415
|
+
max_emails: z2.number().int().positive().max(1e3).optional()
|
|
4416
|
+
},
|
|
4417
|
+
annotations: {
|
|
4418
|
+
readOnlyHint: false,
|
|
4419
|
+
destructiveHint: false
|
|
4420
|
+
}
|
|
4421
|
+
},
|
|
4422
|
+
toolHandler(async ({ rule_name, dry_run, max_emails }) => runRule(rule_name, {
|
|
4423
|
+
dryRun: dry_run,
|
|
4424
|
+
maxEmails: max_emails
|
|
4425
|
+
}))
|
|
4426
|
+
);
|
|
4427
|
+
server.registerTool(
|
|
4428
|
+
"enable_rule",
|
|
4429
|
+
{
|
|
4430
|
+
description: "Enable a deployed rule by name.",
|
|
4431
|
+
inputSchema: {
|
|
4432
|
+
rule_name: z2.string().min(1)
|
|
4433
|
+
},
|
|
4434
|
+
annotations: {
|
|
4435
|
+
readOnlyHint: false,
|
|
4436
|
+
destructiveHint: false
|
|
4437
|
+
}
|
|
4438
|
+
},
|
|
4439
|
+
toolHandler(async ({ rule_name }) => enableRule(rule_name))
|
|
4440
|
+
);
|
|
4441
|
+
server.registerTool(
|
|
4442
|
+
"disable_rule",
|
|
4443
|
+
{
|
|
4444
|
+
description: "Disable a deployed rule by name.",
|
|
4445
|
+
inputSchema: {
|
|
4446
|
+
rule_name: z2.string().min(1)
|
|
4447
|
+
},
|
|
4448
|
+
annotations: {
|
|
4449
|
+
readOnlyHint: false,
|
|
4450
|
+
destructiveHint: false
|
|
4451
|
+
}
|
|
4452
|
+
},
|
|
4453
|
+
toolHandler(async ({ rule_name }) => disableRule(rule_name))
|
|
4454
|
+
);
|
|
4455
|
+
server.registerResource(
|
|
4456
|
+
"recent-inbox",
|
|
4457
|
+
"inbox://recent",
|
|
4458
|
+
{
|
|
4459
|
+
description: "Recent cached inbox email metadata.",
|
|
4460
|
+
mimeType: "application/json"
|
|
4461
|
+
},
|
|
4462
|
+
async (uri) => resourceText(resolveResourceUri(uri, "inbox://recent"), await getRecentEmails(50))
|
|
4463
|
+
);
|
|
4464
|
+
server.registerResource(
|
|
4465
|
+
"inbox-summary",
|
|
4466
|
+
"inbox://summary",
|
|
4467
|
+
{
|
|
4468
|
+
description: "Inbox overview counts from the local cache.",
|
|
4469
|
+
mimeType: "application/json"
|
|
4470
|
+
},
|
|
4471
|
+
async (uri) => resourceText(resolveResourceUri(uri, "inbox://summary"), await getInboxOverview())
|
|
4472
|
+
);
|
|
4473
|
+
server.registerResource(
|
|
4474
|
+
"deployed-rules",
|
|
4475
|
+
"rules://deployed",
|
|
4476
|
+
{
|
|
4477
|
+
description: "All deployed rules with status and run counts.",
|
|
4478
|
+
mimeType: "application/json"
|
|
4479
|
+
},
|
|
4480
|
+
async (uri) => resourceText(resolveResourceUri(uri, "rules://deployed"), await getAllRulesStatus())
|
|
4481
|
+
);
|
|
4482
|
+
server.registerResource(
|
|
4483
|
+
"rules-history",
|
|
4484
|
+
"rules://history",
|
|
4485
|
+
{
|
|
4486
|
+
description: "Recent execution run history across manual actions and rules.",
|
|
4487
|
+
mimeType: "application/json"
|
|
4488
|
+
},
|
|
4489
|
+
async (uri) => resourceText(resolveResourceUri(uri, "rules://history"), await buildRuleHistory())
|
|
4490
|
+
);
|
|
4491
|
+
server.registerResource(
|
|
4492
|
+
"stats-senders",
|
|
4493
|
+
"stats://senders",
|
|
4494
|
+
{
|
|
4495
|
+
description: "Top cached senders ranked by message volume.",
|
|
4496
|
+
mimeType: "application/json"
|
|
4497
|
+
},
|
|
4498
|
+
async (uri) => resourceText(resolveResourceUri(uri, "stats://senders"), await getTopSenders({ limit: 20 }))
|
|
4499
|
+
);
|
|
4500
|
+
server.registerResource(
|
|
4501
|
+
"stats-overview",
|
|
4502
|
+
"stats://overview",
|
|
4503
|
+
{
|
|
4504
|
+
description: "Combined inbox overview, sender, label, and volume stats from the local cache.",
|
|
4505
|
+
mimeType: "application/json"
|
|
4506
|
+
},
|
|
4507
|
+
async (uri) => resourceText(resolveResourceUri(uri, "stats://overview"), await buildStatsOverview())
|
|
4508
|
+
);
|
|
4509
|
+
server.registerPrompt(
|
|
4510
|
+
"summarize-inbox",
|
|
4511
|
+
{
|
|
4512
|
+
description: "Review inbox summary and recent mail, then suggest a short action plan."
|
|
4513
|
+
},
|
|
4514
|
+
async () => promptResult(
|
|
4515
|
+
"Summarize the inbox using inboxctl resources and tools.",
|
|
4516
|
+
[
|
|
4517
|
+
"Use `inbox://summary` and `inbox://recent` first.",
|
|
4518
|
+
"Summarize the current inbox state, call out anything urgent, and note sender or unread patterns.",
|
|
4519
|
+
"If the cache looks stale, suggest calling `sync_inbox` before drawing conclusions.",
|
|
4520
|
+
"Finish with 2-3 concrete actions the user could take now."
|
|
4521
|
+
].join("\n")
|
|
4522
|
+
)
|
|
4523
|
+
);
|
|
4524
|
+
server.registerPrompt(
|
|
4525
|
+
"review-senders",
|
|
4526
|
+
{
|
|
4527
|
+
description: "Review top senders and identify likely noise or cleanup opportunities."
|
|
4528
|
+
},
|
|
4529
|
+
async () => promptResult(
|
|
4530
|
+
"Review top senders and recommend cleanup actions.",
|
|
4531
|
+
[
|
|
4532
|
+
"Use `get_top_senders` and `stats://senders`.",
|
|
4533
|
+
"Focus on senders with high unread rates or high volume.",
|
|
4534
|
+
"For each notable sender, classify them as important, FYI, newsletter, or noise.",
|
|
4535
|
+
"Recommend one of: keep, unsubscribe, archive manually, or create a rule."
|
|
4536
|
+
].join("\n")
|
|
4537
|
+
)
|
|
4538
|
+
);
|
|
4539
|
+
server.registerPrompt(
|
|
4540
|
+
"find-newsletters",
|
|
4541
|
+
{
|
|
4542
|
+
description: "Find likely newsletters and low-value bulk senders."
|
|
4543
|
+
},
|
|
4544
|
+
async () => promptResult(
|
|
4545
|
+
"Find newsletter-like senders and suggest which ones to keep versus clean up.",
|
|
4546
|
+
[
|
|
4547
|
+
"Use `get_newsletter_senders` and `get_top_senders` with a high unread threshold.",
|
|
4548
|
+
"Highlight senders with unsubscribe links, high unread rates, or obvious newsletter patterns.",
|
|
4549
|
+
"Separate likely keepers from likely unsubscribe/archive candidates.",
|
|
4550
|
+
"Suggest follow-up actions such as `archive_emails`, `label_emails`, or a new rule."
|
|
4551
|
+
].join("\n")
|
|
4552
|
+
)
|
|
4553
|
+
);
|
|
4554
|
+
server.registerPrompt(
|
|
4555
|
+
"suggest-rules",
|
|
4556
|
+
{
|
|
4557
|
+
description: "Suggest inboxctl YAML automation rules from observed inbox patterns."
|
|
4558
|
+
},
|
|
4559
|
+
async () => promptResult(
|
|
4560
|
+
"Analyze inbox patterns and propose valid inboxctl rule YAML.",
|
|
4561
|
+
[
|
|
4562
|
+
"Inspect `rules://deployed`, `stats://senders`, and `get_newsletter_senders` first.",
|
|
4563
|
+
"Look for ignored senders, repetitive notifications, and obvious auto-label opportunities.",
|
|
4564
|
+
"For each recommendation, explain why it is safe and include complete YAML the user could deploy with `deploy_rule`.",
|
|
4565
|
+
"Avoid risky suggestions when the evidence is weak."
|
|
4566
|
+
].join("\n")
|
|
4567
|
+
)
|
|
4568
|
+
);
|
|
4569
|
+
server.registerPrompt(
|
|
4570
|
+
"triage-inbox",
|
|
4571
|
+
{
|
|
4572
|
+
description: "Help categorize unread mail into action required, FYI, and noise."
|
|
4573
|
+
},
|
|
4574
|
+
async () => promptResult(
|
|
4575
|
+
"Triage unread mail using inboxctl data sources.",
|
|
4576
|
+
[
|
|
4577
|
+
"Use `inbox://recent`, `inbox://summary`, and `search_emails` for `is:unread` if needed.",
|
|
4578
|
+
"Group unread mail into ACTION REQUIRED, FYI, and NOISE.",
|
|
4579
|
+
"For NOISE, suggest batch actions or rules that would reduce future inbox load.",
|
|
4580
|
+
"Call out any assumptions when message bodies are unavailable."
|
|
4581
|
+
].join("\n")
|
|
4582
|
+
)
|
|
4583
|
+
);
|
|
4584
|
+
server.registerTool(
|
|
4585
|
+
"list_filters",
|
|
4586
|
+
{
|
|
4587
|
+
description: "List all Gmail server-side filters. These run automatically on incoming mail at delivery time \u2014 no client needed. For complex matching (regex, AND/OR, snippet), historical mail, or auditable/undoable operations, use YAML rules (list_rules) instead."
|
|
4588
|
+
},
|
|
4589
|
+
toolHandler(async () => listFilters())
|
|
4590
|
+
);
|
|
4591
|
+
server.registerTool(
|
|
4592
|
+
"get_filter",
|
|
4593
|
+
{
|
|
4594
|
+
description: "Get the details of a specific Gmail server-side filter by ID.",
|
|
4595
|
+
inputSchema: {
|
|
4596
|
+
filter_id: z2.string().min(1).describe("Gmail filter ID")
|
|
4597
|
+
}
|
|
4598
|
+
},
|
|
4599
|
+
toolHandler(async ({ filter_id }) => getFilter(filter_id))
|
|
4600
|
+
);
|
|
4601
|
+
server.registerTool(
|
|
4602
|
+
"create_filter",
|
|
4603
|
+
{
|
|
4604
|
+
description: "Create a Gmail server-side filter that applies automatically to all future incoming mail. Useful for simple, always-on rules (e.g. 'label all mail from newsletter@x.com and archive it'). At least one criteria field and one action field are required. Gmail does not support updating filters \u2014 to change one, delete it and create a new one. For regex matching, OR conditions, snippet matching, or processing existing mail, use YAML rules instead.",
|
|
4605
|
+
inputSchema: {
|
|
4606
|
+
from: z2.string().optional().describe("Match emails from this address"),
|
|
4607
|
+
to: z2.string().optional().describe("Match emails sent to this address"),
|
|
4608
|
+
subject: z2.string().optional().describe("Match emails with this text in the subject"),
|
|
4609
|
+
query: z2.string().optional().describe("Match using Gmail search syntax (e.g. 'has:attachment')"),
|
|
4610
|
+
negated_query: z2.string().optional().describe("Exclude emails matching this Gmail query"),
|
|
4611
|
+
has_attachment: z2.boolean().optional().describe("Match emails with attachments"),
|
|
4612
|
+
exclude_chats: z2.boolean().optional().describe("Exclude chat messages from matches"),
|
|
4613
|
+
size: z2.number().int().positive().optional().describe("Size threshold in bytes"),
|
|
4614
|
+
size_comparison: z2.enum(["larger", "smaller"]).optional().describe("Use with size: match emails larger or smaller than the threshold"),
|
|
4615
|
+
label: z2.string().optional().describe("Apply this label to matching emails (auto-created if it does not exist)"),
|
|
4616
|
+
archive: z2.boolean().optional().describe("Archive matching emails (remove from inbox)"),
|
|
4617
|
+
mark_read: z2.boolean().optional().describe("Mark matching emails as read"),
|
|
4618
|
+
star: z2.boolean().optional().describe("Star matching emails"),
|
|
4619
|
+
forward: z2.string().email().optional().describe("Forward matching emails to this address (address must be verified in Gmail settings)")
|
|
4620
|
+
}
|
|
4621
|
+
},
|
|
4622
|
+
toolHandler(
|
|
4623
|
+
async (args) => createFilter({
|
|
4624
|
+
from: args.from,
|
|
4625
|
+
to: args.to,
|
|
4626
|
+
subject: args.subject,
|
|
4627
|
+
query: args.query,
|
|
4628
|
+
negatedQuery: args.negated_query,
|
|
4629
|
+
hasAttachment: args.has_attachment,
|
|
4630
|
+
excludeChats: args.exclude_chats,
|
|
4631
|
+
size: args.size,
|
|
4632
|
+
sizeComparison: args.size_comparison,
|
|
4633
|
+
labelName: args.label,
|
|
4634
|
+
archive: args.archive,
|
|
4635
|
+
markRead: args.mark_read,
|
|
4636
|
+
star: args.star,
|
|
4637
|
+
forward: args.forward
|
|
4638
|
+
})
|
|
4639
|
+
)
|
|
4640
|
+
);
|
|
4641
|
+
server.registerTool(
|
|
4642
|
+
"delete_filter",
|
|
4643
|
+
{
|
|
4644
|
+
description: "Delete a Gmail server-side filter by ID. The filter stops processing future mail immediately. Already-processed mail is not affected. Use list_filters to find filter IDs.",
|
|
4645
|
+
inputSchema: {
|
|
4646
|
+
filter_id: z2.string().min(1).describe("Gmail filter ID to delete")
|
|
4647
|
+
}
|
|
4648
|
+
},
|
|
4649
|
+
toolHandler(async ({ filter_id }) => {
|
|
4650
|
+
await deleteFilter(filter_id);
|
|
4651
|
+
return { deleted: true, filter_id };
|
|
4652
|
+
})
|
|
4653
|
+
);
|
|
4654
|
+
return {
|
|
4655
|
+
contract: {
|
|
4656
|
+
transport: "stdio",
|
|
4657
|
+
tools: MCP_TOOLS,
|
|
4658
|
+
resources: MCP_RESOURCES,
|
|
4659
|
+
prompts: MCP_PROMPTS,
|
|
4660
|
+
ready: true,
|
|
4661
|
+
warnings
|
|
4662
|
+
},
|
|
4663
|
+
server
|
|
4664
|
+
};
|
|
4665
|
+
}
|
|
4666
|
+
async function startMcpServer() {
|
|
4667
|
+
const { contract, server } = await createMcpServer();
|
|
4668
|
+
const transport = new StdioServerTransport();
|
|
4669
|
+
await server.connect(transport);
|
|
4670
|
+
for (const warning of contract.warnings) {
|
|
4671
|
+
console.error(`[inboxctl:mcp] ${warning}`);
|
|
4672
|
+
}
|
|
4673
|
+
return contract;
|
|
4674
|
+
}
|
|
4675
|
+
|
|
4676
|
+
export {
|
|
4677
|
+
DEFAULT_GOOGLE_REDIRECT_URI,
|
|
4678
|
+
ensureDir,
|
|
4679
|
+
getDefaultDataDir,
|
|
4680
|
+
getConfigFilePath,
|
|
4681
|
+
getGoogleCredentialStatus,
|
|
4682
|
+
loadConfig,
|
|
4683
|
+
getSqlite,
|
|
4684
|
+
initializeDb,
|
|
4685
|
+
closeDb,
|
|
4686
|
+
createExecutionRun,
|
|
4687
|
+
addExecutionItems,
|
|
4688
|
+
getRecentRuns,
|
|
4689
|
+
getRunsByEmail,
|
|
4690
|
+
saveTokens,
|
|
4691
|
+
loadTokens,
|
|
4692
|
+
isTokenExpired,
|
|
4693
|
+
GMAIL_SCOPES,
|
|
4694
|
+
getOAuthReadiness,
|
|
4695
|
+
createOAuthClient,
|
|
4696
|
+
startOAuthFlow,
|
|
4697
|
+
getGmailReadiness,
|
|
4698
|
+
getGmailTransport,
|
|
4699
|
+
setGmailTransportOverride,
|
|
4700
|
+
clearGmailTransportOverride,
|
|
4701
|
+
listMessages,
|
|
4702
|
+
getMessage,
|
|
4703
|
+
syncLabels,
|
|
4704
|
+
listLabels,
|
|
4705
|
+
createLabel,
|
|
4706
|
+
archiveEmails,
|
|
4707
|
+
labelEmails,
|
|
4708
|
+
markRead,
|
|
4709
|
+
markUnread,
|
|
4710
|
+
forwardEmail,
|
|
4711
|
+
undoRun,
|
|
4712
|
+
getLabelDistribution,
|
|
4713
|
+
getNewsletters,
|
|
4714
|
+
getTopSenders,
|
|
4715
|
+
getSenderStats,
|
|
4716
|
+
getVolumeByPeriod,
|
|
4717
|
+
getInboxOverview,
|
|
4718
|
+
loadRuleFile,
|
|
4719
|
+
deployLoadedRule,
|
|
4720
|
+
deployAllRules,
|
|
4721
|
+
getRuleStatus,
|
|
4722
|
+
getAllRulesStatus,
|
|
4723
|
+
detectDrift,
|
|
4724
|
+
enableRule,
|
|
4725
|
+
disableRule,
|
|
4726
|
+
runRule,
|
|
4727
|
+
runAllRules,
|
|
4728
|
+
listFilters,
|
|
4729
|
+
getFilter,
|
|
4730
|
+
createFilter,
|
|
4731
|
+
deleteFilter,
|
|
4732
|
+
getExecutionHistory,
|
|
4733
|
+
getRecentEmails,
|
|
4734
|
+
reconcileCacheForAuthenticatedAccount,
|
|
4735
|
+
fullSync,
|
|
4736
|
+
incrementalSync,
|
|
4737
|
+
getSyncStatus,
|
|
4738
|
+
MCP_TOOLS,
|
|
4739
|
+
MCP_RESOURCES,
|
|
4740
|
+
MCP_PROMPTS,
|
|
4741
|
+
createMcpServer,
|
|
4742
|
+
startMcpServer
|
|
4743
|
+
};
|
|
4744
|
+
//# sourceMappingURL=chunk-EY6VV43S.js.map
|