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.
@@ -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