inboxctl 0.2.0 → 0.4.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";
@@ -2549,8 +2549,46 @@ async function unsubscribe(options) {
2549
2549
  };
2550
2550
  }
2551
2551
 
2552
+ // src/core/stats/anomalies.ts
2553
+ import { z } from "zod";
2554
+
2552
2555
  // src/core/stats/common.ts
2553
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
+ ];
2554
2592
  var SYSTEM_LABEL_NAMES = /* @__PURE__ */ new Map([
2555
2593
  ["INBOX", "Inbox"],
2556
2594
  ["UNREAD", "Unread"],
@@ -2605,9 +2643,69 @@ function getPeriodStart(period = "all", now2 = Date.now()) {
2605
2643
  return null;
2606
2644
  }
2607
2645
  }
2646
+ function extractDomain(email) {
2647
+ const trimmed = email.trim().toLowerCase();
2648
+ const atIndex = trimmed.lastIndexOf("@");
2649
+ if (atIndex <= 0 || atIndex === trimmed.length - 1) {
2650
+ return null;
2651
+ }
2652
+ return trimmed.slice(atIndex + 1);
2653
+ }
2608
2654
  function resolveLabelName(labelId) {
2609
2655
  return SYSTEM_LABEL_NAMES.get(labelId) || getCachedLabelName(labelId) || labelId;
2610
2656
  }
2657
+ function isUserLabel(labelId) {
2658
+ const trimmed = labelId.trim();
2659
+ return trimmed.length > 0 && !SYSTEM_LABEL_ID_SET.has(trimmed) && !trimmed.startsWith(CATEGORY_LABEL_PREFIX);
2660
+ }
2661
+ function isLikelyAutomatedSenderAddress(sender) {
2662
+ const normalized = sender.trim().toLowerCase();
2663
+ return AUTOMATED_ADDRESS_MARKERS.some((marker) => normalized.includes(marker));
2664
+ }
2665
+ function computeConfidence(row) {
2666
+ const signals = [];
2667
+ let score = 0;
2668
+ const hasDefinitiveNewsletterSignal = Boolean(row.listUnsubscribe && row.listUnsubscribe.trim()) || Boolean(row.detectionReason?.includes("list_unsubscribe"));
2669
+ if (row.listUnsubscribe && row.listUnsubscribe.trim()) {
2670
+ signals.push("list_unsubscribe_header");
2671
+ score += 3;
2672
+ }
2673
+ if (row.detectionReason?.includes("list_unsubscribe")) {
2674
+ signals.push("newsletter_list_header");
2675
+ score += 2;
2676
+ }
2677
+ if ((row.totalFromSender ?? 0) >= 20) {
2678
+ signals.push("high_volume_sender");
2679
+ score += 2;
2680
+ } else if ((row.totalFromSender ?? 0) >= 5) {
2681
+ signals.push("moderate_volume_sender");
2682
+ score += 1;
2683
+ }
2684
+ if (row.detectionReason?.includes("known_sender_pattern")) {
2685
+ signals.push("automated_sender_pattern");
2686
+ score += 1;
2687
+ }
2688
+ if (row.detectionReason?.includes("bulk_sender_pattern")) {
2689
+ signals.push("bulk_sender_pattern");
2690
+ score += 1;
2691
+ }
2692
+ if ((row.totalFromSender ?? 0) <= 2 && !hasDefinitiveNewsletterSignal) {
2693
+ signals.push("rare_sender");
2694
+ score -= 3;
2695
+ }
2696
+ if (!row.detectionReason) {
2697
+ signals.push("no_newsletter_signals");
2698
+ score -= 2;
2699
+ }
2700
+ if (!row.detectionReason && !isLikelyAutomatedSenderAddress(row.sender || "")) {
2701
+ signals.push("personal_sender_address");
2702
+ score -= 2;
2703
+ }
2704
+ return {
2705
+ confidence: score >= 3 ? "high" : score >= 0 ? "medium" : "low",
2706
+ signals
2707
+ };
2708
+ }
2611
2709
  function startOfLocalDay(now2 = Date.now()) {
2612
2710
  const date = new Date(now2);
2613
2711
  date.setHours(0, 0, 0, 0);
@@ -2627,28 +2725,6 @@ function startOfLocalMonth(now2 = Date.now()) {
2627
2725
  return date.getTime();
2628
2726
  }
2629
2727
 
2630
- // src/core/stats/labels.ts
2631
- async function getLabelDistribution() {
2632
- const sqlite = getStatsSqlite();
2633
- const rows = sqlite.prepare(
2634
- `
2635
- SELECT
2636
- label.value AS labelId,
2637
- COUNT(*) AS totalMessages,
2638
- SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages
2639
- FROM emails AS e, json_each(e.label_ids) AS label
2640
- GROUP BY label.value
2641
- ORDER BY totalMessages DESC, unreadMessages DESC, label.value ASC
2642
- `
2643
- ).all();
2644
- return rows.map((row) => ({
2645
- labelId: row.labelId,
2646
- labelName: resolveLabelName(row.labelId),
2647
- totalMessages: row.totalMessages,
2648
- unreadMessages: row.unreadMessages
2649
- }));
2650
- }
2651
-
2652
2728
  // src/core/stats/newsletters.ts
2653
2729
  import { randomUUID as randomUUID2 } from "crypto";
2654
2730
  var KNOWN_NEWSLETTER_LOCAL_PART = /^(newsletter|digest|noreply|no-reply|updates|news)([+._-].*)?$/i;
@@ -2812,8 +2888,277 @@ async function getNewsletters(options = {}) {
2812
2888
  return rows.map(mapNewsletterRow);
2813
2889
  }
2814
2890
 
2815
- // src/core/stats/noise.ts
2891
+ // src/core/stats/anomalies.ts
2816
2892
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
2893
+ var BULK_LABELS = /* @__PURE__ */ new Set([
2894
+ "newsletter",
2895
+ "newsletters",
2896
+ "promotion",
2897
+ "promotions",
2898
+ "social"
2899
+ ]);
2900
+ var reviewCategorizedInputSchema = z.object({
2901
+ since: z.string().min(1).optional(),
2902
+ limit: z.number().int().positive().max(200).optional()
2903
+ }).strict();
2904
+ function toIsoString(value) {
2905
+ if (!value) {
2906
+ return null;
2907
+ }
2908
+ return new Date(value).toISOString();
2909
+ }
2910
+ function parseJsonArray3(raw) {
2911
+ if (!raw) {
2912
+ return [];
2913
+ }
2914
+ try {
2915
+ const parsed = JSON.parse(raw);
2916
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
2917
+ } catch {
2918
+ return [];
2919
+ }
2920
+ }
2921
+ function parseActions(raw) {
2922
+ try {
2923
+ const parsed = JSON.parse(raw);
2924
+ return Array.isArray(parsed) ? parsed : [];
2925
+ } catch {
2926
+ return [];
2927
+ }
2928
+ }
2929
+ function resolveSinceTimestamp(since) {
2930
+ if (!since) {
2931
+ return Date.now() - 7 * DAY_MS2;
2932
+ }
2933
+ const parsed = Date.parse(since);
2934
+ if (Number.isNaN(parsed)) {
2935
+ throw new Error(`Invalid since value: ${since}`);
2936
+ }
2937
+ return parsed;
2938
+ }
2939
+ function isArchived(actions, beforeLabelIds, afterLabelIds) {
2940
+ if (actions.some((action) => action.type === "archive")) {
2941
+ return true;
2942
+ }
2943
+ return beforeLabelIds.includes("INBOX") && !afterLabelIds.includes("INBOX");
2944
+ }
2945
+ function resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds) {
2946
+ const labelAction = actions.find(
2947
+ (action) => action.type === "label" && typeof action.label === "string" && action.label.trim().length > 0
2948
+ );
2949
+ if (labelAction) {
2950
+ return labelAction.label.trim();
2951
+ }
2952
+ const beforeUserLabels = new Set(beforeLabelIds.filter(isUserLabel));
2953
+ const afterUserLabels = afterLabelIds.filter(isUserLabel);
2954
+ const addedLabel = afterUserLabels.find((label) => !beforeUserLabels.has(label));
2955
+ return addedLabel || afterUserLabels[0] || null;
2956
+ }
2957
+ function resolvePrimaryAction(actions) {
2958
+ if (actions.some((action) => action.type === "archive")) {
2959
+ return "archive";
2960
+ }
2961
+ if (actions.some((action) => action.type === "label")) {
2962
+ return "label";
2963
+ }
2964
+ return actions[0]?.type || "unknown";
2965
+ }
2966
+ function summarizeReview(totalReviewed, anomalyCount, highCount, mediumCount) {
2967
+ if (anomalyCount === 0) {
2968
+ return `Reviewed ${totalReviewed} recently categorised emails. Found no potential misclassifications.`;
2969
+ }
2970
+ const severityParts = [];
2971
+ if (highCount > 0) {
2972
+ severityParts.push(`${highCount} high severity`);
2973
+ }
2974
+ if (mediumCount > 0) {
2975
+ severityParts.push(`${mediumCount} medium`);
2976
+ }
2977
+ return `Reviewed ${totalReviewed} recently categorised emails. Found ${anomalyCount} potential misclassifications (${severityParts.join(", ")}).`;
2978
+ }
2979
+ function detectAnomaly(row) {
2980
+ const actions = parseActions(row.appliedActions);
2981
+ const beforeLabelIds = parseJsonArray3(row.beforeLabelIds);
2982
+ const afterLabelIds = parseJsonArray3(row.afterLabelIds);
2983
+ const archived = isArchived(actions, beforeLabelIds, afterLabelIds);
2984
+ const assignedLabel = resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds);
2985
+ const action = resolvePrimaryAction(actions);
2986
+ const totalFromSender = row.totalFromSender ?? 0;
2987
+ const hasNewsletterSignals = Boolean(row.detectionReason) || Boolean(row.listUnsubscribe?.trim());
2988
+ const isBulkLabel = assignedLabel ? BULK_LABELS.has(assignedLabel.toLowerCase()) : false;
2989
+ const automatedSender = isLikelyAutomatedSenderAddress(row.sender || "");
2990
+ const undoAvailable = row.runDryRun !== 1 && row.runStatus !== "undone" && row.runUndoneAt === null && row.itemUndoneAt === null;
2991
+ if (archived && totalFromSender <= 3) {
2992
+ return {
2993
+ emailId: row.emailId,
2994
+ from: row.sender || "",
2995
+ subject: row.subject || "",
2996
+ date: toIsoString(row.date),
2997
+ assignedLabel: assignedLabel || "Unlabeled",
2998
+ action,
2999
+ runId: row.runId,
3000
+ severity: "high",
3001
+ rule: "rare_sender_archived",
3002
+ reason: `Archived email from a rare sender with only ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}. Rare senders should be reviewed before archiving.`,
3003
+ undoAvailable
3004
+ };
3005
+ }
3006
+ if (isBulkLabel && !hasNewsletterSignals) {
3007
+ return {
3008
+ emailId: row.emailId,
3009
+ from: row.sender || "",
3010
+ subject: row.subject || "",
3011
+ date: toIsoString(row.date),
3012
+ assignedLabel: assignedLabel || "Unlabeled",
3013
+ action,
3014
+ runId: row.runId,
3015
+ severity: "high",
3016
+ rule: "no_newsletter_signals_as_newsletter",
3017
+ 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"}.`,
3018
+ undoAvailable
3019
+ };
3020
+ }
3021
+ if (archived && !automatedSender && totalFromSender < 5) {
3022
+ return {
3023
+ emailId: row.emailId,
3024
+ from: row.sender || "",
3025
+ subject: row.subject || "",
3026
+ date: toIsoString(row.date),
3027
+ assignedLabel: assignedLabel || "Unlabeled",
3028
+ action,
3029
+ runId: row.runId,
3030
+ severity: "high",
3031
+ rule: "personal_address_archived",
3032
+ 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.`,
3033
+ undoAvailable
3034
+ };
3035
+ }
3036
+ if (isBulkLabel && totalFromSender < 5) {
3037
+ return {
3038
+ emailId: row.emailId,
3039
+ from: row.sender || "",
3040
+ subject: row.subject || "",
3041
+ date: toIsoString(row.date),
3042
+ assignedLabel: assignedLabel || "Unlabeled",
3043
+ action,
3044
+ runId: row.runId,
3045
+ severity: "medium",
3046
+ rule: "low_volume_bulk_label",
3047
+ reason: `Labeled as ${assignedLabel} even though the sender has only ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}. Bulk labels are safer for higher-volume senders.`,
3048
+ undoAvailable
3049
+ };
3050
+ }
3051
+ if (archived && totalFromSender === 1) {
3052
+ return {
3053
+ emailId: row.emailId,
3054
+ from: row.sender || "",
3055
+ subject: row.subject || "",
3056
+ date: toIsoString(row.date),
3057
+ assignedLabel: assignedLabel || "Unlabeled",
3058
+ action,
3059
+ runId: row.runId,
3060
+ severity: "medium",
3061
+ rule: "first_time_sender_archived",
3062
+ reason: "Archived an email from a first-time sender. First-time senders are better surfaced for review before cleanup.",
3063
+ undoAvailable
3064
+ };
3065
+ }
3066
+ return null;
3067
+ }
3068
+ async function reviewCategorized(options = {}) {
3069
+ const parsed = reviewCategorizedInputSchema.parse(options);
3070
+ await detectNewsletters();
3071
+ const sqlite = getStatsSqlite();
3072
+ const sinceTimestamp = resolveSinceTimestamp(parsed.since);
3073
+ const limit = Math.min(200, normalizeLimit(parsed.limit, 50));
3074
+ const rows = sqlite.prepare(
3075
+ `
3076
+ SELECT
3077
+ ei.email_id AS emailId,
3078
+ e.from_address AS sender,
3079
+ e.subject AS subject,
3080
+ e.date AS date,
3081
+ e.list_unsubscribe AS listUnsubscribe,
3082
+ ei.before_label_ids AS beforeLabelIds,
3083
+ ei.after_label_ids AS afterLabelIds,
3084
+ ei.applied_actions AS appliedActions,
3085
+ ei.executed_at AS executedAt,
3086
+ ei.undone_at AS itemUndoneAt,
3087
+ ei.run_id AS runId,
3088
+ er.status AS runStatus,
3089
+ er.dry_run AS runDryRun,
3090
+ er.undone_at AS runUndoneAt,
3091
+ ns.detection_reason AS detectionReason,
3092
+ sender_stats.totalFromSender AS totalFromSender
3093
+ FROM execution_items AS ei
3094
+ INNER JOIN emails AS e
3095
+ ON e.id = ei.email_id
3096
+ INNER JOIN execution_runs AS er
3097
+ ON er.id = ei.run_id
3098
+ LEFT JOIN newsletter_senders AS ns
3099
+ ON LOWER(ns.email) = LOWER(e.from_address)
3100
+ LEFT JOIN (
3101
+ SELECT
3102
+ LOWER(from_address) AS senderKey,
3103
+ COUNT(*) AS totalFromSender
3104
+ FROM emails
3105
+ WHERE from_address IS NOT NULL
3106
+ AND TRIM(from_address) <> ''
3107
+ GROUP BY LOWER(from_address)
3108
+ ) AS sender_stats
3109
+ ON sender_stats.senderKey = LOWER(e.from_address)
3110
+ WHERE ei.status = 'applied'
3111
+ AND er.status IN ('applied', 'partial')
3112
+ AND COALESCE(er.dry_run, 0) = 0
3113
+ AND er.undone_at IS NULL
3114
+ AND ei.undone_at IS NULL
3115
+ AND COALESCE(ei.executed_at, 0) >= ?
3116
+ ORDER BY COALESCE(ei.executed_at, 0) DESC, ei.email_id ASC
3117
+ `
3118
+ ).all(sinceTimestamp);
3119
+ const reviewedRows = rows.filter((row) => {
3120
+ const actions = parseActions(row.appliedActions);
3121
+ const beforeLabelIds = parseJsonArray3(row.beforeLabelIds);
3122
+ const afterLabelIds = parseJsonArray3(row.afterLabelIds);
3123
+ return actions.length > 0 && (resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds) !== null || isArchived(actions, beforeLabelIds, afterLabelIds));
3124
+ });
3125
+ const anomalies = reviewedRows.map((row) => detectAnomaly(row)).filter((anomaly) => anomaly !== null).sort(
3126
+ (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
3127
+ );
3128
+ const highCount = anomalies.filter((anomaly) => anomaly.severity === "high").length;
3129
+ const mediumCount = anomalies.filter((anomaly) => anomaly.severity === "medium").length;
3130
+ return {
3131
+ anomalies: anomalies.slice(0, limit),
3132
+ totalReviewed: reviewedRows.length,
3133
+ anomalyCount: anomalies.length,
3134
+ summary: summarizeReview(reviewedRows.length, anomalies.length, highCount, mediumCount)
3135
+ };
3136
+ }
3137
+
3138
+ // src/core/stats/labels.ts
3139
+ async function getLabelDistribution() {
3140
+ const sqlite = getStatsSqlite();
3141
+ const rows = sqlite.prepare(
3142
+ `
3143
+ SELECT
3144
+ label.value AS labelId,
3145
+ COUNT(*) AS totalMessages,
3146
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages
3147
+ FROM emails AS e, json_each(e.label_ids) AS label
3148
+ GROUP BY label.value
3149
+ ORDER BY totalMessages DESC, unreadMessages DESC, label.value ASC
3150
+ `
3151
+ ).all();
3152
+ return rows.map((row) => ({
3153
+ labelId: row.labelId,
3154
+ labelName: resolveLabelName(row.labelId),
3155
+ totalMessages: row.totalMessages,
3156
+ unreadMessages: row.unreadMessages
3157
+ }));
3158
+ }
3159
+
3160
+ // src/core/stats/noise.ts
3161
+ var DAY_MS3 = 24 * 60 * 60 * 1e3;
2817
3162
  var SUGGESTED_CATEGORY_RULES = [
2818
3163
  { category: "Receipts", keywords: ["receipt", "invoice", "payment", "order"] },
2819
3164
  { category: "Shipping", keywords: ["shipping", "tracking", "delivery", "dispatch"] },
@@ -2822,7 +3167,7 @@ var SUGGESTED_CATEGORY_RULES = [
2822
3167
  { category: "Promotions", keywords: ["promo", "offer", "deal", "sale", "marketing"] },
2823
3168
  { category: "Social", keywords: ["linkedin", "facebook", "twitter", "social"] }
2824
3169
  ];
2825
- function toIsoString(value) {
3170
+ function toIsoString2(value) {
2826
3171
  if (!value) {
2827
3172
  return null;
2828
3173
  }
@@ -2861,7 +3206,7 @@ async function getNoiseSenders(options = {}) {
2861
3206
  const limit = Math.min(50, normalizeLimit(options.limit, 20));
2862
3207
  const minNoiseScore = options.minNoiseScore ?? 5;
2863
3208
  const activeDays = Math.max(1, Math.floor(options.activeDays ?? 90));
2864
- const activeSince = Date.now() - activeDays * DAY_MS2;
3209
+ const activeSince = Date.now() - activeDays * DAY_MS3;
2865
3210
  const sortBy = options.sortBy ?? "noise_score";
2866
3211
  const rows = sqlite.prepare(
2867
3212
  `
@@ -2920,7 +3265,7 @@ async function getNoiseSenders(options = {}) {
2920
3265
  unreadRate,
2921
3266
  noiseScore,
2922
3267
  allTimeNoiseScore,
2923
- lastSeen: toIsoString(row.lastSeen),
3268
+ lastSeen: toIsoString2(row.lastSeen),
2924
3269
  isNewsletter: row.isNewsletter === 1,
2925
3270
  hasUnsubscribeLink: Boolean(unsubscribe2.unsubscribeLink),
2926
3271
  unsubscribeLink: unsubscribe2.unsubscribeLink,
@@ -2930,6 +3275,427 @@ async function getNoiseSenders(options = {}) {
2930
3275
  return { senders };
2931
3276
  }
2932
3277
 
3278
+ // src/core/stats/query.ts
3279
+ import { z as z2 } from "zod";
3280
+ var QUERY_EMAIL_GROUP_BY_VALUES = [
3281
+ "sender",
3282
+ "domain",
3283
+ "label",
3284
+ "year_month",
3285
+ "year_week",
3286
+ "day_of_week",
3287
+ "is_read",
3288
+ "is_newsletter"
3289
+ ];
3290
+ var QUERY_EMAIL_AGGREGATE_VALUES = [
3291
+ "count",
3292
+ "unread_count",
3293
+ "read_count",
3294
+ "unread_rate",
3295
+ "oldest",
3296
+ "newest",
3297
+ "sender_count"
3298
+ ];
3299
+ var QUERY_EMAIL_HAVING_FIELDS = [
3300
+ "count",
3301
+ "unread_count",
3302
+ "unread_rate",
3303
+ "sender_count"
3304
+ ];
3305
+ var CATEGORY_LABEL_LIKE_PATTERN = `${CATEGORY_LABEL_PREFIX.replace(/_/g, "\\_")}%`;
3306
+ var SYSTEM_LABEL_SQL = SYSTEM_LABEL_IDS.map((label) => `'${label}'`).join(", ");
3307
+ var DOMAIN_SQL = `
3308
+ LOWER(
3309
+ CASE
3310
+ WHEN INSTR(COALESCE(e.from_address, ''), '@') > 0
3311
+ THEN SUBSTR(e.from_address, INSTR(e.from_address, '@') + 1)
3312
+ ELSE ''
3313
+ END
3314
+ )
3315
+ `;
3316
+ var queryEmailsFiltersSchema = z2.object({
3317
+ from: z2.string().optional(),
3318
+ from_contains: z2.string().optional(),
3319
+ domain: z2.string().optional(),
3320
+ domain_contains: z2.string().optional(),
3321
+ subject_contains: z2.string().optional(),
3322
+ date_after: z2.string().optional(),
3323
+ date_before: z2.string().optional(),
3324
+ is_read: z2.boolean().optional(),
3325
+ is_newsletter: z2.boolean().optional(),
3326
+ has_label: z2.boolean().optional(),
3327
+ label: z2.string().optional(),
3328
+ has_unsubscribe: z2.boolean().optional(),
3329
+ min_sender_messages: z2.number().int().positive().optional()
3330
+ }).strict();
3331
+ var havingConditionSchema = z2.object({
3332
+ gte: z2.number().optional(),
3333
+ lte: z2.number().optional()
3334
+ }).strict().refine(
3335
+ (value) => value.gte !== void 0 || value.lte !== void 0,
3336
+ { message: "Provide at least one of gte or lte." }
3337
+ );
3338
+ var queryEmailsHavingSchema = z2.object({
3339
+ count: havingConditionSchema.optional(),
3340
+ unread_count: havingConditionSchema.optional(),
3341
+ unread_rate: havingConditionSchema.optional(),
3342
+ sender_count: havingConditionSchema.optional()
3343
+ }).strict();
3344
+ var queryEmailsInputSchema = z2.object({
3345
+ filters: queryEmailsFiltersSchema.optional(),
3346
+ group_by: z2.enum(QUERY_EMAIL_GROUP_BY_VALUES).optional(),
3347
+ aggregates: z2.array(z2.enum(QUERY_EMAIL_AGGREGATE_VALUES)).min(1).optional(),
3348
+ having: queryEmailsHavingSchema.optional(),
3349
+ order_by: z2.string().optional(),
3350
+ limit: z2.number().int().positive().max(500).optional()
3351
+ }).strict();
3352
+ var QUERY_EMAILS_FIELD_SCHEMA = {
3353
+ description: "Available fields for the query_emails tool.",
3354
+ filters: {
3355
+ from: { type: "string", description: "Exact sender email (case-insensitive)" },
3356
+ from_contains: { type: "string", description: "Partial match on sender email" },
3357
+ domain: { type: "string", description: "Exact sender domain" },
3358
+ domain_contains: { type: "string", description: "Partial match on sender domain" },
3359
+ subject_contains: { type: "string", description: "Partial match on subject line" },
3360
+ date_after: { type: "string", description: "ISO date \u2014 emails after this date" },
3361
+ date_before: { type: "string", description: "ISO date \u2014 emails before this date" },
3362
+ is_read: { type: "boolean", description: "Filter by read/unread state" },
3363
+ is_newsletter: { type: "boolean", description: "Sender detected as newsletter" },
3364
+ has_label: { type: "boolean", description: "Has any user-applied label" },
3365
+ label: { type: "string", description: "Has this specific label" },
3366
+ has_unsubscribe: { type: "boolean", description: "Has List-Unsubscribe header" },
3367
+ min_sender_messages: { type: "integer", description: "Sender has at least this many total emails" }
3368
+ },
3369
+ group_by: [
3370
+ { value: "sender", description: "Group by sender email address" },
3371
+ { value: "domain", description: "Group by sender domain" },
3372
+ { value: "label", description: "Group by applied label (expands multi-label emails)" },
3373
+ { value: "year_month", description: "Group by month (YYYY-MM)" },
3374
+ { value: "year_week", description: "Group by week (YYYY-WNN)" },
3375
+ { value: "day_of_week", description: "Group by day of week (0=Sunday)" },
3376
+ { value: "is_read", description: "Group by read/unread state" },
3377
+ { value: "is_newsletter", description: "Group by newsletter detection" }
3378
+ ],
3379
+ aggregates: [
3380
+ { value: "count", description: "Number of emails" },
3381
+ { value: "unread_count", description: "Number of unread emails" },
3382
+ { value: "read_count", description: "Number of read emails" },
3383
+ { value: "unread_rate", description: "Percentage of emails that are unread" },
3384
+ { value: "oldest", description: "Earliest email date (ISO string)" },
3385
+ { value: "newest", description: "Latest email date (ISO string)" },
3386
+ { value: "sender_count", description: "Count of distinct senders" }
3387
+ ],
3388
+ having_fields: [...QUERY_EMAIL_HAVING_FIELDS],
3389
+ example_queries: [
3390
+ {
3391
+ description: "Monthly volume trend for Amazon",
3392
+ query: {
3393
+ filters: { domain_contains: "amazon" },
3394
+ group_by: "year_month",
3395
+ aggregates: ["count", "unread_rate"],
3396
+ order_by: "year_month asc"
3397
+ }
3398
+ },
3399
+ {
3400
+ description: "Domains with 95%+ unread rate and 50+ emails",
3401
+ query: {
3402
+ group_by: "domain",
3403
+ aggregates: ["count", "unread_rate"],
3404
+ having: { count: { gte: 50 }, unread_rate: { gte: 95 } }
3405
+ }
3406
+ },
3407
+ {
3408
+ description: "What day of the week gets the most email?",
3409
+ query: {
3410
+ group_by: "day_of_week",
3411
+ aggregates: ["count", "sender_count"]
3412
+ }
3413
+ }
3414
+ ]
3415
+ };
3416
+ var GROUP_BY_SQL_MAP = {
3417
+ sender: "LOWER(COALESCE(e.from_address, ''))",
3418
+ domain: DOMAIN_SQL,
3419
+ label: "CAST(grouped_label.value AS TEXT)",
3420
+ year_month: "STRFTIME('%Y-%m', e.date / 1000, 'unixepoch')",
3421
+ year_week: "STRFTIME('%Y-W%W', e.date / 1000, 'unixepoch')",
3422
+ day_of_week: "CAST(STRFTIME('%w', e.date / 1000, 'unixepoch') AS INTEGER)",
3423
+ is_read: "COALESCE(e.is_read, 0)",
3424
+ is_newsletter: "CASE WHEN ns.email IS NOT NULL THEN 1 ELSE 0 END"
3425
+ };
3426
+ var AGGREGATE_SQL_MAP = {
3427
+ count: "COUNT(*)",
3428
+ unread_count: "SUM(CASE WHEN COALESCE(e.is_read, 0) = 0 THEN 1 ELSE 0 END)",
3429
+ read_count: "SUM(CASE WHEN COALESCE(e.is_read, 0) = 1 THEN 1 ELSE 0 END)",
3430
+ unread_rate: `
3431
+ ROUND(
3432
+ CASE
3433
+ WHEN COUNT(*) = 0 THEN 0
3434
+ ELSE 100.0 * SUM(CASE WHEN COALESCE(e.is_read, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*)
3435
+ END,
3436
+ 1
3437
+ )
3438
+ `,
3439
+ oldest: "MIN(e.date)",
3440
+ newest: "MAX(e.date)",
3441
+ sender_count: `
3442
+ COUNT(
3443
+ DISTINCT CASE
3444
+ WHEN e.from_address IS NOT NULL AND TRIM(e.from_address) <> ''
3445
+ THEN LOWER(e.from_address)
3446
+ ELSE NULL
3447
+ END
3448
+ )
3449
+ `
3450
+ };
3451
+ function userLabelPredicate(column) {
3452
+ return `
3453
+ ${column} IS NOT NULL
3454
+ AND TRIM(CAST(${column} AS TEXT)) <> ''
3455
+ AND CAST(${column} AS TEXT) NOT IN (${SYSTEM_LABEL_SQL})
3456
+ AND CAST(${column} AS TEXT) NOT LIKE '${CATEGORY_LABEL_LIKE_PATTERN}' ESCAPE '\\'
3457
+ `;
3458
+ }
3459
+ function toIsoString3(value) {
3460
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
3461
+ return null;
3462
+ }
3463
+ return new Date(value).toISOString();
3464
+ }
3465
+ function normalizeGroupValue(groupBy, value) {
3466
+ switch (groupBy) {
3467
+ case "is_read":
3468
+ case "is_newsletter":
3469
+ return Number(value ?? 0) === 1;
3470
+ case "day_of_week":
3471
+ return Number(value ?? 0);
3472
+ case "sender":
3473
+ case "domain":
3474
+ case "label":
3475
+ case "year_month":
3476
+ case "year_week":
3477
+ return typeof value === "string" ? value : value == null ? null : String(value);
3478
+ }
3479
+ }
3480
+ function normalizeAggregateValue(aggregate, value) {
3481
+ switch (aggregate) {
3482
+ case "oldest":
3483
+ case "newest":
3484
+ return toIsoString3(value);
3485
+ case "count":
3486
+ case "unread_count":
3487
+ case "read_count":
3488
+ case "sender_count":
3489
+ return Number(value ?? 0);
3490
+ case "unread_rate":
3491
+ return Number(value ?? 0);
3492
+ }
3493
+ }
3494
+ function parseDateFilter(field, value) {
3495
+ const timestamp = Date.parse(value);
3496
+ if (Number.isNaN(timestamp)) {
3497
+ throw new Error(`Invalid ${field} value: ${value}`);
3498
+ }
3499
+ return timestamp;
3500
+ }
3501
+ function resolveAggregates(aggregates) {
3502
+ return Array.from(new Set(aggregates && aggregates.length > 0 ? aggregates : ["count"]));
3503
+ }
3504
+ function resolveOrderBy(orderBy, groupBy, aggregates) {
3505
+ const defaultField = aggregates.includes("count") ? "count" : aggregates[0];
3506
+ const rawValue = (orderBy || `${defaultField} desc`).trim();
3507
+ const match = rawValue.match(/^([a-z_]+)\s+(asc|desc)$/i);
3508
+ if (!match) {
3509
+ throw new Error(`Invalid order_by value: ${rawValue}`);
3510
+ }
3511
+ const [, field, direction] = match;
3512
+ const allowedFields = new Set(aggregates);
3513
+ if (groupBy) {
3514
+ allowedFields.add(groupBy);
3515
+ }
3516
+ if (!allowedFields.has(field)) {
3517
+ throw new Error(`Invalid order_by field: ${field}`);
3518
+ }
3519
+ return `${field} ${direction.toLowerCase()}`;
3520
+ }
3521
+ function buildWhereClauses(filters, groupBy) {
3522
+ const whereParts = [];
3523
+ const params = [];
3524
+ if (filters.from !== void 0) {
3525
+ whereParts.push("LOWER(COALESCE(e.from_address, '')) = LOWER(?)");
3526
+ params.push(filters.from);
3527
+ }
3528
+ if (filters.from_contains !== void 0) {
3529
+ whereParts.push("LOWER(COALESCE(e.from_address, '')) LIKE '%' || LOWER(?) || '%'");
3530
+ params.push(filters.from_contains);
3531
+ }
3532
+ if (filters.domain !== void 0) {
3533
+ whereParts.push(`${DOMAIN_SQL} = LOWER(?)`);
3534
+ params.push(filters.domain);
3535
+ }
3536
+ if (filters.domain_contains !== void 0) {
3537
+ whereParts.push(`${DOMAIN_SQL} LIKE '%' || LOWER(?) || '%'`);
3538
+ params.push(filters.domain_contains);
3539
+ }
3540
+ if (filters.subject_contains !== void 0) {
3541
+ whereParts.push("LOWER(COALESCE(e.subject, '')) LIKE '%' || LOWER(?) || '%'");
3542
+ params.push(filters.subject_contains);
3543
+ }
3544
+ if (filters.date_after !== void 0) {
3545
+ whereParts.push("COALESCE(e.date, 0) >= ?");
3546
+ params.push(parseDateFilter("date_after", filters.date_after));
3547
+ }
3548
+ if (filters.date_before !== void 0) {
3549
+ whereParts.push("COALESCE(e.date, 0) <= ?");
3550
+ params.push(parseDateFilter("date_before", filters.date_before));
3551
+ }
3552
+ if (filters.is_read !== void 0) {
3553
+ whereParts.push("COALESCE(e.is_read, 0) = ?");
3554
+ params.push(filters.is_read ? 1 : 0);
3555
+ }
3556
+ if (filters.is_newsletter !== void 0) {
3557
+ whereParts.push(filters.is_newsletter ? "ns.email IS NOT NULL" : "ns.email IS NULL");
3558
+ }
3559
+ if (filters.has_label !== void 0) {
3560
+ whereParts.push(
3561
+ filters.has_label ? `EXISTS (
3562
+ SELECT 1
3563
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3564
+ WHERE ${userLabelPredicate("label_filter.value")}
3565
+ )` : `NOT EXISTS (
3566
+ SELECT 1
3567
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3568
+ WHERE ${userLabelPredicate("label_filter.value")}
3569
+ )`
3570
+ );
3571
+ }
3572
+ if (filters.label !== void 0) {
3573
+ whereParts.push(`
3574
+ EXISTS (
3575
+ SELECT 1
3576
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3577
+ WHERE LOWER(CAST(label_filter.value AS TEXT)) = LOWER(?)
3578
+ )
3579
+ `);
3580
+ params.push(filters.label);
3581
+ }
3582
+ if (filters.has_unsubscribe !== void 0) {
3583
+ whereParts.push(
3584
+ filters.has_unsubscribe ? "NULLIF(TRIM(e.list_unsubscribe), '') IS NOT NULL" : "(e.list_unsubscribe IS NULL OR TRIM(e.list_unsubscribe) = '')"
3585
+ );
3586
+ }
3587
+ if (filters.min_sender_messages !== void 0) {
3588
+ whereParts.push("COALESCE(sender_stats.totalFromSender, 0) >= ?");
3589
+ params.push(filters.min_sender_messages);
3590
+ }
3591
+ if (groupBy === "label") {
3592
+ whereParts.push(userLabelPredicate("grouped_label.value"));
3593
+ }
3594
+ return {
3595
+ sql: whereParts.length > 0 ? `WHERE ${whereParts.join("\n AND ")}` : "",
3596
+ params
3597
+ };
3598
+ }
3599
+ function buildHavingClause(having) {
3600
+ const parts = [];
3601
+ for (const field of QUERY_EMAIL_HAVING_FIELDS) {
3602
+ const condition = having[field];
3603
+ if (!condition) {
3604
+ continue;
3605
+ }
3606
+ const expression = AGGREGATE_SQL_MAP[field];
3607
+ if (condition.gte !== void 0) {
3608
+ parts.push(`${expression} >= ${condition.gte}`);
3609
+ }
3610
+ if (condition.lte !== void 0) {
3611
+ parts.push(`${expression} <= ${condition.lte}`);
3612
+ }
3613
+ }
3614
+ return parts.length > 0 ? `HAVING ${parts.join("\n AND ")}` : "";
3615
+ }
3616
+ function normalizeRow(row, groupBy, aggregates) {
3617
+ const normalized = {};
3618
+ if (groupBy) {
3619
+ normalized[groupBy] = normalizeGroupValue(groupBy, row[groupBy]);
3620
+ }
3621
+ for (const aggregate of aggregates) {
3622
+ normalized[aggregate] = normalizeAggregateValue(aggregate, row[aggregate]);
3623
+ }
3624
+ return normalized;
3625
+ }
3626
+ async function queryEmails(options = {}) {
3627
+ const parsed = queryEmailsInputSchema.parse(options);
3628
+ await detectNewsletters();
3629
+ const sqlite = getStatsSqlite();
3630
+ const filters = parsed.filters ?? {};
3631
+ const groupBy = parsed.group_by;
3632
+ const aggregates = resolveAggregates(parsed.aggregates);
3633
+ const having = parsed.having ?? {};
3634
+ const orderBy = resolveOrderBy(parsed.order_by, groupBy, aggregates);
3635
+ const limit = Math.min(500, normalizeLimit(parsed.limit, 50));
3636
+ const { sql: whereSql, params } = buildWhereClauses(filters, groupBy);
3637
+ const havingSql = buildHavingClause(having);
3638
+ const fromSql = [
3639
+ "FROM emails AS e",
3640
+ "LEFT JOIN newsletter_senders AS ns ON LOWER(ns.email) = LOWER(e.from_address)",
3641
+ `LEFT JOIN (
3642
+ SELECT
3643
+ LOWER(from_address) AS senderKey,
3644
+ COUNT(*) AS totalFromSender
3645
+ FROM emails
3646
+ WHERE from_address IS NOT NULL
3647
+ AND TRIM(from_address) <> ''
3648
+ GROUP BY LOWER(from_address)
3649
+ ) AS sender_stats ON sender_stats.senderKey = LOWER(e.from_address)`,
3650
+ groupBy === "label" ? "JOIN json_each(COALESCE(e.label_ids, '[]')) AS grouped_label" : ""
3651
+ ].filter(Boolean).join("\n");
3652
+ const selectParts = [];
3653
+ if (groupBy) {
3654
+ selectParts.push(`${GROUP_BY_SQL_MAP[groupBy]} AS ${groupBy}`);
3655
+ }
3656
+ for (const aggregate of aggregates) {
3657
+ selectParts.push(`${AGGREGATE_SQL_MAP[aggregate]} AS ${aggregate}`);
3658
+ }
3659
+ const groupBySql = groupBy ? `GROUP BY ${GROUP_BY_SQL_MAP[groupBy]}` : "";
3660
+ const orderBySql = `ORDER BY ${orderBy.split(" ")[0]} ${orderBy.split(" ")[1].toUpperCase()}`;
3661
+ const totalRow = groupBy ? sqlite.prepare(
3662
+ `
3663
+ SELECT COUNT(*) AS totalRows
3664
+ FROM (
3665
+ SELECT 1
3666
+ ${fromSql}
3667
+ ${whereSql}
3668
+ ${groupBySql}
3669
+ ${havingSql}
3670
+ ) AS grouped_rows
3671
+ `
3672
+ ).get(...params) : void 0;
3673
+ const rows = sqlite.prepare(
3674
+ `
3675
+ SELECT
3676
+ ${selectParts.join(",\n ")}
3677
+ ${fromSql}
3678
+ ${whereSql}
3679
+ ${groupBySql}
3680
+ ${havingSql}
3681
+ ${orderBySql}
3682
+ LIMIT ?
3683
+ `
3684
+ ).all(...params, limit);
3685
+ return {
3686
+ rows: rows.map((row) => normalizeRow(row, groupBy, aggregates)),
3687
+ totalRows: groupBy ? totalRow?.totalRows ?? 0 : rows.length,
3688
+ query: {
3689
+ filters,
3690
+ group_by: groupBy ?? null,
3691
+ aggregates,
3692
+ having,
3693
+ order_by: orderBy,
3694
+ limit
3695
+ }
3696
+ };
3697
+ }
3698
+
2933
3699
  // src/core/stats/sender.ts
2934
3700
  function buildSenderWhereClause(period) {
2935
3701
  const whereParts = [
@@ -3089,7 +3855,7 @@ async function getSenderStats(emailOrDomain) {
3089
3855
  }
3090
3856
 
3091
3857
  // src/core/stats/uncategorized.ts
3092
- var SYSTEM_LABEL_IDS = [
3858
+ var SYSTEM_LABEL_IDS2 = [
3093
3859
  "INBOX",
3094
3860
  "UNREAD",
3095
3861
  "IMPORTANT",
@@ -3100,13 +3866,13 @@ var SYSTEM_LABEL_IDS = [
3100
3866
  "STARRED"
3101
3867
  ];
3102
3868
  var CATEGORY_LABEL_PATTERN = "CATEGORY\\_%";
3103
- function toIsoString2(value) {
3869
+ function toIsoString4(value) {
3104
3870
  if (!value) {
3105
3871
  return null;
3106
3872
  }
3107
3873
  return new Date(value).toISOString();
3108
3874
  }
3109
- function parseJsonArray3(raw) {
3875
+ function parseJsonArray4(raw) {
3110
3876
  if (!raw) {
3111
3877
  return [];
3112
3878
  }
@@ -3117,7 +3883,7 @@ function parseJsonArray3(raw) {
3117
3883
  return [];
3118
3884
  }
3119
3885
  }
3120
- function resolveSinceTimestamp(since) {
3886
+ function resolveSinceTimestamp2(since) {
3121
3887
  if (!since) {
3122
3888
  return null;
3123
3889
  }
@@ -3135,12 +3901,12 @@ function buildWhereClause(options) {
3135
3901
  FROM json_each(COALESCE(e.label_ids, '[]')) AS label
3136
3902
  WHERE label.value IS NOT NULL
3137
3903
  AND TRIM(CAST(label.value AS TEXT)) <> ''
3138
- AND label.value NOT IN (${SYSTEM_LABEL_IDS.map(() => "?").join(", ")})
3904
+ AND label.value NOT IN (${SYSTEM_LABEL_IDS2.map(() => "?").join(", ")})
3139
3905
  AND label.value NOT LIKE ? ESCAPE '\\'
3140
3906
  )
3141
3907
  `
3142
3908
  ];
3143
- const params = [...SYSTEM_LABEL_IDS, CATEGORY_LABEL_PATTERN];
3909
+ const params = [...SYSTEM_LABEL_IDS2, CATEGORY_LABEL_PATTERN];
3144
3910
  if (options.unreadOnly) {
3145
3911
  whereParts.push("COALESCE(e.is_read, 0) = 0");
3146
3912
  }
@@ -3158,7 +3924,7 @@ async function getUncategorizedEmails(options = {}) {
3158
3924
  const sqlite = getStatsSqlite();
3159
3925
  const limit = Math.min(1e3, normalizeLimit(options.limit, 50));
3160
3926
  const offset = Math.max(0, Math.floor(options.offset ?? 0));
3161
- const sinceTimestamp = resolveSinceTimestamp(options.since);
3927
+ const sinceTimestamp = resolveSinceTimestamp2(options.since);
3162
3928
  const { clause, params } = buildWhereClause({
3163
3929
  sinceTimestamp,
3164
3930
  unreadOnly: options.unreadOnly ?? false
@@ -3183,7 +3949,8 @@ async function getUncategorizedEmails(options = {}) {
3183
3949
  e.is_read AS isRead,
3184
3950
  sender_stats.totalFromSender AS totalFromSender,
3185
3951
  sender_stats.unreadFromSender AS unreadFromSender,
3186
- ns.detection_reason AS detectionReason
3952
+ ns.detection_reason AS detectionReason,
3953
+ e.list_unsubscribe AS listUnsubscribe
3187
3954
  FROM emails AS e
3188
3955
  LEFT JOIN (
3189
3956
  SELECT
@@ -3207,20 +3974,23 @@ async function getUncategorizedEmails(options = {}) {
3207
3974
  const emails2 = rows.map((row) => {
3208
3975
  const totalFromSender = row.totalFromSender ?? 0;
3209
3976
  const unreadFromSender = row.unreadFromSender ?? 0;
3977
+ const confidence = computeConfidence(row);
3210
3978
  return {
3211
3979
  id: row.id,
3212
3980
  threadId: row.threadId || "",
3213
3981
  from: row.sender || "",
3214
3982
  subject: row.subject || "",
3215
- date: toIsoString2(row.date),
3983
+ date: toIsoString4(row.date),
3216
3984
  snippet: row.snippet || "",
3217
- labels: parseJsonArray3(row.labelIds),
3985
+ labels: parseJsonArray4(row.labelIds),
3218
3986
  isRead: row.isRead === 1,
3219
3987
  senderContext: {
3220
3988
  totalFromSender,
3221
3989
  unreadRate: roundPercent(unreadFromSender, totalFromSender),
3222
3990
  isNewsletter: Boolean(row.detectionReason),
3223
- detectionReason: row.detectionReason
3991
+ detectionReason: row.detectionReason,
3992
+ confidence: confidence.confidence,
3993
+ signals: confidence.signals
3224
3994
  }
3225
3995
  };
3226
3996
  });
@@ -3233,8 +4003,260 @@ async function getUncategorizedEmails(options = {}) {
3233
4003
  };
3234
4004
  }
3235
4005
 
4006
+ // src/core/stats/uncategorized-senders.ts
4007
+ var SYSTEM_LABEL_IDS3 = [
4008
+ "INBOX",
4009
+ "UNREAD",
4010
+ "IMPORTANT",
4011
+ "SENT",
4012
+ "DRAFT",
4013
+ "SPAM",
4014
+ "TRASH",
4015
+ "STARRED"
4016
+ ];
4017
+ var CATEGORY_LABEL_PATTERN2 = "CATEGORY\\_%";
4018
+ var MAX_EMAIL_IDS = 500;
4019
+ function toIsoString5(value) {
4020
+ if (!value) {
4021
+ return null;
4022
+ }
4023
+ return new Date(value).toISOString();
4024
+ }
4025
+ function resolveSinceTimestamp3(since) {
4026
+ if (!since) {
4027
+ return null;
4028
+ }
4029
+ const parsed = Date.parse(since);
4030
+ if (Number.isNaN(parsed)) {
4031
+ throw new Error(`Invalid since value: ${since}`);
4032
+ }
4033
+ return parsed;
4034
+ }
4035
+ function buildWhereClause2(sinceTimestamp) {
4036
+ const whereParts = [
4037
+ `
4038
+ NOT EXISTS (
4039
+ SELECT 1
4040
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label
4041
+ WHERE label.value IS NOT NULL
4042
+ AND TRIM(CAST(label.value AS TEXT)) <> ''
4043
+ AND label.value NOT IN (${SYSTEM_LABEL_IDS3.map(() => "?").join(", ")})
4044
+ AND label.value NOT LIKE ? ESCAPE '\\'
4045
+ )
4046
+ `
4047
+ ];
4048
+ const params = [...SYSTEM_LABEL_IDS3, CATEGORY_LABEL_PATTERN2];
4049
+ if (sinceTimestamp !== null) {
4050
+ whereParts.push("COALESCE(e.date, 0) >= ?");
4051
+ params.push(sinceTimestamp);
4052
+ }
4053
+ return {
4054
+ clause: whereParts.join(" AND "),
4055
+ params
4056
+ };
4057
+ }
4058
+ function parseEmailIds(raw) {
4059
+ const ids = (raw || "").split("\n").map((value) => value.trim()).filter(Boolean);
4060
+ return {
4061
+ emailIds: ids.slice(0, MAX_EMAIL_IDS),
4062
+ truncated: ids.length > MAX_EMAIL_IDS
4063
+ };
4064
+ }
4065
+ function compareSenders(sortBy) {
4066
+ return (left, right) => {
4067
+ switch (sortBy) {
4068
+ case "newest":
4069
+ return (right.newestDate || "").localeCompare(left.newestDate || "") || right.emailCount - left.emailCount || right.unreadRate - left.unreadRate || left.sender.localeCompare(right.sender);
4070
+ case "unread_rate":
4071
+ return right.unreadRate - left.unreadRate || right.emailCount - left.emailCount || (right.newestDate || "").localeCompare(left.newestDate || "") || left.sender.localeCompare(right.sender);
4072
+ case "email_count":
4073
+ default:
4074
+ return right.emailCount - left.emailCount || (right.newestDate || "").localeCompare(left.newestDate || "") || right.unreadRate - left.unreadRate || left.sender.localeCompare(right.sender);
4075
+ }
4076
+ };
4077
+ }
4078
+ async function getUncategorizedSenders(options = {}) {
4079
+ await detectNewsletters();
4080
+ const sqlite = getStatsSqlite();
4081
+ const limit = Math.min(500, normalizeLimit(options.limit, 100));
4082
+ const offset = Math.max(0, Math.floor(options.offset ?? 0));
4083
+ const minEmails = Math.max(1, Math.floor(options.minEmails ?? 1));
4084
+ const sinceTimestamp = resolveSinceTimestamp3(options.since);
4085
+ const sortBy = options.sortBy ?? "email_count";
4086
+ const { clause, params } = buildWhereClause2(sinceTimestamp);
4087
+ const rows = sqlite.prepare(
4088
+ `
4089
+ WITH uncategorized AS (
4090
+ SELECT
4091
+ e.id,
4092
+ e.from_address,
4093
+ e.from_name,
4094
+ e.subject,
4095
+ e.snippet,
4096
+ e.date,
4097
+ e.is_read,
4098
+ e.list_unsubscribe
4099
+ FROM emails AS e
4100
+ WHERE e.from_address IS NOT NULL
4101
+ AND TRIM(e.from_address) <> ''
4102
+ AND ${clause}
4103
+ ),
4104
+ sender_totals AS (
4105
+ SELECT
4106
+ LOWER(from_address) AS senderKey,
4107
+ COUNT(*) AS totalFromSender
4108
+ FROM emails
4109
+ WHERE from_address IS NOT NULL
4110
+ AND TRIM(from_address) <> ''
4111
+ GROUP BY LOWER(from_address)
4112
+ )
4113
+ SELECT
4114
+ grouped.sender AS sender,
4115
+ grouped.name AS name,
4116
+ grouped.emailCount AS emailCount,
4117
+ grouped.unreadCount AS unreadCount,
4118
+ grouped.newestDate AS newestDate,
4119
+ (
4120
+ SELECT u2.subject
4121
+ FROM uncategorized AS u2
4122
+ WHERE LOWER(u2.from_address) = grouped.senderKey
4123
+ ORDER BY COALESCE(u2.date, 0) DESC, u2.id ASC
4124
+ LIMIT 1
4125
+ ) AS newestSubject,
4126
+ (
4127
+ SELECT u2.snippet
4128
+ FROM uncategorized AS u2
4129
+ WHERE LOWER(u2.from_address) = grouped.senderKey
4130
+ ORDER BY COALESCE(u2.date, 0) DESC, u2.id ASC
4131
+ LIMIT 1
4132
+ ) AS newestSnippet,
4133
+ (
4134
+ SELECT u2.subject
4135
+ FROM uncategorized AS u2
4136
+ WHERE LOWER(u2.from_address) = grouped.senderKey
4137
+ ORDER BY COALESCE(u2.date, 0) DESC, u2.id ASC
4138
+ LIMIT 1 OFFSET 1
4139
+ ) AS secondSubject,
4140
+ grouped.detectionReason AS detectionReason,
4141
+ grouped.newsletterUnsubscribeLink AS newsletterUnsubscribeLink,
4142
+ grouped.emailUnsubscribeHeaders AS emailUnsubscribeHeaders,
4143
+ COALESCE(sender_totals.totalFromSender, grouped.emailCount) AS totalFromSender,
4144
+ (
4145
+ SELECT GROUP_CONCAT(u2.id, '
4146
+ ')
4147
+ FROM (
4148
+ SELECT id
4149
+ FROM uncategorized
4150
+ WHERE LOWER(from_address) = grouped.senderKey
4151
+ ORDER BY COALESCE(date, 0) DESC, id ASC
4152
+ ) AS u2
4153
+ ) AS emailIds
4154
+ FROM (
4155
+ SELECT
4156
+ LOWER(u.from_address) AS senderKey,
4157
+ MAX(u.from_address) AS sender,
4158
+ COALESCE(MAX(NULLIF(TRIM(u.from_name), '')), MAX(u.from_address)) AS name,
4159
+ COUNT(*) AS emailCount,
4160
+ SUM(CASE WHEN COALESCE(u.is_read, 0) = 0 THEN 1 ELSE 0 END) AS unreadCount,
4161
+ MAX(u.date) AS newestDate,
4162
+ MAX(ns.detection_reason) AS detectionReason,
4163
+ MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
4164
+ GROUP_CONCAT(NULLIF(TRIM(u.list_unsubscribe), ''), '
4165
+ ') AS emailUnsubscribeHeaders
4166
+ FROM uncategorized AS u
4167
+ LEFT JOIN newsletter_senders AS ns
4168
+ ON LOWER(ns.email) = LOWER(u.from_address)
4169
+ GROUP BY LOWER(u.from_address)
4170
+ HAVING COUNT(*) >= ?
4171
+ ) AS grouped
4172
+ LEFT JOIN sender_totals
4173
+ ON sender_totals.senderKey = grouped.senderKey
4174
+ `
4175
+ ).all(...params, minEmails);
4176
+ const filtered = rows.map((row) => {
4177
+ const confidenceResult = computeConfidence({
4178
+ sender: row.sender,
4179
+ totalFromSender: row.totalFromSender,
4180
+ detectionReason: row.detectionReason,
4181
+ listUnsubscribe: row.emailUnsubscribeHeaders
4182
+ });
4183
+ const emailIds = parseEmailIds(row.emailIds);
4184
+ const unsubscribe2 = resolveUnsubscribeTarget(
4185
+ row.newsletterUnsubscribeLink,
4186
+ row.emailUnsubscribeHeaders
4187
+ );
4188
+ const sender = row.sender?.trim() || "";
4189
+ const domain = extractDomain(sender) || "";
4190
+ return {
4191
+ sender,
4192
+ name: row.name?.trim() || sender,
4193
+ emailCount: row.emailCount,
4194
+ emailIds: emailIds.emailIds,
4195
+ emailIdsTruncated: emailIds.truncated,
4196
+ unreadCount: row.unreadCount,
4197
+ unreadRate: roundPercent(row.unreadCount, row.emailCount),
4198
+ newestDate: toIsoString5(row.newestDate),
4199
+ newestSubject: row.newestSubject || null,
4200
+ newestSnippet: row.newestSnippet || null,
4201
+ secondSubject: row.secondSubject || null,
4202
+ isNewsletter: Boolean(row.detectionReason || unsubscribe2.unsubscribeLink),
4203
+ detectionReason: row.detectionReason,
4204
+ hasUnsubscribe: Boolean(unsubscribe2.unsubscribeLink),
4205
+ confidence: confidenceResult.confidence,
4206
+ signals: confidenceResult.signals,
4207
+ totalFromSender: row.totalFromSender ?? row.emailCount,
4208
+ domain
4209
+ };
4210
+ }).filter((sender) => options.confidence ? sender.confidence === options.confidence : true).sort(compareSenders(sortBy));
4211
+ const totalSenders = filtered.length;
4212
+ const totalEmails = filtered.reduce((sum, sender) => sum + sender.emailCount, 0);
4213
+ const byConfidence = filtered.reduce(
4214
+ (summary, sender) => {
4215
+ summary[sender.confidence].senders += 1;
4216
+ summary[sender.confidence].emails += sender.emailCount;
4217
+ return summary;
4218
+ },
4219
+ {
4220
+ high: { senders: 0, emails: 0 },
4221
+ medium: { senders: 0, emails: 0 },
4222
+ low: { senders: 0, emails: 0 }
4223
+ }
4224
+ );
4225
+ const topDomains = Array.from(
4226
+ filtered.reduce((domains, sender) => {
4227
+ if (!sender.domain) {
4228
+ return domains;
4229
+ }
4230
+ const entry = domains.get(sender.domain) || {
4231
+ domain: sender.domain,
4232
+ emails: 0,
4233
+ senders: 0
4234
+ };
4235
+ entry.emails += sender.emailCount;
4236
+ entry.senders += 1;
4237
+ domains.set(sender.domain, entry);
4238
+ return domains;
4239
+ }, /* @__PURE__ */ new Map())
4240
+ ).map(([, entry]) => entry).sort(
4241
+ (left, right) => right.emails - left.emails || right.senders - left.senders || left.domain.localeCompare(right.domain)
4242
+ ).slice(0, 5);
4243
+ const senders = filtered.slice(offset, offset + limit);
4244
+ return {
4245
+ totalSenders,
4246
+ totalEmails,
4247
+ returned: senders.length,
4248
+ offset,
4249
+ hasMore: offset + senders.length < totalSenders,
4250
+ senders,
4251
+ summary: {
4252
+ byConfidence,
4253
+ topDomains
4254
+ }
4255
+ };
4256
+ }
4257
+
3236
4258
  // src/core/stats/unsubscribe.ts
3237
- function toIsoString3(value) {
4259
+ function toIsoString6(value) {
3238
4260
  if (!value) {
3239
4261
  return null;
3240
4262
  }
@@ -3286,8 +4308,8 @@ async function getUnsubscribeSuggestions(options = {}) {
3286
4308
  unreadCount: row.unreadCount,
3287
4309
  unreadRate,
3288
4310
  readRate,
3289
- lastRead: toIsoString3(row.lastRead),
3290
- lastReceived: toIsoString3(row.lastReceived),
4311
+ lastRead: toIsoString6(row.lastRead),
4312
+ lastReceived: toIsoString6(row.lastReceived),
3291
4313
  unsubscribeLink: unsubscribe2.unsubscribeLink,
3292
4314
  unsubscribeMethod: unsubscribe2.unsubscribeMethod,
3293
4315
  impactScore: roundImpactScore(row.messageCount, unreadRate),
@@ -3439,67 +4461,67 @@ import { join as join2 } from "path";
3439
4461
  import YAML from "yaml";
3440
4462
 
3441
4463
  // src/core/rules/types.ts
3442
- import { z } from "zod";
3443
- var RuleNameSchema = z.string().min(1, "Rule name is required").regex(
4464
+ import { z as z3 } from "zod";
4465
+ var RuleNameSchema = z3.string().min(1, "Rule name is required").regex(
3444
4466
  /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
3445
4467
  "Rule name must be kebab-case (lowercase letters, numbers, and single hyphens)"
3446
4468
  );
3447
- var RuleFieldSchema = z.enum(["from", "to", "subject", "snippet", "labels"]);
3448
- var RegexStringSchema = z.string().min(1, "Pattern must not be empty").superRefine((value, ctx) => {
4469
+ var RuleFieldSchema = z3.enum(["from", "to", "subject", "snippet", "labels"]);
4470
+ var RegexStringSchema = z3.string().min(1, "Pattern must not be empty").superRefine((value, ctx) => {
3449
4471
  try {
3450
4472
  new RegExp(value);
3451
4473
  } catch (error) {
3452
4474
  ctx.addIssue({
3453
- code: z.ZodIssueCode.custom,
4475
+ code: z3.ZodIssueCode.custom,
3454
4476
  message: `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`
3455
4477
  });
3456
4478
  }
3457
4479
  });
3458
- var MatcherSchema = z.object({
4480
+ var MatcherSchema = z3.object({
3459
4481
  // `snippet` is the only cached free-text matcher in MVP.
3460
4482
  field: RuleFieldSchema,
3461
4483
  pattern: RegexStringSchema.optional(),
3462
- contains: z.array(z.string().min(1)).min(1).optional(),
3463
- values: z.array(z.string().min(1)).min(1).optional(),
3464
- exclude: z.boolean().default(false)
4484
+ contains: z3.array(z3.string().min(1)).min(1).optional(),
4485
+ values: z3.array(z3.string().min(1)).min(1).optional(),
4486
+ exclude: z3.boolean().default(false)
3465
4487
  }).strict().superRefine((value, ctx) => {
3466
4488
  if (!value.pattern && !value.contains && !value.values) {
3467
4489
  ctx.addIssue({
3468
- code: z.ZodIssueCode.custom,
4490
+ code: z3.ZodIssueCode.custom,
3469
4491
  message: "Matcher must provide at least one of pattern, contains, or values",
3470
4492
  path: ["pattern"]
3471
4493
  });
3472
4494
  }
3473
4495
  });
3474
- var ConditionsSchema = z.object({
3475
- operator: z.enum(["AND", "OR"]),
3476
- matchers: z.array(MatcherSchema).min(1, "At least one matcher is required")
4496
+ var ConditionsSchema = z3.object({
4497
+ operator: z3.enum(["AND", "OR"]),
4498
+ matchers: z3.array(MatcherSchema).min(1, "At least one matcher is required")
3477
4499
  }).strict();
3478
- var LabelActionSchema = z.object({
3479
- type: z.literal("label"),
3480
- label: z.string().min(1, "Label name is required")
4500
+ var LabelActionSchema = z3.object({
4501
+ type: z3.literal("label"),
4502
+ label: z3.string().min(1, "Label name is required")
3481
4503
  });
3482
- var ArchiveActionSchema = z.object({ type: z.literal("archive") });
3483
- var MarkReadActionSchema = z.object({ type: z.literal("mark_read") });
3484
- var ForwardActionSchema = z.object({
3485
- type: z.literal("forward"),
3486
- to: z.string().email("Forward destination must be a valid email address")
4504
+ var ArchiveActionSchema = z3.object({ type: z3.literal("archive") });
4505
+ var MarkReadActionSchema = z3.object({ type: z3.literal("mark_read") });
4506
+ var ForwardActionSchema = z3.object({
4507
+ type: z3.literal("forward"),
4508
+ to: z3.string().email("Forward destination must be a valid email address")
3487
4509
  });
3488
- var MarkSpamActionSchema = z.object({ type: z.literal("mark_spam") });
3489
- var ActionSchema = z.discriminatedUnion("type", [
4510
+ var MarkSpamActionSchema = z3.object({ type: z3.literal("mark_spam") });
4511
+ var ActionSchema = z3.discriminatedUnion("type", [
3490
4512
  LabelActionSchema,
3491
4513
  ArchiveActionSchema,
3492
4514
  MarkReadActionSchema,
3493
4515
  ForwardActionSchema,
3494
4516
  MarkSpamActionSchema
3495
4517
  ]);
3496
- var RuleSchema = z.object({
4518
+ var RuleSchema = z3.object({
3497
4519
  name: RuleNameSchema,
3498
- description: z.string(),
3499
- enabled: z.boolean().default(true),
3500
- priority: z.number().int().min(0).default(50),
4520
+ description: z3.string(),
4521
+ enabled: z3.boolean().default(true),
4522
+ priority: z3.number().int().min(0).default(50),
3501
4523
  conditions: ConditionsSchema,
3502
- actions: z.array(ActionSchema).min(1, "At least one action is required")
4524
+ actions: z3.array(ActionSchema).min(1, "At least one action is required")
3503
4525
  }).strict();
3504
4526
 
3505
4527
  // src/core/rules/loader.ts
@@ -3829,7 +4851,7 @@ function getDatabase4() {
3829
4851
  const config = loadConfig();
3830
4852
  return getSqlite(config.dbPath);
3831
4853
  }
3832
- function parseJsonArray4(value) {
4854
+ function parseJsonArray5(value) {
3833
4855
  if (!value) {
3834
4856
  return [];
3835
4857
  }
@@ -3846,13 +4868,13 @@ function rowToEmail3(row) {
3846
4868
  threadId: row.thread_id ?? "",
3847
4869
  fromAddress: row.from_address ?? "",
3848
4870
  fromName: row.from_name ?? "",
3849
- toAddresses: parseJsonArray4(row.to_addresses),
4871
+ toAddresses: parseJsonArray5(row.to_addresses),
3850
4872
  subject: row.subject ?? "",
3851
4873
  snippet: row.snippet ?? "",
3852
4874
  date: row.date ?? 0,
3853
4875
  isRead: row.is_read === 1,
3854
4876
  isStarred: row.is_starred === 1,
3855
- labelIds: parseJsonArray4(row.label_ids),
4877
+ labelIds: parseJsonArray5(row.label_ids),
3856
4878
  sizeEstimate: row.size_estimate ?? 0,
3857
4879
  hasAttachments: row.has_attachments === 1,
3858
4880
  listUnsubscribe: row.list_unsubscribe
@@ -4789,8 +5811,8 @@ async function getSyncStatus() {
4789
5811
  }
4790
5812
 
4791
5813
  // src/mcp/server.ts
4792
- var DAY_MS3 = 24 * 60 * 60 * 1e3;
4793
- var MCP_VERSION = "0.1.0";
5814
+ var DAY_MS4 = 24 * 60 * 60 * 1e3;
5815
+ var MCP_VERSION = "0.4.0";
4794
5816
  var MCP_TOOLS = [
4795
5817
  "search_emails",
4796
5818
  "get_email",
@@ -4809,6 +5831,9 @@ var MCP_TOOLS = [
4809
5831
  "get_sender_stats",
4810
5832
  "get_newsletter_senders",
4811
5833
  "get_uncategorized_emails",
5834
+ "get_uncategorized_senders",
5835
+ "review_categorized",
5836
+ "query_emails",
4812
5837
  "get_noise_senders",
4813
5838
  "get_unsubscribe_suggestions",
4814
5839
  "unsubscribe",
@@ -4826,6 +5851,7 @@ var MCP_RESOURCES = [
4826
5851
  "inbox://recent",
4827
5852
  "inbox://summary",
4828
5853
  "inbox://action-log",
5854
+ "schema://query-fields",
4829
5855
  "rules://deployed",
4830
5856
  "rules://history",
4831
5857
  "stats://senders",
@@ -4947,7 +5973,7 @@ async function buildStartupWarnings() {
4947
5973
  }
4948
5974
  if (!latestSync) {
4949
5975
  warnings.push("Inbox cache has not been synced yet. Stats and resources will be empty until `sync_inbox` runs.");
4950
- } else if (Date.now() - latestSync > DAY_MS3) {
5976
+ } else if (Date.now() - latestSync > DAY_MS4) {
4951
5977
  warnings.push("Inbox cache appears stale (last sync older than 24 hours). Call `sync_inbox` if freshness matters.");
4952
5978
  }
4953
5979
  return warnings;
@@ -4958,7 +5984,7 @@ async function buildStatsOverview() {
4958
5984
  topSenders: await getTopSenders({ limit: 10 }),
4959
5985
  labelDistribution: (await getLabelDistribution()).slice(0, 10),
4960
5986
  dailyVolume: await getVolumeByPeriod("day", {
4961
- start: Date.now() - 30 * DAY_MS3,
5987
+ start: Date.now() - 30 * DAY_MS4,
4962
5988
  end: Date.now()
4963
5989
  })
4964
5990
  };
@@ -4998,9 +6024,9 @@ async function createMcpServer() {
4998
6024
  {
4999
6025
  description: "Search Gmail using Gmail query syntax and return matching email metadata.",
5000
6026
  inputSchema: {
5001
- query: z2.string().min(1),
5002
- max_results: z2.number().int().positive().max(100).optional(),
5003
- label: z2.string().min(1).optional()
6027
+ query: z4.string().min(1),
6028
+ max_results: z4.number().int().positive().max(100).optional(),
6029
+ label: z4.string().min(1).optional()
5004
6030
  },
5005
6031
  annotations: {
5006
6032
  readOnlyHint: true
@@ -5015,7 +6041,7 @@ async function createMcpServer() {
5015
6041
  {
5016
6042
  description: "Fetch a single email with full content by Gmail message ID.",
5017
6043
  inputSchema: {
5018
- email_id: z2.string().min(1)
6044
+ email_id: z4.string().min(1)
5019
6045
  },
5020
6046
  annotations: {
5021
6047
  readOnlyHint: true
@@ -5028,7 +6054,7 @@ async function createMcpServer() {
5028
6054
  {
5029
6055
  description: "Fetch a full Gmail thread by thread ID.",
5030
6056
  inputSchema: {
5031
- thread_id: z2.string().min(1)
6057
+ thread_id: z4.string().min(1)
5032
6058
  },
5033
6059
  annotations: {
5034
6060
  readOnlyHint: true
@@ -5041,7 +6067,7 @@ async function createMcpServer() {
5041
6067
  {
5042
6068
  description: "Run inbox sync. Uses incremental sync by default and full sync when requested.",
5043
6069
  inputSchema: {
5044
- full: z2.boolean().optional()
6070
+ full: z4.boolean().optional()
5045
6071
  },
5046
6072
  annotations: {
5047
6073
  readOnlyHint: false,
@@ -5055,7 +6081,7 @@ async function createMcpServer() {
5055
6081
  {
5056
6082
  description: "Archive one or more Gmail messages by removing the INBOX label.",
5057
6083
  inputSchema: {
5058
- email_ids: z2.array(z2.string().min(1)).min(1)
6084
+ email_ids: z4.array(z4.string().min(1)).min(1)
5059
6085
  },
5060
6086
  annotations: {
5061
6087
  readOnlyHint: false,
@@ -5069,9 +6095,9 @@ async function createMcpServer() {
5069
6095
  {
5070
6096
  description: "Add and/or remove Gmail labels on one or more messages.",
5071
6097
  inputSchema: {
5072
- email_ids: z2.array(z2.string().min(1)).min(1),
5073
- add_labels: z2.array(z2.string().min(1)).optional(),
5074
- remove_labels: z2.array(z2.string().min(1)).optional()
6098
+ email_ids: z4.array(z4.string().min(1)).min(1),
6099
+ add_labels: z4.array(z4.string().min(1)).optional(),
6100
+ remove_labels: z4.array(z4.string().min(1)).optional()
5075
6101
  },
5076
6102
  annotations: {
5077
6103
  readOnlyHint: false,
@@ -5105,8 +6131,8 @@ async function createMcpServer() {
5105
6131
  {
5106
6132
  description: "Mark one or more Gmail messages as read or unread.",
5107
6133
  inputSchema: {
5108
- email_ids: z2.array(z2.string().min(1)).min(1),
5109
- read: z2.boolean()
6134
+ email_ids: z4.array(z4.string().min(1)).min(1),
6135
+ read: z4.boolean()
5110
6136
  },
5111
6137
  annotations: {
5112
6138
  readOnlyHint: false,
@@ -5123,23 +6149,23 @@ async function createMcpServer() {
5123
6149
  {
5124
6150
  description: "Apply grouped inbox actions in one call for faster AI-driven triage and categorization.",
5125
6151
  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)
6152
+ groups: z4.array(
6153
+ z4.object({
6154
+ email_ids: z4.array(z4.string().min(1)).min(1).max(500),
6155
+ actions: z4.array(
6156
+ z4.discriminatedUnion("type", [
6157
+ z4.object({
6158
+ type: z4.literal("label"),
6159
+ label: z4.string().min(1)
5134
6160
  }),
5135
- z2.object({ type: z2.literal("archive") }),
5136
- z2.object({ type: z2.literal("mark_read") }),
5137
- z2.object({ type: z2.literal("mark_spam") })
6161
+ z4.object({ type: z4.literal("archive") }),
6162
+ z4.object({ type: z4.literal("mark_read") }),
6163
+ z4.object({ type: z4.literal("mark_spam") })
5138
6164
  ])
5139
6165
  ).min(1).max(5)
5140
6166
  })
5141
6167
  ).min(1).max(20),
5142
- dry_run: z2.boolean().optional()
6168
+ dry_run: z4.boolean().optional()
5143
6169
  },
5144
6170
  annotations: {
5145
6171
  readOnlyHint: false,
@@ -5159,8 +6185,8 @@ async function createMcpServer() {
5159
6185
  {
5160
6186
  description: "Forward a Gmail message to another address.",
5161
6187
  inputSchema: {
5162
- email_id: z2.string().min(1),
5163
- to: z2.string().email()
6188
+ email_id: z4.string().min(1),
6189
+ to: z4.string().email()
5164
6190
  },
5165
6191
  annotations: {
5166
6192
  readOnlyHint: false,
@@ -5174,7 +6200,7 @@ async function createMcpServer() {
5174
6200
  {
5175
6201
  description: "Undo a prior inboxctl action run when the underlying Gmail mutations are reversible.",
5176
6202
  inputSchema: {
5177
- run_id: z2.string().min(1)
6203
+ run_id: z4.string().min(1)
5178
6204
  },
5179
6205
  annotations: {
5180
6206
  readOnlyHint: false,
@@ -5198,8 +6224,8 @@ async function createMcpServer() {
5198
6224
  {
5199
6225
  description: "Create a Gmail label if it does not already exist.",
5200
6226
  inputSchema: {
5201
- name: z2.string().min(1),
5202
- color: z2.string().min(1).optional()
6227
+ name: z4.string().min(1),
6228
+ color: z4.string().min(1).optional()
5203
6229
  },
5204
6230
  annotations: {
5205
6231
  readOnlyHint: false,
@@ -5231,9 +6257,9 @@ async function createMcpServer() {
5231
6257
  {
5232
6258
  description: "Return top senders ranked by cached email volume.",
5233
6259
  inputSchema: {
5234
- limit: z2.number().int().positive().max(100).optional(),
5235
- min_unread_rate: z2.number().min(0).max(100).optional(),
5236
- period: z2.enum(["day", "week", "month", "year", "all"]).optional()
6260
+ limit: z4.number().int().positive().max(100).optional(),
6261
+ min_unread_rate: z4.number().min(0).max(100).optional(),
6262
+ period: z4.enum(["day", "week", "month", "year", "all"]).optional()
5237
6263
  },
5238
6264
  annotations: {
5239
6265
  readOnlyHint: true
@@ -5250,7 +6276,7 @@ async function createMcpServer() {
5250
6276
  {
5251
6277
  description: "Return detailed stats for a sender email address or an @domain aggregate.",
5252
6278
  inputSchema: {
5253
- email_or_domain: z2.string().min(1)
6279
+ email_or_domain: z4.string().min(1)
5254
6280
  },
5255
6281
  annotations: {
5256
6282
  readOnlyHint: true
@@ -5270,8 +6296,8 @@ async function createMcpServer() {
5270
6296
  {
5271
6297
  description: "Return senders that look like newsletters or mailing lists based on cached heuristics.",
5272
6298
  inputSchema: {
5273
- min_messages: z2.number().int().positive().optional(),
5274
- min_unread_rate: z2.number().min(0).max(100).optional()
6299
+ min_messages: z4.number().int().positive().optional(),
6300
+ min_unread_rate: z4.number().min(0).max(100).optional()
5275
6301
  },
5276
6302
  annotations: {
5277
6303
  readOnlyHint: true
@@ -5287,10 +6313,10 @@ async function createMcpServer() {
5287
6313
  {
5288
6314
  description: "Return cached emails that have only Gmail system labels and no user-applied organization.",
5289
6315
  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()
6316
+ 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."),
6317
+ offset: z4.number().int().min(0).optional().describe("Number of results to skip for pagination. Use with totalUncategorized and hasMore."),
6318
+ unread_only: z4.boolean().optional(),
6319
+ since: z4.string().min(1).optional()
5294
6320
  },
5295
6321
  annotations: {
5296
6322
  readOnlyHint: true
@@ -5303,15 +6329,62 @@ async function createMcpServer() {
5303
6329
  since
5304
6330
  }))
5305
6331
  );
6332
+ server.registerTool(
6333
+ "get_uncategorized_senders",
6334
+ {
6335
+ description: "Return uncategorized emails grouped by sender so AI clients can categorize at sender-level instead of one email at a time.",
6336
+ inputSchema: {
6337
+ limit: z4.number().int().positive().max(500).optional().describe("Max senders per page. Default 100."),
6338
+ offset: z4.number().int().min(0).optional().describe("Number of senders to skip for pagination."),
6339
+ min_emails: z4.number().int().positive().optional().describe("Only include senders with at least this many uncategorized emails."),
6340
+ confidence: z4.enum(["high", "medium", "low"]).optional().describe("Filter senders by the confidence score inferred from sender signals."),
6341
+ since: z4.string().min(1).optional().describe("Only include uncategorized emails on or after this ISO date."),
6342
+ sort_by: z4.enum(["email_count", "newest", "unread_rate"]).optional().describe("Sort senders by email volume, most recent email, or unread rate.")
6343
+ },
6344
+ annotations: {
6345
+ readOnlyHint: true
6346
+ }
6347
+ },
6348
+ toolHandler(async ({ limit, offset, min_emails, confidence, since, sort_by }) => getUncategorizedSenders({
6349
+ limit,
6350
+ offset,
6351
+ minEmails: min_emails,
6352
+ confidence,
6353
+ since,
6354
+ sortBy: sort_by
6355
+ }))
6356
+ );
6357
+ server.registerTool(
6358
+ "review_categorized",
6359
+ {
6360
+ description: "Scan recently categorized emails for anomalies that suggest a misclassification or over-aggressive archive.",
6361
+ inputSchema: reviewCategorizedInputSchema.shape,
6362
+ annotations: {
6363
+ readOnlyHint: true
6364
+ }
6365
+ },
6366
+ toolHandler(async (args) => reviewCategorized(args))
6367
+ );
6368
+ server.registerTool(
6369
+ "query_emails",
6370
+ {
6371
+ description: "Run structured analytics queries over the cached email dataset using fixed filters, groupings, and aggregates.",
6372
+ inputSchema: queryEmailsInputSchema.shape,
6373
+ annotations: {
6374
+ readOnlyHint: true
6375
+ }
6376
+ },
6377
+ toolHandler(async (args) => queryEmails(args))
6378
+ );
5306
6379
  server.registerTool(
5307
6380
  "get_noise_senders",
5308
6381
  {
5309
6382
  description: "Return a focused list of active, high-noise senders worth categorizing, filtering, or unsubscribing.",
5310
6383
  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.")
6384
+ limit: z4.number().int().positive().max(50).optional(),
6385
+ min_noise_score: z4.number().min(0).optional(),
6386
+ active_days: z4.number().int().positive().optional(),
6387
+ 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.")
5315
6388
  },
5316
6389
  annotations: {
5317
6390
  readOnlyHint: true
@@ -5329,9 +6402,9 @@ async function createMcpServer() {
5329
6402
  {
5330
6403
  description: "Return ranked senders with unsubscribe links, sorted by how much inbox noise unsubscribing would remove.",
5331
6404
  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()
6405
+ limit: z4.number().int().positive().max(50).optional(),
6406
+ min_messages: z4.number().int().positive().optional(),
6407
+ unread_only_senders: z4.boolean().optional()
5335
6408
  },
5336
6409
  annotations: {
5337
6410
  readOnlyHint: true
@@ -5348,9 +6421,9 @@ async function createMcpServer() {
5348
6421
  {
5349
6422
  description: "Return the unsubscribe target for a sender and optionally label/archive existing emails in one undoable run.",
5350
6423
  inputSchema: {
5351
- sender_email: z2.string().min(1),
5352
- also_archive: z2.boolean().optional(),
5353
- also_label: z2.string().min(1).optional()
6424
+ sender_email: z4.string().min(1),
6425
+ also_archive: z4.boolean().optional(),
6426
+ also_label: z4.string().min(1).optional()
5354
6427
  },
5355
6428
  annotations: {
5356
6429
  readOnlyHint: false,
@@ -5368,7 +6441,7 @@ async function createMcpServer() {
5368
6441
  {
5369
6442
  description: "Validate and deploy a rule directly from YAML content.",
5370
6443
  inputSchema: {
5371
- yaml_content: z2.string().min(1)
6444
+ yaml_content: z4.string().min(1)
5372
6445
  },
5373
6446
  annotations: {
5374
6447
  readOnlyHint: false,
@@ -5385,7 +6458,7 @@ async function createMcpServer() {
5385
6458
  {
5386
6459
  description: "List deployed inboxctl rules and their execution status.",
5387
6460
  inputSchema: {
5388
- enabled_only: z2.boolean().optional()
6461
+ enabled_only: z4.boolean().optional()
5389
6462
  },
5390
6463
  annotations: {
5391
6464
  readOnlyHint: true
@@ -5401,9 +6474,9 @@ async function createMcpServer() {
5401
6474
  {
5402
6475
  description: "Run a deployed rule in dry-run mode by default, or apply it when dry_run is false.",
5403
6476
  inputSchema: {
5404
- rule_name: z2.string().min(1),
5405
- dry_run: z2.boolean().optional(),
5406
- max_emails: z2.number().int().positive().max(1e3).optional()
6477
+ rule_name: z4.string().min(1),
6478
+ dry_run: z4.boolean().optional(),
6479
+ max_emails: z4.number().int().positive().max(1e3).optional()
5407
6480
  },
5408
6481
  annotations: {
5409
6482
  readOnlyHint: false,
@@ -5420,7 +6493,7 @@ async function createMcpServer() {
5420
6493
  {
5421
6494
  description: "Enable a deployed rule by name.",
5422
6495
  inputSchema: {
5423
- rule_name: z2.string().min(1)
6496
+ rule_name: z4.string().min(1)
5424
6497
  },
5425
6498
  annotations: {
5426
6499
  readOnlyHint: false,
@@ -5434,7 +6507,7 @@ async function createMcpServer() {
5434
6507
  {
5435
6508
  description: "Disable a deployed rule by name.",
5436
6509
  inputSchema: {
5437
- rule_name: z2.string().min(1)
6510
+ rule_name: z4.string().min(1)
5438
6511
  },
5439
6512
  annotations: {
5440
6513
  readOnlyHint: false,
@@ -5470,6 +6543,15 @@ async function createMcpServer() {
5470
6543
  },
5471
6544
  async (uri) => resourceText(resolveResourceUri(uri, "inbox://action-log"), await buildActionLog())
5472
6545
  );
6546
+ server.registerResource(
6547
+ "query-fields",
6548
+ "schema://query-fields",
6549
+ {
6550
+ description: "Field vocabulary, aggregates, and examples for the query_emails analytics tool.",
6551
+ mimeType: "application/json"
6552
+ },
6553
+ async (uri) => resourceText(resolveResourceUri(uri, "schema://query-fields"), QUERY_EMAILS_FIELD_SCHEMA)
6554
+ );
5473
6555
  server.registerResource(
5474
6556
  "deployed-rules",
5475
6557
  "rules://deployed",
@@ -5529,6 +6611,10 @@ async function createMcpServer() {
5529
6611
  async () => promptResult(
5530
6612
  "Review top senders and recommend cleanup actions.",
5531
6613
  [
6614
+ "Step 0 \u2014 Check for past mistakes:",
6615
+ " Call `review_categorized` to see if any recent categorisations look incorrect.",
6616
+ " If anomalies are found, present them first \u2014 fixing past mistakes takes priority over reviewing new senders.",
6617
+ "",
5532
6618
  "Step 1 \u2014 Gather data:",
5533
6619
  " Use `get_noise_senders` for the most actionable noisy senders.",
5534
6620
  " Use `rules://deployed` to check for existing rules covering these senders.",
@@ -5575,9 +6661,15 @@ async function createMcpServer() {
5575
6661
  [
5576
6662
  "First, inspect these data sources:",
5577
6663
  "- `rules://deployed` \u2014 existing rules (avoid duplicates)",
6664
+ "- `query_emails` \u2014 find high-volume domains, unread-heavy clusters, and labeling opportunities",
5578
6665
  "- `get_noise_senders` \u2014 high-volume low-read senders",
5579
6666
  "- `get_newsletter_senders` \u2014 detected newsletters and mailing lists",
5580
6667
  "",
6668
+ "Useful `query_emails` patterns before drafting rules:",
6669
+ '- `group_by: "domain"`, `aggregates: ["count", "unread_rate"]`, `having: { count: { gte: 20 } }`',
6670
+ '- `group_by: "domain"`, `aggregates: ["count", "unread_rate"]`, `having: { unread_rate: { gte: 80 } }`',
6671
+ "- Cross-check the results against `list_rules` and `list_filters` before proposing new automation.",
6672
+ "",
5581
6673
  "For each recommendation, generate complete YAML using this schema:",
5582
6674
  "",
5583
6675
  " name: kebab-case-name # lowercase, hyphens only",
@@ -5626,6 +6718,10 @@ async function createMcpServer() {
5626
6718
  " FYI \u2014 worth knowing about but no action needed",
5627
6719
  " NOISE \u2014 bulk, promotional, or irrelevant",
5628
6720
  "",
6721
+ "Step 2.5 \u2014 Flag low-confidence items:",
6722
+ ' For any email with `confidence: "low"` in `senderContext`, always categorise it as ACTION REQUIRED.',
6723
+ " Better to surface a false positive than bury a real personal or work email.",
6724
+ "",
5629
6725
  "Step 3 \u2014 Present findings:",
5630
6726
  " List emails grouped by category with: sender, subject, and one-line reason.",
5631
6727
  " For NOISE, suggest a label and whether to archive.",
@@ -5650,14 +6746,16 @@ async function createMcpServer() {
5650
6746
  "Categorise uncategorised emails in the user's inbox.",
5651
6747
  [
5652
6748
  "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.",
6749
+ " Use `get_uncategorized_senders` first (start with limit 100).",
6750
+ " This groups uncategorized emails by sender and is the primary way to process large inbox backlogs efficiently.",
6751
+ " Use `get_uncategorized_emails` only when you need to inspect specific emails from an ambiguous sender.",
6752
+ " If totalSenders is more than 500, ask whether to process the recent batch or paginate through the full backlog.",
5655
6753
  " Use `get_noise_senders` for sender context.",
5656
6754
  " Use `get_unsubscribe_suggestions` for likely unsubscribe candidates.",
5657
6755
  " Use `get_labels` to see what labels already exist.",
5658
6756
  " Use `rules://deployed` to avoid duplicating existing automation.",
5659
6757
  "",
5660
- "Step 2 \u2014 Assign each email a category:",
6758
+ "Step 2 \u2014 Assign each sender a category:",
5661
6759
  " Receipts \u2014 purchase confirmations, invoices, payment notifications",
5662
6760
  " Shipping \u2014 delivery tracking, dispatch notices, shipping updates",
5663
6761
  " Newsletters \u2014 editorial content, digests, weekly roundups",
@@ -5669,26 +6767,39 @@ async function createMcpServer() {
5669
6767
  " Important \u2014 personal or work email requiring attention",
5670
6768
  "",
5671
6769
  "Step 3 \u2014 Present the categorisation plan:",
5672
- " Group emails by assigned category.",
5673
- " For each group show: count, senders involved, sample subjects.",
6770
+ " Group senders by assigned category.",
6771
+ " For each group show: sender count, total emails affected, senders involved, sample subjects.",
5674
6772
  " Note confidence level: HIGH (clear pattern), MEDIUM (reasonable guess), LOW (uncertain).",
5675
- " Flag any LOW confidence items for the user to decide.",
6773
+ " Flag any LOW confidence senders for the user to decide.",
6774
+ " Present the confidence breakdown: X HIGH (auto-apply), Y MEDIUM (label only), Z LOW (review queue).",
6775
+ " If any LOW confidence senders are present, note why they were flagged from the `signals` array.",
6776
+ "",
6777
+ "Step 3.5 \u2014 Apply confidence gating:",
6778
+ " HIGH confidence \u2014 safe to apply directly (label, mark_read, archive as appropriate).",
6779
+ " MEDIUM confidence \u2014 apply the category label only. Do not archive. Keep the email visible in the inbox.",
6780
+ " LOW confidence \u2014 apply only the label `inboxctl/Review`. Do not archive or mark read.",
6781
+ " These senders need human review before any further action.",
5676
6782
  "",
5677
6783
  "Step 4 \u2014 Apply with user approval:",
5678
6784
  " Create labels for any new categories (use `create_label`).",
5679
- " Use `batch_apply_actions` to apply labels in one call.",
6785
+ " Use `batch_apply_actions` to apply labels in one call, grouping by action set and reusing each sender's `emailIds`.",
5680
6786
  " For Newsletters and Promotions with high unread rates, suggest mark_read + archive or `unsubscribe` when a link is available.",
5681
6787
  " For Receipts/Shipping/Notifications, suggest mark_read only (keep in inbox).",
5682
6788
  " For Important, do not mark read or archive.",
5683
6789
  "",
5684
6790
  "Step 5 \u2014 Paginate if needed:",
5685
6791
  " If hasMore is true, ask whether to continue with the next page using offset.",
6792
+ " Each new page is a new set of senders, not more emails from the same senders.",
5686
6793
  " Reuse the same sender categorisations on later pages instead of re-evaluating known senders.",
5687
6794
  "",
5688
6795
  "Step 6 \u2014 Suggest ongoing rules:",
5689
6796
  " For any category with 3+ emails from the same sender, suggest a YAML rule.",
5690
6797
  " This prevents the same categorisation from being needed again.",
5691
- " Use `deploy_rule` after user reviews the YAML."
6798
+ " Use `deploy_rule` after user reviews the YAML.",
6799
+ "",
6800
+ "Step 7 \u2014 Post-categorisation audit:",
6801
+ " After applying actions, call `review_categorized` to check for anomalies.",
6802
+ " If anomalies are found, present them with the option to undo the relevant run."
5692
6803
  ].join("\n")
5693
6804
  )
5694
6805
  );
@@ -5704,7 +6815,7 @@ async function createMcpServer() {
5704
6815
  {
5705
6816
  description: "Get the details of a specific Gmail server-side filter by ID.",
5706
6817
  inputSchema: {
5707
- filter_id: z2.string().min(1).describe("Gmail filter ID")
6818
+ filter_id: z4.string().min(1).describe("Gmail filter ID")
5708
6819
  }
5709
6820
  },
5710
6821
  toolHandler(async ({ filter_id }) => getFilter(filter_id))
@@ -5714,20 +6825,20 @@ async function createMcpServer() {
5714
6825
  {
5715
6826
  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.",
5716
6827
  inputSchema: {
5717
- from: z2.string().optional().describe("Match emails from this address"),
5718
- to: z2.string().optional().describe("Match emails sent to this address"),
5719
- subject: z2.string().optional().describe("Match emails with this text in the subject"),
5720
- query: z2.string().optional().describe("Match using Gmail search syntax (e.g. 'has:attachment')"),
5721
- negated_query: z2.string().optional().describe("Exclude emails matching this Gmail query"),
5722
- has_attachment: z2.boolean().optional().describe("Match emails with attachments"),
5723
- exclude_chats: z2.boolean().optional().describe("Exclude chat messages from matches"),
5724
- size: z2.number().int().positive().optional().describe("Size threshold in bytes"),
5725
- size_comparison: z2.enum(["larger", "smaller"]).optional().describe("Use with size: match emails larger or smaller than the threshold"),
5726
- label: z2.string().optional().describe("Apply this label to matching emails (auto-created if it does not exist)"),
5727
- archive: z2.boolean().optional().describe("Archive matching emails (remove from inbox)"),
5728
- mark_read: z2.boolean().optional().describe("Mark matching emails as read"),
5729
- star: z2.boolean().optional().describe("Star matching emails"),
5730
- forward: z2.string().email().optional().describe("Forward matching emails to this address (address must be verified in Gmail settings)")
6828
+ from: z4.string().optional().describe("Match emails from this address"),
6829
+ to: z4.string().optional().describe("Match emails sent to this address"),
6830
+ subject: z4.string().optional().describe("Match emails with this text in the subject"),
6831
+ query: z4.string().optional().describe("Match using Gmail search syntax (e.g. 'has:attachment')"),
6832
+ negated_query: z4.string().optional().describe("Exclude emails matching this Gmail query"),
6833
+ has_attachment: z4.boolean().optional().describe("Match emails with attachments"),
6834
+ exclude_chats: z4.boolean().optional().describe("Exclude chat messages from matches"),
6835
+ size: z4.number().int().positive().optional().describe("Size threshold in bytes"),
6836
+ size_comparison: z4.enum(["larger", "smaller"]).optional().describe("Use with size: match emails larger or smaller than the threshold"),
6837
+ label: z4.string().optional().describe("Apply this label to matching emails (auto-created if it does not exist)"),
6838
+ archive: z4.boolean().optional().describe("Archive matching emails (remove from inbox)"),
6839
+ mark_read: z4.boolean().optional().describe("Mark matching emails as read"),
6840
+ star: z4.boolean().optional().describe("Star matching emails"),
6841
+ forward: z4.string().email().optional().describe("Forward matching emails to this address (address must be verified in Gmail settings)")
5731
6842
  }
5732
6843
  },
5733
6844
  toolHandler(
@@ -5754,7 +6865,7 @@ async function createMcpServer() {
5754
6865
  {
5755
6866
  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.",
5756
6867
  inputSchema: {
5757
- filter_id: z2.string().min(1).describe("Gmail filter ID to delete")
6868
+ filter_id: z4.string().min(1).describe("Gmail filter ID to delete")
5758
6869
  }
5759
6870
  },
5760
6871
  toolHandler(async ({ filter_id }) => {
@@ -5820,10 +6931,17 @@ export {
5820
6931
  markUnread,
5821
6932
  forwardEmail,
5822
6933
  undoRun,
5823
- getLabelDistribution,
6934
+ getThread,
6935
+ unsubscribe,
5824
6936
  getNewsletters,
6937
+ reviewCategorized,
6938
+ getLabelDistribution,
6939
+ getNoiseSenders,
6940
+ queryEmails,
5825
6941
  getTopSenders,
5826
6942
  getSenderStats,
6943
+ getUncategorizedSenders,
6944
+ getUnsubscribeSuggestions,
5827
6945
  getVolumeByPeriod,
5828
6946
  getInboxOverview,
5829
6947
  loadRuleFile,
@@ -5852,4 +6970,4 @@ export {
5852
6970
  createMcpServer,
5853
6971
  startMcpServer
5854
6972
  };
5855
- //# sourceMappingURL=chunk-NUN2WRBN.js.map
6973
+ //# sourceMappingURL=chunk-2PN3TSVQ.js.map