inboxctl 0.3.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.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Most email tools put the intelligence inside the app. `inboxctl` does the opposite: it is the infrastructure layer, and your AI agent is the brain.
10
10
 
11
- Connect any MCP client, including Claude Desktop, Claude Code, or another MCP-compatible agent, and it gets structured access to search your inbox, review sender patterns, detect newsletter noise, triage unread mail, apply labels, archive, create Gmail filters, and manage rules. `inboxctl` handles the Gmail plumbing, audit trail, and undo path.
11
+ Connect any MCP client, including Claude Desktop, Claude Code, or another MCP-compatible agent, and it gets structured access to search your inbox, review sender patterns, analyze uncategorized mail at sender-level, detect newsletter noise, rank unsubscribe opportunities, review categorization anomalies, run structured inbox queries, apply labels, archive, create Gmail filters, and manage rules. `inboxctl` handles the Gmail plumbing, audit trail, and undo path.
12
12
 
13
13
  The CLI and TUI ship alongside MCP so you can always inspect what an agent did, undo it, or run the same work manually.
14
14
 
@@ -18,9 +18,9 @@ Emails are never deleted. The tool can label, archive, mark read, and forward, b
18
18
 
19
19
  | | Count | Examples |
20
20
  |---|---|---|
21
- | **Tools** | 25 | `search_emails`, `archive_emails`, `label_emails`, `mark_read`, `get_top_senders`, `get_newsletter_senders`, `deploy_rule`, `run_rule`, `create_filter`, `undo_run` |
22
- | **Resources** | 6 | `inbox://recent`, `inbox://summary`, `rules://deployed`, `rules://history`, `stats://senders`, `stats://overview` |
23
- | **Prompts** | 5 | `summarize-inbox`, `review-senders`, `find-newsletters`, `suggest-rules`, `triage-inbox` |
21
+ | **Tools** | 32 | `search_emails`, `get_uncategorized_senders`, `batch_apply_actions`, `query_emails`, `get_noise_senders`, `get_unsubscribe_suggestions`, `deploy_rule`, `create_filter`, `undo_run`, `review_categorized` |
22
+ | **Resources** | 8 | `inbox://recent`, `inbox://summary`, `inbox://action-log`, `schema://query-fields`, `rules://deployed`, `rules://history`, `stats://senders`, `stats://overview` |
23
+ | **Prompts** | 6 | `summarize-inbox`, `review-senders`, `find-newsletters`, `suggest-rules`, `triage-inbox`, `categorize-emails` |
24
24
 
25
25
  An agent can read your inbox summary, review your noisiest senders, suggest a YAML rule to handle them, deploy it in dry-run, show you the results, and apply it, all through MCP calls.
26
26
 
@@ -104,11 +104,13 @@ inboxctl demo # launch the seeded demo mailbox
104
104
  ## Features
105
105
 
106
106
  - **MCP server** with the full feature set exposed as tools, resources, and prompts.
107
+ - **Context-efficient sender workflows** with `get_uncategorized_senders` for large inbox categorization without loading every email into an agent context window.
107
108
  - **Rules as code** in YAML, with deploy, dry-run, apply, drift detection, audit logging, and undo.
108
- - **Local-first analytics** on top senders, unread rates, newsletter detection, labels, and volume trends.
109
+ - **Local-first analytics** on top senders, unread rates, newsletter detection, uncategorized senders, noise scoring, unsubscribe impact, anomaly review, labels, and volume trends.
110
+ - **Structured inbox queries** for fixed filters, aggregations, and grouping across the local cache.
109
111
  - **Gmail filter management** for always-on server-side rules on future incoming mail.
110
112
  - **Full audit trail** with before/after state snapshots for reversible actions.
111
- - **Interactive TUI** for inbox triage, email detail, stats, rules, and search.
113
+ - **Interactive TUI** for inbox triage, email detail, expanded stats dashboards, rules, and search.
112
114
  - **Guided setup wizard** for Google Cloud and local OAuth configuration.
