inboxctl 0.1.0 → 0.2.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.
@@ -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,6 +2371,184 @@ 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
+
2058
2552
  // src/core/stats/common.ts
2059
2553
  var DAY_MS = 24 * 60 * 60 * 1e3;
2060
2554
  var SYSTEM_LABEL_NAMES = /* @__PURE__ */ new Map([
@@ -2318,6 +2812,124 @@ async function getNewsletters(options = {}) {
2318
2812
  return rows.map(mapNewsletterRow);
2319
2813
  }
2320
2814
 
2815
+ // src/core/stats/noise.ts
2816
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
2817
+ var SUGGESTED_CATEGORY_RULES = [
2818
+ { category: "Receipts", keywords: ["receipt", "invoice", "payment", "order"] },
2819
+ { category: "Shipping", keywords: ["shipping", "tracking", "delivery", "dispatch"] },
2820
+ { category: "Newsletters", keywords: ["newsletter", "digest", "weekly", "update"] },
2821
+ { category: "Notifications", keywords: ["noreply", "notification", "alert"] },
2822
+ { category: "Promotions", keywords: ["promo", "offer", "deal", "sale", "marketing"] },
2823
+ { category: "Social", keywords: ["linkedin", "facebook", "twitter", "social"] }
2824
+ ];
2825
+ function toIsoString(value) {
2826
+ if (!value) {
2827
+ return null;
2828
+ }
2829
+ return new Date(value).toISOString();
2830
+ }
2831
+ function roundNoiseScore(messageCount, unreadRate) {
2832
+ return Math.round(messageCount * unreadRate * 10 / 100) / 10;
2833
+ }
2834
+ function getSuggestedCategory(email, name) {
2835
+ const haystack = `${email} ${name}`.toLowerCase();
2836
+ for (const rule of SUGGESTED_CATEGORY_RULES) {
2837
+ if (rule.keywords.some((keyword) => haystack.includes(keyword))) {
2838
+ return rule.category;
2839
+ }
2840
+ }
2841
+ return "Other";
2842
+ }
2843
+ function compareNoiseSenders(sortBy) {
2844
+ return (left, right) => {
2845
+ switch (sortBy) {
2846
+ case "all_time_noise_score":
2847
+ return right.allTimeNoiseScore - left.allTimeNoiseScore || right.noiseScore - left.noiseScore || right.allTimeMessageCount - left.allTimeMessageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
2848
+ case "message_count":
2849
+ return right.messageCount - left.messageCount || right.noiseScore - left.noiseScore || right.allTimeMessageCount - left.allTimeMessageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
2850
+ case "unread_rate":
2851
+ return right.unreadRate - left.unreadRate || right.noiseScore - left.noiseScore || right.messageCount - left.messageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
2852
+ case "noise_score":
2853
+ default:
2854
+ return right.noiseScore - left.noiseScore || right.allTimeNoiseScore - left.allTimeNoiseScore || right.messageCount - left.messageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
2855
+ }
2856
+ };
2857
+ }
2858
+ async function getNoiseSenders(options = {}) {
2859
+ await detectNewsletters();
2860
+ const sqlite = getStatsSqlite();
2861
+ const limit = Math.min(50, normalizeLimit(options.limit, 20));
2862
+ const minNoiseScore = options.minNoiseScore ?? 5;
2863
+ const activeDays = Math.max(1, Math.floor(options.activeDays ?? 90));
2864
+ const activeSince = Date.now() - activeDays * DAY_MS2;
2865
+ const sortBy = options.sortBy ?? "noise_score";
2866
+ const rows = sqlite.prepare(
2867
+ `
2868
+ SELECT
2869
+ e.from_address AS email,
2870
+ COALESCE(MAX(NULLIF(TRIM(e.from_name), '')), e.from_address) AS name,
2871
+ COUNT(*) AS messageCount,
2872
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
2873
+ MAX(e.date) AS lastSeen,
2874
+ MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
2875
+ GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
2876
+ ') AS emailUnsubscribeHeaders,
2877
+ MAX(CASE WHEN ns.email IS NOT NULL THEN 1 ELSE 0 END) AS isNewsletter,
2878
+ COALESCE(MAX(all_time.allTimeCount), COUNT(*)) AS allTimeMessageCount,
2879
+ COALESCE(
2880
+ MAX(all_time.allTimeUnreadCount),
2881
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END)
2882
+ ) AS allTimeUnreadCount
2883
+ FROM emails AS e
2884
+ LEFT JOIN newsletter_senders AS ns
2885
+ ON LOWER(ns.email) = LOWER(e.from_address)
2886
+ LEFT JOIN (
2887
+ SELECT
2888
+ LOWER(from_address) AS senderKey,
2889
+ COUNT(*) AS allTimeCount,
2890
+ SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS allTimeUnreadCount
2891
+ FROM emails
2892
+ WHERE from_address IS NOT NULL
2893
+ AND TRIM(from_address) <> ''
2894
+ GROUP BY LOWER(from_address)
2895
+ ) AS all_time
2896
+ ON all_time.senderKey = LOWER(e.from_address)
2897
+ WHERE e.from_address IS NOT NULL
2898
+ AND TRIM(e.from_address) <> ''
2899
+ AND COALESCE(e.date, 0) >= ?
2900
+ GROUP BY LOWER(e.from_address)
2901
+ `
2902
+ ).all(activeSince);
2903
+ const senders = rows.map((row) => {
2904
+ const unreadRate = roundPercent(row.unreadCount, row.messageCount);
2905
+ const allTimeMessageCount = row.allTimeMessageCount ?? row.messageCount;
2906
+ const allTimeUnreadCount = row.allTimeUnreadCount ?? row.unreadCount;
2907
+ const allTimeUnreadRate = roundPercent(allTimeUnreadCount, allTimeMessageCount);
2908
+ const noiseScore = roundNoiseScore(row.messageCount, unreadRate);
2909
+ const allTimeNoiseScore = roundNoiseScore(allTimeMessageCount, allTimeUnreadRate);
2910
+ const unsubscribe2 = resolveUnsubscribeTarget(
2911
+ row.newsletterUnsubscribeLink,
2912
+ row.emailUnsubscribeHeaders
2913
+ );
2914
+ return {
2915
+ email: row.email,
2916
+ name: row.name?.trim() || row.email,
2917
+ messageCount: row.messageCount,
2918
+ allTimeMessageCount,
2919
+ unreadCount: row.unreadCount,
2920
+ unreadRate,
2921
+ noiseScore,
2922
+ allTimeNoiseScore,
2923
+ lastSeen: toIsoString(row.lastSeen),
2924
+ isNewsletter: row.isNewsletter === 1,
2925
+ hasUnsubscribeLink: Boolean(unsubscribe2.unsubscribeLink),
2926
+ unsubscribeLink: unsubscribe2.unsubscribeLink,
2927
+ suggestedCategory: getSuggestedCategory(row.email, row.name?.trim() || row.email)
2928
+ };
2929
+ }).filter((sender) => sender.noiseScore >= minNoiseScore).sort(compareNoiseSenders(sortBy)).slice(0, limit);
2930
+ return { senders };
2931
+ }
2932
+
2321
2933
  // src/core/stats/sender.ts
2322
2934
  function buildSenderWhereClause(period) {
2323
2935
  const whereParts = [
@@ -2476,6 +3088,222 @@ async function getSenderStats(emailOrDomain) {
2476
3088
  };
2477
3089
  }
2478
3090
 
3091
+ // src/core/stats/uncategorized.ts
3092
+ var SYSTEM_LABEL_IDS = [
3093
+ "INBOX",
3094
+ "UNREAD",
3095
+ "IMPORTANT",
3096
+ "SENT",
3097
+ "DRAFT",
3098
+ "SPAM",
3099
+ "TRASH",
3100
+ "STARRED"
3101
+ ];
3102
+ var CATEGORY_LABEL_PATTERN = "CATEGORY\\_%";
3103
+ function toIsoString2(value) {
3104
+ if (!value) {
3105
+ return null;
3106
+ }
3107
+ return new Date(value).toISOString();
3108
+ }
3109
+ function parseJsonArray3(raw) {
3110
+ if (!raw) {
3111
+ return [];
3112
+ }
3113
+ try {
3114
+ const parsed = JSON.parse(raw);
3115
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
3116
+ } catch {
3117
+ return [];
3118
+ }
3119
+ }
3120
+ function resolveSinceTimestamp(since) {
3121
+ if (!since) {
3122
+ return null;
3123
+ }
3124
+ const parsed = Date.parse(since);
3125
+ if (Number.isNaN(parsed)) {
3126
+ throw new Error(`Invalid since value: ${since}`);
3127
+ }
3128
+ return parsed;
3129
+ }
3130
+ function buildWhereClause(options) {
3131
+ const whereParts = [
3132
+ `
3133
+ NOT EXISTS (
3134
+ SELECT 1
3135
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label
3136
+ WHERE label.value IS NOT NULL
3137
+ AND TRIM(CAST(label.value AS TEXT)) <> ''
3138
+ AND label.value NOT IN (${SYSTEM_LABEL_IDS.map(() => "?").join(", ")})
3139
+ AND label.value NOT LIKE ? ESCAPE '\\'
3140
+ )
3141
+ `
3142
+ ];
3143
+ const params = [...SYSTEM_LABEL_IDS, CATEGORY_LABEL_PATTERN];
3144
+ if (options.unreadOnly) {
3145
+ whereParts.push("COALESCE(e.is_read, 0) = 0");
3146
+ }
3147
+ if (options.sinceTimestamp !== null) {
3148
+ whereParts.push("COALESCE(e.date, 0) >= ?");
3149
+ params.push(options.sinceTimestamp);
3150
+ }
3151
+ return {
3152
+ clause: whereParts.join(" AND "),
3153
+ params
3154
+ };
3155
+ }
3156
+ async function getUncategorizedEmails(options = {}) {
3157
+ await detectNewsletters();
3158
+ const sqlite = getStatsSqlite();
3159
+ const limit = Math.min(1e3, normalizeLimit(options.limit, 50));
3160
+ const offset = Math.max(0, Math.floor(options.offset ?? 0));
3161
+ const sinceTimestamp = resolveSinceTimestamp(options.since);
3162
+ const { clause, params } = buildWhereClause({
3163
+ sinceTimestamp,
3164
+ unreadOnly: options.unreadOnly ?? false
3165
+ });
3166
+ const totalRow = sqlite.prepare(
3167
+ `
3168
+ SELECT COUNT(*) AS total
3169
+ FROM emails AS e
3170
+ WHERE ${clause}
3171
+ `
3172
+ ).get(...params);
3173
+ const rows = sqlite.prepare(
3174
+ `
3175
+ SELECT
3176
+ e.id AS id,
3177
+ e.thread_id AS threadId,
3178
+ e.from_address AS sender,
3179
+ e.subject AS subject,
3180
+ e.date AS date,
3181
+ e.snippet AS snippet,
3182
+ e.label_ids AS labelIds,
3183
+ e.is_read AS isRead,
3184
+ sender_stats.totalFromSender AS totalFromSender,
3185
+ sender_stats.unreadFromSender AS unreadFromSender,
3186
+ ns.detection_reason AS detectionReason
3187
+ FROM emails AS e
3188
+ LEFT JOIN (
3189
+ SELECT
3190
+ LOWER(from_address) AS senderKey,
3191
+ COUNT(*) AS totalFromSender,
3192
+ SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unreadFromSender
3193
+ FROM emails
3194
+ WHERE from_address IS NOT NULL
3195
+ AND TRIM(from_address) <> ''
3196
+ GROUP BY LOWER(from_address)
3197
+ ) AS sender_stats
3198
+ ON sender_stats.senderKey = LOWER(e.from_address)
3199
+ LEFT JOIN newsletter_senders AS ns
3200
+ ON LOWER(ns.email) = LOWER(e.from_address)
3201
+ WHERE ${clause}
3202
+ ORDER BY COALESCE(e.date, 0) DESC, e.id ASC
3203
+ LIMIT ?
3204
+ OFFSET ?
3205
+ `
3206
+ ).all(...params, limit, offset);
3207
+ const emails2 = rows.map((row) => {
3208
+ const totalFromSender = row.totalFromSender ?? 0;
3209
+ const unreadFromSender = row.unreadFromSender ?? 0;
3210
+ return {
3211
+ id: row.id,
3212
+ threadId: row.threadId || "",
3213
+ from: row.sender || "",
3214
+ subject: row.subject || "",
3215
+ date: toIsoString2(row.date),
3216
+ snippet: row.snippet || "",
3217
+ labels: parseJsonArray3(row.labelIds),
3218
+ isRead: row.isRead === 1,
3219
+ senderContext: {
3220
+ totalFromSender,
3221
+ unreadRate: roundPercent(unreadFromSender, totalFromSender),
3222
+ isNewsletter: Boolean(row.detectionReason),
3223
+ detectionReason: row.detectionReason
3224
+ }
3225
+ };
3226
+ });
3227
+ return {
3228
+ totalUncategorized: totalRow?.total ?? 0,
3229
+ returned: emails2.length,
3230
+ offset,
3231
+ hasMore: offset + emails2.length < (totalRow?.total ?? 0),
3232
+ emails: emails2
3233
+ };
3234
+ }
3235
+
3236
+ // src/core/stats/unsubscribe.ts
3237
+ function toIsoString3(value) {
3238
+ if (!value) {
3239
+ return null;
3240
+ }
3241
+ return new Date(value).toISOString();
3242
+ }
3243
+ function roundImpactScore(messageCount, unreadRate) {
3244
+ return Math.round(messageCount * unreadRate * 10 / 100) / 10;
3245
+ }
3246
+ async function getUnsubscribeSuggestions(options = {}) {
3247
+ await detectNewsletters();
3248
+ const sqlite = getStatsSqlite();
3249
+ const limit = Math.min(50, normalizeLimit(options.limit, 20));
3250
+ const minMessages = normalizeLimit(options.minMessages, 5);
3251
+ const rows = sqlite.prepare(
3252
+ `
3253
+ SELECT
3254
+ e.from_address AS email,
3255
+ COALESCE(MAX(NULLIF(TRIM(e.from_name), '')), e.from_address) AS name,
3256
+ COUNT(*) AS messageCount,
3257
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
3258
+ MAX(CASE WHEN e.is_read = 1 THEN e.date ELSE NULL END) AS lastRead,
3259
+ MAX(e.date) AS lastReceived,
3260
+ MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
3261
+ GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
3262
+ ') AS emailUnsubscribeHeaders
3263
+ FROM emails AS e
3264
+ LEFT JOIN newsletter_senders AS ns
3265
+ ON LOWER(ns.email) = LOWER(e.from_address)
3266
+ WHERE e.from_address IS NOT NULL
3267
+ AND TRIM(e.from_address) <> ''
3268
+ GROUP BY LOWER(e.from_address)
3269
+ HAVING COUNT(*) >= ?
3270
+ `
3271
+ ).all(minMessages);
3272
+ const suggestions = rows.map((row) => {
3273
+ const unsubscribe2 = resolveUnsubscribeTarget(
3274
+ row.newsletterUnsubscribeLink,
3275
+ row.emailUnsubscribeHeaders
3276
+ );
3277
+ if (!unsubscribe2.unsubscribeLink || !unsubscribe2.unsubscribeMethod) {
3278
+ return null;
3279
+ }
3280
+ const unreadRate = roundPercent(row.unreadCount, row.messageCount);
3281
+ const readRate = roundPercent(row.messageCount - row.unreadCount, row.messageCount);
3282
+ return {
3283
+ email: row.email,
3284
+ name: row.name?.trim() || row.email,
3285
+ allTimeMessageCount: row.messageCount,
3286
+ unreadCount: row.unreadCount,
3287
+ unreadRate,
3288
+ readRate,
3289
+ lastRead: toIsoString3(row.lastRead),
3290
+ lastReceived: toIsoString3(row.lastReceived),
3291
+ unsubscribeLink: unsubscribe2.unsubscribeLink,
3292
+ unsubscribeMethod: unsubscribe2.unsubscribeMethod,
3293
+ impactScore: roundImpactScore(row.messageCount, unreadRate),
3294
+ reason: buildUnsubscribeReason(unreadRate, row.messageCount)
3295
+ };
3296
+ }).filter((suggestion) => suggestion !== null).filter(
3297
+ (suggestion) => options.unreadOnlySenders ? suggestion.unreadCount === suggestion.allTimeMessageCount : true
3298
+ ).sort(
3299
+ (left, right) => right.impactScore - left.impactScore || right.allTimeMessageCount - left.allTimeMessageCount || right.unreadRate - left.unreadRate || (right.lastReceived || "").localeCompare(left.lastReceived || "") || left.email.localeCompare(right.email)
3300
+ );
3301
+ return {
3302
+ suggestions: suggestions.slice(0, limit),
3303
+ totalWithUnsubscribeLinks: suggestions.length
3304
+ };
3305
+ }
3306
+
2479
3307
  // src/core/stats/volume.ts
2480
3308
  function getBucketExpression(granularity) {
2481
3309
  switch (granularity) {
@@ -2588,6 +3416,18 @@ async function getExecutionHistory(ruleId, limit = 20) {
2588
3416
  const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(limit);
2589
3417
  return runs.slice(0, limit);
2590
3418
  }
3419
+ async function getExecutionStats(ruleId) {
3420
+ const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(1e4);
3421
+ return {
3422
+ totalRuns: runs.length,
3423
+ plannedRuns: runs.filter((run) => run.status === "planned").length,
3424
+ appliedRuns: runs.filter((run) => run.status === "applied").length,
3425
+ partialRuns: runs.filter((run) => run.status === "partial").length,
3426
+ errorRuns: runs.filter((run) => run.status === "error").length,
3427
+ undoneRuns: runs.filter((run) => run.status === "undone").length,
3428
+ lastExecutionAt: runs[0]?.createdAt ?? null
3429
+ };
3430
+ }
2591
3431
 
2592
3432
  // src/core/rules/deploy.ts
2593
3433
  import { randomUUID as randomUUID3 } from "crypto";
@@ -2989,7 +3829,7 @@ function getDatabase4() {
2989
3829
  const config = loadConfig();
2990
3830
  return getSqlite(config.dbPath);
2991
3831
  }
2992
- function parseJsonArray2(value) {
3832
+ function parseJsonArray4(value) {
2993
3833
  if (!value) {
2994
3834
  return [];
2995
3835
  }
@@ -3000,19 +3840,19 @@ function parseJsonArray2(value) {
3000
3840
  return [];
3001
3841
  }
3002
3842
  }
3003
- function rowToEmail2(row) {
3843
+ function rowToEmail3(row) {
3004
3844
  return {
3005
3845
  id: row.id,
3006
3846
  threadId: row.thread_id ?? "",
3007
3847
  fromAddress: row.from_address ?? "",
3008
3848
  fromName: row.from_name ?? "",
3009
- toAddresses: parseJsonArray2(row.to_addresses),
3849
+ toAddresses: parseJsonArray4(row.to_addresses),
3010
3850
  subject: row.subject ?? "",
3011
3851
  snippet: row.snippet ?? "",
3012
3852
  date: row.date ?? 0,
3013
3853
  isRead: row.is_read === 1,
3014
3854
  isStarred: row.is_starred === 1,
3015
- labelIds: parseJsonArray2(row.label_ids),
3855
+ labelIds: parseJsonArray4(row.label_ids),
3016
3856
  sizeEstimate: row.size_estimate ?? 0,
3017
3857
  hasAttachments: row.has_attachments === 1,
3018
3858
  listUnsubscribe: row.list_unsubscribe
@@ -3121,7 +3961,7 @@ async function findMatchingEmails(ruleOrConditions, limit) {
3121
3961
  ).all();
3122
3962
  const matches = [];
3123
3963
  for (const row of rows) {
3124
- const email = rowToEmail2(row);
3964
+ const email = rowToEmail3(row);
3125
3965
  const result = matchEmail(email, conditions);
3126
3966
  if (!result.matches) {
3127
3967
  continue;
@@ -3186,7 +4026,7 @@ async function loadMatchedItems(rule, options) {
3186
4026
  errorMessage: null
3187
4027
  }));
3188
4028
  }
3189
- async function executeAction(emailId, action, options) {
4029
+ async function executeAction2(emailId, action, options) {
3190
4030
  switch (action.type) {
3191
4031
  case "archive":
3192
4032
  return (await archiveEmails([emailId], options)).items[0];
@@ -3207,7 +4047,7 @@ async function applyRuleActions(item, actions, options) {
3207
4047
  };
3208
4048
  for (const action of actions) {
3209
4049
  try {
3210
- const result = await executeAction(item.emailId, action, options);
4050
+ const result = await executeAction2(item.emailId, action, options);
3211
4051
  current = {
3212
4052
  ...current,
3213
4053
  status: result.status,
@@ -3335,7 +4175,7 @@ async function runAllRules(options) {
3335
4175
  }
3336
4176
 
3337
4177
  // src/core/gmail/filters.ts
3338
- async function resolveContext3(options) {
4178
+ async function resolveContext5(options) {
3339
4179
  const config = options?.config ?? loadConfig();
3340
4180
  const transport = options?.transport ?? await getGmailTransport(config);
3341
4181
  return { config, transport };
@@ -3385,7 +4225,7 @@ async function buildLabelMap(context) {
3385
4225
  return map;
3386
4226
  }
3387
4227
  async function listFilters(options) {
3388
- const context = await resolveContext3(options);
4228
+ const context = await resolveContext5(options);
3389
4229
  const [response, labelMap] = await Promise.all([
3390
4230
  context.transport.listFilters(),
3391
4231
  buildLabelMap(context)
@@ -3394,7 +4234,7 @@ async function listFilters(options) {
3394
4234
  return raw.map((f) => toGmailFilter(f, labelMap)).filter((f) => f !== null);
3395
4235
  }
3396
4236
  async function getFilter(id, options) {
3397
- const context = await resolveContext3(options);
4237
+ const context = await resolveContext5(options);
3398
4238
  const [raw, labelMap] = await Promise.all([
3399
4239
  context.transport.getFilter(id),
3400
4240
  buildLabelMap(context)
@@ -3418,7 +4258,7 @@ async function createFilter(input, options) {
3418
4258
  "At least one action is required (labelName, archive, markRead, star, or forward)"
3419
4259
  );
3420
4260
  }
3421
- const context = await resolveContext3(options);
4261
+ const context = await resolveContext5(options);
3422
4262
  const addLabelIds = [];
3423
4263
  if (input.star) {
3424
4264
  addLabelIds.push("STARRED");
@@ -3457,7 +4297,7 @@ async function createFilter(input, options) {
3457
4297
  return filter;
3458
4298
  }
3459
4299
  async function deleteFilter(id, options) {
3460
- const context = await resolveContext3(options);
4300
+ const context = await resolveContext5(options);
3461
4301
  await context.transport.deleteFilter(id);
3462
4302
  }
3463
4303
 
@@ -3949,7 +4789,7 @@ async function getSyncStatus() {
3949
4789
  }
3950
4790
 
3951
4791
  // src/mcp/server.ts
3952
- var DAY_MS2 = 24 * 60 * 60 * 1e3;
4792
+ var DAY_MS3 = 24 * 60 * 60 * 1e3;
3953
4793
  var MCP_VERSION = "0.1.0";
3954
4794
  var MCP_TOOLS = [
3955
4795
  "search_emails",
@@ -3959,6 +4799,7 @@ var MCP_TOOLS = [
3959
4799
  "archive_emails",
3960
4800
  "label_emails",
3961
4801
  "mark_read",
4802
+ "batch_apply_actions",
3962
4803
  "forward_email",
3963
4804
  "undo_run",
3964
4805
  "get_labels",
@@ -3967,6 +4808,10 @@ var MCP_TOOLS = [
3967
4808
  "get_top_senders",
3968
4809
  "get_sender_stats",
3969
4810
  "get_newsletter_senders",
4811
+ "get_uncategorized_emails",
4812
+ "get_noise_senders",
4813
+ "get_unsubscribe_suggestions",
4814
+ "unsubscribe",
3970
4815
  "deploy_rule",
3971
4816
  "list_rules",
3972
4817
  "run_rule",
@@ -3980,6 +4825,7 @@ var MCP_TOOLS = [
3980
4825
  var MCP_RESOURCES = [
3981
4826
  "inbox://recent",
3982
4827
  "inbox://summary",
4828
+ "inbox://action-log",
3983
4829
  "rules://deployed",
3984
4830
  "rules://history",
3985
4831
  "stats://senders",
@@ -3990,7 +4836,8 @@ var MCP_PROMPTS = [
3990
4836
  "review-senders",
3991
4837
  "find-newsletters",
3992
4838
  "suggest-rules",
3993
- "triage-inbox"
4839
+ "triage-inbox",
4840
+ "categorize-emails"
3994
4841
  ];
3995
4842
  function toTextResult(value) {
3996
4843
  return {
@@ -4064,12 +4911,22 @@ function buildSearchQuery(query, label) {
4064
4911
  }
4065
4912
  return trimmedQuery;
4066
4913
  }
4067
- function uniqueStrings(values) {
4914
+ function uniqueStrings3(values) {
4068
4915
  return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
4069
4916
  }
4070
4917
  function resolveResourceUri(uri, fallback) {
4071
4918
  return typeof uri === "string" ? uri : fallback;
4072
4919
  }
4920
+ function formatActionSummary(action) {
4921
+ switch (action.type) {
4922
+ case "label":
4923
+ return action.label ? `label:${action.label}` : "label";
4924
+ case "forward":
4925
+ return action.to ? `forward:${action.to}` : "forward";
4926
+ default:
4927
+ return action.type;
4928
+ }
4929
+ }
4073
4930
  async function buildStartupWarnings() {
4074
4931
  const config = loadConfig();
4075
4932
  initializeDb(config.dbPath);
@@ -4090,7 +4947,7 @@ async function buildStartupWarnings() {
4090
4947
  }
4091
4948
  if (!latestSync) {
4092
4949
  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) {
4950
+ } else if (Date.now() - latestSync > DAY_MS3) {
4094
4951
  warnings.push("Inbox cache appears stale (last sync older than 24 hours). Call `sync_inbox` if freshness matters.");
4095
4952
  }
4096
4953
  return warnings;
@@ -4101,7 +4958,7 @@ async function buildStatsOverview() {
4101
4958
  topSenders: await getTopSenders({ limit: 10 }),
4102
4959
  labelDistribution: (await getLabelDistribution()).slice(0, 10),
4103
4960
  dailyVolume: await getVolumeByPeriod("day", {
4104
- start: Date.now() - 30 * DAY_MS2,
4961
+ start: Date.now() - 30 * DAY_MS3,
4105
4962
  end: Date.now()
4106
4963
  })
4107
4964
  };
@@ -4113,6 +4970,23 @@ async function buildRuleHistory() {
4113
4970
  recentRuns: await getRecentRuns(20)
4114
4971
  };
4115
4972
  }
4973
+ async function buildActionLog() {
4974
+ const recentRuns = await getRecentRuns(10);
4975
+ const stats = await getExecutionStats();
4976
+ return {
4977
+ recentRuns: recentRuns.map((run) => ({
4978
+ runId: run.id,
4979
+ createdAt: new Date(run.createdAt).toISOString(),
4980
+ sourceType: run.sourceType,
4981
+ dryRun: run.dryRun,
4982
+ status: run.status,
4983
+ emailCount: run.itemCount,
4984
+ actions: run.requestedActions.map(formatActionSummary),
4985
+ undoAvailable: !run.dryRun && run.undoneAt === null && run.status !== "planned" && run.status !== "undone" && run.itemCount > 0
4986
+ })),
4987
+ totalRuns: stats.totalRuns
4988
+ };
4989
+ }
4116
4990
  async function createMcpServer() {
4117
4991
  const warnings = await buildStartupWarnings();
4118
4992
  const server = new McpServer({
@@ -4188,7 +5062,7 @@ async function createMcpServer() {
4188
5062
  destructiveHint: false
4189
5063
  }
4190
5064
  },
4191
- toolHandler(async ({ email_ids }) => archiveEmails(uniqueStrings(email_ids)))
5065
+ toolHandler(async ({ email_ids }) => archiveEmails(uniqueStrings3(email_ids)))
4192
5066
  );
4193
5067
  server.registerTool(
4194
5068
  "label_emails",
@@ -4205,9 +5079,9 @@ async function createMcpServer() {
4205
5079
  }
4206
5080
  },
4207
5081
  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);
5082
+ const ids = uniqueStrings3(email_ids);
5083
+ const addLabels = uniqueStrings3(add_labels);
5084
+ const removeLabels = uniqueStrings3(remove_labels);
4211
5085
  if (addLabels.length === 0 && removeLabels.length === 0) {
4212
5086
  throw new Error("Provide at least one label to add or remove.");
4213
5087
  }
@@ -4240,10 +5114,46 @@ async function createMcpServer() {
4240
5114
  }
4241
5115
  },
4242
5116
  toolHandler(async ({ email_ids, read }) => {
4243
- const ids = uniqueStrings(email_ids);
5117
+ const ids = uniqueStrings3(email_ids);
4244
5118
  return read ? markRead(ids) : markUnread(ids);
4245
5119
  })
4246
5120
  );
5121
+ server.registerTool(
5122
+ "batch_apply_actions",
5123
+ {
5124
+ description: "Apply grouped inbox actions in one call for faster AI-driven triage and categorization.",
5125
+ inputSchema: {
5126
+ groups: z2.array(
5127
+ z2.object({
5128
+ email_ids: z2.array(z2.string().min(1)).min(1).max(500),
5129
+ actions: z2.array(
5130
+ z2.discriminatedUnion("type", [
5131
+ z2.object({
5132
+ type: z2.literal("label"),
5133
+ label: z2.string().min(1)
5134
+ }),
5135
+ z2.object({ type: z2.literal("archive") }),
5136
+ z2.object({ type: z2.literal("mark_read") }),
5137
+ z2.object({ type: z2.literal("mark_spam") })
5138
+ ])
5139
+ ).min(1).max(5)
5140
+ })
5141
+ ).min(1).max(20),
5142
+ dry_run: z2.boolean().optional()
5143
+ },
5144
+ annotations: {
5145
+ readOnlyHint: false,
5146
+ destructiveHint: false
5147
+ }
5148
+ },
5149
+ toolHandler(async ({ groups, dry_run }) => batchApplyActions({
5150
+ groups: groups.map((group) => ({
5151
+ emailIds: uniqueStrings3(group.email_ids),
5152
+ actions: group.actions
5153
+ })),
5154
+ dryRun: dry_run
5155
+ }))
5156
+ );
4247
5157
  server.registerTool(
4248
5158
  "forward_email",
4249
5159
  {
@@ -4372,6 +5282,87 @@ async function createMcpServer() {
4372
5282
  minUnreadRate: min_unread_rate
4373
5283
  }))
4374
5284
  );
5285
+ server.registerTool(
5286
+ "get_uncategorized_emails",
5287
+ {
5288
+ description: "Return cached emails that have only Gmail system labels and no user-applied organization.",
5289
+ inputSchema: {
5290
+ limit: z2.number().int().positive().max(1e3).optional().describe("Max emails to return per page. Default 50. AI clients should start with 50-100 and paginate."),
5291
+ offset: z2.number().int().min(0).optional().describe("Number of results to skip for pagination. Use with totalUncategorized and hasMore."),
5292
+ unread_only: z2.boolean().optional(),
5293
+ since: z2.string().min(1).optional()
5294
+ },
5295
+ annotations: {
5296
+ readOnlyHint: true
5297
+ }
5298
+ },
5299
+ toolHandler(async ({ limit, offset, unread_only, since }) => getUncategorizedEmails({
5300
+ limit,
5301
+ offset,
5302
+ unreadOnly: unread_only,
5303
+ since
5304
+ }))
5305
+ );
5306
+ server.registerTool(
5307
+ "get_noise_senders",
5308
+ {
5309
+ description: "Return a focused list of active, high-noise senders worth categorizing, filtering, or unsubscribing.",
5310
+ inputSchema: {
5311
+ limit: z2.number().int().positive().max(50).optional(),
5312
+ min_noise_score: z2.number().min(0).optional(),
5313
+ active_days: z2.number().int().positive().optional(),
5314
+ sort_by: z2.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.")
5315
+ },
5316
+ annotations: {
5317
+ readOnlyHint: true
5318
+ }
5319
+ },
5320
+ toolHandler(async ({ limit, min_noise_score, active_days, sort_by }) => getNoiseSenders({
5321
+ limit,
5322
+ minNoiseScore: min_noise_score,
5323
+ activeDays: active_days,
5324
+ sortBy: sort_by
5325
+ }))
5326
+ );
5327
+ server.registerTool(
5328
+ "get_unsubscribe_suggestions",
5329
+ {
5330
+ description: "Return ranked senders with unsubscribe links, sorted by how much inbox noise unsubscribing would remove.",
5331
+ inputSchema: {
5332
+ limit: z2.number().int().positive().max(50).optional(),
5333
+ min_messages: z2.number().int().positive().optional(),
5334
+ unread_only_senders: z2.boolean().optional()
5335
+ },
5336
+ annotations: {
5337
+ readOnlyHint: true
5338
+ }
5339
+ },
5340
+ toolHandler(async ({ limit, min_messages, unread_only_senders }) => getUnsubscribeSuggestions({
5341
+ limit,
5342
+ minMessages: min_messages,
5343
+ unreadOnlySenders: unread_only_senders
5344
+ }))
5345
+ );
5346
+ server.registerTool(
5347
+ "unsubscribe",
5348
+ {
5349
+ description: "Return the unsubscribe target for a sender and optionally label/archive existing emails in one undoable run.",
5350
+ inputSchema: {
5351
+ sender_email: z2.string().min(1),
5352
+ also_archive: z2.boolean().optional(),
5353
+ also_label: z2.string().min(1).optional()
5354
+ },
5355
+ annotations: {
5356
+ readOnlyHint: false,
5357
+ destructiveHint: false
5358
+ }
5359
+ },
5360
+ toolHandler(async ({ sender_email, also_archive, also_label }) => unsubscribe({
5361
+ senderEmail: sender_email,
5362
+ alsoArchive: also_archive,
5363
+ alsoLabel: also_label
5364
+ }))
5365
+ );
4375
5366
  server.registerTool(
4376
5367
  "deploy_rule",
4377
5368
  {
@@ -4470,6 +5461,15 @@ async function createMcpServer() {
4470
5461
  },
4471
5462
  async (uri) => resourceText(resolveResourceUri(uri, "inbox://summary"), await getInboxOverview())
4472
5463
  );
5464
+ server.registerResource(
5465
+ "inbox-action-log",
5466
+ "inbox://action-log",
5467
+ {
5468
+ description: "Recent action history showing what inboxctl already did and whether undo is still available.",
5469
+ mimeType: "application/json"
5470
+ },
5471
+ async (uri) => resourceText(resolveResourceUri(uri, "inbox://action-log"), await buildActionLog())
5472
+ );
4473
5473
  server.registerResource(
4474
5474
  "deployed-rules",
4475
5475
  "rules://deployed",
@@ -4529,10 +5529,24 @@ async function createMcpServer() {
4529
5529
  async () => promptResult(
4530
5530
  "Review top senders and recommend cleanup actions.",
4531
5531
  [
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."
5532
+ "Step 1 \u2014 Gather data:",
5533
+ " Use `get_noise_senders` for the most actionable noisy senders.",
5534
+ " Use `rules://deployed` to check for existing rules covering these senders.",
5535
+ " Use `get_unsubscribe_suggestions` for senders you can unsubscribe from.",
5536
+ "",
5537
+ "Step 2 \u2014 For each noisy sender, recommend one of:",
5538
+ " KEEP \u2014 important, reduce noise with a label rule",
5539
+ " RULE \u2014 create a rule to auto-label + mark read (or archive)",
5540
+ " UNSUBSCRIBE \u2014 stop receiving entirely (has unsubscribe link, high unread rate)",
5541
+ "",
5542
+ "Step 3 \u2014 Present as a table:",
5543
+ " Sender | Messages | Unread% | Noise Score | Has Unsub | Recommendation | Reason",
5544
+ "",
5545
+ "Step 4 \u2014 Offer to act:",
5546
+ " For senders marked RULE, offer to generate YAML using the rule schema.",
5547
+ " Group similar senders (e.g. all shipping senders) into one rule.",
5548
+ " Present YAML for review before deploying with `deploy_rule`.",
5549
+ " For senders marked UNSUBSCRIBE, use `unsubscribe` with `also_archive: true` and return the link for the user to follow."
4536
5550
  ].join("\n")
4537
5551
  )
4538
5552
  );
@@ -4559,10 +5573,37 @@ async function createMcpServer() {
4559
5573
  async () => promptResult(
4560
5574
  "Analyze inbox patterns and propose valid inboxctl rule YAML.",
4561
5575
  [
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."
5576
+ "First, inspect these data sources:",
5577
+ "- `rules://deployed` \u2014 existing rules (avoid duplicates)",
5578
+ "- `get_noise_senders` \u2014 high-volume low-read senders",
5579
+ "- `get_newsletter_senders` \u2014 detected newsletters and mailing lists",
5580
+ "",
5581
+ "For each recommendation, generate complete YAML using this schema:",
5582
+ "",
5583
+ " name: kebab-case-name # lowercase, hyphens only",
5584
+ " description: What this rule does",
5585
+ " enabled: true",
5586
+ " priority: 50 # 0-100, lower = runs first",
5587
+ " conditions:",
5588
+ " operator: AND # AND or OR",
5589
+ " matchers:",
5590
+ " - field: from # from | to | subject | snippet | labels",
5591
+ " contains: # OR use: values (exact), pattern (regex)",
5592
+ ' - "@example.com"',
5593
+ " exclude: false # true to negate the match",
5594
+ " actions:",
5595
+ " - type: label # label | archive | mark_read | forward | mark_spam",
5596
+ ' label: "Category/Name"',
5597
+ " - type: mark_read",
5598
+ " - type: archive",
5599
+ "",
5600
+ "Matcher fields: `from`, `to`, `subject`, `snippet`, `labels`.",
5601
+ "Match modes (provide exactly one per matcher): `values` (exact), `contains` (substring), `pattern` (regex).",
5602
+ "Action types: `label` (requires `label` field), `archive`, `mark_read`, `forward` (requires `to` field), `mark_spam`.",
5603
+ "",
5604
+ "Group related senders into a single rule where possible (e.g. all shipping notifications in one rule).",
5605
+ "Explain why each rule is safe. Default to `mark_read` + `label` over `archive` unless evidence is strong.",
5606
+ "Present the YAML so the user can review before deploying with `deploy_rule`."
4566
5607
  ].join("\n")
4567
5608
  )
4568
5609
  );
@@ -4574,10 +5615,80 @@ async function createMcpServer() {
4574
5615
  async () => promptResult(
4575
5616
  "Triage unread mail using inboxctl data sources.",
4576
5617
  [
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."
5618
+ "Step 1 \u2014 Gather data:",
5619
+ " Use `get_uncategorized_emails` with `unread_only: true` for uncategorised unread mail.",
5620
+ " Use `inbox://summary` for overall counts.",
5621
+ " If totalUncategorized is large, process in pages rather than all at once.",
5622
+ " If more context is needed on a specific email, use `get_email` or `get_thread`.",
5623
+ "",
5624
+ "Step 2 \u2014 Categorise each email into one of:",
5625
+ " ACTION REQUIRED \u2014 needs a response or decision from the user",
5626
+ " FYI \u2014 worth knowing about but no action needed",
5627
+ " NOISE \u2014 bulk, promotional, or irrelevant",
5628
+ "",
5629
+ "Step 3 \u2014 Present findings:",
5630
+ " List emails grouped by category with: sender, subject, and one-line reason.",
5631
+ " For NOISE, suggest a label and whether to archive.",
5632
+ " For FYI, suggest a label.",
5633
+ " For ACTION REQUIRED, summarise what action seems needed.",
5634
+ "",
5635
+ "Step 4 \u2014 Offer to apply:",
5636
+ " If the user approves, use `batch_apply_actions` to apply all decisions in one call.",
5637
+ " Group emails by their action set (e.g. all `label:Receipts + mark_read` together).",
5638
+ "",
5639
+ "Step 5 \u2014 Offer noise reduction:",
5640
+ " If NOISE senders appear repeatedly, suggest a rule or `unsubscribe` when a link is available."
5641
+ ].join("\n")
5642
+ )
5643
+ );
5644
+ server.registerPrompt(
5645
+ "categorize-emails",
5646
+ {
5647
+ description: "Systematically categorise uncategorised emails using sender patterns, content, and inbox analytics."
5648
+ },
5649
+ async () => promptResult(
5650
+ "Categorise uncategorised emails in the user's inbox.",
5651
+ [
5652
+ "Step 1 \u2014 Gather data:",
5653
+ " Use `get_uncategorized_emails` (start with limit 100).",
5654
+ " If totalUncategorized is more than 500, ask whether to process the recent batch or paginate through the full backlog.",
5655
+ " Use `get_noise_senders` for sender context.",
5656
+ " Use `get_unsubscribe_suggestions` for likely unsubscribe candidates.",
5657
+ " Use `get_labels` to see what labels already exist.",
5658
+ " Use `rules://deployed` to avoid duplicating existing automation.",
5659
+ "",
5660
+ "Step 2 \u2014 Assign each email a category:",
5661
+ " Receipts \u2014 purchase confirmations, invoices, payment notifications",
5662
+ " Shipping \u2014 delivery tracking, dispatch notices, shipping updates",
5663
+ " Newsletters \u2014 editorial content, digests, weekly roundups",
5664
+ " Promotions \u2014 marketing, sales, deals, coupons",
5665
+ " Social \u2014 social network notifications (LinkedIn, Facebook, etc.)",
5666
+ " Notifications \u2014 automated alerts, system notifications, service updates",
5667
+ " Finance \u2014 bank statements, investment updates, tax documents",
5668
+ " Travel \u2014 bookings, itineraries, check-in reminders",
5669
+ " Important \u2014 personal or work email requiring attention",
5670
+ "",
5671
+ "Step 3 \u2014 Present the categorisation plan:",
5672
+ " Group emails by assigned category.",
5673
+ " For each group show: count, senders involved, sample subjects.",
5674
+ " Note confidence level: HIGH (clear pattern), MEDIUM (reasonable guess), LOW (uncertain).",
5675
+ " Flag any LOW confidence items for the user to decide.",
5676
+ "",
5677
+ "Step 4 \u2014 Apply with user approval:",
5678
+ " Create labels for any new categories (use `create_label`).",
5679
+ " Use `batch_apply_actions` to apply labels in one call.",
5680
+ " For Newsletters and Promotions with high unread rates, suggest mark_read + archive or `unsubscribe` when a link is available.",
5681
+ " For Receipts/Shipping/Notifications, suggest mark_read only (keep in inbox).",
5682
+ " For Important, do not mark read or archive.",
5683
+ "",
5684
+ "Step 5 \u2014 Paginate if needed:",
5685
+ " If hasMore is true, ask whether to continue with the next page using offset.",
5686
+ " Reuse the same sender categorisations on later pages instead of re-evaluating known senders.",
5687
+ "",
5688
+ "Step 6 \u2014 Suggest ongoing rules:",
5689
+ " For any category with 3+ emails from the same sender, suggest a YAML rule.",
5690
+ " This prevents the same categorisation from being needed again.",
5691
+ " Use `deploy_rule` after user reviews the YAML."
4581
5692
  ].join("\n")
4582
5693
  )
4583
5694
  );
@@ -4741,4 +5852,4 @@ export {
4741
5852
  createMcpServer,
4742
5853
  startMcpServer
4743
5854
  };
4744
- //# sourceMappingURL=chunk-EY6VV43S.js.map
5855
+ //# sourceMappingURL=chunk-NUN2WRBN.js.map