inboxctl 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  getLabelDistribution,
32
32
  getMessage,
33
33
  getNewsletters,
34
+ getNoiseSenders,
34
35
  getOAuthReadiness,
35
36
  getRecentEmails,
36
37
  getRecentRuns,
@@ -39,7 +40,10 @@ import {
39
40
  getSenderStats,
40
41
  getSqlite,
41
42
  getSyncStatus,
43
+ getThread,
42
44
  getTopSenders,
45
+ getUncategorizedSenders,
46
+ getUnsubscribeSuggestions,
43
47
  getVolumeByPeriod,
44
48
  incrementalSync,
45
49
  initializeDb,
@@ -53,7 +57,9 @@ import {
53
57
  loadTokens,
54
58
  markRead,
55
59
  markUnread,
60
+ queryEmails,
56
61
  reconcileCacheForAuthenticatedAccount,
62
+ reviewCategorized,
57
63
  runAllRules,
58
64
  runRule,
59
65
  saveTokens,
@@ -61,8 +67,9 @@ import {
61
67
  startMcpServer,
62
68
  startOAuthFlow,
63
69
  syncLabels,
64
- undoRun
65
- } from "./chunk-NUN2WRBN.js";
70
+ undoRun,
71
+ unsubscribe
72
+ } from "./chunk-2PN3TSVQ.js";
66
73
 
67
74
  // src/cli.ts
68
75
  import { Command } from "commander";
@@ -254,7 +261,7 @@ function getScreenGuide(screen, focus) {
254
261
  case "email":
255
262
  return "Esc back \u2022 j/k scroll \u2022 a archive \u2022 l label \u2022 r toggle read";
256
263
  case "stats":
257
- return "Esc back \u2022 s senders \u2022 l labels \u2022 n newsletters";
264
+ return "Esc back \u2022 s senders \u2022 l labels \u2022 n newsletters \u2022 o noise \u2022 c uncategorized \u2022 u unsubscribe";
258
265
  case "rules":
259
266
  return `Esc back \u2022 Tab switch ${focus === "history" ? "history" : "rules"} focus \u2022 d deploy \u2022 e toggle \u2022 r dry-run \u2022 R apply \u2022 u undo`;
260
267
  case "search":
@@ -525,6 +532,9 @@ function App({ initialSync = true }) {
525
532
  const [statsSenders, setStatsSenders] = useState([]);
526
533
  const [statsLabels, setStatsLabels] = useState([]);
527
534
  const [statsNewsletters, setStatsNewsletters] = useState([]);
535
+ const [statsNoise, setStatsNoise] = useState([]);
536
+ const [statsUncategorized, setStatsUncategorized] = useState(null);
537
+ const [statsUnsubscribe, setStatsUnsubscribe] = useState([]);
528
538
  const [statsVolume, setStatsVolume] = useState([]);
529
539
  const [rulesLoading, setRulesLoading] = useState(false);
530
540
  const [rulesFocus, setRulesFocus] = useState("rules");
@@ -582,17 +592,23 @@ function App({ initialSync = true }) {
582
592
  async function loadStats() {
583
593
  setStatsLoading(true);
584
594
  try {
585
- const [overview, senders, labels2, newsletters, volume] = await Promise.all([
595
+ const [overview, senders, labels2, newsletters, noise, uncategorized, unsubscribe2, volume] = await Promise.all([
586
596
  getInboxOverview(),
587
597
  getTopSenders({ limit: 10 }),
588
598
  getLabelDistribution(),
589
599
  getNewsletters({ minMessages: 1 }),
600
+ getNoiseSenders({ limit: 10 }),
601
+ getUncategorizedSenders({ limit: 10 }),
602
+ getUnsubscribeSuggestions({ limit: 10 }),
590
603
  getVolumeByPeriod("day", { start: Date.now() - 30 * 24 * 60 * 60 * 1e3, end: Date.now() })
591
604
  ]);
592
605
  setStatsOverview(overview);
593
606
  setStatsSenders(senders);
594
607
  setStatsLabels(labels2.slice(0, 10));
595
608
  setStatsNewsletters(newsletters.slice(0, 10));
609
+ setStatsNoise(noise.senders);
610
+ setStatsUncategorized(uncategorized);
611
+ setStatsUnsubscribe(unsubscribe2.suggestions);
596
612
  setStatsVolume(volume.slice(-7));
597
613
  } catch (error) {
598
614
  pushFlash("error", error instanceof Error ? error.message : String(error));
@@ -1023,6 +1039,18 @@ function App({ initialSync = true }) {
1023
1039
  }
1024
1040
  if (input2 === "n") {
1025
1041
  setStatsTab("newsletters");
1042
+ return;
1043
+ }
1044
+ if (input2 === "o") {
1045
+ setStatsTab("noise");
1046
+ return;
1047
+ }
1048
+ if (input2 === "c") {
1049
+ setStatsTab("uncategorized");
1050
+ return;
1051
+ }
1052
+ if (input2 === "u") {
1053
+ setStatsTab("unsubscribe");
1026
1054
  }
1027
1055
  return;
1028
1056
  }
@@ -1261,7 +1289,7 @@ function App({ initialSync = true }) {
1261
1289
  Panel,
1262
1290
  {
1263
1291
  title: "Stats Dashboard",
1264
- subtitle: "s senders \u2022 l labels \u2022 n newsletters \u2022 Esc back",
1292
+ subtitle: "s senders \u2022 l labels \u2022 n newsletters \u2022 o noise \u2022 c uncategorized \u2022 u unsubscribe \u2022 Esc back",
1265
1293
  accent: "yellow",
1266
1294
  children: statsLoading ? /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
1267
1295
  /* @__PURE__ */ jsx(Spinner, { type: "dots" }),
@@ -1307,7 +1335,13 @@ function App({ initialSync = true }) {
1307
1335
  /* @__PURE__ */ jsx(Text, { children: " " }),
1308
1336
  /* @__PURE__ */ jsx(Text, { color: statsTab === "labels" ? "cyan" : "gray", children: "[Labels]" }),
1309
1337
  /* @__PURE__ */ jsx(Text, { children: " " }),
1310
- /* @__PURE__ */ jsx(Text, { color: statsTab === "newsletters" ? "cyan" : "gray", children: "[Newsletters]" })
1338
+ /* @__PURE__ */ jsx(Text, { color: statsTab === "newsletters" ? "cyan" : "gray", children: "[Newsletters]" }),
1339
+ /* @__PURE__ */ jsx(Text, { children: " " }),
1340
+ /* @__PURE__ */ jsx(Text, { color: statsTab === "noise" ? "cyan" : "gray", children: "[Noise]" }),
1341
+ /* @__PURE__ */ jsx(Text, { children: " " }),
1342
+ /* @__PURE__ */ jsx(Text, { color: statsTab === "uncategorized" ? "cyan" : "gray", children: "[Uncategorized]" }),
1343
+ /* @__PURE__ */ jsx(Text, { children: " " }),
1344
+ /* @__PURE__ */ jsx(Text, { color: statsTab === "unsubscribe" ? "cyan" : "gray", children: "[Unsubscribe]" })
1311
1345
  ] }),
1312
1346
  statsTab === "senders" ? /* @__PURE__ */ jsx(
1313
1347
  Table,
@@ -1349,6 +1383,70 @@ function App({ initialSync = true }) {
1349
1383
  emptyMessage: "No newsletters detected."
1350
1384
  }
1351
1385
  ) : null,
1386
+ statsTab === "noise" ? /* @__PURE__ */ jsx(
1387
+ Table,
1388
+ {
1389
+ title: "Noise Senders",
1390
+ headers: ["SENDER", "EMAILS", "UNREAD%", "SCORE", "UNSUB"],
1391
+ rows: statsNoise.map((sender) => [
1392
+ truncate(sender.name || sender.email, 24),
1393
+ String(sender.messageCount),
1394
+ formatPercent(sender.unreadRate),
1395
+ String(sender.noiseScore),
1396
+ sender.hasUnsubscribeLink ? "Yes" : "No"
1397
+ ]),
1398
+ emptyMessage: "No noisy senders detected."
1399
+ }
1400
+ ) : null,
1401
+ statsTab === "uncategorized" ? /* @__PURE__ */ jsxs(Fragment, { children: [
1402
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1403
+ /* @__PURE__ */ jsxs(Text, { children: [
1404
+ "Uncategorized: ",
1405
+ statsUncategorized?.totalEmails ?? 0,
1406
+ " emails from ",
1407
+ statsUncategorized?.totalSenders ?? 0,
1408
+ " senders"
1409
+ ] }),
1410
+ /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
1411
+ "High ",
1412
+ statsUncategorized?.summary.byConfidence.high.senders ?? 0,
1413
+ " \u2022 Medium ",
1414
+ statsUncategorized?.summary.byConfidence.medium.senders ?? 0,
1415
+ " \u2022 Low ",
1416
+ statsUncategorized?.summary.byConfidence.low.senders ?? 0
1417
+ ] })
1418
+ ] }),
1419
+ /* @__PURE__ */ jsx(
1420
+ Table,
1421
+ {
1422
+ title: "Uncategorized Senders",
1423
+ headers: ["SENDER", "EMAILS", "UNREAD%", "CONF", "SIGNALS"],
1424
+ rows: (statsUncategorized?.senders || []).map((sender) => [
1425
+ truncate(sender.name || sender.sender, 20),
1426
+ String(sender.emailCount),
1427
+ formatPercent(sender.unreadRate),
1428
+ sender.confidence.toUpperCase(),
1429
+ truncate(sender.signals.join(","), 22)
1430
+ ]),
1431
+ emptyMessage: "No uncategorized senders detected."
1432
+ }
1433
+ )
1434
+ ] }) : null,
1435
+ statsTab === "unsubscribe" ? /* @__PURE__ */ jsx(
1436
+ Table,
1437
+ {
1438
+ title: "Unsubscribe Candidates",
1439
+ headers: ["SENDER", "EMAILS", "UNREAD%", "IMPACT", "METHOD"],
1440
+ rows: statsUnsubscribe.map((sender) => [
1441
+ truncate(sender.name || sender.email, 24),
1442
+ String(sender.allTimeMessageCount),
1443
+ formatPercent(sender.unreadRate),
1444
+ String(sender.impactScore),
1445
+ sender.unsubscribeMethod
1446
+ ]),
1447
+ emptyMessage: "No unsubscribe candidates detected."
1448
+ }
1449
+ ) : null,
1352
1450
  /* @__PURE__ */ jsx(
1353
1451
  Table,
1354
1452
  {
@@ -3383,6 +3481,22 @@ function pad2(value, width) {
3383
3481
  function printSection(title) {
3384
3482
  console.log(ui.bold(title));
3385
3483
  }
3484
+ function printSimpleTable(headers, rows) {
3485
+ const widths = headers.map(
3486
+ (header, index) => Math.max(
3487
+ header.length,
3488
+ ...rows.map((row) => stripAnsi2(row[index] || "").length)
3489
+ )
3490
+ );
3491
+ console.log(
3492
+ headers.map((header, index) => pad2(ui.dim(header), widths[index] || header.length)).join(" ")
3493
+ );
3494
+ for (const row of rows) {
3495
+ console.log(
3496
+ row.map((cell, index) => pad2(cell, widths[index] || cell.length)).join(" ")
3497
+ );
3498
+ }
3499
+ }
3386
3500
  function printKeyValue(label, value) {
3387
3501
  console.log(`${ui.dim(`${label}:`)} ${value}`);
3388
3502
  }
@@ -3559,6 +3673,13 @@ function formatPercent2(value) {
3559
3673
  const normalized = Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, "");
3560
3674
  return `${normalized}%`;
3561
3675
  }
3676
+ function maybePrintJson(enabled, value) {
3677
+ if (!enabled) {
3678
+ return false;
3679
+ }
3680
+ console.log(JSON.stringify(value, null, 2));
3681
+ return true;
3682
+ }
3562
3683
  function formatSenderIdentity(name, email, width) {
3563
3684
  const identity = name && name !== email ? `${name} <${email}>` : email;
3564
3685
  return pad2(truncate2(identity, width), width);
@@ -3697,6 +3818,149 @@ function printVolumeTable(label, points) {
3697
3818
  );
3698
3819
  }
3699
3820
  }
3821
+ function formatConfidence(confidence) {
3822
+ const upper = confidence.toUpperCase();
3823
+ switch (confidence) {
3824
+ case "high":
3825
+ return ui.green(upper);
3826
+ case "medium":
3827
+ return ui.yellow(upper);
3828
+ case "low":
3829
+ return ui.red(upper);
3830
+ }
3831
+ }
3832
+ function formatSeverity(severity) {
3833
+ return severity === "high" ? ui.red(severity.toUpperCase()) : ui.yellow(severity.toUpperCase());
3834
+ }
3835
+ function formatYesNo(value) {
3836
+ return value ? ui.green("Yes") : ui.dim("No");
3837
+ }
3838
+ function formatImpactLevel(score) {
3839
+ if (score >= 25) {
3840
+ return ui.red("HIGH");
3841
+ }
3842
+ if (score >= 10) {
3843
+ return ui.yellow("MEDIUM");
3844
+ }
3845
+ return ui.green("LOW");
3846
+ }
3847
+ function formatGenericCell(value) {
3848
+ if (value === null || value === void 0) {
3849
+ return "-";
3850
+ }
3851
+ if (typeof value === "boolean") {
3852
+ return value ? "true" : "false";
3853
+ }
3854
+ if (typeof value === "number") {
3855
+ return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, "");
3856
+ }
3857
+ return String(value);
3858
+ }
3859
+ function printNoiseSendersTable(result) {
3860
+ printSection(`Noise Senders (${result.senders.length})`);
3861
+ printSimpleTable(
3862
+ ["SENDER", "EMAILS", "UNREAD", "SCORE", "UNSUB?"],
3863
+ result.senders.map((sender) => [
3864
+ truncate2(sender.name || sender.email, 34),
3865
+ String(sender.messageCount),
3866
+ formatPercent2(sender.unreadRate),
3867
+ String(sender.noiseScore),
3868
+ formatYesNo(sender.hasUnsubscribeLink)
3869
+ ])
3870
+ );
3871
+ }
3872
+ function printUncategorizedSendersTable(result) {
3873
+ printSection(`Uncategorized: ${result.totalEmails} emails from ${result.totalSenders} senders`);
3874
+ console.log("");
3875
+ printSimpleTable(
3876
+ ["CONFIDENCE", "SENDERS", "EMAILS"],
3877
+ [
3878
+ ["HIGH", String(result.summary.byConfidence.high.senders), String(result.summary.byConfidence.high.emails)],
3879
+ ["MEDIUM", String(result.summary.byConfidence.medium.senders), String(result.summary.byConfidence.medium.emails)],
3880
+ ["LOW", String(result.summary.byConfidence.low.senders), String(result.summary.byConfidence.low.emails)]
3881
+ ]
3882
+ );
3883
+ if (result.summary.topDomains.length > 0) {
3884
+ console.log("");
3885
+ printSection("Top Domains");
3886
+ printSimpleTable(
3887
+ ["DOMAIN", "EMAILS", "SENDERS"],
3888
+ result.summary.topDomains.map((domain) => [
3889
+ domain.domain,
3890
+ String(domain.emails),
3891
+ String(domain.senders)
3892
+ ])
3893
+ );
3894
+ }
3895
+ console.log("");
3896
+ printSection("Top Senders");
3897
+ printSimpleTable(
3898
+ ["SENDER", "EMAILS", "UNREAD", "CONFIDENCE", "SIGNALS"],
3899
+ result.senders.map((sender) => [
3900
+ truncate2(sender.name && sender.name !== sender.sender ? `${sender.name} <${sender.sender}>` : sender.sender, 36),
3901
+ String(sender.emailCount),
3902
+ formatPercent2(sender.unreadRate),
3903
+ formatConfidence(sender.confidence),
3904
+ truncate2(sender.signals.join(", "), 40)
3905
+ ])
3906
+ );
3907
+ }
3908
+ function printUnsubscribeSuggestionsTable(result) {
3909
+ printSection(`Unsubscribe Suggestions (${result.suggestions.length} candidates)`);
3910
+ printSimpleTable(
3911
+ ["SENDER", "EMAILS", "UNREAD", "IMPACT", "METHOD"],
3912
+ result.suggestions.map((sender) => [
3913
+ truncate2(sender.name || sender.email, 34),
3914
+ String(sender.allTimeMessageCount),
3915
+ formatPercent2(sender.unreadRate),
3916
+ formatImpactLevel(sender.impactScore),
3917
+ sender.unsubscribeMethod
3918
+ ])
3919
+ );
3920
+ }
3921
+ function printAnomaliesTable(result) {
3922
+ printSection(result.summary);
3923
+ if (result.anomalies.length === 0) {
3924
+ return;
3925
+ }
3926
+ console.log("");
3927
+ printSimpleTable(
3928
+ ["SEVERITY", "SENDER", "SUBJECT", "LABEL", "RULE"],
3929
+ result.anomalies.map((anomaly) => [
3930
+ formatSeverity(anomaly.severity),
3931
+ truncate2(anomaly.from, 24),
3932
+ truncate2(anomaly.subject || "(no subject)", 32),
3933
+ truncate2(anomaly.assignedLabel, 14),
3934
+ anomaly.rule
3935
+ ])
3936
+ );
3937
+ }
3938
+ function printQueryResult(result) {
3939
+ printSection(`Query Results (${result.rows.length} of ${result.totalRows})`);
3940
+ if (result.rows.length === 0) {
3941
+ console.log("No rows matched that query.");
3942
+ return;
3943
+ }
3944
+ const headers = Object.keys(result.rows[0] || {});
3945
+ printSimpleTable(
3946
+ headers.map((header) => header.toUpperCase()),
3947
+ result.rows.map((row) => headers.map((header) => formatGenericCell(row[header])))
3948
+ );
3949
+ }
3950
+ function printThreadResult(result) {
3951
+ printSection(`Thread ${ui.dim(result.id)}`);
3952
+ printKeyValue("messages", String(result.messages.length));
3953
+ for (const message of result.messages) {
3954
+ console.log("");
3955
+ printSection(message.subject || "(no subject)");
3956
+ printKeyValue("from", message.fromAddress);
3957
+ printKeyValue("to", message.toAddresses.join(", "));
3958
+ printKeyValue("date", new Date(message.date).toISOString());
3959
+ printKeyValue("labels", message.labelIds.join(", ") || "-");
3960
+ console.log("");
3961
+ console.log(message.textPlain || message.body || message.snippet);
3962
+ }
3963
+ }
3700
3964
  function printSenderDetail(detail) {
3701
3965
  printSection(detail.query);
3702
3966
  printKeyValue("type", detail.type);
@@ -4027,7 +4291,7 @@ function printDriftReport(result) {
4027
4291
  );
4028
4292
  }
4029
4293
  }
