inboxctl 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ var __export = (target, all) => {
8
8
  // src/mcp/server.ts
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
- import { z as z2 } from "zod";
11
+ import { z as z4 } from "zod";
12
12
 
13
13
  // src/core/actions/audit.ts
14
14
  import { randomUUID } from "crypto";
@@ -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"],
@@ -2608,6 +2646,14 @@ function getPeriodStart(period = "all", now2 = Date.now()) {
2608
2646
  function resolveLabelName(labelId) {
2609
2647
  return SYSTEM_LABEL_NAMES.get(labelId) || getCachedLabelName(labelId) || labelId;
2610
2648
  }
2649
+ function isUserLabel(labelId) {
2650
+ const trimmed = labelId.trim();
2651
+ return trimmed.length > 0 && !SYSTEM_LABEL_ID_SET.has(trimmed) && !trimmed.startsWith(CATEGORY_LABEL_PREFIX);
2652
+ }
2653
+ function isLikelyAutomatedSenderAddress(sender) {
2654
+ const normalized = sender.trim().toLowerCase();
2655
+ return AUTOMATED_ADDRESS_MARKERS.some((marker) => normalized.includes(marker));
2656
+ }
2611
2657
  function startOfLocalDay(now2 = Date.now()) {
2612
2658
  const date = new Date(now2);
2613
2659
  date.setHours(0, 0, 0, 0);
@@ -2627,28 +2673,6 @@ function startOfLocalMonth(now2 = Date.now()) {
2627
2673
  return date.getTime();
2628
2674
  }
2629
2675
 
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
2676
  // src/core/stats/newsletters.ts
2653
2677
  import { randomUUID as randomUUID2 } from "crypto";
2654
2678
  var KNOWN_NEWSLETTER_LOCAL_PART = /^(newsletter|digest|noreply|no-reply|updates|news)([+._-].*)?$/i;
@@ -2812,8 +2836,277 @@ async function getNewsletters(options = {}) {
2812
2836
  return rows.map(mapNewsletterRow);
2813
2837
  }
2814
2838
 
2815
- // src/core/stats/noise.ts
2839
+ // src/core/stats/anomalies.ts
2816
2840
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
2841
+ var BULK_LABELS = /* @__PURE__ */ new Set([
2842
+ "newsletter",
2843
+ "newsletters",
2844
+ "promotion",
2845
+ "promotions",
2846
+ "social"
2847
+ ]);
2848
+ var reviewCategorizedInputSchema = z.object({
2849
+ since: z.string().min(1).optional(),
2850
+ limit: z.number().int().positive().max(200).optional()
2851
+ }).strict();
2852
+ function toIsoString(value) {
2853
+ if (!value) {
2854
+ return null;
2855
+ }
2856
+ return new Date(value).toISOString();
2857
+ }
2858
+ function parseJsonArray3(raw) {
2859
+ if (!raw) {
2860
+ return [];
2861
+ }
2862
+ try {
2863
+ const parsed = JSON.parse(raw);
2864
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
2865
+ } catch {
2866
+ return [];
2867
+ }
2868
+ }
2869
+ function parseActions(raw) {
2870
+ try {
2871
+ const parsed = JSON.parse(raw);
2872
+ return Array.isArray(parsed) ? parsed : [];
2873
+ } catch {
2874
+ return [];
2875
+ }
2876
+ }
2877
+ function resolveSinceTimestamp(since) {
2878
+ if (!since) {
2879
+ return Date.now() - 7 * DAY_MS2;
2880
+ }
2881
+ const parsed = Date.parse(since);
2882
+ if (Number.isNaN(parsed)) {
2883
+ throw new Error(`Invalid since value: ${since}`);
2884
+ }
2885
+ return parsed;
2886
+ }
2887
+ function isArchived(actions, beforeLabelIds, afterLabelIds) {
2888
+ if (actions.some((action) => action.type === "archive")) {
2889
+ return true;
2890
+ }
2891
+ return beforeLabelIds.includes("INBOX") && !afterLabelIds.includes("INBOX");
2892
+ }
2893
+ function resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds) {
2894
+ const labelAction = actions.find(
2895
+ (action) => action.type === "label" && typeof action.label === "string" && action.label.trim().length > 0
2896
+ );
2897
+ if (labelAction) {
2898
+ return labelAction.label.trim();
2899
+ }
2900
+ const beforeUserLabels = new Set(beforeLabelIds.filter(isUserLabel));
2901
+ const afterUserLabels = afterLabelIds.filter(isUserLabel);
2902
+ const addedLabel = afterUserLabels.find((label) => !beforeUserLabels.has(label));
2903
+ return addedLabel || afterUserLabels[0] || null;
2904
+ }
2905
+ function resolvePrimaryAction(actions) {
2906
+ if (actions.some((action) => action.type === "archive")) {
2907
+ return "archive";
2908
+ }
2909
+ if (actions.some((action) => action.type === "label")) {
2910
+ return "label";
2911
+ }
2912
+ return actions[0]?.type || "unknown";
2913
+ }
2914
+ function summarizeReview(totalReviewed, anomalyCount, highCount, mediumCount) {
2915
+ if (anomalyCount === 0) {
2916
+ return `Reviewed ${totalReviewed} recently categorised emails. Found no potential misclassifications.`;
2917
+ }
2918
+ const severityParts = [];
2919
+ if (highCount > 0) {
2920
+ severityParts.push(`${highCount} high severity`);
2921
+ }
2922
+ if (mediumCount > 0) {
2923
+ severityParts.push(`${mediumCount} medium`);
2924
+ }
2925
+ return `Reviewed ${totalReviewed} recently categorised emails. Found ${anomalyCount} potential misclassifications (${severityParts.join(", ")}).`;
2926
+ }
2927
+ function detectAnomaly(row) {
2928
+ const actions = parseActions(row.appliedActions);
2929
+ const beforeLabelIds = parseJsonArray3(row.beforeLabelIds);
2930
+ const afterLabelIds = parseJsonArray3(row.afterLabelIds);
2931
+ const archived = isArchived(actions, beforeLabelIds, afterLabelIds);
2932
+ const assignedLabel = resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds);
2933
+ const action = resolvePrimaryAction(actions);
2934
+ const totalFromSender = row.totalFromSender ?? 0;
2935
+ const hasNewsletterSignals = Boolean(row.detectionReason) || Boolean(row.listUnsubscribe?.trim());
2936
+ const isBulkLabel = assignedLabel ? BULK_LABELS.has(assignedLabel.toLowerCase()) : false;
2937
+ const automatedSender = isLikelyAutomatedSenderAddress(row.sender || "");
2938
+ const undoAvailable = row.runDryRun !== 1 && row.runStatus !== "undone" && row.runUndoneAt === null && row.itemUndoneAt === null;
2939
+ if (archived && totalFromSender <= 3) {
2940
+ return {
2941
+ emailId: row.emailId,
2942
+ from: row.sender || "",
2943
+ subject: row.subject || "",
2944
+ date: toIsoString(row.date),
2945
+ assignedLabel: assignedLabel || "Unlabeled",
2946
+ action,
2947
+ runId: row.runId,
2948
+ severity: "high",
2949
+ rule: "rare_sender_archived",
2950
+ reason: `Archived email from a rare sender with only ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}. Rare senders should be reviewed before archiving.`,
2951
+ undoAvailable
2952
+ };
2953
+ }
2954
+ if (isBulkLabel && !hasNewsletterSignals) {
2955
+ return {
2956
+ emailId: row.emailId,
2957
+ from: row.sender || "",
2958
+ subject: row.subject || "",
2959
+ date: toIsoString(row.date),
2960
+ assignedLabel: assignedLabel || "Unlabeled",
2961
+ action,
2962
+ runId: row.runId,
2963
+ severity: "high",
2964
+ rule: "no_newsletter_signals_as_newsletter",
2965
+ reason: `Labeled as ${assignedLabel} but sender has no List-Unsubscribe header and no newsletter detection signals. Sender has only sent ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}.`,
2966
+ undoAvailable
2967
+ };
2968
+ }
2969
+ if (archived && !automatedSender && totalFromSender < 5) {
2970
+ return {
2971
+ emailId: row.emailId,
2972
+ from: row.sender || "",
2973
+ subject: row.subject || "",
2974
+ date: toIsoString(row.date),
2975
+ assignedLabel: assignedLabel || "Unlabeled",
2976
+ action,
2977
+ runId: row.runId,
2978
+ severity: "high",
2979
+ rule: "personal_address_archived",
2980
+ reason: `Archived email from a likely personal sender address with fewer than 5 total emails. This address does not look automated and should stay visible.`,
2981
+ undoAvailable
2982
+ };
2983
+ }
2984
+ if (isBulkLabel && totalFromSender < 5) {
2985
+ return {
2986
+ emailId: row.emailId,
2987
+ from: row.sender || "",
2988
+ subject: row.subject || "",
2989
+ date: toIsoString(row.date),
2990
+ assignedLabel: assignedLabel || "Unlabeled",
2991
+ action,
2992
+ runId: row.runId,
2993
+ severity: "medium",
2994
+ rule: "low_volume_bulk_label",
2995
+ reason: `Labeled as ${assignedLabel} even though the sender has only ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}. Bulk labels are safer for higher-volume senders.`,
2996
+ undoAvailable
2997
+ };
2998
+ }
2999
+ if (archived && totalFromSender === 1) {
3000
+ return {
3001
+ emailId: row.emailId,
3002
+ from: row.sender || "",
3003
+ subject: row.subject || "",
3004
+ date: toIsoString(row.date),
3005
+ assignedLabel: assignedLabel || "Unlabeled",
3006
+ action,
3007
+ runId: row.runId,
3008
+ severity: "medium",
3009
+ rule: "first_time_sender_archived",
3010
+ reason: "Archived an email from a first-time sender. First-time senders are better surfaced for review before cleanup.",
3011
+ undoAvailable
3012
+ };
3013
+ }
3014
+ return null;
3015
+ }
3016
+ async function reviewCategorized(options = {}) {
3017
+ const parsed = reviewCategorizedInputSchema.parse(options);
3018
+ await detectNewsletters();
3019
+ const sqlite = getStatsSqlite();
3020
+ const sinceTimestamp = resolveSinceTimestamp(parsed.since);
3021
+ const limit = Math.min(200, normalizeLimit(parsed.limit, 50));
3022
+ const rows = sqlite.prepare(
3023
+ `
3024
+ SELECT
3025
+ ei.email_id AS emailId,
3026
+ e.from_address AS sender,
3027
+ e.subject AS subject,
3028
+ e.date AS date,
3029
+ e.list_unsubscribe AS listUnsubscribe,
3030
+ ei.before_label_ids AS beforeLabelIds,
3031
+ ei.after_label_ids AS afterLabelIds,
3032
+ ei.applied_actions AS appliedActions,
3033
+ ei.executed_at AS executedAt,
3034
+ ei.undone_at AS itemUndoneAt,
3035
+ ei.run_id AS runId,
3036
+ er.status AS runStatus,
3037
+ er.dry_run AS runDryRun,
3038
+ er.undone_at AS runUndoneAt,
3039
+ ns.detection_reason AS detectionReason,
3040
+ sender_stats.totalFromSender AS totalFromSender
3041
+ FROM execution_items AS ei
3042
+ INNER JOIN emails AS e
3043
+ ON e.id = ei.email_id
3044
+ INNER JOIN execution_runs AS er
3045
+ ON er.id = ei.run_id
3046
+ LEFT JOIN newsletter_senders AS ns
3047
+ ON LOWER(ns.email) = LOWER(e.from_address)
3048
+ LEFT JOIN (
3049
+ SELECT
3050
+ LOWER(from_address) AS senderKey,
3051
+ COUNT(*) AS totalFromSender
3052
+ FROM emails
3053
+ WHERE from_address IS NOT NULL
3054
+ AND TRIM(from_address) <> ''
3055
+ GROUP BY LOWER(from_address)
3056
+ ) AS sender_stats
3057
+ ON sender_stats.senderKey = LOWER(e.from_address)
3058
+ WHERE ei.status = 'applied'
3059
+ AND er.status IN ('applied', 'partial')
3060
+ AND COALESCE(er.dry_run, 0) = 0
3061
+ AND er.undone_at IS NULL
3062
+ AND ei.undone_at IS NULL
3063
+ AND COALESCE(ei.executed_at, 0) >= ?
3064
+ ORDER BY COALESCE(ei.executed_at, 0) DESC, ei.email_id ASC
3065
+ `
3066
+ ).all(sinceTimestamp);
3067
+ const reviewedRows = rows.filter((row) => {
3068
+ const actions = parseActions(row.appliedActions);
3069
+ const beforeLabelIds = parseJsonArray3(row.beforeLabelIds);
3070
+ const afterLabelIds = parseJsonArray3(row.afterLabelIds);
3071
+ return actions.length > 0 && (resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds) !== null || isArchived(actions, beforeLabelIds, afterLabelIds));
3072
+ });
3073
+ const anomalies = reviewedRows.map((row) => detectAnomaly(row)).filter((anomaly) => anomaly !== null).sort(
3074
+ (left, right) => (left.severity === "high" ? 1 : 0) === (right.severity === "high" ? 1 : 0) ? (right.date || "").localeCompare(left.date || "") || left.emailId.localeCompare(right.emailId) : left.severity === "high" ? -1 : 1
3075
+ );
3076
+ const highCount = anomalies.filter((anomaly) => anomaly.severity === "high").length;
3077
+ const mediumCount = anomalies.filter((anomaly) => anomaly.severity === "medium").length;
3078
+ return {
3079
+ anomalies: anomalies.slice(0, limit),
3080
+ totalReviewed: reviewedRows.length,
3081
+ anomalyCount: anomalies.length,
3082
+ summary: summarizeReview(reviewedRows.length, anomalies.length, highCount, mediumCount)
3083
+ };
3084
+ }
3085
+
3086
+ // src/core/stats/labels.ts
3087
+ async function getLabelDistribution() {
3088
+ const sqlite = getStatsSqlite();
3089
+ const rows = sqlite.prepare(
3090
+ `
3091
+ SELECT
3092
+ label.value AS labelId,
3093
+ COUNT(*) AS totalMessages,
3094
+ SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages
3095
+ FROM emails AS e, json_each(e.label_ids) AS label
3096
+ GROUP BY label.value
3097
+ ORDER BY totalMessages DESC, unreadMessages DESC, label.value ASC
3098
+ `
3099
+ ).all();
3100
+ return rows.map((row) => ({
3101
+ labelId: row.labelId,
3102
+ labelName: resolveLabelName(row.labelId),
3103
+ totalMessages: row.totalMessages,
3104
+ unreadMessages: row.unreadMessages
3105
+ }));
3106
+ }
3107
+
3108
+ // src/core/stats/noise.ts
3109
+ var DAY_MS3 = 24 * 60 * 60 * 1e3;
2817
3110
  var SUGGESTED_CATEGORY_RULES = [
2818
3111
  { category: "Receipts", keywords: ["receipt", "invoice", "payment", "order"] },
2819
3112
  { category: "Shipping", keywords: ["shipping", "tracking", "delivery", "dispatch"] },
@@ -2822,7 +3115,7 @@ var SUGGESTED_CATEGORY_RULES = [
2822
3115
  { category: "Promotions", keywords: ["promo", "offer", "deal", "sale", "marketing"] },
2823
3116
  { category: "Social", keywords: ["linkedin", "facebook", "twitter", "social"] }
2824
3117
  ];
2825
- function toIsoString(value) {
3118
+ function toIsoString2(value) {
2826
3119
  if (!value) {
2827
3120
  return null;
2828
3121
  }
@@ -2861,7 +3154,7 @@ async function getNoiseSenders(options = {}) {
2861
3154
  const limit = Math.min(50, normalizeLimit(options.limit, 20));
2862
3155
  const minNoiseScore = options.minNoiseScore ?? 5;
2863
3156
  const activeDays = Math.max(1, Math.floor(options.activeDays ?? 90));
2864
- const activeSince = Date.now() - activeDays * DAY_MS2;
3157
+ const activeSince = Date.now() - activeDays * DAY_MS3;
2865
3158
  const sortBy = options.sortBy ?? "noise_score";
2866
3159
  const rows = sqlite.prepare(
2867
3160
  `
@@ -2920,7 +3213,7 @@ async function getNoiseSenders(options = {}) {
2920
3213
  unreadRate,
2921
3214
  noiseScore,
2922
3215
  allTimeNoiseScore,
2923
- lastSeen: toIsoString(row.lastSeen),
3216
+ lastSeen: toIsoString2(row.lastSeen),
2924
3217
  isNewsletter: row.isNewsletter === 1,
2925
3218
  hasUnsubscribeLink: Boolean(unsubscribe2.unsubscribeLink),
2926
3219
  unsubscribeLink: unsubscribe2.unsubscribeLink,
@@ -2930,6 +3223,427 @@ async function getNoiseSenders(options = {}) {
2930
3223
  return { senders };
2931
3224
  }
2932
3225
 
3226
+ // src/core/stats/query.ts
3227
+ import { z as z2 } from "zod";
3228
+ var QUERY_EMAIL_GROUP_BY_VALUES = [
3229
+ "sender",
3230
+ "domain",
3231
+ "label",
3232
+ "year_month",
3233
+ "year_week",
3234
+ "day_of_week",
3235
+ "is_read",
3236
+ "is_newsletter"
3237
+ ];
3238
+ var QUERY_EMAIL_AGGREGATE_VALUES = [
3239
+ "count",
3240
+ "unread_count",
3241
+ "read_count",
3242
+ "unread_rate",
3243
+ "oldest",
3244
+ "newest",
3245
+ "sender_count"
3246
+ ];
3247
+ var QUERY_EMAIL_HAVING_FIELDS = [
3248
+ "count",
3249
+ "unread_count",
3250
+ "unread_rate",
3251
+ "sender_count"
3252
+ ];
3253
+ var CATEGORY_LABEL_LIKE_PATTERN = `${CATEGORY_LABEL_PREFIX.replace(/_/g, "\\_")}%`;
3254
+ var SYSTEM_LABEL_SQL = SYSTEM_LABEL_IDS.map((label) => `'${label}'`).join(", ");
3255
+ var DOMAIN_SQL = `
3256
+ LOWER(
3257
+ CASE
3258
+ WHEN INSTR(COALESCE(e.from_address, ''), '@') > 0
3259
+ THEN SUBSTR(e.from_address, INSTR(e.from_address, '@') + 1)
3260
+ ELSE ''
3261
+ END
3262
+ )
3263
+ `;
3264
+ var queryEmailsFiltersSchema = z2.object({
3265
+ from: z2.string().optional(),
3266
+ from_contains: z2.string().optional(),
3267
+ domain: z2.string().optional(),
3268
+ domain_contains: z2.string().optional(),
3269
+ subject_contains: z2.string().optional(),
3270
+ date_after: z2.string().optional(),
3271
+ date_before: z2.string().optional(),
3272
+ is_read: z2.boolean().optional(),
3273
+ is_newsletter: z2.boolean().optional(),
3274
+ has_label: z2.boolean().optional(),
3275
+ label: z2.string().optional(),
3276
+ has_unsubscribe: z2.boolean().optional(),
3277
+ min_sender_messages: z2.number().int().positive().optional()
3278
+ }).strict();
3279
+ var havingConditionSchema = z2.object({
3280
+ gte: z2.number().optional(),
3281
+ lte: z2.number().optional()
3282
+ }).strict().refine(
3283
+ (value) => value.gte !== void 0 || value.lte !== void 0,
3284
+ { message: "Provide at least one of gte or lte." }
3285
+ );
3286
+ var queryEmailsHavingSchema = z2.object({
3287
+ count: havingConditionSchema.optional(),
3288
+ unread_count: havingConditionSchema.optional(),
3289
+ unread_rate: havingConditionSchema.optional(),
3290
+ sender_count: havingConditionSchema.optional()
3291
+ }).strict();
3292
+ var queryEmailsInputSchema = z2.object({
3293
+ filters: queryEmailsFiltersSchema.optional(),
3294
+ group_by: z2.enum(QUERY_EMAIL_GROUP_BY_VALUES).optional(),
3295
+ aggregates: z2.array(z2.enum(QUERY_EMAIL_AGGREGATE_VALUES)).min(1).optional(),
3296
+ having: queryEmailsHavingSchema.optional(),
3297
+ order_by: z2.string().optional(),
3298
+ limit: z2.number().int().positive().max(500).optional()
3299
+ }).strict();
3300
+ var QUERY_EMAILS_FIELD_SCHEMA = {
3301
+ description: "Available fields for the query_emails tool.",
3302
+ filters: {
3303
+ from: { type: "string", description: "Exact sender email (case-insensitive)" },
3304
+ from_contains: { type: "string", description: "Partial match on sender email" },
3305
+ domain: { type: "string", description: "Exact sender domain" },
3306
+ domain_contains: { type: "string", description: "Partial match on sender domain" },
3307
+ subject_contains: { type: "string", description: "Partial match on subject line" },
3308
+ date_after: { type: "string", description: "ISO date \u2014 emails after this date" },
3309
+ date_before: { type: "string", description: "ISO date \u2014 emails before this date" },
3310
+ is_read: { type: "boolean", description: "Filter by read/unread state" },
3311
+ is_newsletter: { type: "boolean", description: "Sender detected as newsletter" },
3312
+ has_label: { type: "boolean", description: "Has any user-applied label" },
3313
+ label: { type: "string", description: "Has this specific label" },
3314
+ has_unsubscribe: { type: "boolean", description: "Has List-Unsubscribe header" },
3315
+ min_sender_messages: { type: "integer", description: "Sender has at least this many total emails" }
3316
+ },
3317
+ group_by: [
3318
+ { value: "sender", description: "Group by sender email address" },
3319
+ { value: "domain", description: "Group by sender domain" },
3320
+ { value: "label", description: "Group by applied label (expands multi-label emails)" },
3321
+ { value: "year_month", description: "Group by month (YYYY-MM)" },
3322
+ { value: "year_week", description: "Group by week (YYYY-WNN)" },
3323
+ { value: "day_of_week", description: "Group by day of week (0=Sunday)" },
3324
+ { value: "is_read", description: "Group by read/unread state" },
3325
+ { value: "is_newsletter", description: "Group by newsletter detection" }
3326
+ ],
3327
+ aggregates: [
3328
+ { value: "count", description: "Number of emails" },
3329
+ { value: "unread_count", description: "Number of unread emails" },
3330
+ { value: "read_count", description: "Number of read emails" },
3331
+ { value: "unread_rate", description: "Percentage of emails that are unread" },
3332
+ { value: "oldest", description: "Earliest email date (ISO string)" },
3333
+ { value: "newest", description: "Latest email date (ISO string)" },
3334
+ { value: "sender_count", description: "Count of distinct senders" }
3335
+ ],
3336
+ having_fields: [...QUERY_EMAIL_HAVING_FIELDS],
3337
+ example_queries: [
3338
+ {
3339
+ description: "Monthly volume trend for Amazon",
3340
+ query: {
3341
+ filters: { domain_contains: "amazon" },
3342
+ group_by: "year_month",
3343
+ aggregates: ["count", "unread_rate"],
3344
+ order_by: "year_month asc"
3345
+ }
3346
+ },
3347
+ {
3348
+ description: "Domains with 95%+ unread rate and 50+ emails",
3349
+ query: {
3350
+ group_by: "domain",
3351
+ aggregates: ["count", "unread_rate"],
3352
+ having: { count: { gte: 50 }, unread_rate: { gte: 95 } }
3353
+ }
3354
+ },
3355
+ {
3356
+ description: "What day of the week gets the most email?",
3357
+ query: {
3358
+ group_by: "day_of_week",
3359
+ aggregates: ["count", "sender_count"]
3360
+ }
3361
+ }
3362
+ ]
3363
+ };
3364
+ var GROUP_BY_SQL_MAP = {
3365
+ sender: "LOWER(COALESCE(e.from_address, ''))",
3366
+ domain: DOMAIN_SQL,
3367
+ label: "CAST(grouped_label.value AS TEXT)",
3368
+ year_month: "STRFTIME('%Y-%m', e.date / 1000, 'unixepoch')",
3369
+ year_week: "STRFTIME('%Y-W%W', e.date / 1000, 'unixepoch')",
3370
+ day_of_week: "CAST(STRFTIME('%w', e.date / 1000, 'unixepoch') AS INTEGER)",
3371
+ is_read: "COALESCE(e.is_read, 0)",
3372
+ is_newsletter: "CASE WHEN ns.email IS NOT NULL THEN 1 ELSE 0 END"
3373
+ };
3374
+ var AGGREGATE_SQL_MAP = {
3375
+ count: "COUNT(*)",
3376
+ unread_count: "SUM(CASE WHEN COALESCE(e.is_read, 0) = 0 THEN 1 ELSE 0 END)",
3377
+ read_count: "SUM(CASE WHEN COALESCE(e.is_read, 0) = 1 THEN 1 ELSE 0 END)",
3378
+ unread_rate: `
3379
+ ROUND(
3380
+ CASE
3381
+ WHEN COUNT(*) = 0 THEN 0
3382
+ ELSE 100.0 * SUM(CASE WHEN COALESCE(e.is_read, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*)
3383
+ END,
3384
+ 1
3385
+ )
3386
+ `,
3387
+ oldest: "MIN(e.date)",
3388
+ newest: "MAX(e.date)",
3389
+ sender_count: `
3390
+ COUNT(
3391
+ DISTINCT CASE
3392
+ WHEN e.from_address IS NOT NULL AND TRIM(e.from_address) <> ''
3393
+ THEN LOWER(e.from_address)
3394
+ ELSE NULL
3395
+ END
3396
+ )
3397
+ `
3398
+ };
3399
+ function userLabelPredicate(column) {
3400
+ return `
3401
+ ${column} IS NOT NULL
3402
+ AND TRIM(CAST(${column} AS TEXT)) <> ''
3403
+ AND CAST(${column} AS TEXT) NOT IN (${SYSTEM_LABEL_SQL})
3404
+ AND CAST(${column} AS TEXT) NOT LIKE '${CATEGORY_LABEL_LIKE_PATTERN}' ESCAPE '\\'
3405
+ `;
3406
+ }
3407
+ function toIsoString3(value) {
3408
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
3409
+ return null;
3410
+ }
3411
+ return new Date(value).toISOString();
3412
+ }
3413
+ function normalizeGroupValue(groupBy, value) {
3414
+ switch (groupBy) {
3415
+ case "is_read":
3416
+ case "is_newsletter":
3417
+ return Number(value ?? 0) === 1;
3418
+ case "day_of_week":
3419
+ return Number(value ?? 0);
3420
+ case "sender":
3421
+ case "domain":
3422
+ case "label":
3423
+ case "year_month":
3424
+ case "year_week":
3425
+ return typeof value === "string" ? value : value == null ? null : String(value);
3426
+ }
3427
+ }
3428
+ function normalizeAggregateValue(aggregate, value) {
3429
+ switch (aggregate) {
3430
+ case "oldest":
3431
+ case "newest":
3432
+ return toIsoString3(value);
3433
+ case "count":
3434
+ case "unread_count":
3435
+ case "read_count":
3436
+ case "sender_count":
3437
+ return Number(value ?? 0);
3438
+ case "unread_rate":
3439
+ return Number(value ?? 0);
3440
+ }
3441
+ }
3442
+ function parseDateFilter(field, value) {
3443
+ const timestamp = Date.parse(value);
3444
+ if (Number.isNaN(timestamp)) {
3445
+ throw new Error(`Invalid ${field} value: ${value}`);
3446
+ }
3447
+ return timestamp;
3448
+ }
3449
+ function resolveAggregates(aggregates) {
3450
+ return Array.from(new Set(aggregates && aggregates.length > 0 ? aggregates : ["count"]));
3451
+ }
3452
+ function resolveOrderBy(orderBy, groupBy, aggregates) {
3453
+ const defaultField = aggregates.includes("count") ? "count" : aggregates[0];
3454
+ const rawValue = (orderBy || `${defaultField} desc`).trim();
3455
+ const match = rawValue.match(/^([a-z_]+)\s+(asc|desc)$/i);
3456
+ if (!match) {
3457
+ throw new Error(`Invalid order_by value: ${rawValue}`);
3458
+ }
3459
+ const [, field, direction] = match;
3460
+ const allowedFields = new Set(aggregates);
3461
+ if (groupBy) {
3462
+ allowedFields.add(groupBy);
3463
+ }
3464
+ if (!allowedFields.has(field)) {
3465
+ throw new Error(`Invalid order_by field: ${field}`);
3466
+ }
3467
+ return `${field} ${direction.toLowerCase()}`;
3468
+ }
3469
+ function buildWhereClauses(filters, groupBy) {
3470
+ const whereParts = [];
3471
+ const params = [];
3472
+ if (filters.from !== void 0) {
3473
+ whereParts.push("LOWER(COALESCE(e.from_address, '')) = LOWER(?)");
3474
+ params.push(filters.from);
3475
+ }
3476
+ if (filters.from_contains !== void 0) {
3477
+ whereParts.push("LOWER(COALESCE(e.from_address, '')) LIKE '%' || LOWER(?) || '%'");
3478
+ params.push(filters.from_contains);
3479
+ }
3480
+ if (filters.domain !== void 0) {
3481
+ whereParts.push(`${DOMAIN_SQL} = LOWER(?)`);
3482
+ params.push(filters.domain);
3483
+ }
3484
+ if (filters.domain_contains !== void 0) {
3485
+ whereParts.push(`${DOMAIN_SQL} LIKE '%' || LOWER(?) || '%'`);
3486
+ params.push(filters.domain_contains);
3487
+ }
3488
+ if (filters.subject_contains !== void 0) {
3489
+ whereParts.push("LOWER(COALESCE(e.subject, '')) LIKE '%' || LOWER(?) || '%'");
3490
+ params.push(filters.subject_contains);
3491
+ }
3492
+ if (filters.date_after !== void 0) {
3493
+ whereParts.push("COALESCE(e.date, 0) >= ?");
3494
+ params.push(parseDateFilter("date_after", filters.date_after));
3495
+ }
3496
+ if (filters.date_before !== void 0) {
3497
+ whereParts.push("COALESCE(e.date, 0) <= ?");
3498
+ params.push(parseDateFilter("date_before", filters.date_before));
3499
+ }
3500
+ if (filters.is_read !== void 0) {
3501
+ whereParts.push("COALESCE(e.is_read, 0) = ?");
3502
+ params.push(filters.is_read ? 1 : 0);
3503
+ }
3504
+ if (filters.is_newsletter !== void 0) {
3505
+ whereParts.push(filters.is_newsletter ? "ns.email IS NOT NULL" : "ns.email IS NULL");
3506
+ }
3507
+ if (filters.has_label !== void 0) {
3508
+ whereParts.push(
3509
+ filters.has_label ? `EXISTS (
3510
+ SELECT 1
3511
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3512
+ WHERE ${userLabelPredicate("label_filter.value")}
3513
+ )` : `NOT EXISTS (
3514
+ SELECT 1
3515
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3516
+ WHERE ${userLabelPredicate("label_filter.value")}
3517
+ )`
3518
+ );
3519
+ }
3520
+ if (filters.label !== void 0) {
3521
+ whereParts.push(`
3522
+ EXISTS (
3523
+ SELECT 1
3524
+ FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
3525
+ WHERE LOWER(CAST(label_filter.value AS TEXT)) = LOWER(?)
3526
+ )
3527
+ `);
3528
+ params.push(filters.label);
3529
+ }
3530
+ if (filters.has_unsubscribe !== void 0) {
3531
+ whereParts.push(
3532
+ filters.has_unsubscribe ? "NULLIF(TRIM(e.list_unsubscribe), '') IS NOT NULL" : "(e.list_unsubscribe IS NULL OR TRIM(e.list_unsubscribe) = '')"
3533
+ );
3534
+ }
3535
+ if (filters.min_sender_messages !== void 0) {
3536
+ whereParts.push("COALESCE(sender_stats.totalFromSender, 0) >= ?");
3537
+ params.push(filters.min_sender_messages);
3538
+ }
3539
+ if (groupBy === "label") {
3540
+ whereParts.push(userLabelPredicate("grouped_label.value"));
3541
+ }
3542
+ return {
3543
+ sql: whereParts.length > 0 ? `WHERE ${whereParts.join("\n AND ")}` : "",
3544
+ params
3545
+ };
3546
+ }
3547
+ function buildHavingClause(having) {
3548
+ const parts = [];
3549
+ for (const field of QUERY_EMAIL_HAVING_FIELDS) {
3550
+ const condition = having[field];
3551
+ if (!condition) {
3552
+ continue;
3553
+ }
3554
+ const expression = AGGREGATE_SQL_MAP[field];
3555
+ if (condition.gte !== void 0) {
3556
+ parts.push(`${expression} >= ${condition.gte}`);
3557
+ }
3558
+ if (condition.lte !== void 0) {
3559
+ parts.push(`${expression} <= ${condition.lte}`);
3560
+ }
3561
+ }
3562
+ return parts.length > 0 ? `HAVING ${parts.join("\n AND ")}` : "";
3563
+ }
3564
+ function normalizeRow(row, groupBy, aggregates) {
3565
+ const normalized = {};
3566
+ if (groupBy) {
3567
+ normalized[groupBy] = normalizeGroupValue(groupBy, row[groupBy]);
3568
+ }
3569
+ for (const aggregate of aggregates) {
3570
+ normalized[aggregate] = normalizeAggregateValue(aggregate, row[aggregate]);
3571
+ }
3572
+ return normalized;
3573
+ }
3574
+ async function queryEmails(options = {}) {
3575
+ const parsed = queryEmailsInputSchema.parse(options);
3576
+ await detectNewsletters();
3577
+ const sqlite = getStatsSqlite();
3578
+ const filters = parsed.filters ?? {};
3579
+ const groupBy = parsed.group_by;
3580
+ const aggregates = resolveAggregates(parsed.aggregates);
3581
+ const having = parsed.having ?? {};
3582
+ const orderBy = resolveOrderBy(parsed.order_by, groupBy, aggregates);
3583
+ const limit = Math.min(500, normalizeLimit(parsed.limit, 50));
3584
+ const { sql: whereSql, params } = buildWhereClauses(filters, groupBy);
3585
+ const havingSql = buildHavingClause(having);
3586
+ const fromSql = [
3587
+ "FROM emails AS e",
3588
+ "LEFT JOIN newsletter_senders AS ns ON LOWER(ns.email) = LOWER(e.from_address)",
3589
+ `LEFT JOIN (
3590
+ SELECT
3591
+ LOWER(from_address) AS senderKey,
3592
+ COUNT(*) AS totalFromSender
3593
+ FROM emails
3594
+ WHERE from_address IS NOT NULL
3595
+ AND TRIM(from_address) <> ''
3596
+ GROUP BY LOWER(from_address)
3597
+ ) AS sender_stats ON sender_stats.senderKey = LOWER(e.from_address)`,
3598
+ groupBy === "label" ? "JOIN json_each(COALESCE(e.label_ids, '[]')) AS grouped_label" : ""
3599
+ ].filter(Boolean).join("\n");
3600
+ const selectParts = [];
3601
+ if (groupBy) {
3602
+ selectParts.push(`${GROUP_BY_SQL_MAP[groupBy]} AS ${groupBy}`);
3603
+ }
3604
+ for (const aggregate of aggregates) {
3605
+ selectParts.push(`${AGGREGATE_SQL_MAP[aggregate]} AS ${aggregate}`);
3606
+ }
3607
+ const groupBySql = groupBy ? `GROUP BY ${GROUP_BY_SQL_MAP[groupBy]}` : "";
3608
+ const orderBySql = `ORDER BY ${orderBy.split(" ")[0]} ${orderBy.split(" ")[1].toUpperCase()}`;
3609
+ const totalRow = groupBy ? sqlite.prepare(
3610
+ `
3611
+ SELECT COUNT(*) AS totalRows
3612
+ FROM (
3613
+ SELECT 1
3614
+ ${fromSql}
3615
+ ${whereSql}
3616
+ ${groupBySql}
3617
+ ${havingSql}
3618
+ ) AS grouped_rows
3619
+ `
3620
+ ).get(...params) : void 0;
3621
+ const rows = sqlite.prepare(
3622
+ `
3623
+ SELECT
3624
+ ${selectParts.join(",\n ")}
3625
+ ${fromSql}
3626
+ ${whereSql}
3627
+ ${groupBySql}
3628
+ ${havingSql}
3629
+ ${orderBySql}
3630
+ LIMIT ?
3631
+ `
3632
+ ).all(...params, limit);
3633
+ return {
3634
+ rows: rows.map((row) => normalizeRow(row, groupBy, aggregates)),
3635
+ totalRows: groupBy ? totalRow?.totalRows ?? 0 : rows.length,
3636
+ query: {
3637
+ filters,
3638
+ group_by: groupBy ?? null,
3639
+ aggregates,
3640
+ having,
3641
+ order_by: orderBy,
3642
+ limit
3643
+ }
3644
+ };
3645
+ }
3646
+
2933
3647
  // src/core/stats/sender.ts
2934
3648
  function buildSenderWhereClause(period) {
2935
3649
  const whereParts = [
@@ -3089,7 +3803,7 @@ async function getSenderStats(emailOrDomain) {
3089
3803
  }
3090
3804
 
3091
3805
  // src/core/stats/uncategorized.ts
3092
- var SYSTEM_LABEL_IDS = [
3806
+ var SYSTEM_LABEL_IDS2 = [
3093
3807
  "INBOX",
3094
3808
  "UNREAD",
3095
3809
  "IMPORTANT",
@@ -3100,13 +3814,13 @@ var SYSTEM_LABEL_IDS = [
3100
3814
  "STARRED"
3101
3815
  ];
3102
3816
  var CATEGORY_LABEL_PATTERN = "CATEGORY\\_%";
3103
- function toIsoString2(value) {
3817
+ function toIsoString4(value) {
3104
3818
  if (!value) {
3105
3819
  return null;
3106
3820
  }
3107
3821
  return new Date(value).toISOString();
3108
3822
  }
3109
- function parseJsonArray3(raw) {
3823
+ function parseJsonArray4(raw) {
3110
3824
  if (!raw) {
3111
3825
  return [];
3112
3826
  }
@@ -3117,7 +3831,7 @@ function parseJsonArray3(raw) {
3117
3831
  return [];
3118
3832
  }
3119
3833
  }
3120
- function resolveSinceTimestamp(since) {
3834
+ function resolveSinceTimestamp2(since) {
3121
3835
  if (!since) {
3122
3836
  return null;
3123
3837
  }
@@ -3127,6 +3841,50 @@ function resolveSinceTimestamp(since) {
3127
3841
  }
3128
3842
  return parsed;
3129
3843
  }
3844
+ function computeConfidence(row) {
3845
+ const signals = [];
3846
+ let score = 0;
3847
+ const hasDefinitiveNewsletterSignal = Boolean(row.listUnsubscribe && row.listUnsubscribe.trim()) || Boolean(row.detectionReason?.includes("list_unsubscribe"));
3848
+ if (row.listUnsubscribe && row.listUnsubscribe.trim()) {
3849
+ signals.push("list_unsubscribe_header");
3850
+ score += 3;
3851
+ }
3852
+ if (row.detectionReason?.includes("list_unsubscribe")) {
3853
+ signals.push("newsletter_list_header");
3854
+ score += 2;
3855
+ }
3856
+ if ((row.totalFromSender ?? 0) >= 20) {
3857
+ signals.push("high_volume_sender");
3858
+ score += 2;
3859
+ } else if ((row.totalFromSender ?? 0) >= 5) {
3860
+ signals.push("moderate_volume_sender");
3861
+ score += 1;
3862
+ }
3863
+ if (row.detectionReason?.includes("known_sender_pattern")) {
3864
+ signals.push("automated_sender_pattern");
3865
+ score += 1;
3866
+ }
3867
+ if (row.detectionReason?.includes("bulk_sender_pattern")) {
3868
+ signals.push("bulk_sender_pattern");
3869
+ score += 1;
3870
+ }
3871
+ if ((row.totalFromSender ?? 0) <= 2 && !hasDefinitiveNewsletterSignal) {
3872
+ signals.push("rare_sender");
3873
+ score -= 3;
3874
+ }
3875
+ if (!row.detectionReason) {
3876
+ signals.push("no_newsletter_signals");
3877
+ score -= 2;
3878
+ }
3879
+ if (!row.detectionReason && !isLikelyAutomatedSenderAddress(row.sender || "")) {
3880
+ signals.push("personal_sender_address");
3881
+ score -= 2;
3882
+ }
3883
+ return {
3884
+ confidence: score >= 3 ? "high" : score >= 0 ? "medium" : "low",
3885
+ signals
3886
+ };
3887
+ }
3130
3888
  function buildWhereClause(options) {
3131
3889
  const whereParts = [
3132
3890
  `
@@ -3135,12 +3893,12 @@ function buildWhereClause(options) {
3135
3893
  FROM json_each(COALESCE(e.label_ids, '[]')) AS label
3136
3894
  WHERE label.value IS NOT NULL
3137
3895
  AND TRIM(CAST(label.value AS TEXT)) <> ''
3138
- AND label.value NOT IN (${SYSTEM_LABEL_IDS.map(() => "?").join(", ")})
3896
+ AND label.value NOT IN (${SYSTEM_LABEL_IDS2.map(() => "?").join(", ")})
3139
3897
  AND label.value NOT LIKE ? ESCAPE '\\'
3140
3898
  )
3141
3899
  `
3142
3900
  ];
3143
- const params = [...SYSTEM_LABEL_IDS, CATEGORY_LABEL_PATTERN];
3901
+ const params = [...SYSTEM_LABEL_IDS2, CATEGORY_LABEL_PATTERN];
3144
3902
  if (options.unreadOnly) {
3145
3903
  whereParts.push("COALESCE(e.is_read, 0) = 0");
3146
3904
  }
@@ -3158,7 +3916,7 @@ async function getUncategorizedEmails(options = {}) {
3158
3916
  const sqlite = getStatsSqlite();
3159
3917
  const limit = Math.min(1e3, normalizeLimit(options.limit, 50));
3160
3918
  const offset = Math.max(0, Math.floor(options.offset ?? 0));
3161
- const sinceTimestamp = resolveSinceTimestamp(options.since);
3919
+ const sinceTimestamp = resolveSinceTimestamp2(options.since);
3162
3920
  const { clause, params } = buildWhereClause({
3163
3921
  sinceTimestamp,
3164
3922
  unreadOnly: options.unreadOnly ?? false
@@ -3183,7 +3941,8 @@ async function getUncategorizedEmails(options = {}) {
3183
3941
  e.is_read AS isRead,
3184
3942
  sender_stats.totalFromSender AS totalFromSender,
3185
3943
  sender_stats.unreadFromSender AS unreadFromSender,
3186
- ns.detection_reason AS detectionReason
3944
+ ns.detection_reason AS detectionReason,
3945
+ e.list_unsubscribe AS listUnsubscribe
3187
3946
  FROM emails AS e
3188
3947
  LEFT JOIN (
3189
3948
  SELECT
@@ -3207,20 +3966,23 @@ async function getUncategorizedEmails(options = {}) {
3207
3966
  const emails2 = rows.map((row) => {
3208
3967
  const totalFromSender = row.totalFromSender ?? 0;
3209
3968
  const unreadFromSender = row.unreadFromSender ?? 0;
3969
+ const confidence = computeConfidence(row);
3210
3970
  return {
3211
3971
  id: row.id,
3212
3972
  threadId: row.threadId || "",
3213
3973
  from: row.sender || "",
3214
3974
  subject: row.subject || "",
3215
- date: toIsoString2(row.date),
3975
+ date: toIsoString4(row.date),
3216
3976
  snippet: row.snippet || "",
3217
- labels: parseJsonArray3(row.labelIds),
3977
+ labels: parseJsonArray4(row.labelIds),
3218
3978
  isRead: row.isRead === 1,
3219
3979
  senderContext: {
3220
3980
  totalFromSender,
3221
3981
  unreadRate: roundPercent(unreadFromSender, totalFromSender),
3222
3982
  isNewsletter: Boolean(row.detectionReason),
3223
- detectionReason: row.detectionReason
3983
+ detectionReason: row.detectionReason,
3984
+ confidence: confidence.confidence,
3985
+ signals: confidence.signals
3224
3986
  }
3225
3987
  };
3226
3988
  });
@@ -3234,7 +3996,7 @@ async function getUncategorizedEmails(options = {}) {
3234
3996
  }
3235
3997
 
3236
3998
  // src/core/stats/unsubscribe.ts
3237
- function toIsoString3(value) {
3999
+ function toIsoString5(value) {
3238
4000
  if (!value) {
3239
4001
  return null;
3240
4002
  }
@@ -3286,8 +4048,8 @@ async function getUnsubscribeSuggestions(options = {}) {
3286
4048
  unreadCount: row.unreadCount,
3287
4049
  unreadRate,
3288
4050
  readRate,
3289
- lastRead: toIsoString3(row.lastRead),
3290
- lastReceived: toIsoString3(row.lastReceived),
4051
+ lastRead: toIsoString5(row.lastRead),
4052
+ lastReceived: toIsoString5(row.lastReceived),
3291
4053
  unsubscribeLink: unsubscribe2.unsubscribeLink,
3292
4054
  unsubscribeMethod: unsubscribe2.unsubscribeMethod,
3293
4055
  impactScore: roundImpactScore(row.messageCount, unreadRate),
@@ -3439,67 +4201,67 @@ import { join as join2 } from "path";
3439
4201
  import YAML from "yaml";
3440
4202
 
3441
4203
  // src/core/rules/types.ts
3442
- import { z } from "zod";
3443
- var RuleNameSchema = z.string().min(1, "Rule name is required").regex(
4204
+ import { z as z3 } from "zod";
4205
+ var RuleNameSchema = z3.string().min(1, "Rule name is required").regex(
3444
4206
  /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
3445
4207
  "Rule name must be kebab-case (lowercase letters, numbers, and single hyphens)"
3446
4208
  );
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) => {
4209
+ var RuleFieldSchema = z3.enum(["from", "to", "subject", "snippet", "labels"]);
4210
+ var RegexStringSchema = z3.string().min(1, "Pattern must not be empty").superRefine((value, ctx) => {
3449
4211
  try {
3450
4212
  new RegExp(value);
3451
4213
  } catch (error) {
3452
4214
  ctx.addIssue({
3453
- code: z.ZodIssueCode.custom,
4215
+ code: z3.ZodIssueCode.custom,
3454
4216
  message: `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`
3455
4217
  });
3456
4218
  }
3457
4219
  });
3458
- var MatcherSchema = z.object({
4220
+ var MatcherSchema = z3.object({
3459
4221
  // `snippet` is the only cached free-text matcher in MVP.
3460
4222
  field: RuleFieldSchema,
3461
4223
  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)
4224
+ contains: z3.array(z3.string().min(1)).min(1).optional(),
4225
+ values: z3.array(z3.string().min(1)).min(1).optional(),
4226
+ exclude: z3.boolean().default(false)
3465
4227
  }).strict().superRefine((value, ctx) => {
3466
4228
  if (!value.pattern && !value.contains && !value.values) {
3467
4229
  ctx.addIssue({
3468
- code: z.ZodIssueCode.custom,
4230
+ code: z3.ZodIssueCode.custom,
3469
4231
  message: "Matcher must provide at least one of pattern, contains, or values",
3470
4232
  path: ["pattern"]
3471
4233
  });
3472
4234
  }
3473
4235
  });
3474
- var ConditionsSchema = z.object({
3475
- operator: z.enum(["AND", "OR"]),
3476
- matchers: z.array(MatcherSchema).min(1, "At least one matcher is required")
4236
+ var ConditionsSchema = z3.object({
4237
+ operator: z3.enum(["AND", "OR"]),
4238
+ matchers: z3.array(MatcherSchema).min(1, "At least one matcher is required")
3477
4239
  }).strict();
3478
- var LabelActionSchema = z.object({
3479
- type: z.literal("label"),
3480
- label: z.string().min(1, "Label name is required")
4240
+ var LabelActionSchema = z3.object({
4241
+ type: z3.literal("label"),
4242
+ label: z3.string().min(1, "Label name is required")
3481
4243
  });
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")
4244
+ var ArchiveActionSchema = z3.object({ type: z3.literal("archive") });
4245
+ var MarkReadActionSchema = z3.object({ type: z3.literal("mark_read") });
4246
+ var ForwardActionSchema = z3.object({
4247
+ type: z3.literal("forward"),
4248
+ to: z3.string().email("Forward destination must be a valid email address")
3487
4249
  });
3488
- var MarkSpamActionSchema = z.object({ type: z.literal("mark_spam") });
3489
- var ActionSchema = z.discriminatedUnion("type", [
4250
+ var MarkSpamActionSchema = z3.object({ type: z3.literal("mark_spam") });
4251
+ var ActionSchema = z3.discriminatedUnion("type", [
3490
4252
  LabelActionSchema,
3491
4253
  ArchiveActionSchema,
3492
4254
  MarkReadActionSchema,
3493
4255
  ForwardActionSchema,
3494
4256
  MarkSpamActionSchema
3495
4257
  ]);
3496
- var RuleSchema = z.object({
4258
+ var RuleSchema = z3.object({
3497
4259
  name: RuleNameSchema,
3498
- description: z.string(),
3499
- enabled: z.boolean().default(true),
3500
- priority: z.number().int().min(0).default(50),
4260
+ description: z3.string(),
4261
+ enabled: z3.boolean().default(true),
4262
+ priority: z3.number().int().min(0).default(50),
3501
4263
  conditions: ConditionsSchema,
3502
- actions: z.array(ActionSchema).min(1, "At least one action is required")
4264
+ actions: z3.array(ActionSchema).min(1, "At least one action is required")
3503
4265
  }).strict();
3504
4266
 
3505
4267
  // src/core/rules/loader.ts
@@ -3829,7 +4591,7 @@ function getDatabase4() {
3829
4591
  const config = loadConfig();
3830
4592
  return getSqlite(config.dbPath);
3831
4593
  }
3832
- function parseJsonArray4(value) {
4594
+ function parseJsonArray5(value) {
3833
4595
  if (!value) {
3834
4596
  return [];
3835
4597
  }
@@ -3846,13 +4608,13 @@ function rowToEmail3(row) {
3846
4608
  threadId: row.thread_id ?? "",
3847
4609
  fromAddress: row.from_address ?? "",
3848
4610
  fromName: row.from_name ?? "",
3849
- toAddresses: parseJsonArray4(row.to_addresses),
4611
+ toAddresses: parseJsonArray5(row.to_addresses),
3850
4612
  subject: row.subject ?? "",
3851
4613
  snippet: row.snippet ?? "",
3852
4614
  date: row.date ?? 0,
3853
4615
  isRead: row.is_read === 1,
3854
4616
  isStarred: row.is_starred === 1,
3855
- labelIds: parseJsonArray4(row.label_ids),
4617
+ labelIds: parseJsonArray5(row.label_ids),
3856
4618
  sizeEstimate: row.size_estimate ?? 0,
3857
4619
  hasAttachments: row.has_attachments === 1,
3858
4620
  listUnsubscribe: row.list_unsubscribe
@@ -4789,8 +5551,8 @@ async function getSyncStatus() {
4789
5551
  }
4790
5552
 
4791
5553
  // src/mcp/server.ts
4792
- var DAY_MS3 = 24 * 60 * 60 * 1e3;
4793
- var MCP_VERSION = "0.1.0";
5554
+ var DAY_MS4 = 24 * 60 * 60 * 1e3;
5555
+ var MCP_VERSION = "0.3.0";
4794
5556
  var MCP_TOOLS = [
4795
5557
  "search_emails",
4796
5558
  "get_email",
@@ -4809,6 +5571,8 @@ var MCP_TOOLS = [
4809
5571
  "get_sender_stats",
4810
5572
  "get_newsletter_senders",
4811
5573
  "get_uncategorized_emails",
5574
+ "review_categorized",
5575
+ "query_emails",
4812
5576
  "get_noise_senders",
4813
5577
  "get_unsubscribe_suggestions",
4814
5578
  "unsubscribe",
@@ -4826,6 +5590,7 @@ var MCP_RESOURCES = [
4826
5590
  "inbox://recent",
4827
5591
  "inbox://summary",
4828
5592
  "inbox://action-log",
5593
+ "schema://query-fields",
4829
5594
  "rules://deployed",
4830
5595
  "rules://history",
4831
5596
  "stats://senders",
@@ -4947,7 +5712,7 @@ async function buildStartupWarnings() {
4947
5712
  }
4948
5713
  if (!latestSync) {
4949
5714
  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) {
5715
+ } else if (Date.now() - latestSync > DAY_MS4) {
4951
5716
  warnings.push("Inbox cache appears stale (last sync older than 24 hours). Call `sync_inbox` if freshness matters.");
4952
5717
  }
4953
5718
  return warnings;
@@ -4958,7 +5723,7 @@ async function buildStatsOverview() {
4958
5723
  topSenders: await getTopSenders({ limit: 10 }),
4959
5724
  labelDistribution: (await getLabelDistribution()).slice(0, 10),
4960
5725
  dailyVolume: await getVolumeByPeriod("day", {
4961
- start: Date.now() - 30 * DAY_MS3,
5726
+ start: Date.now() - 30 * DAY_MS4,
4962
5727
  end: Date.now()
4963
5728
  })
4964
5729
  };
@@ -4998,9 +5763,9 @@ async function createMcpServer() {
4998
5763
  {
4999
5764
  description: "Search Gmail using Gmail query syntax and return matching email metadata.",
5000
5765
  inputSchema: {
5001
- query: z2.string().min(1),
5002
- max_results: z2.number().int().positive().max(100).optional(),
5003
- label: z2.string().min(1).optional()
5766
+ query: z4.string().min(1),
5767
+ max_results: z4.number().int().positive().max(100).optional(),
5768
+ label: z4.string().min(1).optional()
5004
5769
  },
5005
5770
  annotations: {
5006
5771
  readOnlyHint: true
@@ -5015,7 +5780,7 @@ async function createMcpServer() {
5015
5780
  {
5016
5781
  description: "Fetch a single email with full content by Gmail message ID.",
5017
5782
  inputSchema: {
5018
- email_id: z2.string().min(1)
5783
+ email_id: z4.string().min(1)
5019
5784
  },
5020
5785
  annotations: {
5021
5786
  readOnlyHint: true
@@ -5028,7 +5793,7 @@ async function createMcpServer() {
5028
5793
  {
5029
5794
  description: "Fetch a full Gmail thread by thread ID.",
5030
5795
  inputSchema: {
5031
- thread_id: z2.string().min(1)
5796
+ thread_id: z4.string().min(1)
5032
5797
  },
5033
5798
  annotations: {
5034
5799
  readOnlyHint: true
@@ -5041,7 +5806,7 @@ async function createMcpServer() {
5041
5806
  {
5042
5807
  description: "Run inbox sync. Uses incremental sync by default and full sync when requested.",
5043
5808
  inputSchema: {
5044
- full: z2.boolean().optional()
5809
+ full: z4.boolean().optional()
5045
5810
  },
5046
5811
  annotations: {
5047
5812
  readOnlyHint: false,
@@ -5055,7 +5820,7 @@ async function createMcpServer() {
5055
5820
  {
5056
5821
  description: "Archive one or more Gmail messages by removing the INBOX label.",
5057
5822
  inputSchema: {
5058
- email_ids: z2.array(z2.string().min(1)).min(1)
5823
+ email_ids: z4.array(z4.string().min(1)).min(1)
5059
5824
  },
5060
5825
  annotations: {
5061
5826
  readOnlyHint: false,
@@ -5069,9 +5834,9 @@ async function createMcpServer() {
5069
5834
  {
5070
5835
  description: "Add and/or remove Gmail labels on one or more messages.",
5071
5836
  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()
5837
+ email_ids: z4.array(z4.string().min(1)).min(1),
5838
+ add_labels: z4.array(z4.string().min(1)).optional(),
5839
+ remove_labels: z4.array(z4.string().min(1)).optional()
5075
5840
  },
5076
5841
  annotations: {
5077
5842
  readOnlyHint: false,
@@ -5105,8 +5870,8 @@ async function createMcpServer() {
5105
5870
  {
5106
5871
  description: "Mark one or more Gmail messages as read or unread.",
5107
5872
  inputSchema: {
5108
- email_ids: z2.array(z2.string().min(1)).min(1),
5109
- read: z2.boolean()
5873
+ email_ids: z4.array(z4.string().min(1)).min(1),
5874
+ read: z4.boolean()
5110
5875
  },
5111
5876
  annotations: {
5112
5877
  readOnlyHint: false,
@@ -5123,23 +5888,23 @@ async function createMcpServer() {
5123
5888
  {
5124
5889
  description: "Apply grouped inbox actions in one call for faster AI-driven triage and categorization.",
5125
5890
  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)
5891
+ groups: z4.array(
5892
+ z4.object({
5893
+ email_ids: z4.array(z4.string().min(1)).min(1).max(500),
5894
+ actions: z4.array(
5895
+ z4.discriminatedUnion("type", [
5896
+ z4.object({
5897
+ type: z4.literal("label"),
5898
+ label: z4.string().min(1)
5134
5899
  }),
5135
- z2.object({ type: z2.literal("archive") }),
5136
- z2.object({ type: z2.literal("mark_read") }),
5137
- z2.object({ type: z2.literal("mark_spam") })
5900
+ z4.object({ type: z4.literal("archive") }),
5901
+ z4.object({ type: z4.literal("mark_read") }),
5902
+ z4.object({ type: z4.literal("mark_spam") })
5138
5903
  ])
5139
5904
  ).min(1).max(5)
5140
5905
  })
5141
5906
  ).min(1).max(20),
5142
- dry_run: z2.boolean().optional()
5907
+ dry_run: z4.boolean().optional()
5143
5908
  },
5144
5909
  annotations: {
5145
5910
  readOnlyHint: false,
@@ -5159,8 +5924,8 @@ async function createMcpServer() {
5159
5924
  {
5160
5925
  description: "Forward a Gmail message to another address.",
5161
5926
  inputSchema: {
5162
- email_id: z2.string().min(1),
5163
- to: z2.string().email()
5927
+ email_id: z4.string().min(1),
5928
+ to: z4.string().email()
5164
5929
  },
5165
5930
  annotations: {
5166
5931
  readOnlyHint: false,
@@ -5174,7 +5939,7 @@ async function createMcpServer() {
5174
5939
  {
5175
5940
  description: "Undo a prior inboxctl action run when the underlying Gmail mutations are reversible.",
5176
5941
  inputSchema: {
5177
- run_id: z2.string().min(1)
5942
+ run_id: z4.string().min(1)
5178
5943
  },
5179
5944
  annotations: {
5180
5945
  readOnlyHint: false,
@@ -5198,8 +5963,8 @@ async function createMcpServer() {
5198
5963
  {
5199
5964
  description: "Create a Gmail label if it does not already exist.",
5200
5965
  inputSchema: {
5201
- name: z2.string().min(1),
5202
- color: z2.string().min(1).optional()
5966
+ name: z4.string().min(1),
5967
+ color: z4.string().min(1).optional()
5203
5968
  },
5204
5969
  annotations: {
5205
5970
  readOnlyHint: false,
@@ -5231,9 +5996,9 @@ async function createMcpServer() {
5231
5996
  {
5232
5997
  description: "Return top senders ranked by cached email volume.",
5233
5998
  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()
5999
+ limit: z4.number().int().positive().max(100).optional(),
6000
+ min_unread_rate: z4.number().min(0).max(100).optional(),
6001
+ period: z4.enum(["day", "week", "month", "year", "all"]).optional()
5237
6002
  },
5238
6003
  annotations: {
5239
6004
  readOnlyHint: true
@@ -5250,7 +6015,7 @@ async function createMcpServer() {
5250
6015
  {
5251
6016
  description: "Return detailed stats for a sender email address or an @domain aggregate.",
5252
6017
  inputSchema: {
5253
- email_or_domain: z2.string().min(1)
6018
+ email_or_domain: z4.string().min(1)
5254
6019
  },
5255
6020
  annotations: {
5256
6021
  readOnlyHint: true
@@ -5270,8 +6035,8 @@ async function createMcpServer() {
5270
6035
  {
5271
6036
  description: "Return senders that look like newsletters or mailing lists based on cached heuristics.",
5272
6037
  inputSchema: {
5273
- min_messages: z2.number().int().positive().optional(),
5274
- min_unread_rate: z2.number().min(0).max(100).optional()
6038
+ min_messages: z4.number().int().positive().optional(),
6039
+ min_unread_rate: z4.number().min(0).max(100).optional()
5275
6040
  },
5276
6041
  annotations: {
5277
6042
  readOnlyHint: true
@@ -5287,10 +6052,10 @@ async function createMcpServer() {
5287
6052
  {
5288
6053
  description: "Return cached emails that have only Gmail system labels and no user-applied organization.",
5289
6054
  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()
6055
+ limit: z4.number().int().positive().max(1e3).optional().describe("Max emails to return per page. Default 50. AI clients should start with 50-100 and paginate."),
6056
+ offset: z4.number().int().min(0).optional().describe("Number of results to skip for pagination. Use with totalUncategorized and hasMore."),
6057
+ unread_only: z4.boolean().optional(),
6058
+ since: z4.string().min(1).optional()
5294
6059
  },
5295
6060
  annotations: {
5296
6061
  readOnlyHint: true
@@ -5303,15 +6068,37 @@ async function createMcpServer() {
5303
6068
  since
5304
6069
  }))
5305
6070
  );
6071
+ server.registerTool(
6072
+ "review_categorized",
6073
+ {
6074
+ description: "Scan recently categorized emails for anomalies that suggest a misclassification or over-aggressive archive.",
6075
+ inputSchema: reviewCategorizedInputSchema.shape,
6076
+ annotations: {
6077
+ readOnlyHint: true
6078
+ }
6079
+ },
6080
+ toolHandler(async (args) => reviewCategorized(args))
6081
+ );
6082
+ server.registerTool(
6083
+ "query_emails",
6084
+ {
6085
+ description: "Run structured analytics queries over the cached email dataset using fixed filters, groupings, and aggregates.",
6086
+ inputSchema: queryEmailsInputSchema.shape,
6087
+ annotations: {
6088
+ readOnlyHint: true
6089
+ }
6090
+ },
6091
+ toolHandler(async (args) => queryEmails(args))
6092
+ );
5306
6093
  server.registerTool(
5307
6094
  "get_noise_senders",
5308
6095
  {
5309
6096
  description: "Return a focused list of active, high-noise senders worth categorizing, filtering, or unsubscribing.",
5310
6097
  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.")
6098
+ limit: z4.number().int().positive().max(50).optional(),
6099
+ min_noise_score: z4.number().min(0).optional(),
6100
+ active_days: z4.number().int().positive().optional(),
6101
+ sort_by: z4.enum(["noise_score", "all_time_noise_score", "message_count", "unread_rate"]).optional().describe("Sort order. Default: noise_score. Use all_time_noise_score for lifetime perspective.")
5315
6102
  },
5316
6103
  annotations: {
5317
6104
  readOnlyHint: true
@@ -5329,9 +6116,9 @@ async function createMcpServer() {
5329
6116
  {
5330
6117
  description: "Return ranked senders with unsubscribe links, sorted by how much inbox noise unsubscribing would remove.",
5331
6118
  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()
6119
+ limit: z4.number().int().positive().max(50).optional(),
6120
+ min_messages: z4.number().int().positive().optional(),
6121
+ unread_only_senders: z4.boolean().optional()
5335
6122
  },
5336
6123
  annotations: {
5337
6124
  readOnlyHint: true
@@ -5348,9 +6135,9 @@ async function createMcpServer() {
5348
6135
  {
5349
6136
  description: "Return the unsubscribe target for a sender and optionally label/archive existing emails in one undoable run.",
5350
6137
  inputSchema: {
5351
- sender_email: z2.string().min(1),
5352
- also_archive: z2.boolean().optional(),
5353
- also_label: z2.string().min(1).optional()
6138
+ sender_email: z4.string().min(1),
6139
+ also_archive: z4.boolean().optional(),
6140
+ also_label: z4.string().min(1).optional()
5354
6141
  },
5355
6142
  annotations: {
5356
6143
  readOnlyHint: false,
@@ -5368,7 +6155,7 @@ async function createMcpServer() {
5368
6155
  {
5369
6156
  description: "Validate and deploy a rule directly from YAML content.",
5370
6157
  inputSchema: {
5371
- yaml_content: z2.string().min(1)
6158
+ yaml_content: z4.string().min(1)
5372
6159
  },
5373
6160
  annotations: {
5374
6161
  readOnlyHint: false,
@@ -5385,7 +6172,7 @@ async function createMcpServer() {
5385
6172
  {
5386
6173
  description: "List deployed inboxctl rules and their execution status.",
5387
6174
  inputSchema: {
5388
- enabled_only: z2.boolean().optional()
6175
+ enabled_only: z4.boolean().optional()
5389
6176
  },
5390
6177
  annotations: {
5391
6178
  readOnlyHint: true
@@ -5401,9 +6188,9 @@ async function createMcpServer() {
5401
6188
  {
5402
6189
  description: "Run a deployed rule in dry-run mode by default, or apply it when dry_run is false.",
5403
6190
  inputSchema: {
5404
- rule_name: z2.string().min(1),
5405
- dry_run: z2.boolean().optional(),
5406
- max_emails: z2.number().int().positive().max(1e3).optional()
6191
+ rule_name: z4.string().min(1),
6192
+ dry_run: z4.boolean().optional(),
6193
+ max_emails: z4.number().int().positive().max(1e3).optional()
5407
6194
  },
5408
6195
  annotations: {
5409
6196
  readOnlyHint: false,
@@ -5420,7 +6207,7 @@ async function createMcpServer() {
5420
6207
  {
5421
6208
  description: "Enable a deployed rule by name.",
5422
6209
  inputSchema: {
5423
- rule_name: z2.string().min(1)
6210
+ rule_name: z4.string().min(1)
5424
6211
  },
5425
6212
  annotations: {
5426
6213
  readOnlyHint: false,
@@ -5434,7 +6221,7 @@ async function createMcpServer() {
5434
6221
  {
5435
6222
  description: "Disable a deployed rule by name.",
5436
6223
  inputSchema: {
5437
- rule_name: z2.string().min(1)
6224
+ rule_name: z4.string().min(1)
5438
6225
  },
5439
6226
  annotations: {
5440
6227
  readOnlyHint: false,
@@ -5470,6 +6257,15 @@ async function createMcpServer() {
5470
6257
  },
5471
6258
  async (uri) => resourceText(resolveResourceUri(uri, "inbox://action-log"), await buildActionLog())
5472
6259
  );
6260
+ server.registerResource(
6261
+ "query-fields",
6262
+ "schema://query-fields",
6263
+ {
6264
+ description: "Field vocabulary, aggregates, and examples for the query_emails analytics tool.",
6265
+ mimeType: "application/json"
6266
+ },
6267
+ async (uri) => resourceText(resolveResourceUri(uri, "schema://query-fields"), QUERY_EMAILS_FIELD_SCHEMA)
6268
+ );
5473
6269
  server.registerResource(
5474
6270
  "deployed-rules",
5475
6271
  "rules://deployed",
@@ -5529,6 +6325,10 @@ async function createMcpServer() {
5529
6325
  async () => promptResult(
5530
6326
  "Review top senders and recommend cleanup actions.",
5531
6327
  [
6328
+ "Step 0 \u2014 Check for past mistakes:",
6329
+ " Call `review_categorized` to see if any recent categorisations look incorrect.",
6330
+ " If anomalies are found, present them first \u2014 fixing past mistakes takes priority over reviewing new senders.",
6331
+ "",
5532
6332
  "Step 1 \u2014 Gather data:",
5533
6333
  " Use `get_noise_senders` for the most actionable noisy senders.",
5534
6334
  " Use `rules://deployed` to check for existing rules covering these senders.",
@@ -5626,6 +6426,10 @@ async function createMcpServer() {
5626
6426
  " FYI \u2014 worth knowing about but no action needed",
5627
6427
  " NOISE \u2014 bulk, promotional, or irrelevant",
5628
6428
  "",
6429
+ "Step 2.5 \u2014 Flag low-confidence items:",
6430
+ ' For any email with `confidence: "low"` in `senderContext`, always categorise it as ACTION REQUIRED.',
6431
+ " Better to surface a false positive than bury a real personal or work email.",
6432
+ "",
5629
6433
  "Step 3 \u2014 Present findings:",
5630
6434
  " List emails grouped by category with: sender, subject, and one-line reason.",
5631
6435
  " For NOISE, suggest a label and whether to archive.",
@@ -5673,6 +6477,14 @@ async function createMcpServer() {
5673
6477
  " For each group show: count, senders involved, sample subjects.",
5674
6478
  " Note confidence level: HIGH (clear pattern), MEDIUM (reasonable guess), LOW (uncertain).",
5675
6479
  " Flag any LOW confidence items for the user to decide.",
6480
+ " Present the confidence breakdown: X HIGH (auto-apply), Y MEDIUM (label only), Z LOW (review queue).",
6481
+ " If any LOW confidence emails are present, note why they were flagged from the `signals` array.",
6482
+ "",
6483
+ "Step 3.5 \u2014 Apply confidence gating:",
6484
+ " HIGH confidence \u2014 safe to apply directly (label, mark_read, archive as appropriate).",
6485
+ " MEDIUM confidence \u2014 apply the category label only. Do not archive. Keep the email visible in the inbox.",
6486
+ " LOW confidence \u2014 apply only the label `inboxctl/Review`. Do not archive or mark read.",
6487
+ " These emails need human review before any further action.",
5676
6488
  "",
5677
6489
  "Step 4 \u2014 Apply with user approval:",
5678
6490
  " Create labels for any new categories (use `create_label`).",
@@ -5688,7 +6500,11 @@ async function createMcpServer() {
5688
6500
  "Step 6 \u2014 Suggest ongoing rules:",
5689
6501
  " For any category with 3+ emails from the same sender, suggest a YAML rule.",
5690
6502
  " This prevents the same categorisation from being needed again.",
5691
- " Use `deploy_rule` after user reviews the YAML."
6503
+ " Use `deploy_rule` after user reviews the YAML.",
6504
+ "",
6505
+ "Step 7 \u2014 Post-categorisation audit:",
6506
+ " After applying actions, call `review_categorized` to check for anomalies.",
6507
+ " If anomalies are found, present them with the option to undo the relevant run."
5692
6508
  ].join("\n")
5693
6509
  )
5694
6510
  );
@@ -5704,7 +6520,7 @@ async function createMcpServer() {
5704
6520
  {
5705
6521
  description: "Get the details of a specific Gmail server-side filter by ID.",
5706
6522
  inputSchema: {
5707
- filter_id: z2.string().min(1).describe("Gmail filter ID")
6523
+ filter_id: z4.string().min(1).describe("Gmail filter ID")
5708
6524
  }
5709
6525
  },
5710
6526
  toolHandler(async ({ filter_id }) => getFilter(filter_id))
@@ -5714,20 +6530,20 @@ async function createMcpServer() {
5714
6530
  {
5715
6531
  description: "Create a Gmail server-side filter that applies automatically to all future incoming mail. Useful for simple, always-on rules (e.g. 'label all mail from newsletter@x.com and archive it'). At least one criteria field and one action field are required. Gmail does not support updating filters \u2014 to change one, delete it and create a new one. For regex matching, OR conditions, snippet matching, or processing existing mail, use YAML rules instead.",
5716
6532
  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)")
6533
+ from: z4.string().optional().describe("Match emails from this address"),
6534
+ to: z4.string().optional().describe("Match emails sent to this address"),
6535
+ subject: z4.string().optional().describe("Match emails with this text in the subject"),
6536
+ query: z4.string().optional().describe("Match using Gmail search syntax (e.g. 'has:attachment')"),
6537
+ negated_query: z4.string().optional().describe("Exclude emails matching this Gmail query"),
6538
+ has_attachment: z4.boolean().optional().describe("Match emails with attachments"),
6539
+ exclude_chats: z4.boolean().optional().describe("Exclude chat messages from matches"),
6540
+ size: z4.number().int().positive().optional().describe("Size threshold in bytes"),
6541
+ size_comparison: z4.enum(["larger", "smaller"]).optional().describe("Use with size: match emails larger or smaller than the threshold"),
6542
+ label: z4.string().optional().describe("Apply this label to matching emails (auto-created if it does not exist)"),
6543
+ archive: z4.boolean().optional().describe("Archive matching emails (remove from inbox)"),
6544
+ mark_read: z4.boolean().optional().describe("Mark matching emails as read"),
6545
+ star: z4.boolean().optional().describe("Star matching emails"),
6546
+ forward: z4.string().email().optional().describe("Forward matching emails to this address (address must be verified in Gmail settings)")
5731
6547
  }
5732
6548
  },
5733
6549
  toolHandler(
@@ -5754,7 +6570,7 @@ async function createMcpServer() {
5754
6570
  {
5755
6571
  description: "Delete a Gmail server-side filter by ID. The filter stops processing future mail immediately. Already-processed mail is not affected. Use list_filters to find filter IDs.",
5756
6572
  inputSchema: {
5757
- filter_id: z2.string().min(1).describe("Gmail filter ID to delete")
6573
+ filter_id: z4.string().min(1).describe("Gmail filter ID to delete")
5758
6574
  }
5759
6575
  },
5760
6576
  toolHandler(async ({ filter_id }) => {
@@ -5852,4 +6668,4 @@ export {
5852
6668
  createMcpServer,
5853
6669
  startMcpServer
5854
6670
  };
5855
- //# sourceMappingURL=chunk-NUN2WRBN.js.map
6671
+ //# sourceMappingURL=chunk-OLL3OA5B.js.map