113
115
  - **Demo mode** with realistic seeded data for screenshots, recordings, and safe exploration.
114
116
 
@@ -151,6 +153,7 @@ inboxctl sync --full
151
153
  inboxctl inbox -n 20
152
154
  inboxctl search "from:github.com"
153
155
  inboxctl email <id>
156
+ inboxctl thread <thread-id>
154
157
 
155
158
  # actions
156
159
  inboxctl archive <id>
@@ -164,8 +167,14 @@ inboxctl history
164
167
  # analytics
165
168
  inboxctl stats
166
169
  inboxctl stats senders --top 20
170
+ inboxctl stats noise --top 20
167
171
  inboxctl stats newsletters
172
+ inboxctl stats uncategorized --confidence high
173
+ inboxctl stats unsubscribe --top 20
174
+ inboxctl stats anomalies --since 2026-04-01
168
175
  inboxctl stats volume --period week
176
+ inboxctl query --group-by domain --aggregate count unread_rate --sort "count desc"
177
+ inboxctl unsubscribe newsletter@example.com --no-archive
169
178
 
170
179
  # rules
171
180
  inboxctl rules deploy
@@ -2643,6 +2643,14 @@ function getPeriodStart(period = "all", now2 = Date.now()) {
2643
2643
  return null;
2644
2644
  }
2645
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
+ }
2646
2654
  function resolveLabelName(labelId) {
2647
2655
  return SYSTEM_LABEL_NAMES.get(labelId) || getCachedLabelName(labelId) || labelId;
2648
2656
  }
@@ -2654,6 +2662,50 @@ function isLikelyAutomatedSenderAddress(sender) {
2654
2662
  const normalized = sender.trim().toLowerCase();
2655
2663
  return AUTOMATED_ADDRESS_MARKERS.some((marker) => normalized.includes(marker));
2656
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
+ }
2657
2709
  function startOfLocalDay(now2 = Date.now()) {
2658
2710
  const date = new Date(now2);
2659
2711
  date.setHours(0, 0, 0, 0);
@@ -3841,50 +3893,6 @@ function resolveSinceTimestamp2(since) {
3841
3893
  }
3842
3894
  return parsed;
3843
3895
  }