4030
- program.name("inboxctl").description("CLI email management with MCP server, rules-as-code, and TUI").version("0.1.0").option("--demo", "Launch the seeded demo mailbox").option("--no-sync", "Launch the TUI without running the initial background sync");
4294
+ program.name("inboxctl").description("CLI email management with MCP server, rules-as-code, and TUI").version("0.4.0").option("--demo", "Launch the seeded demo mailbox").option("--no-sync", "Launch the TUI without running the initial background sync");
4031
4295
  program.command("setup").description("Run the interactive Google Cloud and OAuth setup wizard").option("--skip-gcloud", "Skip gcloud detection and API enablement").option("--project <id>", "Pre-set the Google Cloud project ID").action(async (options) => {
4032
4296
  try {
4033
4297
  await runSetupWizard({
@@ -4141,6 +4405,20 @@ program.command("email <id>").description("View a single email").action(async (i
4141
4405
  console.log("");
4142
4406
  console.log(email.textPlain || email.body || email.snippet);
4143
4407
  });
4408
+ program.command("thread <id>").description("View a full email thread").action(async (id) => {
4409
+ const status = await loadRuntimeStatus();
4410
+ if (!status.gmailReady) {
4411
+ await requireLiveGmailReadiness("thread");
4412
+ return;
4413
+ }
4414
+ try {
4415
+ const thread = await getThread(id);
4416
+ printThreadResult(thread);
4417
+ } catch (error) {
4418
+ console.log(error instanceof Error ? error.message : String(error));
4419
+ process.exitCode = 1;
4420
+ }
4421
+ });
4144
4422
  program.command("archive [id]").description("Archive email(s)").option("-q, --query <query>", "Archive all emails matching query").action(async (id, options) => {
4145
4423
  const status = await loadRuntimeStatus();
4146
4424
  if (!status.gmailReady) {
@@ -4270,6 +4548,100 @@ program.command("history").description("Show recent run history").option("-n, --
4270
4548
  }
4271
4549
  printHistoryTable(runs);
4272
4550
  });
4551
+ program.command("query").description("Run structured analytics queries over the cached inbox").option("--group-by <dimension>", "Group by sender|domain|label|year_month|year_week|day_of_week|is_read|is_newsletter").option("--aggregate <values...>", "Aggregates to return (count, unread_count, unread_rate, newest, oldest, sender_count)").option("--from <address>", "Exact sender email").option("--from-contains <text>", "Partial sender match").option("--domain <domain>", "Exact sender domain").option("--domain-contains <text>", "Partial sender domain match").option("--subject-contains <text>", "Partial subject match").option("--since <date>", "Filter to emails on or after this ISO date").option("--before <date>", "Filter to emails on or before this ISO date").option("--read", "Only read emails").option("--unread", "Only unread emails").option("--newsletter", "Only newsletter senders").option("--without-newsletter", "Exclude newsletter senders").option("--label <name>", "Only emails with a specific label").option("--has-label", "Only emails with any user label").option("--without-label", "Only emails with no user labels").option("--has-unsubscribe", "Only emails with a List-Unsubscribe header").option("--min-sender-messages <number>", "Only senders with at least this many total emails").option("--having-count-gte <number>", "Require grouped count to be >= this value").option("--having-count-lte <number>", "Require grouped count to be <= this value").option("--having-unread-rate-gte <number>", "Require grouped unread_rate to be >= this value").option("--sort <value>", 'Sort expression, for example: "count desc"').option("--limit <number>", "Maximum rows to return", "50").option("--json", "Output raw JSON").action(async (options) => {
4552
+ try {
4553
+ if (options.read && options.unread) {
4554
+ throw new Error("Use only one of --read or --unread.");
4555
+ }
4556
+ if (options.newsletter && options.withoutNewsletter) {
4557
+ throw new Error("Use only one of --newsletter or --without-newsletter.");
4558
+ }
4559
+ if (options.hasLabel && options.withoutLabel) {
4560
+ throw new Error("Use only one of --has-label or --without-label.");
4561
+ }
4562
+ const result = await queryEmails({
4563
+ filters: {
4564
+ ...options.from ? { from: options.from } : {},
4565
+ ...options.fromContains ? { from_contains: options.fromContains } : {},
4566
+ ...options.domain ? { domain: options.domain } : {},
4567
+ ...options.domainContains ? { domain_contains: options.domainContains } : {},
4568
+ ...options.subjectContains ? { subject_contains: options.subjectContains } : {},
4569
+ ...options.since ? { date_after: options.since } : {},
4570
+ ...options.before ? { date_before: options.before } : {},
4571
+ ...options.read ? { is_read: true } : {},
4572
+ ...options.unread ? { is_read: false } : {},
4573
+ ...options.newsletter ? { is_newsletter: true } : {},
4574
+ ...options.withoutNewsletter ? { is_newsletter: false } : {},
4575
+ ...options.label ? { label: options.label } : {},
4576
+ ...options.hasLabel ? { has_label: true } : {},
4577
+ ...options.withoutLabel ? { has_label: false } : {},
4578
+ ...options.hasUnsubscribe ? { has_unsubscribe: true } : {},
4579
+ ...options.minSenderMessages ? { min_sender_messages: parseIntegerOption(options.minSenderMessages, "min-sender-messages") } : {}
4580
+ },
4581
+ ...options.groupBy ? { group_by: options.groupBy } : {},
4582
+ ...options.aggregate ? { aggregates: options.aggregate } : {},
4583
+ ...options.sort ? { order_by: options.sort } : {},
4584
+ ...options.havingCountGte || options.havingCountLte || options.havingUnreadRateGte ? {
4585
+ having: {
4586
+ ...options.havingCountGte || options.havingCountLte ? {
4587
+ count: {
4588
+ ...options.havingCountGte ? { gte: parseIntegerOption(options.havingCountGte, "having-count-gte") } : {},
4589
+ ...options.havingCountLte ? { lte: parseIntegerOption(options.havingCountLte, "having-count-lte") } : {}
4590
+ }
4591
+ } : {},
4592
+ ...options.havingUnreadRateGte ? {
4593
+ unread_rate: {
4594
+ gte: parsePercentOption(options.havingUnreadRateGte, "having-unread-rate-gte")
4595
+ }
4596
+ } : {}
4597
+ }
4598
+ } : {},
4599
+ limit: parseIntegerOption(options.limit, "limit")
4600
+ });
4601
+ if (maybePrintJson(options.json, result)) {
4602
+ return;
4603
+ }
4604
+ printQueryResult(result);
4605
+ } catch (error) {
4606
+ console.log(error instanceof Error ? error.message : String(error));
4607
+ process.exitCode = 1;
4608
+ }
4609
+ });
4610
+ program.command("unsubscribe <sender>").description("Return an unsubscribe target for a sender and optionally archive or label existing mail").option("--no-archive", "Only return the unsubscribe link, do not archive existing mail").option("--label <name>", "Label existing emails while unsubscribing").action(async (sender, options) => {
4611
+ if (options.archive || options.label) {
4612
+ const status = await loadRuntimeStatus();
4613
+ if (!status.gmailReady) {
4614
+ await requireLiveGmailReadiness("unsubscribe");
4615
+ return;
4616
+ }
4617
+ }
4618
+ try {
4619
+ const result = await unsubscribe({
4620
+ senderEmail: sender,
4621
+ alsoArchive: options.archive,
4622
+ alsoLabel: options.label
4623
+ });
4624
+ printSection(`Unsubscribing from ${result.sender}`);
4625
+ printKeyValue("messages", String(result.messageCount));
4626
+ printKeyValue("archived", String(result.archivedCount));
4627
+ printKeyValue("labeled", String(result.labeledCount));
4628
+ if (result.runId) {
4629
+ printKeyValue("runId", `${ui.dim(result.runId)} (undo with: inboxctl undo ${result.runId})`);
4630
+ }
4631
+ printKeyValue("method", result.unsubscribeMethod);
4632
+ console.log("");
4633
+ console.log(result.instruction);
4634
+ console.log("");
4635
+ console.log(result.unsubscribeLink);
4636
+ if (options.archive) {
4637
+ console.log("");
4638
+ console.log(`Tip: Create a Gmail filter to auto-archive future mail: inboxctl filters create --from ${result.sender} --archive`);
4639
+ }
4640
+ } catch (error) {
4641
+ console.log(error instanceof Error ? error.message : String(error));
4642
+ process.exitCode = 1;
4643
+ }
4644
+ });
4273
4645
  var labels = program.command("labels").description("Manage Gmail labels");
4274
4646
  labels.command("list").description("List all Gmail labels").action(async () => {
4275
4647
  const status = await loadRuntimeStatus();
@@ -4348,6 +4720,90 @@ stats.command("senders").description("Top senders by volume").option("--top <num
4348
4720
  process.exitCode = 1;
4349
4721
  }
4350
4722
  });
4723
+ stats.command("noise").description("High-noise senders ranked by noise score").option("--top <number>", "Number of senders", "20").option("--sort <mode>", "Sort by noise_score|all_time_noise_score|message_count|unread_rate", "noise_score").option("--min-score <number>", "Minimum noise score", "5").option("--active-days <number>", "Only consider recent activity within this many days", "90").option("--json", "Output raw JSON").action(async (options) => {
4724
+ try {
4725
+ const result = await getNoiseSenders({
4726
+ limit: parseIntegerOption(options.top, "top"),
4727
+ sortBy: options.sort,
4728
+ minNoiseScore: Number(options.minScore),
4729
+ activeDays: parseIntegerOption(options.activeDays, "active-days")
4730
+ });
4731
+ if (maybePrintJson(options.json, result)) {
4732
+ return;
4733
+ }
4734
+ if (result.senders.length === 0) {
4735
+ console.log("No noisy senders matched that filter.");
4736
+ return;
4737
+ }
4738
+ printNoiseSendersTable(result);
4739
+ } catch (error) {
4740
+ console.log(error instanceof Error ? error.message : String(error));
4741
+ process.exitCode = 1;
4742
+ }
4743
+ });
4744
+ stats.command("uncategorized").description("Summarize uncategorized emails by sender").option("--top <number>", "Number of senders", "20").option("--confidence <level>", "Filter by confidence: high|medium|low").option("--min-emails <number>", "Minimum uncategorized emails per sender", "1").option("--since <date>", "Only include uncategorized emails on or after this ISO date").option("--sort <mode>", "Sort by email_count|newest|unread_rate", "email_count").option("--json", "Output raw JSON").action(async (options) => {
4745
+ try {
4746
+ const result = await getUncategorizedSenders({
4747
+ limit: parseIntegerOption(options.top, "top"),
4748
+ confidence: options.confidence,
4749
+ minEmails: parseIntegerOption(options.minEmails, "min-emails"),
4750
+ since: options.since,
4751
+ sortBy: options.sort
4752
+ });
4753
+ if (maybePrintJson(options.json, result)) {
4754
+ return;
4755
+ }
4756
+ if (result.totalSenders === 0) {
4757
+ console.log("No uncategorized senders matched that filter.");
4758
+ return;
4759
+ }
4760
+ printUncategorizedSendersTable(result);
4761
+ } catch (error) {
4762
+ console.log(error instanceof Error ? error.message : String(error));
4763
+ process.exitCode = 1;
4764
+ }
4765
+ });
4766
+ stats.command("unsubscribe").description("Rank unsubscribe candidates by impact").option("--top <number>", "Number of senders", "20").option("--min-emails <number>", "Minimum emails from a sender", "5").option("--unread-only-senders", "Only show senders where every email is unread").option("--json", "Output raw JSON").action(async (options) => {
4767
+ try {
4768
+ const result = await getUnsubscribeSuggestions({
4769
+ limit: parseIntegerOption(options.top, "top"),
4770
+ minMessages: parseIntegerOption(options.minEmails, "min-emails"),
4771
+ unreadOnlySenders: options.unreadOnlySenders
4772
+ });
4773
+ if (maybePrintJson(options.json, result)) {
4774
+ return;
4775
+ }
4776
+ if (result.suggestions.length === 0) {
4777
+ console.log("No unsubscribe suggestions matched that filter.");
4778
+ return;
4779
+ }
4780
+ printUnsubscribeSuggestionsTable(result);
4781
+ console.log("");
4782
+ console.log("Run `inboxctl unsubscribe <sender>` to process a specific sender.");
4783
+ } catch (error) {
4784
+ console.log(error instanceof Error ? error.message : String(error));
4785
+ process.exitCode = 1;
4786
+ }
4787
+ });
4788
+ stats.command("anomalies").description("Review recently categorized emails for potential misclassifications").option("--since <date>", "Only review items on or after this ISO date").option("--limit <number>", "Maximum anomalies to return", "20").option("--json", "Output raw JSON").action(async (options) => {
4789
+ try {
4790
+ const result = await reviewCategorized({
4791
+ ...options.since ? { since: options.since } : {},
4792
+ limit: parseIntegerOption(options.limit, "limit")
4793
+ });
4794
+ if (maybePrintJson(options.json, result)) {
4795
+ return;
4796
+ }
4797
+ printAnomaliesTable(result);
4798
+ if (result.anomalies.length > 0) {
4799
+ console.log("");
4800
+ console.log("Undo a run with `inboxctl undo <run-id>`.");
4801
+ }
4802
+ } catch (error) {
4803
+ console.log(error instanceof Error ? error.message : String(error));
4804
+ process.exitCode = 1;
4805
+ }
4806
+ });
4351
4807
  stats.command("newsletters").description("Detected newsletters and mailing lists").option("--min-unread <percent>", "Minimum unread rate filter").action(async (options) => {
4352
4808
  try {
4353
4809
  const minUnread = parsePercentOption(options.minUnread, "min-unread");