inboxctl 0.1.2 → 0.3.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.
@@ -8,7 +8,7 @@ var __export = (target, all) => {
8
8
  // src/mcp/server.ts
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
- import { z as z2 } from "zod";
11
+ import { z as z4 } from "zod";
12
12
 
13
13
  // src/core/actions/audit.ts
14
14
  import { randomUUID } from "crypto";
@@ -436,7 +436,7 @@ function getDatabase() {
436
436
  return getSqlite(config.dbPath);
437
437
  }
438
438
  function ensureValidSourceType(sourceType) {
439
- if (sourceType !== "manual" && sourceType !== "rule") {
439
+ if (sourceType !== "manual" && sourceType !== "rule" && sourceType !== "unsubscribe") {
440
440
  throw new Error(`Invalid execution source type: ${sourceType}`);
441
441
  }
442
442
  }
@@ -2041,6 +2041,322 @@ async function undoRun(runId) {
2041
2041
  };
2042
2042
  }
2043
2043
 
2044
+ // src/core/gmail/batch.ts
2045
+ var MESSAGE_FETCH_HEADERS2 = ["From", "To", "Subject", "Date", "List-Unsubscribe"];
2046
+ function makePlaceholders2(values) {
2047
+ return values.map(() => "?").join(", ");
2048
+ }
2049
+ function parseJsonArray2(raw) {
2050
+ if (!raw) {
2051
+ return [];
2052
+ }
2053
+ try {
2054
+ const parsed = JSON.parse(raw);
2055
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
2056
+ } catch {
2057
+ return [];
2058
+ }
2059
+ }
2060
+ function rowToEmail2(row) {
2061
+ return {
2062
+ id: row.id,
2063
+ threadId: row.thread_id || "",
2064
+ fromAddress: row.from_address || "",
2065
+ fromName: row.from_name || "",
2066
+ toAddresses: parseJsonArray2(row.to_addresses),
2067
+ subject: row.subject || "",
2068
+ snippet: row.snippet || "",
2069
+ date: row.date || 0,
2070
+ isRead: row.is_read === 1,
2071
+ isStarred: row.is_starred === 1,
2072
+ labelIds: parseJsonArray2(row.label_ids),
2073
+ sizeEstimate: row.size_estimate || 0,
2074
+ hasAttachments: row.has_attachments === 1,
2075
+ listUnsubscribe: row.list_unsubscribe
2076
+ };
2077
+ }
2078
+ function uniqueStrings(values) {
2079
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
2080
+ }
2081
+ function actionSignature(action) {
2082
+ switch (action.type) {
2083
+ case "label":
2084
+ return `label:${action.label}`;
2085
+ default:
2086
+ return action.type;
2087
+ }
2088
+ }
2089
+ function combineItemStatus(current, next) {
2090
+ if (current === "error" || next === "error") {
2091
+ return "error";
2092
+ }
2093
+ if (current === "warning" || next === "warning") {
2094
+ return "warning";
2095
+ }
2096
+ return "applied";
2097
+ }
2098
+ function resolveExecutionStatus(items, dryRun) {
2099
+ if (dryRun) {
2100
+ return "planned";
2101
+ }
2102
+ if (items.length === 0) {
2103
+ return "applied";
2104
+ }
2105
+ if (items.every((item) => item.status === "applied")) {
2106
+ return "applied";
2107
+ }
2108
+ if (items.some((item) => item.status === "applied" || item.status === "warning")) {
2109
+ return "partial";
2110
+ }
2111
+ return "error";
2112
+ }
2113
+ function normalizeGroups(groups) {
2114
+ if (groups.length === 0) {
2115
+ throw new Error("Provide at least one action group.");
2116
+ }
2117
+ if (groups.length > 20) {
2118
+ throw new Error("A batch may contain at most 20 groups.");
2119
+ }
2120
+ const seenEmailIds = /* @__PURE__ */ new Set();
2121
+ return groups.map((group, index2) => {
2122
+ const emailIds = uniqueStrings(group.emailIds);
2123
+ const actionMap = /* @__PURE__ */ new Map();
2124
+ for (const action of group.actions) {
2125
+ if (action.type === "label" && !action.label.trim()) {
2126
+ throw new Error(`Group ${index2 + 1}: label actions require a label name.`);
2127
+ }
2128
+ actionMap.set(actionSignature(action), action);
2129
+ }
2130
+ if (emailIds.length === 0) {
2131
+ throw new Error(`Group ${index2 + 1}: provide at least one email ID.`);
2132
+ }
2133
+ if (emailIds.length > 500) {
2134
+ throw new Error(`Group ${index2 + 1}: a group may contain at most 500 email IDs.`);
2135
+ }
2136
+ const actions = [...actionMap.values()];
2137
+ if (actions.length === 0) {
2138
+ throw new Error(`Group ${index2 + 1}: provide at least one action.`);
2139
+ }
2140
+ if (actions.length > 5) {
2141
+ throw new Error(`Group ${index2 + 1}: a group may contain at most 5 actions.`);
2142
+ }
2143
+ for (const emailId of emailIds) {
2144
+ if (seenEmailIds.has(emailId)) {
2145
+ throw new Error(`Email ${emailId} appears in more than one group. Each email may only be targeted once per batch.`);
2146
+ }
2147
+ seenEmailIds.add(emailId);
2148
+ }
2149
+ return {
2150
+ emailIds,
2151
+ actions
2152
+ };
2153
+ });
2154
+ }
2155
+ async function resolveContext3(options) {
2156
+ const config = options?.config || loadConfig();
2157
+ const transport = options?.transport || await getGmailTransport(config);
2158
+ return { config, transport };
2159
+ }
2160
+ function readSnapshotEmails(config, ids) {
2161
+ const sqlite = getSqlite(config.dbPath);
2162
+ const rows = sqlite.prepare(
2163
+ `
2164
+ SELECT id, thread_id, from_address, from_name, to_addresses, subject, snippet, date,
2165
+ is_read, is_starred, label_ids, size_estimate, has_attachments, list_unsubscribe
2166
+ FROM emails
2167
+ WHERE id IN (${makePlaceholders2(ids)})
2168
+ `
2169
+ ).all(...ids);
2170
+ return new Map(rows.map((row) => [row.id, rowToEmail2(row)]));
2171
+ }
2172
+ async function fetchMissingSnapshotEmails(transport, ids, snapshots) {
2173
+ const missingIds = ids.filter((id) => !snapshots.has(id));
2174
+ const fetched = await Promise.all(
2175
+ missingIds.map(async (id) => {
2176
+ const response = await transport.getMessage({
2177
+ id,
2178
+ format: "metadata",
2179
+ metadataHeaders: MESSAGE_FETCH_HEADERS2
2180
+ });
2181
+ if (!response.id) {
2182
+ throw new Error(`Gmail message not found: ${id}`);
2183
+ }
2184
+ return parseMessage(response);
2185
+ })
2186
+ );
2187
+ for (const email of fetched) {
2188
+ snapshots.set(email.id, email);
2189
+ }
2190
+ }
2191
+ async function loadSnapshots(ids, context) {
2192
+ const snapshots = readSnapshotEmails(context.config, ids);
2193
+ await fetchMissingSnapshotEmails(context.transport, ids, snapshots);
2194
+ return snapshots;
2195
+ }
2196
+ async function executeAction(action, emailIds, context) {
2197
+ switch (action.type) {
2198
+ case "archive":
2199
+ return (await archiveEmails(emailIds, context)).items;
2200
+ case "label":
2201
+ await createLabel(action.label, void 0, context);
2202
+ return (await labelEmails(emailIds, action.label, context)).items;
2203
+ case "mark_read":
2204
+ return (await markRead(emailIds, context)).items;
2205
+ case "mark_spam":
2206
+ return (await markSpam(emailIds, context)).items;
2207
+ }
2208
+ }
2209
+ async function executeGroup(group, context, dryRun) {
2210
+ const summary = {
2211
+ emailCount: group.emailIds.length,
2212
+ actionsApplied: group.actions.map(actionSignature),
2213
+ status: dryRun ? "planned" : "applied"
2214
+ };
2215
+ if (dryRun) {
2216
+ return {
2217
+ summary,
2218
+ items: []
2219
+ };
2220
+ }
2221
+ const items = group.emailIds.map((emailId) => ({
2222
+ emailId,
2223
+ status: "applied",
2224
+ appliedActions: [],
2225
+ beforeLabelIds: [],
2226
+ afterLabelIds: [],
2227
+ errorMessage: null
2228
+ }));
2229
+ const itemMap = new Map(items.map((item) => [item.emailId, item]));
2230
+ try {
2231
+ const snapshots = await loadSnapshots(group.emailIds, context);
2232
+ for (const emailId of group.emailIds) {
2233
+ const snapshot = snapshots.get(emailId);
2234
+ const item = itemMap.get(emailId);
2235
+ if (!item) {
2236
+ continue;
2237
+ }
2238
+ if (!snapshot) {
2239
+ item.status = "error";
2240
+ item.errorMessage = `Unable to resolve Gmail message snapshot for ${emailId}`;
2241
+ continue;
2242
+ }
2243
+ item.beforeLabelIds = [...snapshot.labelIds];
2244
+ item.afterLabelIds = [...snapshot.labelIds];
2245
+ }
2246
+ } catch (error) {
2247
+ const message = error instanceof Error ? error.message : String(error);
2248
+ for (const item of items) {
2249
+ item.status = "error";
2250
+ item.errorMessage = message;
2251
+ }
2252
+ summary.status = resolveExecutionStatus(items, false);
2253
+ return { summary, items };
2254
+ }
2255
+ for (const action of group.actions) {
2256
+ const activeIds = items.filter((item) => item.status !== "error").map((item) => item.emailId);
2257
+ if (activeIds.length === 0) {
2258
+ break;
2259
+ }
2260
+ try {
2261
+ const results = await executeAction(action, activeIds, context);
2262
+ const resultMap = new Map(results.map((result) => [result.emailId, result]));
2263
+ for (const emailId of activeIds) {
2264
+ const item = itemMap.get(emailId);
2265
+ if (!item) {
2266
+ continue;
2267
+ }
2268
+ const result = resultMap.get(emailId);
2269
+ if (!result) {
2270
+ item.status = "error";
2271
+ item.errorMessage = `Missing Gmail mutation result for ${emailId}`;
2272
+ continue;
2273
+ }
2274
+ item.status = combineItemStatus(item.status, result.status);
2275
+ item.afterLabelIds = [...result.afterLabelIds];
2276
+ item.appliedActions = [...item.appliedActions, ...result.appliedActions];
2277
+ item.errorMessage = result.errorMessage ?? item.errorMessage;
2278
+ }
2279
+ } catch (error) {
2280
+ const message = error instanceof Error ? error.message : String(error);
2281
+ for (const emailId of activeIds) {
2282
+ const item = itemMap.get(emailId);
2283
+ if (!item) {
2284
+ continue;
2285
+ }
2286
+ item.status = "error";
2287
+ item.errorMessage = message;
2288
+ }
2289
+ break;
2290
+ }
2291
+ }
2292
+ summary.status = resolveExecutionStatus(items, false);
2293
+ return {
2294
+ summary,
2295
+ items
2296
+ };
2297
+ }
2298
+ function collectRequestedActions(groups) {
2299
+ const actions = /* @__PURE__ */ new Map();
2300
+ for (const group of groups) {
2301
+ for (const action of group.actions) {
2302
+ actions.set(actionSignature(action), action);
2303
+ }
2304
+ }
2305
+ return [...actions.values()];
2306
+ }
2307
+ async function batchApplyActions(options) {
2308
+ const groups = normalizeGroups(options.groups);
2309
+ const dryRun = options.dryRun ?? false;
2310
+ if (dryRun) {
2311
+ const summaries2 = groups.map((group) => ({
2312
+ emailCount: group.emailIds.length,
2313
+ actionsApplied: group.actions.map(actionSignature),
2314
+ status: "planned"
2315
+ }));
2316
+ return {
2317
+ runId: null,
2318
+ dryRun: true,
2319
+ groups: summaries2,
2320
+ totalEmailsAffected: summaries2.reduce((sum, group) => sum + group.emailCount, 0),
2321
+ undoAvailable: false
2322
+ };
2323
+ }
2324
+ const context = await resolveContext3(options);
2325
+ const summaries = [];
2326
+ const executionItems2 = [];
2327
+ for (const group of groups) {
2328
+ const result = await executeGroup(group, context, false);
2329
+ summaries.push(result.summary);
2330
+ executionItems2.push(...result.items);
2331
+ }
2332
+ const status = resolveExecutionStatus(executionItems2, false);
2333
+ const run = await createExecutionRun({
2334
+ sourceType: options.sourceType ?? "manual",
2335
+ dryRun: false,
2336
+ requestedActions: collectRequestedActions(groups),
2337
+ query: options.query ?? null,
2338
+ status
2339
+ });
2340
+ await addExecutionItems(
2341
+ run.id,
2342
+ executionItems2.map((item) => ({
2343
+ emailId: item.emailId,
2344
+ status: item.status,
2345
+ appliedActions: item.appliedActions,
2346
+ beforeLabelIds: item.beforeLabelIds,
2347
+ afterLabelIds: item.afterLabelIds,
2348
+ errorMessage: item.errorMessage
2349
+ }))
2350
+ );
2351
+ return {
2352
+ runId: run.id,
2353
+ dryRun: false,
2354
+ groups: summaries,
2355
+ totalEmailsAffected: summaries.reduce((sum, group) => sum + group.emailCount, 0),
2356
+ undoAvailable: status === "applied" || status === "partial" || status === "error"
2357
+ };
2358
+ }
2359
+
2044
2360
  // src/core/gmail/threads.ts
2045
2361
  async function getThread(id) {
2046
2362
  const config = loadConfig();
@@ -2055,8 +2371,224 @@ async function getThread(id) {
2055
2371
  };
2056
2372
  }
2057
2373
 
2374
+ // src/core/unsubscribe.ts
2375
+ function uniqueStrings2(values) {
2376
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
2377
+ }
2378
+ function extractCandidates(value) {
2379
+ const matches = [...value.matchAll(/<([^>]+)>/g)].map((match) => match[1]?.trim() || "").filter(Boolean);
2380
+ if (matches.length > 0) {
2381
+ return matches;
2382
+ }
2383
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
2384
+ }
2385
+ function parseListUnsubscribeValues(...values) {
2386
+ const links = [];
2387
+ const mailtos = [];
2388
+ for (const rawValue of values) {
2389
+ if (!rawValue?.trim()) {
2390
+ continue;
2391
+ }
2392
+ for (const candidate of extractCandidates(rawValue)) {
2393
+ const normalized = candidate.trim();
2394
+ if (/^mailto:/i.test(normalized)) {
2395
+ mailtos.push(normalized);
2396
+ continue;
2397
+ }
2398
+ if (/^https?:\/\//i.test(normalized)) {
2399
+ links.push(normalized);
2400
+ }
2401
+ }
2402
+ }
2403
+ return {
2404
+ links: uniqueStrings2(links),
2405
+ mailtos: uniqueStrings2(mailtos)
2406
+ };
2407
+ }
2408
+ function resolveUnsubscribeTarget(...values) {
2409
+ const parsed = parseListUnsubscribeValues(...values);
2410
+ if (parsed.links.length > 0 && parsed.mailtos.length > 0) {
2411
+ return {
2412
+ unsubscribeLink: parsed.links[0] || null,
2413
+ unsubscribeMethod: "both"
2414
+ };
2415
+ }
2416
+ if (parsed.links.length > 0) {
2417
+ return {
2418
+ unsubscribeLink: parsed.links[0] || null,
2419
+ unsubscribeMethod: "link"
2420
+ };
2421
+ }
2422
+ if (parsed.mailtos.length > 0) {
2423
+ return {
2424
+ unsubscribeLink: parsed.mailtos[0] || null,
2425
+ unsubscribeMethod: "mailto"
2426
+ };
2427
+ }
2428
+ return {
2429
+ unsubscribeLink: null,
2430
+ unsubscribeMethod: null
2431
+ };
2432
+ }
2433
+ function buildUnsubscribeReason(unreadRate, messageCount) {
2434
+ if (unreadRate >= 90) {
2435
+ return `${unreadRate}% unread across ${messageCount} emails \u2014 you never engage with this sender`;
2436
+ }
2437
+ if (unreadRate >= 50) {
2438
+ return `${unreadRate}% unread across ${messageCount} emails \u2014 you rarely engage with this sender`;
2439
+ }
2440
+ if (unreadRate >= 25) {
2441
+ return `${unreadRate}% unread across ${messageCount} emails \u2014 you sometimes read this sender`;
2442
+ }
2443
+ return `High volume sender (${messageCount} emails) with ${unreadRate}% unread`;
2444
+ }
2445
+
2446
+ // src/core/gmail/unsubscribe.ts
2447
+ function resolveContext4(options) {
2448
+ return {
2449
+ config: options.config || loadConfig(),
2450
+ transport: options.transport
2451
+ };
2452
+ }
2453
+ function buildInstruction(method, archivedCount, labeledCount) {
2454
+ const cleanupParts = [];
2455
+ if (labeledCount > 0) {
2456
+ cleanupParts.push(`${labeledCount} emails labeled`);
2457
+ }
2458
+ if (archivedCount > 0) {
2459
+ cleanupParts.push(`${archivedCount} emails archived`);
2460
+ }
2461
+ const cleanup = cleanupParts.length > 0 ? `${cleanupParts.join(" and ")}. ` : "";
2462
+ if (method === "mailto") {
2463
+ return `${cleanup}Open this mailto link in your email client to complete the unsubscribe process. inboxctl cannot auto-submit unsubscribe forms.`;
2464
+ }
2465
+ return `${cleanup}Open this link in your browser to complete the unsubscribe process. inboxctl cannot auto-submit unsubscribe forms.`;
2466
+ }
2467
+ function getSenderAggregate(sqlite, senderEmail) {
2468
+ const row = sqlite.prepare(
2469
+ `
2470
+ SELECT
2471
+ COUNT(*) AS messageCount,
2472
+ MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
2473
+ GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
2474
+ ') AS emailUnsubscribeHeaders
2475
+ FROM emails AS e
2476
+ LEFT JOIN newsletter_senders AS ns
2477
+ ON LOWER(ns.email) = LOWER(e.from_address)
2478
+ WHERE LOWER(e.from_address) = LOWER(?)
2479
+ GROUP BY LOWER(e.from_address)
2480
+ `
2481
+ ).get(senderEmail);
2482
+ return row || null;
2483
+ }
2484
+ function getSenderEmailIds(sqlite, senderEmail) {
2485
+ const rows = sqlite.prepare(
2486
+ `
2487
+ SELECT id
2488
+ FROM emails
2489
+ WHERE LOWER(from_address) = LOWER(?)
2490
+ ORDER BY COALESCE(date, 0) DESC, id ASC
2491
+ `
2492
+ ).all(senderEmail);
2493
+ return rows.map((row) => row.id);
2494
+ }
2495
+ async function unsubscribe(options) {
2496
+ const senderEmail = options.senderEmail.trim();
2497
+ if (!senderEmail) {
2498
+ throw new Error("senderEmail is required");
2499
+ }
2500
+ const context = resolveContext4(options);
2501
+ const sqlite = getSqlite(context.config.dbPath);
2502
+ const aggregate = getSenderAggregate(sqlite, senderEmail);
2503
+ if (!aggregate) {
2504
+ throw new Error(`No emails found from ${senderEmail}`);
2505
+ }
2506
+ const unsubscribeTarget = resolveUnsubscribeTarget(
2507
+ aggregate.newsletterUnsubscribeLink,
2508
+ aggregate.emailUnsubscribeHeaders
2509
+ );
2510
+ if (!unsubscribeTarget.unsubscribeLink || !unsubscribeTarget.unsubscribeMethod) {
2511
+ throw new Error(
2512
+ `No unsubscribe link found for ${senderEmail}. This sender does not include List-Unsubscribe headers.`
2513
+ );
2514
+ }
2515
+ const alsoLabel = options.alsoLabel?.trim() || void 0;
2516
+ const actions = [];
2517
+ if (alsoLabel) {
2518
+ actions.push({ type: "label", label: alsoLabel });
2519
+ }
2520
+ if (options.alsoArchive) {
2521
+ actions.push({ type: "archive" });
2522
+ }
2523
+ const emailIds = actions.length > 0 ? getSenderEmailIds(sqlite, senderEmail) : [];
2524
+ const batchResult = actions.length > 0 ? await batchApplyActions({
2525
+ groups: [{ emailIds, actions }],
2526
+ config: context.config,
2527
+ transport: context.transport,
2528
+ sourceType: "unsubscribe",
2529
+ query: senderEmail
2530
+ }) : null;
2531
+ const archivedCount = options.alsoArchive ? aggregate.messageCount : 0;
2532
+ const labeledCount = alsoLabel ? aggregate.messageCount : 0;
2533
+ return {
2534
+ sender: senderEmail,
2535
+ unsubscribeLink: unsubscribeTarget.unsubscribeLink,
2536
+ unsubscribeMethod: unsubscribeTarget.unsubscribeMethod,
2537
+ messageCount: aggregate.messageCount,
2538
+ archivedCount,
2539
+ labeledCount,
2540
+ ...batchResult?.runId ? {
2541
+ runId: batchResult.runId,
2542
+ undoAvailable: batchResult.undoAvailable
2543
+ } : {},
2544
+ instruction: buildInstruction(
2545
+ unsubscribeTarget.unsubscribeMethod,
2546
+ archivedCount,
2547
+ labeledCount
2548
+ )
2549
+ };
2550
+ }
2551
+
2552
+ // src/core/stats/anomalies.ts
2553
+ import { z } from "zod";
2554
+
2058
2555
  // src/core/stats/common.ts
2059
2556
  var DAY_MS = 24 * 60 * 60 * 1e3;
2557
+ var SYSTEM_LABEL_IDS = [
2558
+ "INBOX",
2559
+ "UNREAD",
2560
+ "STARRED",
2561
+ "IMPORTANT",
2562
+ "SENT",
2563
+ "DRAFT",
2564
+ "TRASH",
2565
+ "SPAM",
2566
+ "ALL_MAIL",
2567
+ "SNOOZED",
2568
+ "CHAT",
2569
+ "CATEGORY_PERSONAL",
2570
+ "CATEGORY_SOCIAL",
2571
+ "CATEGORY_PROMOTIONS",
2572
+ "CATEGORY_UPDATES",
2573
+ "CATEGORY_FORUMS"
2574
+ ];
2575
+ var CATEGORY_LABEL_PREFIX = "CATEGORY_";
2576
+ var SYSTEM_LABEL_ID_SET = new Set(SYSTEM_LABEL_IDS);
2577
+ var AUTOMATED_ADDRESS_MARKERS = [
2578
+ "noreply",
2579
+ "no-reply",
2580
+ "no_reply",
2581
+ "newsletter",
2582
+ "notifications",
2583
+ "notification",
2584
+ "mailer",
2585
+ "info@",
2586
+ "hello@",
2587
+ "support@",
2588
+ "marketing",
2589
+ "promo",
2590
+ "updates"
2591
+ ];
2060
2592
  var SYSTEM_LABEL_NAMES = /* @__PURE__ */ new Map([
2061
2593
  ["INBOX", "Inbox"],
2062
2594
  ["UNREAD", "Unread"],
@@ -2114,6 +2646,14 @@ function getPeriodStart(period = "all", now2 = Date.now()) {
2114
2646
  function resolveLabelName(labelId) {
2115
2647
  return SYSTEM_LABEL_NAMES.get(labelId) || getCachedLabelName(labelId) || labelId;
2116
2648
  }
2649
+ function isUserLabel(labelId) {
2650
+ const trimmed = labelId.trim();
2651
+ return trimmed.length > 0 && !SYSTEM_LABEL_ID_SET.has(trimmed) && !trimmed.startsWith(CATEGORY_LABEL_PREFIX);
2652
+ }
2653
+ function isLikelyAutomatedSenderAddress(sender) {
2654
+ const normalized = sender.trim().toLowerCase();
2655
+ return AUTOMATED_ADDRESS_MARKERS.some((marker) => normalized.includes(marker));
2656
+ }
2117
2657
  function startOfLocalDay(now2 = Date.now()) {
2118
2658
  const date = new Date(now2);
2119
2659
  date.setHours(0, 0, 0, 0);
@@ -2133,28 +2673,6 @@ function startOfLocalMonth(now2 = Date.now()) {
2133
2673
  return date.getTime();
2134
2674
  }
2135
2675
 
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
2676
  // src/core/stats/newsletters.ts
2159
2677
  import { randomUUID as randomUUID2 } from "crypto";
2160
2678
  var KNOWN_NEWSLETTER_LOCAL_PART = /^(newsletter|digest|noreply|no-reply|updates|news)([+._-].*)?$/i;
@@ -2318,14 +2836,822 @@ async function getNewsletters(options = {}) {
2318
2836
  return rows.map(mapNewsletterRow);
2319
2837
  }
2320
2838
 
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);
2839
+ // src/core/stats/anomalies.ts
2840
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
2841
+ var BULK_LABELS = /* @__PURE__ */ new Set([
2842
+ "newsletter",
2843
+ "newsletters",
2844
+ "promotion",
2845
+ "promotions",
2846
+ "social"
2847
+ ]);
2848
+ var reviewCategorizedInputSchema = z.object({
2849
+ since: z.string().min(1).optional(),
2850
+ limit: z.number().int().positive().max(200).optional()
2851
+ }).strict();
2852
+ function toIsoString(value) {
2853
+ if (!value) {
2854
+ return null;
2855
+ }
2856
+ return new Date(value).toISOString();
2857
+ }
2858
+ function parseJsonArray3(raw) {
2859
+ if (!raw) {
2860
+ return [];
2861
+ }
2862
+ try {
2863
+ const parsed = JSON.parse(raw);
2864
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
2865
+ } catch {
2866
+ return [];
2867
+ }
2868
+ }
2869
+ function parseActions(raw) {
2870
+ try {
2871
+ const parsed = JSON.parse(raw);
2872
+ return Array.isArray(parsed) ? parsed : [];
2873
+ } catch {
2874
+ return [];
2875
+ }
2876
+ }
2877
+ function resolveSinceTimestamp(since) {
2878
+ if (!since) {
2879
+ return Date.now() - 7 * DAY_MS2;
2880
+ }
2881
+ const parsed = Date.parse(since);
2882
+ if (Number.isNaN(parsed)) {
2883
+ throw new Error(`Invalid since value: ${since}`);
2884
+ }
2885
+ return parsed;
2886
+ }
2887
+ function isArchived(actions, beforeLabelIds, afterLabelIds) {
2888
+ if (actions.some((action) => action.type === "archive")) {
2889
+ return true;
2890
+ }
2891
+ return beforeLabelIds.includes("INBOX") && !afterLabelIds.includes("INBOX");
2892
+ }
2893
+ function resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds) {
2894
+ const labelAction = actions.find(
2895
+ (action) => action.type === "label" && typeof action.label === "string" && action.label.trim().length > 0
2896
+ );
2897
+ if (labelAction) {
2898
+ return labelAction.label.trim();
2899
+ }
2900
+ const beforeUserLabels = new Set(beforeLabelIds.filter(isUserLabel));
2901
+ const afterUserLabels = afterLabelIds.filter(isUserLabel);
2902
+ const addedLabel = afterUserLabels.find((label) => !beforeUserLabels.has(label));
2903
+ return addedLabel || afterUserLabels[0] || null;
2904
+ }
2905
+ function resolvePrimaryAction(actions) {
2906
+ if (actions.some((action) => action.type === "archive")) {
2907
+ return "archive";
2908
+ }
2909
+ if (actions.some((action) => action.type === "label")) {
2910
+ return "label";
2911
+ }
2912
+ return actions[0]?.type || "unknown";
2913
+ }
2914
+ function summarizeReview(totalReviewed, anomalyCount, highCount, mediumCount) {
2915
+ if (anomalyCount === 0) {
2916
+ return `Reviewed ${totalReviewed} recently categorised emails. Found no potential misclassifications.`;
2917
+ }
2918
+ const severityParts = [];
2919
+ if (highCount > 0) {
2920
+ severityParts.push(`${highCount} high severity`);
2921
+ }
2922
+ if (mediumCount > 0) {
2923
+ severityParts.push(`${mediumCount} medium`);
2924
+ }
2925
+ return `Reviewed ${totalReviewed} recently categorised emails. Found ${anomalyCount} potential misclassifications (${severityParts.join(", ")}).`;
2926
+ }
2927
+ function detectAnomaly(row) {
2928
+ const actions = parseActions(row.appliedActions);
2929
+ const beforeLabelIds = parseJsonArray3(row.beforeLabelIds);
2930
+ const afterLabelIds = parseJsonArray3(row.afterLabelIds);
2931
+ const archived = isArchived(actions, beforeLabelIds, afterLabelIds);
2932
+ const assignedLabel = resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds);
2933
+ const action = resolvePrimaryAction(actions);
2934
+ const totalFromSender = row.totalFromSender ?? 0;
2935
+ const hasNewsletterSignals = Boolean(row.detectionReason) || Boolean(row.listUnsubscribe?.trim());
2936
+ const isBulkLabel = assignedLabel ? BULK_LABELS.has(assignedLabel.toLowerCase()) : false;
2937
+ const automatedSender = isLikelyAutomatedSenderAddress(row.sender || "");
2938
+ const undoAvailable = row.runDryRun !== 1 && row.runStatus !== "undone" && row.runUndoneAt === null && row.itemUndoneAt === null;
2939
+ if (archived && totalFromSender <= 3) {
2940
+ return {
2941
+ emailId: row.emailId,
2942
+ from: row.sender || "",
2943
+ subject: row.subject || "",
2944
+ date: toIsoString(row.date),
2945
+ assignedLabel: assignedLabel || "Unlabeled",
2946
+ action,
2947
+ runId: row.runId,
2948
+ severity: "high",
2949
+ rule: "rare_sender_archived",
2950
+ reason: `Archived email from a rare sender with only ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}. Rare senders should be reviewed before archiving.`,
2951
+ undoAvailable
2952
+ };
2953
+ }
2954
+ if (isBulkLabel && !hasNewsletterSignals) {
2955
+ return {
2956
+ emailId: row.emailId,
2957
+ from: row.sender || "",
2958
+ subject: row.subject || "",
2959
+ date: toIsoString(row.date),
2960
+ assignedLabel: assignedLabel || "Unlabeled",
2961
+ action,
2962
+ runId: row.runId,
2963
+ severity: "high",
2964
+ rule: "no_newsletter_signals_as_newsletter",
2965
+ reason: `Labeled as ${assignedLabel} but sender has no List-Unsubscribe header and no newsletter detection signals. Sender has only sent ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}.`,
2966
+ undoAvailable
2967
+ };
2968
+ }
2969
+ if (archived && !automatedSender && totalFromSender < 5) {
2970
+ return {
2971
+ emailId: row.emailId,
2972
+ from: row.sender || "",
2973
+ subject: row.subject || "",
2974
+ date: toIsoString(row.date),
2975
+ assignedLabel: assignedLabel || "Unlabeled",
2976
+ action,
2977
+ runId: row.runId,
2978
+ severity: "high",
2979
+ rule: "personal_address_archived",
2980
+ reason: `Archived email from a likely personal sender address with fewer than 5 total emails. This address does not look automated and should stay visible.`,
2981
+ undoAvailable
2982
+ };
2983
+ }
2984
+ if (isBulkLabel && totalFromSender < 5) {
2985
+ return {
2986
+ emailId: row.emailId,
2987
+ from: row.sender || "",
2988
+ subject: row.subject || "",
2989
+ date: toIsoString(row.date),
2990
+ assignedLabel: assignedLabel || "Unlabeled",
2991
+ action,
2992
+ runId: row.runId,
2993
+ severity: "medium",
2994
+ rule: "low_volume_bulk_label",
2995
+ reason: `Labeled as ${assignedLabel} even though the sender has only ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}. Bulk labels are safer for higher-volume senders.`,
2996
+ undoAvailable
2997
+ };
2998
+ }
2999
+ if (archived && totalFromSender === 1) {
3000
+ return {
3001
+ emailId: row.emailId,
3002
+ from: row.sender || "",
3003
+ subject: row.subject || "",
3004
+ date: toIsoString(row.date),
3005
+ assignedLabel: assignedLabel || "Unlabeled",
3006
+ action,
3007
+ runId: row.runId,
3008
+ severity: "medium",
3009
+ rule: "first_time_sender_archived",
3010
+ reason: "Archived an email from a first-time sender. First-time senders are better surfaced for review before cleanup.",
3011
+ undoAvailable
3012
+ };
3013
+ }
3014
+ return null;
3015
+ }
3016
+ async function reviewCategorized(options = {}) {
3017
+ const parsed = reviewCategorizedInputSchema.parse(options);
3018
+ await detectNewsletters();
3019
+ const sqlite = getStatsSqlite();
3020
+ const sinceTimestamp = resolveSinceTimestamp(parsed.since);
3021
+ const limit = Math.min(200, normalizeLimit(parsed.limit, 50));
3022
+ const rows = sqlite.prepare(
3023
+ `
3024
+ SELECT
3025
+ ei.email_id AS emailId,
3026
+ e.from_address AS sender,
3027
+ e.subject AS subject,
3028
+ e.date AS date,
3029
+ e.list_unsubscribe AS listUnsubscribe,
3030
+ ei.before_label_ids AS beforeLabelIds,
3031
+ ei.after_label_ids AS afterLabelIds,
3032
+ ei.applied_actions AS appliedActions,
3033
+ ei.executed_at AS executedAt,
3034
+ ei.undone_at AS itemUndoneAt,
3035
+ ei.run_id AS runId,
3036
+ er.status AS runStatus,
3037
+ er.dry_run AS runDryRun,
3038
+ er.undone_at AS runUndoneAt,
3039
+ ns.detection_reason AS detectionReason,
3040
+ sender_stats.totalFromSender AS totalFromSender
3041
+ FROM execution_items AS ei
3042
+ INNER JOIN emails AS e
3043
+ ON e.id = ei.email_id
3044
+ INNER JOIN execution_runs AS er
3045
+ ON er.id = ei.run_id
3046
+ LEFT JOIN newsletter_senders AS ns
3047
+ ON LOWER(ns.email) = LOWER(e.from_address)
3048
+ LEFT JOIN (
3049
+ SELECT
3050
+ LOWER(from_address) AS senderKey,
3051
+ COUNT(*) AS totalFromSender
3052
+ FROM emails
3053
+ WHERE from_address IS NOT NULL
3054
+ AND TRIM(from_address) <> ''
3055
+ GROUP BY LOWER(from_address)
3056
+ ) AS sender_stats
3057
+ ON sender_stats.senderKey = LOWER(e.from_address)
3058
+ WHERE ei.status = 'applied'
3059
+ AND er.status IN ('applied', 'partial')
3060
+ AND COALESCE(er.dry_run, 0) = 0
3061
+ AND er.undone_at IS NULL
3062
+ AND ei.undone_at IS NULL
3063
+ AND COALESCE(ei.executed_at, 0) >= ?
3064
+ ORDER BY COALESCE(ei.executed_at, 0) DESC, ei.email_id ASC
3065
+ `
3066
+ ).all(sinceTimestamp);
3067
+ const reviewedRows = rows.filter((row) => {
3068
+ const actions = parseActions(row.appliedActions);
3069
+ const beforeLabelIds = parseJsonArray3(row.beforeLabelIds);
3070
+ const afterLabelIds = parseJsonArray3(row.afterLabelIds);
3071
+ return actions.length > 0 && (resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds) !== null || isArchived(actions, beforeLabelIds, afterLabelIds));
3072
+ });
3073
+ const anomalies = reviewedRows.map((row) => detectAnomaly(row)).filter((anomaly) => anomaly !== null).sort(
3074
+ (left, right) => (left.severity === "high" ? 1 : 0) === (right.severity === "high" ? 1 : 0) ? (right.date || "").localeCompare(left.date || "") || left.emailId.localeCompare(right.emailId) : left.severity === "high" ? -1 : 1
3075
+ );
3076
+ const highCount = anomalies.filter((anomaly) => anomaly.severity === "high").length;
3077
+ const mediumCount = anomalies.filter((anomaly) => anomaly.severity === "medium").length;
3078
+ return {
3079
+ anomalies: anomalies.slice(0, limit),
3080
+ totalReviewed: reviewedRows.length,
3081
+ anomalyCount: anomalies.length,
3082
+ summary: summarizeReview(reviewedRows.length, anomalies.length, highCount, mediumCount)
3083
+ };
3084
+ }
3085
+
3086
+ // src/core/stats/labels.ts
3087
+ async function getLabelDistribution() {
3088
+ const sqlite = getStatsSqlite();
3089
+ const rows = sqlite.prepare(
3090
+ `
3091
+ SELECT
3092
+ label.value AS labelId,
3093
+ COUNT(*) AS totalMessages,
3094
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages
3095
+ FROM emails AS e, json_each(e.label_ids) AS label
3096
+ GROUP BY label.value
3097
+ ORDER BY totalMessages DESC, unreadMessages DESC, label.value ASC
3098
+ `
3099
+ ).all();
3100
+ return rows.map((row) => ({
3101
+ labelId: row.labelId,
3102
+ labelName: resolveLabelName(row.labelId),
3103
+ totalMessages: row.totalMessages,
3104
+ unreadMessages: row.unreadMessages
3105
+ }));
3106
+ }
3107
+
3108
+ // src/core/stats/noise.ts
3109
+ var DAY_MS3 = 24 * 60 * 60 * 1e3;
3110
+ var SUGGESTED_CATEGORY_RULES = [
3111
+ { category: "Receipts", keywords: ["receipt", "invoice", "payment", "order"] },
3112
+ { category: "Shipping", keywords: ["shipping", "tracking", "delivery", "dispatch"] },
3113
+ { category: "Newsletters", keywords: ["newsletter", "digest", "weekly", "update"] },
3114
+ { category: "Notifications", keywords: ["noreply", "notification", "alert"] },
3115
+ { category: "Promotions", keywords: ["promo", "offer", "deal", "sale", "marketing"] },
3116
+ { category: "Social", keywords: ["linkedin", "facebook", "twitter", "social"] }
3117
+ ];
3118
+ function toIsoString2(value) {
3119
+ if (!value) {
3120
+ return null;
3121
+ }
3122
+ return new Date(value).toISOString();
3123
+ }
3124
+ function roundNoiseScore(messageCount, unreadRate) {
3125
+ return Math.round(messageCount * unreadRate * 10 / 100) / 10;
3126
+ }
3127
+ function getSuggestedCategory(email, name) {
3128
+ const haystack = `${email} ${name}`.toLowerCase();
3129
+ for (const rule of SUGGESTED_CATEGORY_RULES) {
3130
+ if (rule.keywords.some((keyword) => haystack.includes(keyword))) {
3131
+ return rule.category;
3132
+ }
3133
+ }
3134
+ return "Other";
3135
+ }
3136
+ function compareNoiseSenders(sortBy) {
3137
+ return (left, right) => {
3138
+ switch (sortBy) {
3139
+ case "all_time_noise_score":
3140
+ return right.allTimeNoiseScore - left.allTimeNoiseScore || right.noiseScore - left.noiseScore || right.allTimeMessageCount - left.allTimeMessageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
3141
+ case "message_count":
3142
+ return right.messageCount - left.messageCount || right.noiseScore - left.noiseScore || right.allTimeMessageCount - left.allTimeMessageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
3143
+ case "unread_rate":
3144
+ return right.unreadRate - left.unreadRate || right.noiseScore - left.noiseScore || right.messageCount - left.messageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
3145
+ case "noise_score":
3146
+ default:
3147
+ return right.noiseScore - left.noiseScore || right.allTimeNoiseScore - left.allTimeNoiseScore || right.messageCount - left.messageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
3148
+ }
3149
+ };
3150
+ }
3151
+ async function getNoiseSenders(options = {}) {
3152
+ await detectNewsletters();
3153
+ const sqlite = getStatsSqlite();
3154
+ const limit = Math.min(50, normalizeLimit(options.limit, 20));
3155
+ const minNoiseScore = options.minNoiseScore ?? 5;
3156
+ const activeDays = Math.max(1, Math.floor(options.activeDays ?? 90));
3157
+ const activeSince = Date.now() - activeDays * DAY_MS3;
3158
+ const sortBy = options.sortBy ?? "noise_score";
3159
+ const rows = sqlite.prepare(
3160
+ `
3161
+ SELECT
3162
+ e.from_address AS email,
3163
+ COALESCE(MAX(NULLIF(TRIM(e.from_name), '')), e.from_address) AS name,
3164
+ COUNT(*) AS messageCount,
3165
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
3166
+ MAX(e.date) AS lastSeen,
3167
+ MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
3168
+ GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
3169
+ ') AS emailUnsubscribeHeaders,
3170
+ MAX(CASE WHEN ns.email IS NOT NULL THEN 1 ELSE 0 END) AS isNewsletter,
3171
+ COALESCE(MAX(all_time.allTimeCount), COUNT(*)) AS allTimeMessageCount,
3172
+ COALESCE(
3173
+ MAX(all_time.allTimeUnreadCount),
3174
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END)
3175
+ ) AS allTimeUnreadCount
3176
+ FROM emails AS e
3177
+ LEFT JOIN newsletter_senders AS ns
3178
+ ON LOWER(ns.email) = LOWER(e.from_address)
3179
+ LEFT JOIN (
3180
+ SELECT
3181
+ LOWER(from_address) AS senderKey,
3182
+ COUNT(*) AS allTimeCount,
3183
+ SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS allTimeUnreadCount
3184
+ FROM emails
3185
+ WHERE from_address IS NOT NULL
3186
+ AND TRIM(from_address) <> ''
3187
+ GROUP BY LOWER(from_address)
3188
+ ) AS all_time
3189
+ ON all_time.senderKey = LOWER(e.from_address)
3190
+ WHERE e.from_address IS NOT NULL
3191
+ AND TRIM(e.from_address) <> ''
3192
+ AND COALESCE(e.date, 0) >= ?
3193
+ GROUP BY LOWER(e.from_address)
3194
+ `
3195
+ ).all(activeSince);
3196
+ const senders = rows.map((row) => {
3197
+ const unreadRate = roundPercent(row.unreadCount, row.messageCount);
3198
+ const allTimeMessageCount = row.allTimeMessageCount ?? row.messageCount;
3199
+ const allTimeUnreadCount = row.allTimeUnreadCount ?? row.unreadCount;
3200
+ const allTimeUnreadRate = roundPercent(allTimeUnreadCount, allTimeMessageCount);
3201
+ const noiseScore = roundNoiseScore(row.messageCount, unreadRate);
3202
+ const allTimeNoiseScore = roundNoiseScore(allTimeMessageCount, allTimeUnreadRate);
3203
+ const unsubscribe2 = resolveUnsubscribeTarget(
3204
+ row.newsletterUnsubscribeLink,
3205
+ row.emailUnsubscribeHeaders
3206
+ );
3207
+ return {
3208
+ email: row.email,
3209
+ name: row.name?.trim() || row.email,
3210
+ messageCount: row.messageCount,
3211
+ allTimeMessageCount,
3212
+ unreadCount: row.unreadCount,
3213
+ unreadRate,
3214
+ noiseScore,
3215
+ allTimeNoiseScore,
3216
+ lastSeen: toIsoString2(row.lastSeen),
3217
+ isNewsletter: row.isNewsletter === 1,
3218
+ hasUnsubscribeLink: Boolean(unsubscribe2.unsubscribeLink),
3219
+ unsubscribeLink: unsubscribe2.unsubscribeLink,
3220
+ suggestedCategory: getSuggestedCategory(row.email, row.name?.trim() || row.email)
3221
+ };
3222
+ }).filter((sender) => sender.noiseScore >= minNoiseScore).sort(compareNoiseSenders(sortBy)).slice(0, limit);
3223
+ return { senders };
3224
+ }
3225
+
3226
+ // src/core/stats/query.ts
3227
+ import { z as z2 } from "zod";
3228
+ var QUERY_EMAIL_GROUP_BY_VALUES = [
3229
+ "sender",
3230
+ "domain",
3231
+ "label",
3232
+ "year_month",
3233
+ "year_week",
3234
+ "day_of_week",
3235
+ "is_read",
3236
+ "is_newsletter"
3237
+ ];
3238
+ var QUERY_EMAIL_AGGREGATE_VALUES = [
3239
+ "count",
3240
+ "unread_count",
3241
+ "read_count",
3242
+ "unread_rate",
3243
+ "oldest",
3244
+ "newest",
3245
+ "sender_count"
3246
+ ];
3247
+ var QUERY_EMAIL_HAVING_FIELDS = [
3248
+ "count",
3249
+ "unread_count",
3250
+ "unread_rate",
3251
+ "sender_count"
3252
+ ];
3253
+ var CATEGORY_LABEL_LIKE_PATTERN = `${CATEGORY_LABEL_PREFIX.replace(/_/g, "\\_")}%`;
3254
+ var SYSTEM_LABEL_SQL = SYSTEM_LABEL_IDS.map((label) => `'${label}'`).join(", ");
3255
+ var DOMAIN_SQL = `
3256
+ LOWER(
3257
+ CASE
3258
+ WHEN INSTR(COALESCE(e.from_address, ''), '@') > 0
3259
+ THEN SUBSTR(e.from_address, INSTR(e.from_address, '@') + 1)
3260
+ ELSE ''
3261
+ END
3262
+ )
3263
+ `;
3264
+ var queryEmailsFiltersSchema = z2.object({
3265
+ from: z2.string().optional(),
3266
+ from_contains: z2.string().optional(),
3267
+ domain: z2.string().optional(),
3268
+ domain_contains: z2.string().optional(),
3269
+ subject_contains: z2.string().optional(),
3270
+ date_after: z2.string().optional(),
3271
+ date_before: z2.string().optional(),
3272
+ is_read: z2.boolean().optional(),
3273
+ is_newsletter: z2.boolean().optional(),
3274
+ has_label: z2.boolean().optional(),
3275
+ label: z2.string().optional(),
3276
+ has_unsubscribe: z2.boolean().optional(),
3277
+ min_sender_messages: z2.number().int().positive().optional()
3278
+ }).strict();
3279
+ var havingConditionSchema = z2.object({
3280
+ gte: z2.number().optional(),
3281
+ lte: z2.number().optional()
3282
+ }).strict().refine(
3283
+ (value) => value.gte !== void 0 || value.lte !== void 0,
3284
+ { message: "Provide at least one of gte or lte." }
3285
+ );
3286
+ var queryEmailsHavingSchema = z2.object({
3287
+ count: havingConditionSchema.optional(),
3288
+ unread_count: havingConditionSchema.optional(),
3289
+ unread_rate: havingConditionSchema.optional(),
3290
+ sender_count: havingConditionSchema.optional()
3291
+ }).strict();
3292
+ var queryEmailsInputSchema = z2.object({
3293
+ filters: queryEmailsFiltersSchema.optional(),
3294
+ group_by: z2.enum(QUERY_EMAIL_GROUP_BY_VALUES).optional(),
3295
+ aggregates: z2.array(z2.enum(QUERY_EMAIL_AGGREGATE_VALUES)).min(1).optional(),
3296
+ having: queryEmailsHavingSchema.optional(),
3297
+ order_by: z2.string().optional(),
3298
+ limit: z2.number().int().positive().max(500).optional()
3299
+ }).strict();
3300
+ var QUERY_EMAILS_FIELD_SCHEMA = {
3301
+ description: "Available fields for the query_emails tool.",
3302
+ filters: {
3303
+ from: { type: "string", description: "Exact sender email (case-insensitive)" },
3304
+ from_contains: { type: "string", description: "Partial match on sender email" },
3305
+ domain: { type: "string", description: "Exact sender domain" },
3306
+ domain_contains: { type: "string", description: "Partial match on sender domain" },
3307
+ subject_contains: { type: "string", description: "Partial match on subject line" },
3308
+ date_after: { type: "string", description: "ISO date \u2014 emails after this date" },
3309
+ date_before: { type: "string", description: "ISO date \u2014 emails before this date" },
3310
+ is_read: { type: "boolean", description: "Filter by read/unread state" },
3311
+ is_newsletter: { type: "boolean", description: "Sender detected as newsletter" },
3312
+ has_label: { type: "boolean", description: "Has any user-applied label" },
3313
+ label: { type: "string", description: "Has this specific label" },
3314
+ has_unsubscribe: { type: "boolean", description: "Has List-Unsubscribe header" },
3315
+ min_sender_messages: { type: "integer", description: "Sender has at least this many total emails" }
3316
+ },
3317
+ group_by: [
3318
+ { value: "sender", description: "Group by sender email address" },
3319
+ { value: "domain", description: "Group by sender domain" },
3320
+ { value: "label", description: "Group by applied label (expands multi-label emails)" },
3321
+ { value: "year_month", description: "Group by month (YYYY-MM)" },
3322
+ { value: "year_week", description: "Group by week (YYYY-WNN)" },
3323
+ { value: "day_of_week", description: "Group by day of week (0=Sunday)" },
3324
+ { value: "is_read", description: "Group by read/unread state" },
3325
+ { value: "is_newsletter", description: "Group by newsletter detection" }
3326
+ ],
3327
+ aggregates: [
3328
+ { value: "count", description: "Number of emails" },
3329
+ { value: "unread_count", description: "Number of unread emails" },
3330
+ { value: "read_count", description: "Number of read emails" },
3331
+ { value: "unread_rate", description: "Percentage of emails that are unread" },
3332
+ { value: "oldest", description: "Earliest email date (ISO string)" },
3333
+ { value: "newest", description: "Latest email date (ISO string)" },
3334
+ { value: "sender_count", description: "Count of distinct senders" }
3335
+ ],
3336
+ having_fields: [...QUERY_EMAIL_HAVING_FIELDS],
3337
+ example_queries: [
3338
+ {
3339
+ description: "Monthly volume trend for Amazon",
3340
+ query: {
3341
+ filters: { domain_contains: "amazon" },
3342
+ group_by: "year_month",
3343
+ aggregates: ["count", "unread_rate"],
3344
+ order_by: "year_month asc"
3345
+ }
3346
+ },
3347
+ {
3348
+ description: "Domains with 95%+ unread rate and 50+ emails",
3349
+ query: {
3350
+ group_by: "domain",
3351
+ aggregates: ["count", "unread_rate"],
3352
+ having: { count: { gte: 50 }, unread_rate: { gte: 95 } }
3353
+ }
3354
+ },
3355
+ {
3356
+ description: "What day of the week gets the most email?",
3357
+ query: {
3358
+ group_by: "day_of_week",
3359
+ aggregates: ["count", "sender_count"]
3360
+ }
3361
+ }
3362
+ ]
3363
+ };
3364
+ var GROUP_BY_SQL_MAP = {
3365
+ sender: "LOWER(COALESCE(e.from_address, ''))",
3366
+ domain: DOMAIN_SQL,
3367
+ label: "CAST(grouped_label.value AS TEXT)",
3368
+ year_month: "STRFTIME('%Y-%m', e.date / 1000, 'unixepoch')",
3369
+ year_week: "STRFTIME('%Y-W%W', e.date / 1000, 'unixepoch')",
3370
+ day_of_week: "CAST(STRFTIME('%w', e.date / 1000, 'unixepoch') AS INTEGER)",
3371
+ is_read: "COALESCE(e.is_read, 0)",
3372
+ is_newsletter: "CASE WHEN ns.email IS NOT NULL THEN 1 ELSE 0 END"
3373
+ };
3374
+ var AGGREGATE_SQL_MAP = {
3375
+ count: "COUNT(*)",
3376
+ unread_count: "SUM(CASE WHEN COALESCE(e.is_read, 0) = 0 THEN 1 ELSE 0 END)",
3377
+ read_count: "SUM(CASE WHEN COALESCE(e.is_read, 0) = 1 THEN 1 ELSE 0 END)",
3378
+ unread_rate: `
3379
+ ROUND(
3380
+ CASE
3381
+ WHEN COUNT(*) = 0 THEN 0
3382
+ ELSE 100.0 * SUM(CASE WHEN COALESCE(e.is_read, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*)
3383
+ END,
3384
+ 1
3385
+ )
3386
+ `,
3387
+ oldest: "MIN(e.date)",
3388
+ newest: "MAX(e.date)",
3389
+ sender_count: `
3390
+ COUNT(
3391
+ DISTINCT CASE
3392
+ WHEN e.from_address IS NOT NULL AND TRIM(e.from_address) <> ''
3393
+ THEN LOWER(e.from_address)
3394
+ ELSE NULL
3395
+ END
3396
+ )
3397
+ `
3398
+ };
3399
+ function userLabelPredicate(column) {
3400
+ return `
3401
+ ${column} IS NOT NULL
3402
+ AND TRIM(CAST(${column} AS TEXT)) <> ''
3403
+ AND CAST(${column} AS TEXT) NOT IN (${SYSTEM_LABEL_SQL})
3404
+ AND CAST(${column} AS TEXT) NOT LIKE '${CATEGORY_LABEL_LIKE_PATTERN}' ESCAPE '\\'
3405
+ `;
3406
+ }
3407
+ function toIsoString3(value) {
3408
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
3409
+ return null;
3410
+ }
3411
+ return new Date(value).toISOString();
3412
+ }
3413
+ function normalizeGroupValue(groupBy, value) {
3414
+ switch (groupBy) {
3415
+ case "is_read":
3416
+ case "is_newsletter":
3417
+ return Number(value ?? 0) === 1;
3418
+ case "day_of_week":
3419
+ return Number(value ?? 0);
3420
+ case "sender":
3421
+ case "domain":
3422
+ case "label":
3423
+ case "year_month":
3424
+ case "year_week":
3425
+ return typeof value === "string" ? value : value == null ? null : String(value);
3426
+ }
3427
+ }
3428
+ function normalizeAggregateValue(aggregate, value) {
3429
+ switch (aggregate) {
3430
+ case "oldest":
3431
+ case "newest":
3432
+ return toIsoString3(value);
3433
+ case "count":
3434
+ case "unread_count":
3435
+ case "read_count":
3436
+ case "sender_count":
3437
+ return Number(value ?? 0);
3438
+ case "unread_rate":
3439
+ return Number(value ?? 0);
3440
+ }
3441
+ }
3442
+ function parseDateFilter(field, value) {
3443
+ const timestamp = Date.parse(value);
3444
+ if (Number.isNaN(timestamp)) {
3445
+ throw new Error(`Invalid ${field} value: ${value}`);
3446
+ }
3447
+ return timestamp;
3448
+ }
3449
+ function resolveAggregates(aggregates) {
3450
+ return Array.from(new Set(aggregates && aggregates.length > 0 ? aggregates : ["count"]));
3451
+ }
3452
+ function resolveOrderBy(orderBy, groupBy, aggregates) {
3453
+ const defaultField = aggregates.includes("count") ? "count" : aggregates[0];
3454
+ const rawValue = (orderBy || `${defaultField} desc`).trim();
3455
+ const match = rawValue.match(/^([a-z_]+)\s+(asc|desc)$/i);
3456
+ if (!match) {
3457
+ throw new Error(`Invalid order_by value: ${rawValue}`);
3458
+ }
3459
+ const [, field, direction] = match;
3460
+ const allowedFields = new Set(aggregates);
3461
+ if (groupBy) {
3462
+ allowedFields.add(groupBy);
3463
+ }
3464
+ if (!allowedFields.has(field)) {
3465
+ throw new Error(`Invalid order_by field: ${field}`);
3466
+ }
3467
+ return `${field} ${direction.toLowerCase()}`;
3468
+ }
3469
+ function buildWhereClauses(filters, groupBy) {
3470
+ const whereParts = [];
3471
+ const params = [];
3472
+ if (filters.from !== void 0) {
3473
+ whereParts.push("LOWER(COALESCE(e.from_address, '')) = LOWER(?)");
3474
+ params.push(filters.from);
3475
+ }
3476
+ if (filters.from_contains !== void 0) {
3477
+ whereParts.push("LOWER(COALESCE(e.from_address, '')) LIKE '%' || LOWER(?) || '%'");
3478
+ params.push(filters.from_contains);
3479
+ }
3480
+ if (filters.domain !== void 0) {
3481
+ whereParts.push(`${DOMAIN_SQL} = LOWER(?)`);
3482
+ params.push(filters.domain);
3483
+ }
3484
+ if (filters.domain_contains !== void 0) {
3485
+ whereParts.push(`${DOMAIN_SQL} LIKE '%' || LOWER(?) || '%'`);
3486
+ params.push(filters.domain_contains);
3487
+ }
3488
+ if (filters.subject_contains !== void 0) {
3489
+ whereParts.push("LOWER(COALESCE(e.subject, '')) LIKE '%' || LOWER(?) || '%'");
3490
+ params.push(filters.subject_contains);
3491
+ }
3492
+ if (filters.date_after !== void 0) {
3493
+ whereParts.push("COALESCE(e.date, 0) >= ?");
3494
+ params.push(parseDateFilter("date_after", filters.date_after));
3495
+ }
3496
+ if (filters.date_before !== void 0) {
3497
+ whereParts.push("COALESCE(e.date, 0) <= ?");
3498
+ params.push(parseDateFilter("date_before", filters.date_before));
3499
+ }
3500
+ if (filters.is_read !== void 0) {
3501
+ whereParts.push("COALESCE(e.is_read, 0) = ?");
3502
+ params.push(filters.is_read ? 1 : 0);
3503
+ }
3504
+ if (filters.is_newsletter !== void 0) {
3505
+ whereParts.push(filters.is_newsletter ? "ns.email IS NOT NULL" : "ns.email IS NULL");
3506
+ }
3507
+ if (filters.has_label !== void 0) {
3508
+ whereParts.push(
3509
+ filters.has_label ? `EXISTS (
3510
+ SELECT 1
3511
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3512
+ WHERE ${userLabelPredicate("label_filter.value")}
3513
+ )` : `NOT EXISTS (
3514
+ SELECT 1
3515
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3516
+ WHERE ${userLabelPredicate("label_filter.value")}
3517
+ )`
3518
+ );
3519
+ }
3520
+ if (filters.label !== void 0) {
3521
+ whereParts.push(`
3522
+ EXISTS (
3523
+ SELECT 1
3524
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3525
+ WHERE LOWER(CAST(label_filter.value AS TEXT)) = LOWER(?)
3526
+ )
3527
+ `);
3528
+ params.push(filters.label);
3529
+ }
3530
+ if (filters.has_unsubscribe !== void 0) {
3531
+ whereParts.push(
3532
+ filters.has_unsubscribe ? "NULLIF(TRIM(e.list_unsubscribe), '') IS NOT NULL" : "(e.list_unsubscribe IS NULL OR TRIM(e.list_unsubscribe) = '')"
3533
+ );
3534
+ }
3535
+ if (filters.min_sender_messages !== void 0) {
3536
+ whereParts.push("COALESCE(sender_stats.totalFromSender, 0) >= ?");
3537
+ params.push(filters.min_sender_messages);
3538
+ }
3539
+ if (groupBy === "label") {
3540
+ whereParts.push(userLabelPredicate("grouped_label.value"));
3541
+ }
3542
+ return {
3543
+ sql: whereParts.length > 0 ? `WHERE ${whereParts.join("\n AND ")}` : "",
3544
+ params
3545
+ };
3546
+ }
3547
+ function buildHavingClause(having) {
3548
+ const parts = [];
3549
+ for (const field of QUERY_EMAIL_HAVING_FIELDS) {
3550
+ const condition = having[field];
3551
+ if (!condition) {
3552
+ continue;
3553
+ }
3554
+ const expression = AGGREGATE_SQL_MAP[field];
3555
+ if (condition.gte !== void 0) {
3556
+ parts.push(`${expression} >= ${condition.gte}`);
3557
+ }
3558
+ if (condition.lte !== void 0) {
3559
+ parts.push(`${expression} <= ${condition.lte}`);
3560
+ }
3561
+ }
3562
+ return parts.length > 0 ? `HAVING ${parts.join("\n AND ")}` : "";
3563
+ }
3564
+ function normalizeRow(row, groupBy, aggregates) {
3565
+ const normalized = {};
3566
+ if (groupBy) {
3567
+ normalized[groupBy] = normalizeGroupValue(groupBy, row[groupBy]);
3568
+ }
3569
+ for (const aggregate of aggregates) {
3570
+ normalized[aggregate] = normalizeAggregateValue(aggregate, row[aggregate]);
3571
+ }
3572
+ return normalized;
3573
+ }
3574
+ async function queryEmails(options = {}) {
3575
+ const parsed = queryEmailsInputSchema.parse(options);
3576
+ await detectNewsletters();
3577
+ const sqlite = getStatsSqlite();
3578
+ const filters = parsed.filters ?? {};
3579
+ const groupBy = parsed.group_by;
3580
+ const aggregates = resolveAggregates(parsed.aggregates);
3581
+ const having = parsed.having ?? {};
3582
+ const orderBy = resolveOrderBy(parsed.order_by, groupBy, aggregates);
3583
+ const limit = Math.min(500, normalizeLimit(parsed.limit, 50));
3584
+ const { sql: whereSql, params } = buildWhereClauses(filters, groupBy);
3585
+ const havingSql = buildHavingClause(having);
3586
+ const fromSql = [
3587
+ "FROM emails AS e",
3588
+ "LEFT JOIN newsletter_senders AS ns ON LOWER(ns.email) = LOWER(e.from_address)",
3589
+ `LEFT JOIN (
3590
+ SELECT
3591
+ LOWER(from_address) AS senderKey,
3592
+ COUNT(*) AS totalFromSender
3593
+ FROM emails
3594
+ WHERE from_address IS NOT NULL
3595
+ AND TRIM(from_address) <> ''
3596
+ GROUP BY LOWER(from_address)
3597
+ ) AS sender_stats ON sender_stats.senderKey = LOWER(e.from_address)`,
3598
+ groupBy === "label" ? "JOIN json_each(COALESCE(e.label_ids, '[]')) AS grouped_label" : ""
3599
+ ].filter(Boolean).join("\n");
3600
+ const selectParts = [];
3601
+ if (groupBy) {
3602
+ selectParts.push(`${GROUP_BY_SQL_MAP[groupBy]} AS ${groupBy}`);
3603
+ }
3604
+ for (const aggregate of aggregates) {
3605
+ selectParts.push(`${AGGREGATE_SQL_MAP[aggregate]} AS ${aggregate}`);
3606
+ }
3607
+ const groupBySql = groupBy ? `GROUP BY ${GROUP_BY_SQL_MAP[groupBy]}` : "";
3608
+ const orderBySql = `ORDER BY ${orderBy.split(" ")[0]} ${orderBy.split(" ")[1].toUpperCase()}`;
3609
+ const totalRow = groupBy ? sqlite.prepare(
3610
+ `
3611
+ SELECT COUNT(*) AS totalRows
3612
+ FROM (
3613
+ SELECT 1
3614
+ ${fromSql}
3615
+ ${whereSql}
3616
+ ${groupBySql}
3617
+ ${havingSql}
3618
+ ) AS grouped_rows
3619
+ `
3620
+ ).get(...params) : void 0;
3621
+ const rows = sqlite.prepare(
3622
+ `
3623
+ SELECT
3624
+ ${selectParts.join(",\n ")}
3625
+ ${fromSql}
3626
+ ${whereSql}
3627
+ ${groupBySql}
3628
+ ${havingSql}
3629
+ ${orderBySql}
3630
+ LIMIT ?
3631
+ `
3632
+ ).all(...params, limit);
3633
+ return {
3634
+ rows: rows.map((row) => normalizeRow(row, groupBy, aggregates)),
3635
+ totalRows: groupBy ? totalRow?.totalRows ?? 0 : rows.length,
3636
+ query: {
3637
+ filters,
3638
+ group_by: groupBy ?? null,
3639
+ aggregates,
3640
+ having,
3641
+ order_by: orderBy,
3642
+ limit
3643
+ }
3644
+ };
3645
+ }
3646
+
3647
+ // src/core/stats/sender.ts
3648
+ function buildSenderWhereClause(period) {
3649
+ const whereParts = [
3650
+ "from_address IS NOT NULL",
3651
+ "TRIM(from_address) <> ''"
3652
+ ];
3653
+ const params = [];
3654
+ const periodStart = getPeriodStart(period);
2329
3655
  if (periodStart !== null) {
2330
3656
  whereParts.push("date >= ?");
2331
3657
  params.push(periodStart);
@@ -2476,6 +3802,270 @@ async function getSenderStats(emailOrDomain) {
2476
3802
  };
2477
3803
  }
2478
3804
 
3805
+ // src/core/stats/uncategorized.ts
3806
+ var SYSTEM_LABEL_IDS2 = [
3807
+ "INBOX",
3808
+ "UNREAD",
3809
+ "IMPORTANT",
3810
+ "SENT",
3811
+ "DRAFT",
3812
+ "SPAM",
3813
+ "TRASH",
3814
+ "STARRED"
3815
+ ];
3816
+ var CATEGORY_LABEL_PATTERN = "CATEGORY\\_%";
3817
+ function toIsoString4(value) {
3818
+ if (!value) {
3819
+ return null;
3820
+ }
3821
+ return new Date(value).toISOString();
3822
+ }
3823
+ function parseJsonArray4(raw) {
3824
+ if (!raw) {
3825
+ return [];
3826
+ }
3827
+ try {
3828
+ const parsed = JSON.parse(raw);
3829
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
3830
+ } catch {
3831
+ return [];
3832
+ }
3833
+ }
3834
+ function resolveSinceTimestamp2(since) {
3835
+ if (!since) {
3836
+ return null;
3837
+ }
3838
+ const parsed = Date.parse(since);
3839
+ if (Number.isNaN(parsed)) {
3840
+ throw new Error(`Invalid since value: ${since}`);
3841
+ }
3842
+ return parsed;
3843
+ }
3844
+ function computeConfidence(row) {
3845
+ const signals = [];
3846
+ let score = 0;
3847
+ const hasDefinitiveNewsletterSignal = Boolean(row.listUnsubscribe && row.listUnsubscribe.trim()) || Boolean(row.detectionReason?.includes("list_unsubscribe"));
3848
+ if (row.listUnsubscribe && row.listUnsubscribe.trim()) {
3849
+ signals.push("list_unsubscribe_header");
3850
+ score += 3;
3851
+ }
3852
+ if (row.detectionReason?.includes("list_unsubscribe")) {
3853
+ signals.push("newsletter_list_header");
3854
+ score += 2;
3855
+ }
3856
+ if ((row.totalFromSender ?? 0) >= 20) {
3857
+ signals.push("high_volume_sender");
3858
+ score += 2;
3859
+ } else if ((row.totalFromSender ?? 0) >= 5) {
3860
+ signals.push("moderate_volume_sender");
3861
+ score += 1;
3862
+ }
3863
+ if (row.detectionReason?.includes("known_sender_pattern")) {
3864
+ signals.push("automated_sender_pattern");
3865
+ score += 1;
3866
+ }
3867
+ if (row.detectionReason?.includes("bulk_sender_pattern")) {
3868
+ signals.push("bulk_sender_pattern");
3869
+ score += 1;
3870
+ }
3871
+ if ((row.totalFromSender ?? 0) <= 2 && !hasDefinitiveNewsletterSignal) {
3872
+ signals.push("rare_sender");
3873
+ score -= 3;
3874
+ }
3875
+ if (!row.detectionReason) {
3876
+ signals.push("no_newsletter_signals");
3877
+ score -= 2;
3878
+ }
3879
+ if (!row.detectionReason && !isLikelyAutomatedSenderAddress(row.sender || "")) {
3880
+ signals.push("personal_sender_address");
3881
+ score -= 2;
3882
+ }
3883
+ return {
3884
+ confidence: score >= 3 ? "high" : score >= 0 ? "medium" : "low",
3885
+ signals
3886
+ };
3887
+ }
3888
+ function buildWhereClause(options) {
3889
+ const whereParts = [
3890
+ `
3891
+ NOT EXISTS (
3892
+ SELECT 1
3893
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label
3894
+ WHERE label.value IS NOT NULL
3895
+ AND TRIM(CAST(label.value AS TEXT)) <> ''
3896
+ AND label.value NOT IN (${SYSTEM_LABEL_IDS2.map(() => "?").join(", ")})
3897
+ AND label.value NOT LIKE ? ESCAPE '\\'
3898
+ )
3899
+ `
3900
+ ];
3901
+ const params = [...SYSTEM_LABEL_IDS2, CATEGORY_LABEL_PATTERN];
3902
+ if (options.unreadOnly) {
3903
+ whereParts.push("COALESCE(e.is_read, 0) = 0");
3904
+ }
3905
+ if (options.sinceTimestamp !== null) {
3906
+ whereParts.push("COALESCE(e.date, 0) >= ?");
3907
+ params.push(options.sinceTimestamp);
3908
+ }
3909
+ return {
3910
+ clause: whereParts.join(" AND "),
3911
+ params
3912
+ };
3913
+ }
3914
+ async function getUncategorizedEmails(options = {}) {
3915
+ await detectNewsletters();
3916
+ const sqlite = getStatsSqlite();
3917
+ const limit = Math.min(1e3, normalizeLimit(options.limit, 50));
3918
+ const offset = Math.max(0, Math.floor(options.offset ?? 0));
3919
+ const sinceTimestamp = resolveSinceTimestamp2(options.since);
3920
+ const { clause, params } = buildWhereClause({
3921
+ sinceTimestamp,
3922
+ unreadOnly: options.unreadOnly ?? false
3923
+ });
3924
+ const totalRow = sqlite.prepare(
3925
+ `
3926
+ SELECT COUNT(*) AS total
3927
+ FROM emails AS e
3928
+ WHERE ${clause}
3929
+ `
3930
+ ).get(...params);
3931
+ const rows = sqlite.prepare(
3932
+ `
3933
+ SELECT
3934
+ e.id AS id,
3935
+ e.thread_id AS threadId,
3936
+ e.from_address AS sender,
3937
+ e.subject AS subject,
3938
+ e.date AS date,
3939
+ e.snippet AS snippet,
3940
+ e.label_ids AS labelIds,
3941
+ e.is_read AS isRead,
3942
+ sender_stats.totalFromSender AS totalFromSender,
3943
+ sender_stats.unreadFromSender AS unreadFromSender,
3944
+ ns.detection_reason AS detectionReason,
3945
+ e.list_unsubscribe AS listUnsubscribe
3946
+ FROM emails AS e
3947
+ LEFT JOIN (
3948
+ SELECT
3949
+ LOWER(from_address) AS senderKey,
3950
+ COUNT(*) AS totalFromSender,
3951
+ SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unreadFromSender
3952
+ FROM emails
3953
+ WHERE from_address IS NOT NULL
3954
+ AND TRIM(from_address) <> ''
3955
+ GROUP BY LOWER(from_address)
3956
+ ) AS sender_stats
3957
+ ON sender_stats.senderKey = LOWER(e.from_address)
3958
+ LEFT JOIN newsletter_senders AS ns
3959
+ ON LOWER(ns.email) = LOWER(e.from_address)
3960
+ WHERE ${clause}
3961
+ ORDER BY COALESCE(e.date, 0) DESC, e.id ASC
3962
+ LIMIT ?
3963
+ OFFSET ?
3964
+ `
3965
+ ).all(...params, limit, offset);
3966
+ const emails2 = rows.map((row) => {
3967
+ const totalFromSender = row.totalFromSender ?? 0;
3968
+ const unreadFromSender = row.unreadFromSender ?? 0;
3969
+ const confidence = computeConfidence(row);
3970
+ return {
3971
+ id: row.id,
3972
+ threadId: row.threadId || "",
3973
+ from: row.sender || "",
3974
+ subject: row.subject || "",
3975
+ date: toIsoString4(row.date),
3976
+ snippet: row.snippet || "",
3977
+ labels: parseJsonArray4(row.labelIds),
3978
+ isRead: row.isRead === 1,
3979
+ senderContext: {
3980
+ totalFromSender,
3981
+ unreadRate: roundPercent(unreadFromSender, totalFromSender),
3982
+ isNewsletter: Boolean(row.detectionReason),
3983
+ detectionReason: row.detectionReason,
3984
+ confidence: confidence.confidence,
3985
+ signals: confidence.signals
3986
+ }
3987
+ };
3988
+ });
3989
+ return {
3990
+ totalUncategorized: totalRow?.total ?? 0,
3991
+ returned: emails2.length,
3992
+ offset,
3993
+ hasMore: offset + emails2.length < (totalRow?.total ?? 0),
3994
+ emails: emails2
3995
+ };
3996
+ }
3997
+
3998
+ // src/core/stats/unsubscribe.ts
3999
+ function toIsoString5(value) {
4000
+ if (!value) {
4001
+ return null;
4002
+ }
4003
+ return new Date(value).toISOString();
4004
+ }
4005
+ function roundImpactScore(messageCount, unreadRate) {
4006
+ return Math.round(messageCount * unreadRate * 10 / 100) / 10;
4007
+ }
4008
+ async function getUnsubscribeSuggestions(options = {}) {
4009
+ await detectNewsletters();
4010
+ const sqlite = getStatsSqlite();
4011
+ const limit = Math.min(50, normalizeLimit(options.limit, 20));
4012
+ const minMessages = normalizeLimit(options.minMessages, 5);
4013
+ const rows = sqlite.prepare(
4014
+ `
4015
+ SELECT
4016
+ e.from_address AS email,
4017
+ COALESCE(MAX(NULLIF(TRIM(e.from_name), '')), e.from_address) AS name,
4018
+ COUNT(*) AS messageCount,
4019
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
4020
+ MAX(CASE WHEN e.is_read = 1 THEN e.date ELSE NULL END) AS lastRead,
4021
+ MAX(e.date) AS lastReceived,
4022
+ MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
4023
+ GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
4024
+ ') AS emailUnsubscribeHeaders
4025
+ FROM emails AS e
4026
+ LEFT JOIN newsletter_senders AS ns
4027
+ ON LOWER(ns.email) = LOWER(e.from_address)
4028
+ WHERE e.from_address IS NOT NULL
4029
+ AND TRIM(e.from_address) <> ''
4030
+ GROUP BY LOWER(e.from_address)
4031
+ HAVING COUNT(*) >= ?
4032
+ `
4033
+ ).all(minMessages);
4034
+ const suggestions = rows.map((row) => {
4035
+ const unsubscribe2 = resolveUnsubscribeTarget(
4036
+ row.newsletterUnsubscribeLink,
4037
+ row.emailUnsubscribeHeaders
4038
+ );
4039
+ if (!unsubscribe2.unsubscribeLink || !unsubscribe2.unsubscribeMethod) {
4040
+ return null;
4041
+ }
4042
+ const unreadRate = roundPercent(row.unreadCount, row.messageCount);
4043
+ const readRate = roundPercent(row.messageCount - row.unreadCount, row.messageCount);
4044
+ return {
4045
+ email: row.email,
4046
+ name: row.name?.trim() || row.email,
4047
+ allTimeMessageCount: row.messageCount,
4048
+ unreadCount: row.unreadCount,
4049
+ unreadRate,
4050
+ readRate,
4051
+ lastRead: toIsoString5(row.lastRead),
4052
+ lastReceived: toIsoString5(row.lastReceived),
4053
+ unsubscribeLink: unsubscribe2.unsubscribeLink,
4054
+ unsubscribeMethod: unsubscribe2.unsubscribeMethod,
4055
+ impactScore: roundImpactScore(row.messageCount, unreadRate),
4056
+ reason: buildUnsubscribeReason(unreadRate, row.messageCount)
4057
+ };
4058
+ }).filter((suggestion) => suggestion !== null).filter(
4059
+ (suggestion) => options.unreadOnlySenders ? suggestion.unreadCount === suggestion.allTimeMessageCount : true
4060
+ ).sort(
4061
+ (left, right) => right.impactScore - left.impactScore || right.allTimeMessageCount - left.allTimeMessageCount || right.unreadRate - left.unreadRate || (right.lastReceived || "").localeCompare(left.lastReceived || "") || left.email.localeCompare(right.email)
4062
+ );
4063
+ return {
4064
+ suggestions: suggestions.slice(0, limit),
4065
+ totalWithUnsubscribeLinks: suggestions.length
4066
+ };
4067
+ }
4068
+
2479
4069
  // src/core/stats/volume.ts
2480
4070
  function getBucketExpression(granularity) {
2481
4071
  switch (granularity) {
@@ -2588,6 +4178,18 @@ async function getExecutionHistory(ruleId, limit = 20) {
2588
4178
  const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(limit);
2589
4179
  return runs.slice(0, limit);
2590
4180
  }
4181
+ async function getExecutionStats(ruleId) {
4182
+ const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(1e4);
4183
+ return {
4184
+ totalRuns: runs.length,
4185
+ plannedRuns: runs.filter((run) => run.status === "planned").length,
4186
+ appliedRuns: runs.filter((run) => run.status === "applied").length,
4187
+ partialRuns: runs.filter((run) => run.status === "partial").length,
4188
+ errorRuns: runs.filter((run) => run.status === "error").length,
4189
+ undoneRuns: runs.filter((run) => run.status === "undone").length,
4190
+ lastExecutionAt: runs[0]?.createdAt ?? null
4191
+ };
4192
+ }
2591
4193
 
2592
4194
  // src/core/rules/deploy.ts
2593
4195
  import { randomUUID as randomUUID3 } from "crypto";
@@ -2599,67 +4201,67 @@ import { join as join2 } from "path";
2599
4201
  import YAML from "yaml";
2600
4202
 
2601
4203
  // src/core/rules/types.ts
2602
- import { z } from "zod";
2603
- var RuleNameSchema = z.string().min(1, "Rule name is required").regex(
4204
+ import { z as z3 } from "zod";
4205
+ var RuleNameSchema = z3.string().min(1, "Rule name is required").regex(
2604
4206
  /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
2605
4207
  "Rule name must be kebab-case (lowercase letters, numbers, and single hyphens)"
2606
4208
  );
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) => {
4209
+ var RuleFieldSchema = z3.enum(["from", "to", "subject", "snippet", "labels"]);
4210
+ var RegexStringSchema = z3.string().min(1, "Pattern must not be empty").superRefine((value, ctx) => {
2609
4211
  try {
2610
4212
  new RegExp(value);
2611
4213
  } catch (error) {
2612
4214
  ctx.addIssue({
2613
- code: z.ZodIssueCode.custom,
4215
+ code: z3.ZodIssueCode.custom,
2614
4216
  message: `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`
2615
4217
  });
2616
4218
  }
2617
4219
  });
2618
- var MatcherSchema = z.object({
4220
+ var MatcherSchema = z3.object({
2619
4221
  // `snippet` is the only cached free-text matcher in MVP.
2620
4222
  field: RuleFieldSchema,
2621
4223
  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)
4224
+ contains: z3.array(z3.string().min(1)).min(1).optional(),
4225
+ values: z3.array(z3.string().min(1)).min(1).optional(),
4226
+ exclude: z3.boolean().default(false)
2625
4227
  }).strict().superRefine((value, ctx) => {
2626
4228
  if (!value.pattern && !value.contains && !value.values) {
2627
4229
  ctx.addIssue({
2628
- code: z.ZodIssueCode.custom,
4230
+ code: z3.ZodIssueCode.custom,
2629
4231
  message: "Matcher must provide at least one of pattern, contains, or values",
2630
4232
  path: ["pattern"]
2631
4233
  });
2632
4234
  }
2633
4235
  });
2634
- var ConditionsSchema = z.object({
2635
- operator: z.enum(["AND", "OR"]),
2636
- matchers: z.array(MatcherSchema).min(1, "At least one matcher is required")
4236
+ var ConditionsSchema = z3.object({
4237
+ operator: z3.enum(["AND", "OR"]),
4238
+ matchers: z3.array(MatcherSchema).min(1, "At least one matcher is required")
2637
4239
  }).strict();
2638
- var LabelActionSchema = z.object({
2639
- type: z.literal("label"),
2640
- label: z.string().min(1, "Label name is required")
4240
+ var LabelActionSchema = z3.object({
4241
+ type: z3.literal("label"),
4242
+ label: z3.string().min(1, "Label name is required")
2641
4243
  });
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")
4244
+ var ArchiveActionSchema = z3.object({ type: z3.literal("archive") });
4245
+ var MarkReadActionSchema = z3.object({ type: z3.literal("mark_read") });
4246
+ var ForwardActionSchema = z3.object({
4247
+ type: z3.literal("forward"),
4248
+ to: z3.string().email("Forward destination must be a valid email address")
2647
4249
  });
2648
- var MarkSpamActionSchema = z.object({ type: z.literal("mark_spam") });
2649
- var ActionSchema = z.discriminatedUnion("type", [
4250
+ var MarkSpamActionSchema = z3.object({ type: z3.literal("mark_spam") });
4251
+ var ActionSchema = z3.discriminatedUnion("type", [
2650
4252
  LabelActionSchema,
2651
4253
  ArchiveActionSchema,
2652
4254
  MarkReadActionSchema,
2653
4255
  ForwardActionSchema,
2654
4256
  MarkSpamActionSchema
2655
4257
  ]);
2656
- var RuleSchema = z.object({
4258
+ var RuleSchema = z3.object({
2657
4259
  name: RuleNameSchema,
2658
- description: z.string(),
2659
- enabled: z.boolean().default(true),
2660
- priority: z.number().int().min(0).default(50),
4260
+ description: z3.string(),
4261
+ enabled: z3.boolean().default(true),
4262
+ priority: z3.number().int().min(0).default(50),
2661
4263
  conditions: ConditionsSchema,
2662
- actions: z.array(ActionSchema).min(1, "At least one action is required")
4264
+ actions: z3.array(ActionSchema).min(1, "At least one action is required")
2663
4265
  }).strict();
2664
4266
 
2665
4267
  // src/core/rules/loader.ts
@@ -2989,7 +4591,7 @@ function getDatabase4() {
2989
4591
  const config = loadConfig();
2990
4592
  return getSqlite(config.dbPath);
2991
4593
  }
2992
- function parseJsonArray2(value) {
4594
+ function parseJsonArray5(value) {
2993
4595
  if (!value) {
2994
4596
  return [];
2995
4597
  }
@@ -3000,19 +4602,19 @@ function parseJsonArray2(value) {
3000
4602
  return [];
3001
4603
  }
3002
4604
  }
3003
- function rowToEmail2(row) {
4605
+ function rowToEmail3(row) {
3004
4606
  return {
3005
4607
  id: row.id,
3006
4608
  threadId: row.thread_id ?? "",
3007
4609
  fromAddress: row.from_address ?? "",
3008
4610
  fromName: row.from_name ?? "",
3009
- toAddresses: parseJsonArray2(row.to_addresses),
4611
+ toAddresses: parseJsonArray5(row.to_addresses),
3010
4612
  subject: row.subject ?? "",
3011
4613
  snippet: row.snippet ?? "",
3012
4614
  date: row.date ?? 0,
3013
4615
  isRead: row.is_read === 1,
3014
4616
  isStarred: row.is_starred === 1,
3015
- labelIds: parseJsonArray2(row.label_ids),
4617
+ labelIds: parseJsonArray5(row.label_ids),
3016
4618
  sizeEstimate: row.size_estimate ?? 0,
3017
4619
  hasAttachments: row.has_attachments === 1,
3018
4620
  listUnsubscribe: row.list_unsubscribe
@@ -3121,7 +4723,7 @@ async function findMatchingEmails(ruleOrConditions, limit) {
3121
4723
  ).all();
3122
4724
  const matches = [];
3123
4725
  for (const row of rows) {
3124
- const email = rowToEmail2(row);
4726
+ const email = rowToEmail3(row);
3125
4727
  const result = matchEmail(email, conditions);
3126
4728
  if (!result.matches) {
3127
4729
  continue;
@@ -3186,7 +4788,7 @@ async function loadMatchedItems(rule, options) {
3186
4788
  errorMessage: null
3187
4789
  }));
3188
4790
  }
3189
- async function executeAction(emailId, action, options) {
4791
+ async function executeAction2(emailId, action, options) {
3190
4792
  switch (action.type) {
3191
4793
  case "archive":
3192
4794
  return (await archiveEmails([emailId], options)).items[0];
@@ -3207,7 +4809,7 @@ async function applyRuleActions(item, actions, options) {
3207
4809
  };
3208
4810
  for (const action of actions) {
3209
4811
  try {
3210
- const result = await executeAction(item.emailId, action, options);
4812
+ const result = await executeAction2(item.emailId, action, options);
3211
4813
  current = {
3212
4814
  ...current,
3213
4815
  status: result.status,
@@ -3335,7 +4937,7 @@ async function runAllRules(options) {
3335
4937
  }
3336
4938
 
3337
4939
  // src/core/gmail/filters.ts
3338
- async function resolveContext3(options) {
4940
+ async function resolveContext5(options) {
3339
4941
  const config = options?.config ?? loadConfig();
3340
4942
  const transport = options?.transport ?? await getGmailTransport(config);
3341
4943
  return { config, transport };
@@ -3385,7 +4987,7 @@ async function buildLabelMap(context) {
3385
4987
  return map;
3386
4988
  }
3387
4989
  async function listFilters(options) {
3388
- const context = await resolveContext3(options);
4990
+ const context = await resolveContext5(options);
3389
4991
  const [response, labelMap] = await Promise.all([
3390
4992
  context.transport.listFilters(),
3391
4993
  buildLabelMap(context)
@@ -3394,7 +4996,7 @@ async function listFilters(options) {
3394
4996
  return raw.map((f) => toGmailFilter(f, labelMap)).filter((f) => f !== null);
3395
4997
  }
3396
4998
  async function getFilter(id, options) {
3397
- const context = await resolveContext3(options);
4999
+ const context = await resolveContext5(options);
3398
5000
  const [raw, labelMap] = await Promise.all([
3399
5001
  context.transport.getFilter(id),
3400
5002
  buildLabelMap(context)
@@ -3418,7 +5020,7 @@ async function createFilter(input, options) {
3418
5020
  "At least one action is required (labelName, archive, markRead, star, or forward)"
3419
5021
  );
3420
5022
  }
3421
- const context = await resolveContext3(options);
5023
+ const context = await resolveContext5(options);
3422
5024
  const addLabelIds = [];
3423
5025
  if (input.star) {
3424
5026
  addLabelIds.push("STARRED");
@@ -3457,7 +5059,7 @@ async function createFilter(input, options) {
3457
5059
  return filter;
3458
5060
  }
3459
5061
  async function deleteFilter(id, options) {
3460
- const context = await resolveContext3(options);
5062
+ const context = await resolveContext5(options);
3461
5063
  await context.transport.deleteFilter(id);
3462
5064
  }
3463
5065
 
@@ -3949,8 +5551,8 @@ async function getSyncStatus() {
3949
5551
  }
3950
5552
 
3951
5553
  // src/mcp/server.ts
3952
- var DAY_MS2 = 24 * 60 * 60 * 1e3;
3953
- var MCP_VERSION = "0.1.0";
5554
+ var DAY_MS4 = 24 * 60 * 60 * 1e3;
5555
+ var MCP_VERSION = "0.3.0";
3954
5556
  var MCP_TOOLS = [
3955
5557
  "search_emails",
3956
5558
  "get_email",
@@ -3959,6 +5561,7 @@ var MCP_TOOLS = [
3959
5561
  "archive_emails",
3960
5562
  "label_emails",
3961
5563
  "mark_read",
5564
+ "batch_apply_actions",
3962
5565
  "forward_email",
3963
5566
  "undo_run",
3964
5567
  "get_labels",
@@ -3967,6 +5570,12 @@ var MCP_TOOLS = [
3967
5570
  "get_top_senders",
3968
5571
  "get_sender_stats",
3969
5572
  "get_newsletter_senders",
5573
+ "get_uncategorized_emails",
5574
+ "review_categorized",
5575
+ "query_emails",
5576
+ "get_noise_senders",
5577
+ "get_unsubscribe_suggestions",
5578
+ "unsubscribe",
3970
5579
  "deploy_rule",
3971
5580
  "list_rules",
3972
5581
  "run_rule",
@@ -3980,6 +5589,8 @@ var MCP_TOOLS = [
3980
5589
  var MCP_RESOURCES = [
3981
5590
  "inbox://recent",
3982
5591
  "inbox://summary",
5592
+ "inbox://action-log",
5593
+ "schema://query-fields",
3983
5594
  "rules://deployed",
3984
5595
  "rules://history",
3985
5596
  "stats://senders",
@@ -3990,7 +5601,8 @@ var MCP_PROMPTS = [
3990
5601
  "review-senders",
3991
5602
  "find-newsletters",
3992
5603
  "suggest-rules",
3993
- "triage-inbox"
5604
+ "triage-inbox",
5605
+ "categorize-emails"
3994
5606
  ];
3995
5607
  function toTextResult(value) {
3996
5608
  return {
@@ -4064,12 +5676,22 @@ function buildSearchQuery(query, label) {
4064
5676
  }
4065
5677
  return trimmedQuery;
4066
5678
  }
4067
- function uniqueStrings(values) {
5679
+ function uniqueStrings3(values) {
4068
5680
  return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
4069
5681
  }
4070
5682
  function resolveResourceUri(uri, fallback) {
4071
5683
  return typeof uri === "string" ? uri : fallback;
4072
5684
  }
5685
+ function formatActionSummary(action) {
5686
+ switch (action.type) {
5687
+ case "label":
5688
+ return action.label ? `label:${action.label}` : "label";
5689
+ case "forward":
5690
+ return action.to ? `forward:${action.to}` : "forward";
5691
+ default:
5692
+ return action.type;
5693
+ }
5694
+ }
4073
5695
  async function buildStartupWarnings() {
4074
5696
  const config = loadConfig();
4075
5697
  initializeDb(config.dbPath);
@@ -4090,7 +5712,7 @@ async function buildStartupWarnings() {
4090
5712
  }
4091
5713
  if (!latestSync) {
4092
5714
  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) {
5715
+ } else if (Date.now() - latestSync > DAY_MS4) {
4094
5716
  warnings.push("Inbox cache appears stale (last sync older than 24 hours). Call `sync_inbox` if freshness matters.");
4095
5717
  }
4096
5718
  return warnings;
@@ -4101,7 +5723,7 @@ async function buildStatsOverview() {
4101
5723
  topSenders: await getTopSenders({ limit: 10 }),
4102
5724
  labelDistribution: (await getLabelDistribution()).slice(0, 10),
4103
5725
  dailyVolume: await getVolumeByPeriod("day", {
4104
- start: Date.now() - 30 * DAY_MS2,
5726
+ start: Date.now() - 30 * DAY_MS4,
4105
5727
  end: Date.now()
4106
5728
  })
4107
5729
  };
@@ -4113,6 +5735,23 @@ async function buildRuleHistory() {
4113
5735
  recentRuns: await getRecentRuns(20)
4114
5736
  };
4115
5737
  }
5738
+ async function buildActionLog() {
5739
+ const recentRuns = await getRecentRuns(10);
5740
+ const stats = await getExecutionStats();
5741
+ return {
5742
+ recentRuns: recentRuns.map((run) => ({
5743
+ runId: run.id,
5744
+ createdAt: new Date(run.createdAt).toISOString(),
5745
+ sourceType: run.sourceType,
5746
+ dryRun: run.dryRun,
5747
+ status: run.status,
5748
+ emailCount: run.itemCount,
5749
+ actions: run.requestedActions.map(formatActionSummary),
5750
+ undoAvailable: !run.dryRun && run.undoneAt === null && run.status !== "planned" && run.status !== "undone" && run.itemCount > 0
5751
+ })),
5752
+ totalRuns: stats.totalRuns
5753
+ };
5754
+ }
4116
5755
  async function createMcpServer() {
4117
5756
  const warnings = await buildStartupWarnings();
4118
5757
  const server = new McpServer({
@@ -4124,9 +5763,9 @@ async function createMcpServer() {
4124
5763
  {
4125
5764
  description: "Search Gmail using Gmail query syntax and return matching email metadata.",
4126
5765
  inputSchema: {
4127
- query: z2.string().min(1),
4128
- max_results: z2.number().int().positive().max(100).optional(),
4129
- label: z2.string().min(1).optional()
5766
+ query: z4.string().min(1),
5767
+ max_results: z4.number().int().positive().max(100).optional(),
5768
+ label: z4.string().min(1).optional()
4130
5769
  },
4131
5770
  annotations: {
4132
5771
  readOnlyHint: true
@@ -4141,7 +5780,7 @@ async function createMcpServer() {
4141
5780
  {
4142
5781
  description: "Fetch a single email with full content by Gmail message ID.",
4143
5782
  inputSchema: {
4144
- email_id: z2.string().min(1)
5783
+ email_id: z4.string().min(1)
4145
5784
  },
4146
5785
  annotations: {
4147
5786
  readOnlyHint: true
@@ -4154,7 +5793,7 @@ async function createMcpServer() {
4154
5793
  {
4155
5794
  description: "Fetch a full Gmail thread by thread ID.",
4156
5795
  inputSchema: {
4157
- thread_id: z2.string().min(1)
5796
+ thread_id: z4.string().min(1)
4158
5797
  },
4159
5798
  annotations: {
4160
5799
  readOnlyHint: true
@@ -4167,7 +5806,7 @@ async function createMcpServer() {
4167
5806
  {
4168
5807
  description: "Run inbox sync. Uses incremental sync by default and full sync when requested.",
4169
5808
  inputSchema: {
4170
- full: z2.boolean().optional()
5809
+ full: z4.boolean().optional()
4171
5810
  },
4172
5811
  annotations: {
4173
5812
  readOnlyHint: false,
@@ -4181,23 +5820,23 @@ async function createMcpServer() {
4181
5820
  {
4182
5821
  description: "Archive one or more Gmail messages by removing the INBOX label.",
4183
5822
  inputSchema: {
4184
- email_ids: z2.array(z2.string().min(1)).min(1)
5823
+ email_ids: z4.array(z4.string().min(1)).min(1)
4185
5824
  },
4186
5825
  annotations: {
4187
5826
  readOnlyHint: false,
4188
5827
  destructiveHint: false
4189
5828
  }
4190
5829
  },
4191
- toolHandler(async ({ email_ids }) => archiveEmails(uniqueStrings(email_ids)))
5830
+ toolHandler(async ({ email_ids }) => archiveEmails(uniqueStrings3(email_ids)))
4192
5831
  );
4193
5832
  server.registerTool(
4194
5833
  "label_emails",
4195
5834
  {
4196
5835
  description: "Add and/or remove Gmail labels on one or more messages.",
4197
5836
  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()
5837
+ email_ids: z4.array(z4.string().min(1)).min(1),
5838
+ add_labels: z4.array(z4.string().min(1)).optional(),
5839
+ remove_labels: z4.array(z4.string().min(1)).optional()
4201
5840
  },
4202
5841
  annotations: {
4203
5842
  readOnlyHint: false,
@@ -4205,9 +5844,9 @@ async function createMcpServer() {
4205
5844
  }
4206
5845
  },
4207
5846
  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);
5847
+ const ids = uniqueStrings3(email_ids);
5848
+ const addLabels = uniqueStrings3(add_labels);
5849
+ const removeLabels = uniqueStrings3(remove_labels);
4211
5850
  if (addLabels.length === 0 && removeLabels.length === 0) {
4212
5851
  throw new Error("Provide at least one label to add or remove.");
4213
5852
  }
@@ -4231,8 +5870,8 @@ async function createMcpServer() {
4231
5870
  {
4232
5871
  description: "Mark one or more Gmail messages as read or unread.",
4233
5872
  inputSchema: {
4234
- email_ids: z2.array(z2.string().min(1)).min(1),
4235
- read: z2.boolean()
5873
+ email_ids: z4.array(z4.string().min(1)).min(1),
5874
+ read: z4.boolean()
4236
5875
  },
4237
5876
  annotations: {
4238
5877
  readOnlyHint: false,
@@ -4240,17 +5879,53 @@ async function createMcpServer() {
4240
5879
  }
4241
5880
  },
4242
5881
  toolHandler(async ({ email_ids, read }) => {
4243
- const ids = uniqueStrings(email_ids);
5882
+ const ids = uniqueStrings3(email_ids);
4244
5883
  return read ? markRead(ids) : markUnread(ids);
4245
5884
  })
4246
5885
  );
5886
+ server.registerTool(
5887
+ "batch_apply_actions",
5888
+ {
5889
+ description: "Apply grouped inbox actions in one call for faster AI-driven triage and categorization.",
5890
+ inputSchema: {
5891
+ groups: z4.array(
5892
+ z4.object({
5893
+ email_ids: z4.array(z4.string().min(1)).min(1).max(500),
5894
+ actions: z4.array(
5895
+ z4.discriminatedUnion("type", [
5896
+ z4.object({
5897
+ type: z4.literal("label"),
5898
+ label: z4.string().min(1)
5899
+ }),
5900
+ z4.object({ type: z4.literal("archive") }),
5901
+ z4.object({ type: z4.literal("mark_read") }),
5902
+ z4.object({ type: z4.literal("mark_spam") })
5903
+ ])
5904
+ ).min(1).max(5)
5905
+ })
5906
+ ).min(1).max(20),
5907
+ dry_run: z4.boolean().optional()
5908
+ },
5909
+ annotations: {
5910
+ readOnlyHint: false,
5911
+ destructiveHint: false
5912
+ }
5913
+ },
5914
+ toolHandler(async ({ groups, dry_run }) => batchApplyActions({
5915
+ groups: groups.map((group) => ({
5916
+ emailIds: uniqueStrings3(group.email_ids),
5917
+ actions: group.actions
5918
+ })),
5919
+ dryRun: dry_run
5920
+ }))
5921
+ );
4247
5922
  server.registerTool(
4248
5923
  "forward_email",
4249
5924
  {
4250
5925
  description: "Forward a Gmail message to another address.",
4251
5926
  inputSchema: {
4252
- email_id: z2.string().min(1),
4253
- to: z2.string().email()
5927
+ email_id: z4.string().min(1),
5928
+ to: z4.string().email()
4254
5929
  },
4255
5930
  annotations: {
4256
5931
  readOnlyHint: false,
@@ -4264,7 +5939,7 @@ async function createMcpServer() {
4264
5939
  {
4265
5940
  description: "Undo a prior inboxctl action run when the underlying Gmail mutations are reversible.",
4266
5941
  inputSchema: {
4267
- run_id: z2.string().min(1)
5942
+ run_id: z4.string().min(1)
4268
5943
  },
4269
5944
  annotations: {
4270
5945
  readOnlyHint: false,
@@ -4288,8 +5963,8 @@ async function createMcpServer() {
4288
5963
  {
4289
5964
  description: "Create a Gmail label if it does not already exist.",
4290
5965
  inputSchema: {
4291
- name: z2.string().min(1),
4292
- color: z2.string().min(1).optional()
5966
+ name: z4.string().min(1),
5967
+ color: z4.string().min(1).optional()
4293
5968
  },
4294
5969
  annotations: {
4295
5970
  readOnlyHint: false,
@@ -4321,9 +5996,9 @@ async function createMcpServer() {
4321
5996
  {
4322
5997
  description: "Return top senders ranked by cached email volume.",
4323
5998
  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()
5999
+ limit: z4.number().int().positive().max(100).optional(),
6000
+ min_unread_rate: z4.number().min(0).max(100).optional(),
6001
+ period: z4.enum(["day", "week", "month", "year", "all"]).optional()
4327
6002
  },
4328
6003
  annotations: {
4329
6004
  readOnlyHint: true
@@ -4340,7 +6015,7 @@ async function createMcpServer() {
4340
6015
  {
4341
6016
  description: "Return detailed stats for a sender email address or an @domain aggregate.",
4342
6017
  inputSchema: {
4343
- email_or_domain: z2.string().min(1)
6018
+ email_or_domain: z4.string().min(1)
4344
6019
  },
4345
6020
  annotations: {
4346
6021
  readOnlyHint: true
@@ -4360,8 +6035,8 @@ async function createMcpServer() {
4360
6035
  {
4361
6036
  description: "Return senders that look like newsletters or mailing lists based on cached heuristics.",
4362
6037
  inputSchema: {
4363
- min_messages: z2.number().int().positive().optional(),
4364
- min_unread_rate: z2.number().min(0).max(100).optional()
6038
+ min_messages: z4.number().int().positive().optional(),
6039
+ min_unread_rate: z4.number().min(0).max(100).optional()
4365
6040
  },
4366
6041
  annotations: {
4367
6042
  readOnlyHint: true
@@ -4372,12 +6047,115 @@ async function createMcpServer() {
4372
6047
  minUnreadRate: min_unread_rate
4373
6048
  }))
4374
6049
  );
6050
+ server.registerTool(
6051
+ "get_uncategorized_emails",
6052
+ {
6053
+ description: "Return cached emails that have only Gmail system labels and no user-applied organization.",
6054
+ inputSchema: {
6055
+ limit: z4.number().int().positive().max(1e3).optional().describe("Max emails to return per page. Default 50. AI clients should start with 50-100 and paginate."),
6056
+ offset: z4.number().int().min(0).optional().describe("Number of results to skip for pagination. Use with totalUncategorized and hasMore."),
6057
+ unread_only: z4.boolean().optional(),
6058
+ since: z4.string().min(1).optional()
6059
+ },
6060
+ annotations: {
6061
+ readOnlyHint: true
6062
+ }
6063
+ },
6064
+ toolHandler(async ({ limit, offset, unread_only, since }) => getUncategorizedEmails({
6065
+ limit,
6066
+ offset,
6067
+ unreadOnly: unread_only,
6068
+ since
6069
+ }))
6070
+ );
6071
+ server.registerTool(
6072
+ "review_categorized",
6073
+ {
6074
+ description: "Scan recently categorized emails for anomalies that suggest a misclassification or over-aggressive archive.",
6075
+ inputSchema: reviewCategorizedInputSchema.shape,
6076
+ annotations: {
6077
+ readOnlyHint: true
6078
+ }
6079
+ },
6080
+ toolHandler(async (args) => reviewCategorized(args))
6081
+ );
6082
+ server.registerTool(
6083
+ "query_emails",
6084
+ {
6085
+ description: "Run structured analytics queries over the cached email dataset using fixed filters, groupings, and aggregates.",
6086
+ inputSchema: queryEmailsInputSchema.shape,
6087
+ annotations: {
6088
+ readOnlyHint: true
6089
+ }
6090
+ },
6091
+ toolHandler(async (args) => queryEmails(args))
6092
+ );
6093
+ server.registerTool(
6094
+ "get_noise_senders",
6095
+ {
6096
+ description: "Return a focused list of active, high-noise senders worth categorizing, filtering, or unsubscribing.",
6097
+ inputSchema: {
6098
+ limit: z4.number().int().positive().max(50).optional(),
6099
+ min_noise_score: z4.number().min(0).optional(),
6100
+ active_days: z4.number().int().positive().optional(),
6101
+ sort_by: z4.enum(["noise_score", "all_time_noise_score", "message_count", "unread_rate"]).optional().describe("Sort order. Default: noise_score. Use all_time_noise_score for lifetime perspective.")
6102
+ },
6103
+ annotations: {
6104
+ readOnlyHint: true
6105
+ }
6106
+ },
6107
+ toolHandler(async ({ limit, min_noise_score, active_days, sort_by }) => getNoiseSenders({
6108
+ limit,
6109
+ minNoiseScore: min_noise_score,
6110
+ activeDays: active_days,
6111
+ sortBy: sort_by
6112
+ }))
6113
+ );
6114
+ server.registerTool(
6115
+ "get_unsubscribe_suggestions",
6116
+ {
6117
+ description: "Return ranked senders with unsubscribe links, sorted by how much inbox noise unsubscribing would remove.",
6118
+ inputSchema: {
6119
+ limit: z4.number().int().positive().max(50).optional(),
6120
+ min_messages: z4.number().int().positive().optional(),
6121
+ unread_only_senders: z4.boolean().optional()
6122
+ },
6123
+ annotations: {
6124
+ readOnlyHint: true
6125
+ }
6126
+ },
6127
+ toolHandler(async ({ limit, min_messages, unread_only_senders }) => getUnsubscribeSuggestions({
6128
+ limit,
6129
+ minMessages: min_messages,
6130
+ unreadOnlySenders: unread_only_senders
6131
+ }))
6132
+ );
6133
+ server.registerTool(
6134
+ "unsubscribe",
6135
+ {
6136
+ description: "Return the unsubscribe target for a sender and optionally label/archive existing emails in one undoable run.",
6137
+ inputSchema: {
6138
+ sender_email: z4.string().min(1),
6139
+ also_archive: z4.boolean().optional(),
6140
+ also_label: z4.string().min(1).optional()
6141
+ },
6142
+ annotations: {
6143
+ readOnlyHint: false,
6144
+ destructiveHint: false
6145
+ }
6146
+ },
6147
+ toolHandler(async ({ sender_email, also_archive, also_label }) => unsubscribe({
6148
+ senderEmail: sender_email,
6149
+ alsoArchive: also_archive,
6150
+ alsoLabel: also_label
6151
+ }))
6152
+ );
4375
6153
  server.registerTool(
4376
6154
  "deploy_rule",
4377
6155
  {
4378
6156
  description: "Validate and deploy a rule directly from YAML content.",
4379
6157
  inputSchema: {
4380
- yaml_content: z2.string().min(1)
6158
+ yaml_content: z4.string().min(1)
4381
6159
  },
4382
6160
  annotations: {
4383
6161
  readOnlyHint: false,
@@ -4394,7 +6172,7 @@ async function createMcpServer() {
4394
6172
  {
4395
6173
  description: "List deployed inboxctl rules and their execution status.",
4396
6174
  inputSchema: {
4397
- enabled_only: z2.boolean().optional()
6175
+ enabled_only: z4.boolean().optional()
4398
6176
  },
4399
6177
  annotations: {
4400
6178
  readOnlyHint: true
@@ -4410,9 +6188,9 @@ async function createMcpServer() {
4410
6188
  {
4411
6189
  description: "Run a deployed rule in dry-run mode by default, or apply it when dry_run is false.",
4412
6190
  inputSchema: {
4413
- rule_name: z2.string().min(1),
4414
- dry_run: z2.boolean().optional(),
4415
- max_emails: z2.number().int().positive().max(1e3).optional()
6191
+ rule_name: z4.string().min(1),
6192
+ dry_run: z4.boolean().optional(),
6193
+ max_emails: z4.number().int().positive().max(1e3).optional()
4416
6194
  },
4417
6195
  annotations: {
4418
6196
  readOnlyHint: false,
@@ -4429,7 +6207,7 @@ async function createMcpServer() {
4429
6207
  {
4430
6208
  description: "Enable a deployed rule by name.",
4431
6209
  inputSchema: {
4432
- rule_name: z2.string().min(1)
6210
+ rule_name: z4.string().min(1)
4433
6211
  },
4434
6212
  annotations: {
4435
6213
  readOnlyHint: false,
@@ -4443,7 +6221,7 @@ async function createMcpServer() {
4443
6221
  {
4444
6222
  description: "Disable a deployed rule by name.",
4445
6223
  inputSchema: {
4446
- rule_name: z2.string().min(1)
6224
+ rule_name: z4.string().min(1)
4447
6225
  },
4448
6226
  annotations: {
4449
6227
  readOnlyHint: false,
@@ -4470,6 +6248,24 @@ async function createMcpServer() {
4470
6248
  },
4471
6249
  async (uri) => resourceText(resolveResourceUri(uri, "inbox://summary"), await getInboxOverview())
4472
6250
  );
6251
+ server.registerResource(
6252
+ "inbox-action-log",
6253
+ "inbox://action-log",
6254
+ {
6255
+ description: "Recent action history showing what inboxctl already did and whether undo is still available.",
6256
+ mimeType: "application/json"
6257
+ },
6258
+ async (uri) => resourceText(resolveResourceUri(uri, "inbox://action-log"), await buildActionLog())
6259
+ );
6260
+ server.registerResource(
6261
+ "query-fields",
6262
+ "schema://query-fields",
6263
+ {
6264
+ description: "Field vocabulary, aggregates, and examples for the query_emails analytics tool.",
6265
+ mimeType: "application/json"
6266
+ },
6267
+ async (uri) => resourceText(resolveResourceUri(uri, "schema://query-fields"), QUERY_EMAILS_FIELD_SCHEMA)
6268
+ );
4473
6269
  server.registerResource(
4474
6270
  "deployed-rules",
4475
6271
  "rules://deployed",
@@ -4529,10 +6325,28 @@ async function createMcpServer() {
4529
6325
  async () => promptResult(
4530
6326
  "Review top senders and recommend cleanup actions.",
4531
6327
  [
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."
6328
+ "Step 0 \u2014 Check for past mistakes:",
6329
+ " Call `review_categorized` to see if any recent categorisations look incorrect.",
6330
+ " If anomalies are found, present them first \u2014 fixing past mistakes takes priority over reviewing new senders.",
6331
+ "",
6332
+ "Step 1 \u2014 Gather data:",
6333
+ " Use `get_noise_senders` for the most actionable noisy senders.",
6334
+ " Use `rules://deployed` to check for existing rules covering these senders.",
6335
+ " Use `get_unsubscribe_suggestions` for senders you can unsubscribe from.",
6336
+ "",
6337
+ "Step 2 \u2014 For each noisy sender, recommend one of:",
6338
+ " KEEP \u2014 important, reduce noise with a label rule",
6339
+ " RULE \u2014 create a rule to auto-label + mark read (or archive)",
6340
+ " UNSUBSCRIBE \u2014 stop receiving entirely (has unsubscribe link, high unread rate)",
6341
+ "",
6342
+ "Step 3 \u2014 Present as a table:",
6343
+ " Sender | Messages | Unread% | Noise Score | Has Unsub | Recommendation | Reason",
6344
+ "",
6345
+ "Step 4 \u2014 Offer to act:",
6346
+ " For senders marked RULE, offer to generate YAML using the rule schema.",
6347
+ " Group similar senders (e.g. all shipping senders) into one rule.",
6348
+ " Present YAML for review before deploying with `deploy_rule`.",
6349
+ " For senders marked UNSUBSCRIBE, use `unsubscribe` with `also_archive: true` and return the link for the user to follow."
4536
6350
  ].join("\n")
4537
6351
  )
4538
6352
  );
@@ -4559,10 +6373,37 @@ async function createMcpServer() {
4559
6373
  async () => promptResult(
4560
6374
  "Analyze inbox patterns and propose valid inboxctl rule YAML.",
4561
6375
  [
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."
6376
+ "First, inspect these data sources:",
6377
+ "- `rules://deployed` \u2014 existing rules (avoid duplicates)",
6378
+ "- `get_noise_senders` \u2014 high-volume low-read senders",
6379
+ "- `get_newsletter_senders` \u2014 detected newsletters and mailing lists",
6380
+ "",
6381
+ "For each recommendation, generate complete YAML using this schema:",
6382
+ "",
6383
+ " name: kebab-case-name # lowercase, hyphens only",
6384
+ " description: What this rule does",
6385
+ " enabled: true",
6386
+ " priority: 50 # 0-100, lower = runs first",
6387
+ " conditions:",
6388
+ " operator: AND # AND or OR",
6389
+ " matchers:",
6390
+ " - field: from # from | to | subject | snippet | labels",
6391
+ " contains: # OR use: values (exact), pattern (regex)",
6392
+ ' - "@example.com"',
6393
+ " exclude: false # true to negate the match",
6394
+ " actions:",
6395
+ " - type: label # label | archive | mark_read | forward | mark_spam",
6396
+ ' label: "Category/Name"',
6397
+ " - type: mark_read",
6398
+ " - type: archive",
6399
+ "",
6400
+ "Matcher fields: `from`, `to`, `subject`, `snippet`, `labels`.",
6401
+ "Match modes (provide exactly one per matcher): `values` (exact), `contains` (substring), `pattern` (regex).",
6402
+ "Action types: `label` (requires `label` field), `archive`, `mark_read`, `forward` (requires `to` field), `mark_spam`.",
6403
+ "",
6404
+ "Group related senders into a single rule where possible (e.g. all shipping notifications in one rule).",
6405
+ "Explain why each rule is safe. Default to `mark_read` + `label` over `archive` unless evidence is strong.",
6406
+ "Present the YAML so the user can review before deploying with `deploy_rule`."
4566
6407
  ].join("\n")
4567
6408
  )
4568
6409
  );
@@ -4574,10 +6415,96 @@ async function createMcpServer() {
4574
6415
  async () => promptResult(
4575
6416
  "Triage unread mail using inboxctl data sources.",
4576
6417
  [
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."
6418
+ "Step 1 \u2014 Gather data:",
6419
+ " Use `get_uncategorized_emails` with `unread_only: true` for uncategorised unread mail.",
6420
+ " Use `inbox://summary` for overall counts.",
6421
+ " If totalUncategorized is large, process in pages rather than all at once.",
6422
+ " If more context is needed on a specific email, use `get_email` or `get_thread`.",
6423
+ "",
6424
+ "Step 2 \u2014 Categorise each email into one of:",
6425
+ " ACTION REQUIRED \u2014 needs a response or decision from the user",
6426
+ " FYI \u2014 worth knowing about but no action needed",
6427
+ " NOISE \u2014 bulk, promotional, or irrelevant",
6428
+ "",
6429
+ "Step 2.5 \u2014 Flag low-confidence items:",
6430
+ ' For any email with `confidence: "low"` in `senderContext`, always categorise it as ACTION REQUIRED.',
6431
+ " Better to surface a false positive than bury a real personal or work email.",
6432
+ "",
6433
+ "Step 3 \u2014 Present findings:",
6434
+ " List emails grouped by category with: sender, subject, and one-line reason.",
6435
+ " For NOISE, suggest a label and whether to archive.",
6436
+ " For FYI, suggest a label.",
6437
+ " For ACTION REQUIRED, summarise what action seems needed.",
6438
+ "",
6439
+ "Step 4 \u2014 Offer to apply:",
6440
+ " If the user approves, use `batch_apply_actions` to apply all decisions in one call.",
6441
+ " Group emails by their action set (e.g. all `label:Receipts + mark_read` together).",
6442
+ "",
6443
+ "Step 5 \u2014 Offer noise reduction:",
6444
+ " If NOISE senders appear repeatedly, suggest a rule or `unsubscribe` when a link is available."
6445
+ ].join("\n")
6446
+ )
6447
+ );
6448
+ server.registerPrompt(
6449
+ "categorize-emails",
6450
+ {
6451
+ description: "Systematically categorise uncategorised emails using sender patterns, content, and inbox analytics."
6452
+ },
6453
+ async () => promptResult(
6454
+ "Categorise uncategorised emails in the user's inbox.",
6455
+ [
6456
+ "Step 1 \u2014 Gather data:",
6457
+ " Use `get_uncategorized_emails` (start with limit 100).",
6458
+ " If totalUncategorized is more than 500, ask whether to process the recent batch or paginate through the full backlog.",
6459
+ " Use `get_noise_senders` for sender context.",
6460
+ " Use `get_unsubscribe_suggestions` for likely unsubscribe candidates.",
6461
+ " Use `get_labels` to see what labels already exist.",
6462
+ " Use `rules://deployed` to avoid duplicating existing automation.",
6463
+ "",
6464
+ "Step 2 \u2014 Assign each email a category:",
6465
+ " Receipts \u2014 purchase confirmations, invoices, payment notifications",
6466
+ " Shipping \u2014 delivery tracking, dispatch notices, shipping updates",
6467
+ " Newsletters \u2014 editorial content, digests, weekly roundups",
6468
+ " Promotions \u2014 marketing, sales, deals, coupons",
6469
+ " Social \u2014 social network notifications (LinkedIn, Facebook, etc.)",
6470
+ " Notifications \u2014 automated alerts, system notifications, service updates",
6471
+ " Finance \u2014 bank statements, investment updates, tax documents",
6472
+ " Travel \u2014 bookings, itineraries, check-in reminders",
6473
+ " Important \u2014 personal or work email requiring attention",
6474
+ "",
6475
+ "Step 3 \u2014 Present the categorisation plan:",
6476
+ " Group emails by assigned category.",
6477
+ " For each group show: count, senders involved, sample subjects.",
6478
+ " Note confidence level: HIGH (clear pattern), MEDIUM (reasonable guess), LOW (uncertain).",
6479
+ " Flag any LOW confidence items for the user to decide.",
6480
+ " Present the confidence breakdown: X HIGH (auto-apply), Y MEDIUM (label only), Z LOW (review queue).",
6481
+ " If any LOW confidence emails are present, note why they were flagged from the `signals` array.",
6482
+ "",
6483
+ "Step 3.5 \u2014 Apply confidence gating:",
6484
+ " HIGH confidence \u2014 safe to apply directly (label, mark_read, archive as appropriate).",
6485
+ " MEDIUM confidence \u2014 apply the category label only. Do not archive. Keep the email visible in the inbox.",
6486
+ " LOW confidence \u2014 apply only the label `inboxctl/Review`. Do not archive or mark read.",
6487
+ " These emails need human review before any further action.",
6488
+ "",
6489
+ "Step 4 \u2014 Apply with user approval:",
6490
+ " Create labels for any new categories (use `create_label`).",
6491
+ " Use `batch_apply_actions` to apply labels in one call.",
6492
+ " For Newsletters and Promotions with high unread rates, suggest mark_read + archive or `unsubscribe` when a link is available.",
6493
+ " For Receipts/Shipping/Notifications, suggest mark_read only (keep in inbox).",
6494
+ " For Important, do not mark read or archive.",
6495
+ "",
6496
+ "Step 5 \u2014 Paginate if needed:",
6497
+ " If hasMore is true, ask whether to continue with the next page using offset.",
6498
+ " Reuse the same sender categorisations on later pages instead of re-evaluating known senders.",
6499
+ "",
6500
+ "Step 6 \u2014 Suggest ongoing rules:",
6501
+ " For any category with 3+ emails from the same sender, suggest a YAML rule.",
6502
+ " This prevents the same categorisation from being needed again.",
6503
+ " Use `deploy_rule` after user reviews the YAML.",
6504
+ "",
6505
+ "Step 7 \u2014 Post-categorisation audit:",
6506
+ " After applying actions, call `review_categorized` to check for anomalies.",
6507
+ " If anomalies are found, present them with the option to undo the relevant run."
4581
6508
  ].join("\n")
4582
6509
  )
4583
6510
  );
@@ -4593,7 +6520,7 @@ async function createMcpServer() {
4593
6520
  {
4594
6521
  description: "Get the details of a specific Gmail server-side filter by ID.",
4595
6522
  inputSchema: {
4596
- filter_id: z2.string().min(1).describe("Gmail filter ID")
6523
+ filter_id: z4.string().min(1).describe("Gmail filter ID")
4597
6524
  }
4598
6525
  },
4599
6526
  toolHandler(async ({ filter_id }) => getFilter(filter_id))
@@ -4603,20 +6530,20 @@ async function createMcpServer() {
4603
6530
  {
4604
6531
  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
6532
  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)")
6533
+ from: z4.string().optional().describe("Match emails from this address"),
6534
+ to: z4.string().optional().describe("Match emails sent to this address"),
6535
+ subject: z4.string().optional().describe("Match emails with this text in the subject"),
6536
+ query: z4.string().optional().describe("Match using Gmail search syntax (e.g. 'has:attachment')"),
6537
+ negated_query: z4.string().optional().describe("Exclude emails matching this Gmail query"),
6538
+ has_attachment: z4.boolean().optional().describe("Match emails with attachments"),
6539
+ exclude_chats: z4.boolean().optional().describe("Exclude chat messages from matches"),
6540
+ size: z4.number().int().positive().optional().describe("Size threshold in bytes"),
6541
+ size_comparison: z4.enum(["larger", "smaller"]).optional().describe("Use with size: match emails larger or smaller than the threshold"),
6542
+ label: z4.string().optional().describe("Apply this label to matching emails (auto-created if it does not exist)"),
6543
+ archive: z4.boolean().optional().describe("Archive matching emails (remove from inbox)"),
6544
+ mark_read: z4.boolean().optional().describe("Mark matching emails as read"),
6545
+ star: z4.boolean().optional().describe("Star matching emails"),
6546
+ forward: z4.string().email().optional().describe("Forward matching emails to this address (address must be verified in Gmail settings)")
4620
6547
  }
4621
6548
  },
4622
6549
  toolHandler(
@@ -4643,7 +6570,7 @@ async function createMcpServer() {
4643
6570
  {
4644
6571
  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
6572
  inputSchema: {
4646
- filter_id: z2.string().min(1).describe("Gmail filter ID to delete")
6573
+ filter_id: z4.string().min(1).describe("Gmail filter ID to delete")
4647
6574
  }
4648
6575
  },
4649
6576
  toolHandler(async ({ filter_id }) => {
@@ -4741,4 +6668,4 @@ export {
4741
6668
  createMcpServer,
4742
6669
  startMcpServer
4743
6670
  };
4744
- //# sourceMappingURL=chunk-EY6VV43S.js.map
6671
+ //# sourceMappingURL=chunk-OLL3OA5B.js.map