3844
- function computeConfidence(row) {
3845
- const signals = [];
3846
- let score = 0;
3847
- const hasDefinitiveNewsletterSignal = Boolean(row.listUnsubscribe && row.listUnsubscribe.trim()) || Boolean(row.detectionReason?.includes("list_unsubscribe"));
3848
- if (row.listUnsubscribe && row.listUnsubscribe.trim()) {
3849
- signals.push("list_unsubscribe_header");
3850
- score += 3;
3851
- }
3852
- if (row.detectionReason?.includes("list_unsubscribe")) {
3853
- signals.push("newsletter_list_header");
3854
- score += 2;
3855
- }
3856
- if ((row.totalFromSender ?? 0) >= 20) {
3857
- signals.push("high_volume_sender");
3858
- score += 2;
3859
- } else if ((row.totalFromSender ?? 0) >= 5) {
3860
- signals.push("moderate_volume_sender");
3861
- score += 1;
3862
- }
3863
- if (row.detectionReason?.includes("known_sender_pattern")) {
3864
- signals.push("automated_sender_pattern");
3865
- score += 1;
3866
- }
3867
- if (row.detectionReason?.includes("bulk_sender_pattern")) {
3868
- signals.push("bulk_sender_pattern");
3869
- score += 1;
3870
- }
3871
- if ((row.totalFromSender ?? 0) <= 2 && !hasDefinitiveNewsletterSignal) {
3872
- signals.push("rare_sender");
3873
- score -= 3;
3874
- }
3875
- if (!row.detectionReason) {
3876
- signals.push("no_newsletter_signals");
3877
- score -= 2;
3878
- }
3879
- if (!row.detectionReason && !isLikelyAutomatedSenderAddress(row.sender || "")) {
3880
- signals.push("personal_sender_address");
3881
- score -= 2;
3882
- }
3883
- return {
3884
- confidence: score >= 3 ? "high" : score >= 0 ? "medium" : "low",
3885
- signals
3886
- };
3887
- }
3888
3896
  function buildWhereClause(options) {
3889
3897
  const whereParts = [
3890
3898
  `
@@ -3995,13 +4003,265 @@ async function getUncategorizedEmails(options = {}) {
3995
4003
  };
3996
4004
  }
3997
4005
 
3998
- // src/core/stats/unsubscribe.ts
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;
3999
4019
  function toIsoString5(value) {
4000
4020
  if (!value) {
4001
4021
  return null;
4002
4022
  }
4003
4023
  return new Date(value).toISOString();
4004
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
+
4258
+ // src/core/stats/unsubscribe.ts
4259
+ function toIsoString6(value) {
4260
+ if (!value) {
4261
+ return null;
4262
+ }
4263
+ return new Date(value).toISOString();
4264
+ }
4005
4265
  function roundImpactScore(messageCount, unreadRate) {
4006
4266
  return Math.round(messageCount * unreadRate * 10 / 100) / 10;
4007
4267
  }
@@ -4048,8 +4308,8 @@ async function getUnsubscribeSuggestions(options = {}) {
4048
4308
  unreadCount: row.unreadCount,
4049
4309
  unreadRate,
4050
4310
  readRate,
4051
- lastRead: toIsoString5(row.lastRead),
4052
- lastReceived: toIsoString5(row.lastReceived),
4311
+ lastRead: toIsoString6(row.lastRead),
4312
+ lastReceived: toIsoString6(row.lastReceived),
4053
4313
  unsubscribeLink: unsubscribe2.unsubscribeLink,
4054
4314
  unsubscribeMethod: unsubscribe2.unsubscribeMethod,
4055
4315
  impactScore: roundImpactScore(row.messageCount, unreadRate),
@@ -5552,7 +5812,7 @@ async function getSyncStatus() {
5552
5812
 
5553
5813
  // src/mcp/server.ts
5554
5814
  var DAY_MS4 = 24 * 60 * 60 * 1e3;
5555
- var MCP_VERSION = "0.3.0";
5815
+ var MCP_VERSION = "0.4.0";
5556
5816
  var MCP_TOOLS = [
5557
5817
  "search_emails",
5558
5818
  "get_email",
@@ -5571,6 +5831,7 @@ var MCP_TOOLS = [
5571
5831
  "get_sender_stats",
5572
5832
  "get_newsletter_senders",
5573
5833
  "get_uncategorized_emails",
5834
+ "get_uncategorized_senders",
5574
5835
  "review_categorized",
5575
5836
  "query_emails",
5576
5837
  "get_noise_senders",
@@ -6068,6 +6329,31 @@ async function createMcpServer() {
6068
6329
  since
6069
6330
  }))
6070
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
+ );
6071
6357
  server.registerTool(
6072
6358
  "review_categorized",
6073
6359
  {
@@ -6375,9 +6661,15 @@ async function createMcpServer() {
6375
6661
  [
6376
6662
  "First, inspect these data sources:",
6377
6663
  "- `rules://deployed` \u2014 existing rules (avoid duplicates)",
6664
+ "- `query_emails` \u2014 find high-volume domains, unread-heavy clusters, and labeling opportunities",
6378
6665
  "- `get_noise_senders` \u2014 high-volume low-read senders",
6379
6666
  "- `get_newsletter_senders` \u2014 detected newsletters and mailing lists",
6380
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
+ "",
6381
6673
  "For each recommendation, generate complete YAML using this schema:",
6382
6674
  "",
6383
6675
  " name: kebab-case-name # lowercase, hyphens only",
@@ -6454,14 +6746,16 @@ async function createMcpServer() {
6454
6746
  "Categorise uncategorised emails in the user's inbox.",
6455
6747
  [
6456
6748
  "Step 1 \u2014 Gather data:",
6457
- " Use `get_uncategorized_emails` (start with limit 100).",
6458
- " If totalUncategorized is more than 500, ask whether to process the recent batch or paginate through the full backlog.",
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.",
6459
6753
  " Use `get_noise_senders` for sender context.",
6460
6754
  " Use `get_unsubscribe_suggestions` for likely unsubscribe candidates.",
6461
6755
  " Use `get_labels` to see what labels already exist.",
6462
6756
  " Use `rules://deployed` to avoid duplicating existing automation.",
6463
6757
  "",
6464
- "Step 2 \u2014 Assign each email a category:",
6758
+ "Step 2 \u2014 Assign each sender a category:",
6465
6759
  " Receipts \u2014 purchase confirmations, invoices, payment notifications",
6466
6760
  " Shipping \u2014 delivery tracking, dispatch notices, shipping updates",
6467
6761
  " Newsletters \u2014 editorial content, digests, weekly roundups",
@@ -6473,28 +6767,29 @@ async function createMcpServer() {
6473
6767
  " Important \u2014 personal or work email requiring attention",
6474
6768
  "",
6475
6769
  "Step 3 \u2014 Present the categorisation plan:",
6476
- " Group emails by assigned category.",
6477
- " 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.",
6478
6772
  " Note confidence level: HIGH (clear pattern), MEDIUM (reasonable guess), LOW (uncertain).",
6479
- " Flag any LOW confidence items for the user to decide.",
6773
+ " Flag any LOW confidence senders for the user to decide.",
6480
6774
  " 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.",
6775
+ " If any LOW confidence senders are present, note why they were flagged from the `signals` array.",
6482
6776
  "",
6483
6777
  "Step 3.5 \u2014 Apply confidence gating:",
6484
6778
  " HIGH confidence \u2014 safe to apply directly (label, mark_read, archive as appropriate).",
6485
6779
  " MEDIUM confidence \u2014 apply the category label only. Do not archive. Keep the email visible in the inbox.",
6486
6780
  " 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.",
6781
+ " These senders need human review before any further action.",
6488
6782
  "",
6489
6783
  "Step 4 \u2014 Apply with user approval:",
6490
6784
  " Create labels for any new categories (use `create_label`).",
6491
- " 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`.",
6492
6786
  " For Newsletters and Promotions with high unread rates, suggest mark_read + archive or `unsubscribe` when a link is available.",
6493
6787
  " For Receipts/Shipping/Notifications, suggest mark_read only (keep in inbox).",
6494
6788
  " For Important, do not mark read or archive.",
6495
6789
  "",
6496
6790
  "Step 5 \u2014 Paginate if needed:",
6497
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.",
6498
6793
  " Reuse the same sender categorisations on later pages instead of re-evaluating known senders.",
6499
6794
  "",
6500
6795
  "Step 6 \u2014 Suggest ongoing rules:",
@@ -6636,10 +6931,17 @@ export {
6636
6931
  markUnread,
6637
6932
  forwardEmail,
6638
6933
  undoRun,
6639
- getLabelDistribution,
6934
+ getThread,
6935
+ unsubscribe,
6640
6936
  getNewsletters,
6937
+ reviewCategorized,
6938
+ getLabelDistribution,
6939
+ getNoiseSenders,
6940
+ queryEmails,
6641
6941
  getTopSenders,
6642
6942
  getSenderStats,
6943
+ getUncategorizedSenders,
6944
+ getUnsubscribeSuggestions,
6643
6945
  getVolumeByPeriod,
6644
6946
  getInboxOverview,
6645
6947
  loadRuleFile,
@@ -6668,4 +6970,4 @@ export {
6668
6970
  createMcpServer,
6669
6971
  startMcpServer
6670
6972
  };
6671
- //# sourceMappingURL=chunk-OLL3OA5B.js.map
6973
+ //# sourceMappingURL=chunk-2PN3TSVQ.js.map