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