inboxctl 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-EY6VV43S.js → chunk-OLL3OA5B.js} +2069 -142
- package/dist/chunk-OLL3OA5B.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-EY6VV43S.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";
|
|
@@ -436,7 +436,7 @@ function getDatabase() {
|
|
|
436
436
|
return getSqlite(config.dbPath);
|
|
437
437
|
}
|
|
438
438
|
function ensureValidSourceType(sourceType) {
|
|
439
|
-
if (sourceType !== "manual" && sourceType !== "rule") {
|
|
439
|
+
if (sourceType !== "manual" && sourceType !== "rule" && sourceType !== "unsubscribe") {
|
|
440
440
|
throw new Error(`Invalid execution source type: ${sourceType}`);
|
|
441
441
|
}
|
|
442
442
|
}
|
|
@@ -2041,6 +2041,322 @@ async function undoRun(runId) {
|
|
|
2041
2041
|
};
|
|
2042
2042
|
}
|
|
2043
2043
|
|
|
2044
|
+
// src/core/gmail/batch.ts
|
|
2045
|
+
var MESSAGE_FETCH_HEADERS2 = ["From", "To", "Subject", "Date", "List-Unsubscribe"];
|
|
2046
|
+
function makePlaceholders2(values) {
|
|
2047
|
+
return values.map(() => "?").join(", ");
|
|
2048
|
+
}
|
|
2049
|
+
function parseJsonArray2(raw) {
|
|
2050
|
+
if (!raw) {
|
|
2051
|
+
return [];
|
|
2052
|
+
}
|
|
2053
|
+
try {
|
|
2054
|
+
const parsed = JSON.parse(raw);
|
|
2055
|
+
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
|
|
2056
|
+
} catch {
|
|
2057
|
+
return [];
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
function rowToEmail2(row) {
|
|
2061
|
+
return {
|
|
2062
|
+
id: row.id,
|
|
2063
|
+
threadId: row.thread_id || "",
|
|
2064
|
+
fromAddress: row.from_address || "",
|
|
2065
|
+
fromName: row.from_name || "",
|
|
2066
|
+
toAddresses: parseJsonArray2(row.to_addresses),
|
|
2067
|
+
subject: row.subject || "",
|
|
2068
|
+
snippet: row.snippet || "",
|
|
2069
|
+
date: row.date || 0,
|
|
2070
|
+
isRead: row.is_read === 1,
|
|
2071
|
+
isStarred: row.is_starred === 1,
|
|
2072
|
+
labelIds: parseJsonArray2(row.label_ids),
|
|
2073
|
+
sizeEstimate: row.size_estimate || 0,
|
|
2074
|
+
hasAttachments: row.has_attachments === 1,
|
|
2075
|
+
listUnsubscribe: row.list_unsubscribe
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
function uniqueStrings(values) {
|
|
2079
|
+
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
2080
|
+
}
|
|
2081
|
+
function actionSignature(action) {
|
|
2082
|
+
switch (action.type) {
|
|
2083
|
+
case "label":
|
|
2084
|
+
return `label:${action.label}`;
|
|
2085
|
+
default:
|
|
2086
|
+
return action.type;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
function combineItemStatus(current, next) {
|
|
2090
|
+
if (current === "error" || next === "error") {
|
|
2091
|
+
return "error";
|
|
2092
|
+
}
|
|
2093
|
+
if (current === "warning" || next === "warning") {
|
|
2094
|
+
return "warning";
|
|
2095
|
+
}
|
|
2096
|
+
return "applied";
|
|
2097
|
+
}
|
|
2098
|
+
function resolveExecutionStatus(items, dryRun) {
|
|
2099
|
+
if (dryRun) {
|
|
2100
|
+
return "planned";
|
|
2101
|
+
}
|
|
2102
|
+
if (items.length === 0) {
|
|
2103
|
+
return "applied";
|
|
2104
|
+
}
|
|
2105
|
+
if (items.every((item) => item.status === "applied")) {
|
|
2106
|
+
return "applied";
|
|
2107
|
+
}
|
|
2108
|
+
if (items.some((item) => item.status === "applied" || item.status === "warning")) {
|
|
2109
|
+
return "partial";
|
|
2110
|
+
}
|
|
2111
|
+
return "error";
|
|
2112
|
+
}
|
|
2113
|
+
function normalizeGroups(groups) {
|
|
2114
|
+
if (groups.length === 0) {
|
|
2115
|
+
throw new Error("Provide at least one action group.");
|
|
2116
|
+
}
|
|
2117
|
+
if (groups.length > 20) {
|
|
2118
|
+
throw new Error("A batch may contain at most 20 groups.");
|
|
2119
|
+
}
|
|
2120
|
+
const seenEmailIds = /* @__PURE__ */ new Set();
|
|
2121
|
+
return groups.map((group, index2) => {
|
|
2122
|
+
const emailIds = uniqueStrings(group.emailIds);
|
|
2123
|
+
const actionMap = /* @__PURE__ */ new Map();
|
|
2124
|
+
for (const action of group.actions) {
|
|
2125
|
+
if (action.type === "label" && !action.label.trim()) {
|
|
2126
|
+
throw new Error(`Group ${index2 + 1}: label actions require a label name.`);
|
|
2127
|
+
}
|
|
2128
|
+
actionMap.set(actionSignature(action), action);
|
|
2129
|
+
}
|
|
2130
|
+
if (emailIds.length === 0) {
|
|
2131
|
+
throw new Error(`Group ${index2 + 1}: provide at least one email ID.`);
|
|
2132
|
+
}
|
|
2133
|
+
if (emailIds.length > 500) {
|
|
2134
|
+
throw new Error(`Group ${index2 + 1}: a group may contain at most 500 email IDs.`);
|
|
2135
|
+
}
|
|
2136
|
+
const actions = [...actionMap.values()];
|
|
2137
|
+
if (actions.length === 0) {
|
|
2138
|
+
throw new Error(`Group ${index2 + 1}: provide at least one action.`);
|
|
2139
|
+
}
|
|
2140
|
+
if (actions.length > 5) {
|
|
2141
|
+
throw new Error(`Group ${index2 + 1}: a group may contain at most 5 actions.`);
|
|
2142
|
+
}
|
|
2143
|
+
for (const emailId of emailIds) {
|
|
2144
|
+
if (seenEmailIds.has(emailId)) {
|
|
2145
|
+
throw new Error(`Email ${emailId} appears in more than one group. Each email may only be targeted once per batch.`);
|
|
2146
|
+
}
|
|
2147
|
+
seenEmailIds.add(emailId);
|
|
2148
|
+
}
|
|
2149
|
+
return {
|
|
2150
|
+
emailIds,
|
|
2151
|
+
actions
|
|
2152
|
+
};
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
async function resolveContext3(options) {
|
|
2156
|
+
const config = options?.config || loadConfig();
|
|
2157
|
+
const transport = options?.transport || await getGmailTransport(config);
|
|
2158
|
+
return { config, transport };
|
|
2159
|
+
}
|
|
2160
|
+
function readSnapshotEmails(config, ids) {
|
|
2161
|
+
const sqlite = getSqlite(config.dbPath);
|
|
2162
|
+
const rows = sqlite.prepare(
|
|
2163
|
+
`
|
|
2164
|
+
SELECT id, thread_id, from_address, from_name, to_addresses, subject, snippet, date,
|
|
2165
|
+
is_read, is_starred, label_ids, size_estimate, has_attachments, list_unsubscribe
|
|
2166
|
+
FROM emails
|
|
2167
|
+
WHERE id IN (${makePlaceholders2(ids)})
|
|
2168
|
+
`
|
|
2169
|
+
).all(...ids);
|
|
2170
|
+
return new Map(rows.map((row) => [row.id, rowToEmail2(row)]));
|
|
2171
|
+
}
|
|
2172
|
+
async function fetchMissingSnapshotEmails(transport, ids, snapshots) {
|
|
2173
|
+
const missingIds = ids.filter((id) => !snapshots.has(id));
|
|
2174
|
+
const fetched = await Promise.all(
|
|
2175
|
+
missingIds.map(async (id) => {
|
|
2176
|
+
const response = await transport.getMessage({
|
|
2177
|
+
id,
|
|
2178
|
+
format: "metadata",
|
|
2179
|
+
metadataHeaders: MESSAGE_FETCH_HEADERS2
|
|
2180
|
+
});
|
|
2181
|
+
if (!response.id) {
|
|
2182
|
+
throw new Error(`Gmail message not found: ${id}`);
|
|
2183
|
+
}
|
|
2184
|
+
return parseMessage(response);
|
|
2185
|
+
})
|
|
2186
|
+
);
|
|
2187
|
+
for (const email of fetched) {
|
|
2188
|
+
snapshots.set(email.id, email);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
async function loadSnapshots(ids, context) {
|
|
2192
|
+
const snapshots = readSnapshotEmails(context.config, ids);
|
|
2193
|
+
await fetchMissingSnapshotEmails(context.transport, ids, snapshots);
|
|
2194
|
+
return snapshots;
|
|
2195
|
+
}
|
|
2196
|
+
async function executeAction(action, emailIds, context) {
|
|
2197
|
+
switch (action.type) {
|
|
2198
|
+
case "archive":
|
|
2199
|
+
return (await archiveEmails(emailIds, context)).items;
|
|
2200
|
+
case "label":
|
|
2201
|
+
await createLabel(action.label, void 0, context);
|
|
2202
|
+
return (await labelEmails(emailIds, action.label, context)).items;
|
|
2203
|
+
case "mark_read":
|
|
2204
|
+
return (await markRead(emailIds, context)).items;
|
|
2205
|
+
case "mark_spam":
|
|
2206
|
+
return (await markSpam(emailIds, context)).items;
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
async function executeGroup(group, context, dryRun) {
|
|
2210
|
+
const summary = {
|
|
2211
|
+
emailCount: group.emailIds.length,
|
|
2212
|
+
actionsApplied: group.actions.map(actionSignature),
|
|
2213
|
+
status: dryRun ? "planned" : "applied"
|
|
2214
|
+
};
|
|
2215
|
+
if (dryRun) {
|
|
2216
|
+
return {
|
|
2217
|
+
summary,
|
|
2218
|
+
items: []
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
const items = group.emailIds.map((emailId) => ({
|
|
2222
|
+
emailId,
|
|
2223
|
+
status: "applied",
|
|
2224
|
+
appliedActions: [],
|
|
2225
|
+
beforeLabelIds: [],
|
|
2226
|
+
afterLabelIds: [],
|
|
2227
|
+
errorMessage: null
|
|
2228
|
+
}));
|
|
2229
|
+
const itemMap = new Map(items.map((item) => [item.emailId, item]));
|
|
2230
|
+
try {
|
|
2231
|
+
const snapshots = await loadSnapshots(group.emailIds, context);
|
|
2232
|
+
for (const emailId of group.emailIds) {
|
|
2233
|
+
const snapshot = snapshots.get(emailId);
|
|
2234
|
+
const item = itemMap.get(emailId);
|
|
2235
|
+
if (!item) {
|
|
2236
|
+
continue;
|
|
2237
|
+
}
|
|
2238
|
+
if (!snapshot) {
|
|
2239
|
+
item.status = "error";
|
|
2240
|
+
item.errorMessage = `Unable to resolve Gmail message snapshot for ${emailId}`;
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
item.beforeLabelIds = [...snapshot.labelIds];
|
|
2244
|
+
item.afterLabelIds = [...snapshot.labelIds];
|
|
2245
|
+
}
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2248
|
+
for (const item of items) {
|
|
2249
|
+
item.status = "error";
|
|
2250
|
+
item.errorMessage = message;
|
|
2251
|
+
}
|
|
2252
|
+
summary.status = resolveExecutionStatus(items, false);
|
|
2253
|
+
return { summary, items };
|
|
2254
|
+
}
|
|
2255
|
+
for (const action of group.actions) {
|
|
2256
|
+
const activeIds = items.filter((item) => item.status !== "error").map((item) => item.emailId);
|
|
2257
|
+
if (activeIds.length === 0) {
|
|
2258
|
+
break;
|
|
2259
|
+
}
|
|
2260
|
+
try {
|
|
2261
|
+
const results = await executeAction(action, activeIds, context);
|
|
2262
|
+
const resultMap = new Map(results.map((result) => [result.emailId, result]));
|
|
2263
|
+
for (const emailId of activeIds) {
|
|
2264
|
+
const item = itemMap.get(emailId);
|
|
2265
|
+
if (!item) {
|
|
2266
|
+
continue;
|
|
2267
|
+
}
|
|
2268
|
+
const result = resultMap.get(emailId);
|
|
2269
|
+
if (!result) {
|
|
2270
|
+
item.status = "error";
|
|
2271
|
+
item.errorMessage = `Missing Gmail mutation result for ${emailId}`;
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
item.status = combineItemStatus(item.status, result.status);
|
|
2275
|
+
item.afterLabelIds = [...result.afterLabelIds];
|
|
2276
|
+
item.appliedActions = [...item.appliedActions, ...result.appliedActions];
|
|
2277
|
+
item.errorMessage = result.errorMessage ?? item.errorMessage;
|
|
2278
|
+
}
|
|
2279
|
+
} catch (error) {
|
|
2280
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2281
|
+
for (const emailId of activeIds) {
|
|
2282
|
+
const item = itemMap.get(emailId);
|
|
2283
|
+
if (!item) {
|
|
2284
|
+
continue;
|
|
2285
|
+
}
|
|
2286
|
+
item.status = "error";
|
|
2287
|
+
item.errorMessage = message;
|
|
2288
|
+
}
|
|
2289
|
+
break;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
summary.status = resolveExecutionStatus(items, false);
|
|
2293
|
+
return {
|
|
2294
|
+
summary,
|
|
2295
|
+
items
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
function collectRequestedActions(groups) {
|
|
2299
|
+
const actions = /* @__PURE__ */ new Map();
|
|
2300
|
+
for (const group of groups) {
|
|
2301
|
+
for (const action of group.actions) {
|
|
2302
|
+
actions.set(actionSignature(action), action);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
return [...actions.values()];
|
|
2306
|
+
}
|
|
2307
|
+
async function batchApplyActions(options) {
|
|
2308
|
+
const groups = normalizeGroups(options.groups);
|
|
2309
|
+
const dryRun = options.dryRun ?? false;
|
|
2310
|
+
if (dryRun) {
|
|
2311
|
+
const summaries2 = groups.map((group) => ({
|
|
2312
|
+
emailCount: group.emailIds.length,
|
|
2313
|
+
actionsApplied: group.actions.map(actionSignature),
|
|
2314
|
+
status: "planned"
|
|
2315
|
+
}));
|
|
2316
|
+
return {
|
|
2317
|
+
runId: null,
|
|
2318
|
+
dryRun: true,
|
|
2319
|
+
groups: summaries2,
|
|
2320
|
+
totalEmailsAffected: summaries2.reduce((sum, group) => sum + group.emailCount, 0),
|
|
2321
|
+
undoAvailable: false
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
const context = await resolveContext3(options);
|
|
2325
|
+
const summaries = [];
|
|
2326
|
+
const executionItems2 = [];
|
|
2327
|
+
for (const group of groups) {
|
|
2328
|
+
const result = await executeGroup(group, context, false);
|
|
2329
|
+
summaries.push(result.summary);
|
|
2330
|
+
executionItems2.push(...result.items);
|
|
2331
|
+
}
|
|
2332
|
+
const status = resolveExecutionStatus(executionItems2, false);
|
|
2333
|
+
const run = await createExecutionRun({
|
|
2334
|
+
sourceType: options.sourceType ?? "manual",
|
|
2335
|
+
dryRun: false,
|
|
2336
|
+
requestedActions: collectRequestedActions(groups),
|
|
2337
|
+
query: options.query ?? null,
|
|
2338
|
+
status
|
|
2339
|
+
});
|
|
2340
|
+
await addExecutionItems(
|
|
2341
|
+
run.id,
|
|
2342
|
+
executionItems2.map((item) => ({
|
|
2343
|
+
emailId: item.emailId,
|
|
2344
|
+
status: item.status,
|
|
2345
|
+
appliedActions: item.appliedActions,
|
|
2346
|
+
beforeLabelIds: item.beforeLabelIds,
|
|
2347
|
+
afterLabelIds: item.afterLabelIds,
|
|
2348
|
+
errorMessage: item.errorMessage
|
|
2349
|
+
}))
|
|
2350
|
+
);
|
|
2351
|
+
return {
|
|
2352
|
+
runId: run.id,
|
|
2353
|
+
dryRun: false,
|
|
2354
|
+
groups: summaries,
|
|
2355
|
+
totalEmailsAffected: summaries.reduce((sum, group) => sum + group.emailCount, 0),
|
|
2356
|
+
undoAvailable: status === "applied" || status === "partial" || status === "error"
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2044
2360
|
// src/core/gmail/threads.ts
|
|
2045
2361
|
async function getThread(id) {
|
|
2046
2362
|
const config = loadConfig();
|
|
@@ -2055,8 +2371,224 @@ async function getThread(id) {
|
|
|
2055
2371
|
};
|
|
2056
2372
|
}
|
|
2057
2373
|
|
|
2374
|
+
// src/core/unsubscribe.ts
|
|
2375
|
+
function uniqueStrings2(values) {
|
|
2376
|
+
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
2377
|
+
}
|
|
2378
|
+
function extractCandidates(value) {
|
|
2379
|
+
const matches = [...value.matchAll(/<([^>]+)>/g)].map((match) => match[1]?.trim() || "").filter(Boolean);
|
|
2380
|
+
if (matches.length > 0) {
|
|
2381
|
+
return matches;
|
|
2382
|
+
}
|
|
2383
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
2384
|
+
}
|
|
2385
|
+
function parseListUnsubscribeValues(...values) {
|
|
2386
|
+
const links = [];
|
|
2387
|
+
const mailtos = [];
|
|
2388
|
+
for (const rawValue of values) {
|
|
2389
|
+
if (!rawValue?.trim()) {
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
for (const candidate of extractCandidates(rawValue)) {
|
|
2393
|
+
const normalized = candidate.trim();
|
|
2394
|
+
if (/^mailto:/i.test(normalized)) {
|
|
2395
|
+
mailtos.push(normalized);
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
if (/^https?:\/\//i.test(normalized)) {
|
|
2399
|
+
links.push(normalized);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
return {
|
|
2404
|
+
links: uniqueStrings2(links),
|
|
2405
|
+
mailtos: uniqueStrings2(mailtos)
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
function resolveUnsubscribeTarget(...values) {
|
|
2409
|
+
const parsed = parseListUnsubscribeValues(...values);
|
|
2410
|
+
if (parsed.links.length > 0 && parsed.mailtos.length > 0) {
|
|
2411
|
+
return {
|
|
2412
|
+
unsubscribeLink: parsed.links[0] || null,
|
|
2413
|
+
unsubscribeMethod: "both"
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
if (parsed.links.length > 0) {
|
|
2417
|
+
return {
|
|
2418
|
+
unsubscribeLink: parsed.links[0] || null,
|
|
2419
|
+
unsubscribeMethod: "link"
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
if (parsed.mailtos.length > 0) {
|
|
2423
|
+
return {
|
|
2424
|
+
unsubscribeLink: parsed.mailtos[0] || null,
|
|
2425
|
+
unsubscribeMethod: "mailto"
|
|
2426
|
+
};
|
|
2427
|
+
}
|
|
2428
|
+
return {
|
|
2429
|
+
unsubscribeLink: null,
|
|
2430
|
+
unsubscribeMethod: null
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
function buildUnsubscribeReason(unreadRate, messageCount) {
|
|
2434
|
+
if (unreadRate >= 90) {
|
|
2435
|
+
return `${unreadRate}% unread across ${messageCount} emails \u2014 you never engage with this sender`;
|
|
2436
|
+
}
|
|
2437
|
+
if (unreadRate >= 50) {
|
|
2438
|
+
return `${unreadRate}% unread across ${messageCount} emails \u2014 you rarely engage with this sender`;
|
|
2439
|
+
}
|
|
2440
|
+
if (unreadRate >= 25) {
|
|
2441
|
+
return `${unreadRate}% unread across ${messageCount} emails \u2014 you sometimes read this sender`;
|
|
2442
|
+
}
|
|
2443
|
+
return `High volume sender (${messageCount} emails) with ${unreadRate}% unread`;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// src/core/gmail/unsubscribe.ts
|
|
2447
|
+
function resolveContext4(options) {
|
|
2448
|
+
return {
|
|
2449
|
+
config: options.config || loadConfig(),
|
|
2450
|
+
transport: options.transport
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
function buildInstruction(method, archivedCount, labeledCount) {
|
|
2454
|
+
const cleanupParts = [];
|
|
2455
|
+
if (labeledCount > 0) {
|
|
2456
|
+
cleanupParts.push(`${labeledCount} emails labeled`);
|
|
2457
|
+
}
|
|
2458
|
+
if (archivedCount > 0) {
|
|
2459
|
+
cleanupParts.push(`${archivedCount} emails archived`);
|
|
2460
|
+
}
|
|
2461
|
+
const cleanup = cleanupParts.length > 0 ? `${cleanupParts.join(" and ")}. ` : "";
|
|
2462
|
+
if (method === "mailto") {
|
|
2463
|
+
return `${cleanup}Open this mailto link in your email client to complete the unsubscribe process. inboxctl cannot auto-submit unsubscribe forms.`;
|
|
2464
|
+
}
|
|
2465
|
+
return `${cleanup}Open this link in your browser to complete the unsubscribe process. inboxctl cannot auto-submit unsubscribe forms.`;
|
|
2466
|
+
}
|
|
2467
|
+
function getSenderAggregate(sqlite, senderEmail) {
|
|
2468
|
+
const row = sqlite.prepare(
|
|
2469
|
+
`
|
|
2470
|
+
SELECT
|
|
2471
|
+
COUNT(*) AS messageCount,
|
|
2472
|
+
MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
|
|
2473
|
+
GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
|
|
2474
|
+
') AS emailUnsubscribeHeaders
|
|
2475
|
+
FROM emails AS e
|
|
2476
|
+
LEFT JOIN newsletter_senders AS ns
|
|
2477
|
+
ON LOWER(ns.email) = LOWER(e.from_address)
|
|
2478
|
+
WHERE LOWER(e.from_address) = LOWER(?)
|
|
2479
|
+
GROUP BY LOWER(e.from_address)
|
|
2480
|
+
`
|
|
2481
|
+
).get(senderEmail);
|
|
2482
|
+
return row || null;
|
|
2483
|
+
}
|
|
2484
|
+
function getSenderEmailIds(sqlite, senderEmail) {
|
|
2485
|
+
const rows = sqlite.prepare(
|
|
2486
|
+
`
|
|
2487
|
+
SELECT id
|
|
2488
|
+
FROM emails
|
|
2489
|
+
WHERE LOWER(from_address) = LOWER(?)
|
|
2490
|
+
ORDER BY COALESCE(date, 0) DESC, id ASC
|
|
2491
|
+
`
|
|
2492
|
+
).all(senderEmail);
|
|
2493
|
+
return rows.map((row) => row.id);
|
|
2494
|
+
}
|
|
2495
|
+
async function unsubscribe(options) {
|
|
2496
|
+
const senderEmail = options.senderEmail.trim();
|
|
2497
|
+
if (!senderEmail) {
|
|
2498
|
+
throw new Error("senderEmail is required");
|
|
2499
|
+
}
|
|
2500
|
+
const context = resolveContext4(options);
|
|
2501
|
+
const sqlite = getSqlite(context.config.dbPath);
|
|
2502
|
+
const aggregate = getSenderAggregate(sqlite, senderEmail);
|
|
2503
|
+
if (!aggregate) {
|
|
2504
|
+
throw new Error(`No emails found from ${senderEmail}`);
|
|
2505
|
+
}
|
|
2506
|
+
const unsubscribeTarget = resolveUnsubscribeTarget(
|
|
2507
|
+
aggregate.newsletterUnsubscribeLink,
|
|
2508
|
+
aggregate.emailUnsubscribeHeaders
|
|
2509
|
+
);
|
|
2510
|
+
if (!unsubscribeTarget.unsubscribeLink || !unsubscribeTarget.unsubscribeMethod) {
|
|
2511
|
+
throw new Error(
|
|
2512
|
+
`No unsubscribe link found for ${senderEmail}. This sender does not include List-Unsubscribe headers.`
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
const alsoLabel = options.alsoLabel?.trim() || void 0;
|
|
2516
|
+
const actions = [];
|
|
2517
|
+
if (alsoLabel) {
|
|
2518
|
+
actions.push({ type: "label", label: alsoLabel });
|
|
2519
|
+
}
|
|
2520
|
+
if (options.alsoArchive) {
|
|
2521
|
+
actions.push({ type: "archive" });
|
|
2522
|
+
}
|
|
2523
|
+
const emailIds = actions.length > 0 ? getSenderEmailIds(sqlite, senderEmail) : [];
|
|
2524
|
+
const batchResult = actions.length > 0 ? await batchApplyActions({
|
|
2525
|
+
groups: [{ emailIds, actions }],
|
|
2526
|
+
config: context.config,
|
|
2527
|
+
transport: context.transport,
|
|
2528
|
+
sourceType: "unsubscribe",
|
|
2529
|
+
query: senderEmail
|
|
2530
|
+
}) : null;
|
|
2531
|
+
const archivedCount = options.alsoArchive ? aggregate.messageCount : 0;
|
|
2532
|
+
const labeledCount = alsoLabel ? aggregate.messageCount : 0;
|
|
2533
|
+
return {
|
|
2534
|
+
sender: senderEmail,
|
|
2535
|
+
unsubscribeLink: unsubscribeTarget.unsubscribeLink,
|
|
2536
|
+
unsubscribeMethod: unsubscribeTarget.unsubscribeMethod,
|
|
2537
|
+
messageCount: aggregate.messageCount,
|
|
2538
|
+
archivedCount,
|
|
2539
|
+
labeledCount,
|
|
2540
|
+
...batchResult?.runId ? {
|
|
2541
|
+
runId: batchResult.runId,
|
|
2542
|
+
undoAvailable: batchResult.undoAvailable
|
|
2543
|
+
} : {},
|
|
2544
|
+
instruction: buildInstruction(
|
|
2545
|
+
unsubscribeTarget.unsubscribeMethod,
|
|
2546
|
+
archivedCount,
|
|
2547
|
+
labeledCount
|
|
2548
|
+
)
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// src/core/stats/anomalies.ts
|
|
2553
|
+
import { z } from "zod";
|
|
2554
|
+
|
|
2058
2555
|
// src/core/stats/common.ts
|
|
2059
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
|
+
];
|
|
2060
2592
|
var SYSTEM_LABEL_NAMES = /* @__PURE__ */ new Map([
|
|
2061
2593
|
["INBOX", "Inbox"],
|
|
2062
2594
|
["UNREAD", "Unread"],
|
|
@@ -2114,6 +2646,14 @@ function getPeriodStart(period = "all", now2 = Date.now()) {
|
|
|
2114
2646
|
function resolveLabelName(labelId) {
|
|
2115
2647
|
return SYSTEM_LABEL_NAMES.get(labelId) || getCachedLabelName(labelId) || labelId;
|
|
2116
2648
|
}
|
|
2649
|
+
function isUserLabel(labelId) {
|
|
2650
|
+
const trimmed = labelId.trim();
|
|
2651
|
+
return trimmed.length > 0 && !SYSTEM_LABEL_ID_SET.has(trimmed) && !trimmed.startsWith(CATEGORY_LABEL_PREFIX);
|
|
2652
|
+
}
|
|
2653
|
+
function isLikelyAutomatedSenderAddress(sender) {
|
|
2654
|
+
const normalized = sender.trim().toLowerCase();
|
|
2655
|
+
return AUTOMATED_ADDRESS_MARKERS.some((marker) => normalized.includes(marker));
|
|
2656
|
+
}
|
|
2117
2657
|
function startOfLocalDay(now2 = Date.now()) {
|
|
2118
2658
|
const date = new Date(now2);
|
|
2119
2659
|
date.setHours(0, 0, 0, 0);
|
|
@@ -2133,28 +2673,6 @@ function startOfLocalMonth(now2 = Date.now()) {
|
|
|
2133
2673
|
return date.getTime();
|
|
2134
2674
|
}
|
|
2135
2675
|
|
|
2136
|
-
// src/core/stats/labels.ts
|
|
2137
|
-
async function getLabelDistribution() {
|
|
2138
|
-
const sqlite = getStatsSqlite();
|
|
2139
|
-
const rows = sqlite.prepare(
|
|
2140
|
-
`
|
|
2141
|
-
SELECT
|
|
2142
|
-
label.value AS labelId,
|
|
2143
|
-
COUNT(*) AS totalMessages,
|
|
2144
|
-
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages
|
|
2145
|
-
FROM emails AS e, json_each(e.label_ids) AS label
|
|
2146
|
-
GROUP BY label.value
|
|
2147
|
-
ORDER BY totalMessages DESC, unreadMessages DESC, label.value ASC
|
|
2148
|
-
`
|
|
2149
|
-
).all();
|
|
2150
|
-
return rows.map((row) => ({
|
|
2151
|
-
labelId: row.labelId,
|
|
2152
|
-
labelName: resolveLabelName(row.labelId),
|
|
2153
|
-
totalMessages: row.totalMessages,
|
|
2154
|
-
unreadMessages: row.unreadMessages
|
|
2155
|
-
}));
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
2676
|
// src/core/stats/newsletters.ts
|
|
2159
2677
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2160
2678
|
var KNOWN_NEWSLETTER_LOCAL_PART = /^(newsletter|digest|noreply|no-reply|updates|news)([+._-].*)?$/i;
|
|
@@ -2318,14 +2836,822 @@ async function getNewsletters(options = {}) {
|
|
|
2318
2836
|
return rows.map(mapNewsletterRow);
|
|
2319
2837
|
}
|
|
2320
2838
|
|
|
2321
|
-
// src/core/stats/
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2839
|
+
// src/core/stats/anomalies.ts
|
|
2840
|
+
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
2841
|
+
var BULK_LABELS = /* @__PURE__ */ new Set([
|
|
2842
|
+
"newsletter",
|
|
2843
|
+
"newsletters",
|
|
2844
|
+
"promotion",
|
|
2845
|
+
"promotions",
|
|
2846
|
+
"social"
|
|
2847
|
+
]);
|
|
2848
|
+
var reviewCategorizedInputSchema = z.object({
|
|
2849
|
+
since: z.string().min(1).optional(),
|
|
2850
|
+
limit: z.number().int().positive().max(200).optional()
|
|
2851
|
+
}).strict();
|
|
2852
|
+
function toIsoString(value) {
|
|
2853
|
+
if (!value) {
|
|
2854
|
+
return null;
|
|
2855
|
+
}
|
|
2856
|
+
return new Date(value).toISOString();
|
|
2857
|
+
}
|
|
2858
|
+
function parseJsonArray3(raw) {
|
|
2859
|
+
if (!raw) {
|
|
2860
|
+
return [];
|
|
2861
|
+
}
|
|
2862
|
+
try {
|
|
2863
|
+
const parsed = JSON.parse(raw);
|
|
2864
|
+
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
|
|
2865
|
+
} catch {
|
|
2866
|
+
return [];
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
function parseActions(raw) {
|
|
2870
|
+
try {
|
|
2871
|
+
const parsed = JSON.parse(raw);
|
|
2872
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
2873
|
+
} catch {
|
|
2874
|
+
return [];
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
function resolveSinceTimestamp(since) {
|
|
2878
|
+
if (!since) {
|
|
2879
|
+
return Date.now() - 7 * DAY_MS2;
|
|
2880
|
+
}
|
|
2881
|
+
const parsed = Date.parse(since);
|
|
2882
|
+
if (Number.isNaN(parsed)) {
|
|
2883
|
+
throw new Error(`Invalid since value: ${since}`);
|
|
2884
|
+
}
|
|
2885
|
+
return parsed;
|
|
2886
|
+
}
|
|
2887
|
+
function isArchived(actions, beforeLabelIds, afterLabelIds) {
|
|
2888
|
+
if (actions.some((action) => action.type === "archive")) {
|
|
2889
|
+
return true;
|
|
2890
|
+
}
|
|
2891
|
+
return beforeLabelIds.includes("INBOX") && !afterLabelIds.includes("INBOX");
|
|
2892
|
+
}
|
|
2893
|
+
function resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds) {
|
|
2894
|
+
const labelAction = actions.find(
|
|
2895
|
+
(action) => action.type === "label" && typeof action.label === "string" && action.label.trim().length > 0
|
|
2896
|
+
);
|
|
2897
|
+
if (labelAction) {
|
|
2898
|
+
return labelAction.label.trim();
|
|
2899
|
+
}
|
|
2900
|
+
const beforeUserLabels = new Set(beforeLabelIds.filter(isUserLabel));
|
|
2901
|
+
const afterUserLabels = afterLabelIds.filter(isUserLabel);
|
|
2902
|
+
const addedLabel = afterUserLabels.find((label) => !beforeUserLabels.has(label));
|
|
2903
|
+
return addedLabel || afterUserLabels[0] || null;
|
|
2904
|
+
}
|
|
2905
|
+
function resolvePrimaryAction(actions) {
|
|
2906
|
+
if (actions.some((action) => action.type === "archive")) {
|
|
2907
|
+
return "archive";
|
|
2908
|
+
}
|
|
2909
|
+
if (actions.some((action) => action.type === "label")) {
|
|
2910
|
+
return "label";
|
|
2911
|
+
}
|
|
2912
|
+
return actions[0]?.type || "unknown";
|
|
2913
|
+
}
|
|
2914
|
+
function summarizeReview(totalReviewed, anomalyCount, highCount, mediumCount) {
|
|
2915
|
+
if (anomalyCount === 0) {
|
|
2916
|
+
return `Reviewed ${totalReviewed} recently categorised emails. Found no potential misclassifications.`;
|
|
2917
|
+
}
|
|
2918
|
+
const severityParts = [];
|
|
2919
|
+
if (highCount > 0) {
|
|
2920
|
+
severityParts.push(`${highCount} high severity`);
|
|
2921
|
+
}
|
|
2922
|
+
if (mediumCount > 0) {
|
|
2923
|
+
severityParts.push(`${mediumCount} medium`);
|
|
2924
|
+
}
|
|
2925
|
+
return `Reviewed ${totalReviewed} recently categorised emails. Found ${anomalyCount} potential misclassifications (${severityParts.join(", ")}).`;
|
|
2926
|
+
}
|
|
2927
|
+
function detectAnomaly(row) {
|
|
2928
|
+
const actions = parseActions(row.appliedActions);
|
|
2929
|
+
const beforeLabelIds = parseJsonArray3(row.beforeLabelIds);
|
|
2930
|
+
const afterLabelIds = parseJsonArray3(row.afterLabelIds);
|
|
2931
|
+
const archived = isArchived(actions, beforeLabelIds, afterLabelIds);
|
|
2932
|
+
const assignedLabel = resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds);
|
|
2933
|
+
const action = resolvePrimaryAction(actions);
|
|
2934
|
+
const totalFromSender = row.totalFromSender ?? 0;
|
|
2935
|
+
const hasNewsletterSignals = Boolean(row.detectionReason) || Boolean(row.listUnsubscribe?.trim());
|
|
2936
|
+
const isBulkLabel = assignedLabel ? BULK_LABELS.has(assignedLabel.toLowerCase()) : false;
|
|
2937
|
+
const automatedSender = isLikelyAutomatedSenderAddress(row.sender || "");
|
|
2938
|
+
const undoAvailable = row.runDryRun !== 1 && row.runStatus !== "undone" && row.runUndoneAt === null && row.itemUndoneAt === null;
|
|
2939
|
+
if (archived && totalFromSender <= 3) {
|
|
2940
|
+
return {
|
|
2941
|
+
emailId: row.emailId,
|
|
2942
|
+
from: row.sender || "",
|
|
2943
|
+
subject: row.subject || "",
|
|
2944
|
+
date: toIsoString(row.date),
|
|
2945
|
+
assignedLabel: assignedLabel || "Unlabeled",
|
|
2946
|
+
action,
|
|
2947
|
+
runId: row.runId,
|
|
2948
|
+
severity: "high",
|
|
2949
|
+
rule: "rare_sender_archived",
|
|
2950
|
+
reason: `Archived email from a rare sender with only ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}. Rare senders should be reviewed before archiving.`,
|
|
2951
|
+
undoAvailable
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
if (isBulkLabel && !hasNewsletterSignals) {
|
|
2955
|
+
return {
|
|
2956
|
+
emailId: row.emailId,
|
|
2957
|
+
from: row.sender || "",
|
|
2958
|
+
subject: row.subject || "",
|
|
2959
|
+
date: toIsoString(row.date),
|
|
2960
|
+
assignedLabel: assignedLabel || "Unlabeled",
|
|
2961
|
+
action,
|
|
2962
|
+
runId: row.runId,
|
|
2963
|
+
severity: "high",
|
|
2964
|
+
rule: "no_newsletter_signals_as_newsletter",
|
|
2965
|
+
reason: `Labeled as ${assignedLabel} but sender has no List-Unsubscribe header and no newsletter detection signals. Sender has only sent ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}.`,
|
|
2966
|
+
undoAvailable
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
if (archived && !automatedSender && totalFromSender < 5) {
|
|
2970
|
+
return {
|
|
2971
|
+
emailId: row.emailId,
|
|
2972
|
+
from: row.sender || "",
|
|
2973
|
+
subject: row.subject || "",
|
|
2974
|
+
date: toIsoString(row.date),
|
|
2975
|
+
assignedLabel: assignedLabel || "Unlabeled",
|
|
2976
|
+
action,
|
|
2977
|
+
runId: row.runId,
|
|
2978
|
+
severity: "high",
|
|
2979
|
+
rule: "personal_address_archived",
|
|
2980
|
+
reason: `Archived email from a likely personal sender address with fewer than 5 total emails. This address does not look automated and should stay visible.`,
|
|
2981
|
+
undoAvailable
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2984
|
+
if (isBulkLabel && totalFromSender < 5) {
|
|
2985
|
+
return {
|
|
2986
|
+
emailId: row.emailId,
|
|
2987
|
+
from: row.sender || "",
|
|
2988
|
+
subject: row.subject || "",
|
|
2989
|
+
date: toIsoString(row.date),
|
|
2990
|
+
assignedLabel: assignedLabel || "Unlabeled",
|
|
2991
|
+
action,
|
|
2992
|
+
runId: row.runId,
|
|
2993
|
+
severity: "medium",
|
|
2994
|
+
rule: "low_volume_bulk_label",
|
|
2995
|
+
reason: `Labeled as ${assignedLabel} even though the sender has only ${totalFromSender} total email${totalFromSender === 1 ? "" : "s"}. Bulk labels are safer for higher-volume senders.`,
|
|
2996
|
+
undoAvailable
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
if (archived && totalFromSender === 1) {
|
|
3000
|
+
return {
|
|
3001
|
+
emailId: row.emailId,
|
|
3002
|
+
from: row.sender || "",
|
|
3003
|
+
subject: row.subject || "",
|
|
3004
|
+
date: toIsoString(row.date),
|
|
3005
|
+
assignedLabel: assignedLabel || "Unlabeled",
|
|
3006
|
+
action,
|
|
3007
|
+
runId: row.runId,
|
|
3008
|
+
severity: "medium",
|
|
3009
|
+
rule: "first_time_sender_archived",
|
|
3010
|
+
reason: "Archived an email from a first-time sender. First-time senders are better surfaced for review before cleanup.",
|
|
3011
|
+
undoAvailable
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
return null;
|
|
3015
|
+
}
|
|
3016
|
+
async function reviewCategorized(options = {}) {
|
|
3017
|
+
const parsed = reviewCategorizedInputSchema.parse(options);
|
|
3018
|
+
await detectNewsletters();
|
|
3019
|
+
const sqlite = getStatsSqlite();
|
|
3020
|
+
const sinceTimestamp = resolveSinceTimestamp(parsed.since);
|
|
3021
|
+
const limit = Math.min(200, normalizeLimit(parsed.limit, 50));
|
|
3022
|
+
const rows = sqlite.prepare(
|
|
3023
|
+
`
|
|
3024
|
+
SELECT
|
|
3025
|
+
ei.email_id AS emailId,
|
|
3026
|
+
e.from_address AS sender,
|
|
3027
|
+
e.subject AS subject,
|
|
3028
|
+
e.date AS date,
|
|
3029
|
+
e.list_unsubscribe AS listUnsubscribe,
|
|
3030
|
+
ei.before_label_ids AS beforeLabelIds,
|
|
3031
|
+
ei.after_label_ids AS afterLabelIds,
|
|
3032
|
+
ei.applied_actions AS appliedActions,
|
|
3033
|
+
ei.executed_at AS executedAt,
|
|
3034
|
+
ei.undone_at AS itemUndoneAt,
|
|
3035
|
+
ei.run_id AS runId,
|
|
3036
|
+
er.status AS runStatus,
|
|
3037
|
+
er.dry_run AS runDryRun,
|
|
3038
|
+
er.undone_at AS runUndoneAt,
|
|
3039
|
+
ns.detection_reason AS detectionReason,
|
|
3040
|
+
sender_stats.totalFromSender AS totalFromSender
|
|
3041
|
+
FROM execution_items AS ei
|
|
3042
|
+
INNER JOIN emails AS e
|
|
3043
|
+
ON e.id = ei.email_id
|
|
3044
|
+
INNER JOIN execution_runs AS er
|
|
3045
|
+
ON er.id = ei.run_id
|
|
3046
|
+
LEFT JOIN newsletter_senders AS ns
|
|
3047
|
+
ON LOWER(ns.email) = LOWER(e.from_address)
|
|
3048
|
+
LEFT JOIN (
|
|
3049
|
+
SELECT
|
|
3050
|
+
LOWER(from_address) AS senderKey,
|
|
3051
|
+
COUNT(*) AS totalFromSender
|
|
3052
|
+
FROM emails
|
|
3053
|
+
WHERE from_address IS NOT NULL
|
|
3054
|
+
AND TRIM(from_address) <> ''
|
|
3055
|
+
GROUP BY LOWER(from_address)
|
|
3056
|
+
) AS sender_stats
|
|
3057
|
+
ON sender_stats.senderKey = LOWER(e.from_address)
|
|
3058
|
+
WHERE ei.status = 'applied'
|
|
3059
|
+
AND er.status IN ('applied', 'partial')
|
|
3060
|
+
AND COALESCE(er.dry_run, 0) = 0
|
|
3061
|
+
AND er.undone_at IS NULL
|
|
3062
|
+
AND ei.undone_at IS NULL
|
|
3063
|
+
AND COALESCE(ei.executed_at, 0) >= ?
|
|
3064
|
+
ORDER BY COALESCE(ei.executed_at, 0) DESC, ei.email_id ASC
|
|
3065
|
+
`
|
|
3066
|
+
).all(sinceTimestamp);
|
|
3067
|
+
const reviewedRows = rows.filter((row) => {
|
|
3068
|
+
const actions = parseActions(row.appliedActions);
|
|
3069
|
+
const beforeLabelIds = parseJsonArray3(row.beforeLabelIds);
|
|
3070
|
+
const afterLabelIds = parseJsonArray3(row.afterLabelIds);
|
|
3071
|
+
return actions.length > 0 && (resolveAssignedLabel(actions, beforeLabelIds, afterLabelIds) !== null || isArchived(actions, beforeLabelIds, afterLabelIds));
|
|
3072
|
+
});
|
|
3073
|
+
const anomalies = reviewedRows.map((row) => detectAnomaly(row)).filter((anomaly) => anomaly !== null).sort(
|
|
3074
|
+
(left, right) => (left.severity === "high" ? 1 : 0) === (right.severity === "high" ? 1 : 0) ? (right.date || "").localeCompare(left.date || "") || left.emailId.localeCompare(right.emailId) : left.severity === "high" ? -1 : 1
|
|
3075
|
+
);
|
|
3076
|
+
const highCount = anomalies.filter((anomaly) => anomaly.severity === "high").length;
|
|
3077
|
+
const mediumCount = anomalies.filter((anomaly) => anomaly.severity === "medium").length;
|
|
3078
|
+
return {
|
|
3079
|
+
anomalies: anomalies.slice(0, limit),
|
|
3080
|
+
totalReviewed: reviewedRows.length,
|
|
3081
|
+
anomalyCount: anomalies.length,
|
|
3082
|
+
summary: summarizeReview(reviewedRows.length, anomalies.length, highCount, mediumCount)
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
// src/core/stats/labels.ts
|
|
3087
|
+
async function getLabelDistribution() {
|
|
3088
|
+
const sqlite = getStatsSqlite();
|
|
3089
|
+
const rows = sqlite.prepare(
|
|
3090
|
+
`
|
|
3091
|
+
SELECT
|
|
3092
|
+
label.value AS labelId,
|
|
3093
|
+
COUNT(*) AS totalMessages,
|
|
3094
|
+
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadMessages
|
|
3095
|
+
FROM emails AS e, json_each(e.label_ids) AS label
|
|
3096
|
+
GROUP BY label.value
|
|
3097
|
+
ORDER BY totalMessages DESC, unreadMessages DESC, label.value ASC
|
|
3098
|
+
`
|
|
3099
|
+
).all();
|
|
3100
|
+
return rows.map((row) => ({
|
|
3101
|
+
labelId: row.labelId,
|
|
3102
|
+
labelName: resolveLabelName(row.labelId),
|
|
3103
|
+
totalMessages: row.totalMessages,
|
|
3104
|
+
unreadMessages: row.unreadMessages
|
|
3105
|
+
}));
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
// src/core/stats/noise.ts
|
|
3109
|
+
var DAY_MS3 = 24 * 60 * 60 * 1e3;
|
|
3110
|
+
var SUGGESTED_CATEGORY_RULES = [
|
|
3111
|
+
{ category: "Receipts", keywords: ["receipt", "invoice", "payment", "order"] },
|
|
3112
|
+
{ category: "Shipping", keywords: ["shipping", "tracking", "delivery", "dispatch"] },
|
|
3113
|
+
{ category: "Newsletters", keywords: ["newsletter", "digest", "weekly", "update"] },
|
|
3114
|
+
{ category: "Notifications", keywords: ["noreply", "notification", "alert"] },
|
|
3115
|
+
{ category: "Promotions", keywords: ["promo", "offer", "deal", "sale", "marketing"] },
|
|
3116
|
+
{ category: "Social", keywords: ["linkedin", "facebook", "twitter", "social"] }
|
|
3117
|
+
];
|
|
3118
|
+
function toIsoString2(value) {
|
|
3119
|
+
if (!value) {
|
|
3120
|
+
return null;
|
|
3121
|
+
}
|
|
3122
|
+
return new Date(value).toISOString();
|
|
3123
|
+
}
|
|
3124
|
+
function roundNoiseScore(messageCount, unreadRate) {
|
|
3125
|
+
return Math.round(messageCount * unreadRate * 10 / 100) / 10;
|
|
3126
|
+
}
|
|
3127
|
+
function getSuggestedCategory(email, name) {
|
|
3128
|
+
const haystack = `${email} ${name}`.toLowerCase();
|
|
3129
|
+
for (const rule of SUGGESTED_CATEGORY_RULES) {
|
|
3130
|
+
if (rule.keywords.some((keyword) => haystack.includes(keyword))) {
|
|
3131
|
+
return rule.category;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
return "Other";
|
|
3135
|
+
}
|
|
3136
|
+
function compareNoiseSenders(sortBy) {
|
|
3137
|
+
return (left, right) => {
|
|
3138
|
+
switch (sortBy) {
|
|
3139
|
+
case "all_time_noise_score":
|
|
3140
|
+
return right.allTimeNoiseScore - left.allTimeNoiseScore || right.noiseScore - left.noiseScore || right.allTimeMessageCount - left.allTimeMessageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
|
|
3141
|
+
case "message_count":
|
|
3142
|
+
return right.messageCount - left.messageCount || right.noiseScore - left.noiseScore || right.allTimeMessageCount - left.allTimeMessageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
|
|
3143
|
+
case "unread_rate":
|
|
3144
|
+
return right.unreadRate - left.unreadRate || right.noiseScore - left.noiseScore || right.messageCount - left.messageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
|
|
3145
|
+
case "noise_score":
|
|
3146
|
+
default:
|
|
3147
|
+
return right.noiseScore - left.noiseScore || right.allTimeNoiseScore - left.allTimeNoiseScore || right.messageCount - left.messageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
|
|
3148
|
+
}
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3151
|
+
async function getNoiseSenders(options = {}) {
|
|
3152
|
+
await detectNewsletters();
|
|
3153
|
+
const sqlite = getStatsSqlite();
|
|
3154
|
+
const limit = Math.min(50, normalizeLimit(options.limit, 20));
|
|
3155
|
+
const minNoiseScore = options.minNoiseScore ?? 5;
|
|
3156
|
+
const activeDays = Math.max(1, Math.floor(options.activeDays ?? 90));
|
|
3157
|
+
const activeSince = Date.now() - activeDays * DAY_MS3;
|
|
3158
|
+
const sortBy = options.sortBy ?? "noise_score";
|
|
3159
|
+
const rows = sqlite.prepare(
|
|
3160
|
+
`
|
|
3161
|
+
SELECT
|
|
3162
|
+
e.from_address AS email,
|
|
3163
|
+
COALESCE(MAX(NULLIF(TRIM(e.from_name), '')), e.from_address) AS name,
|
|
3164
|
+
COUNT(*) AS messageCount,
|
|
3165
|
+
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
|
|
3166
|
+
MAX(e.date) AS lastSeen,
|
|
3167
|
+
MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
|
|
3168
|
+
GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
|
|
3169
|
+
') AS emailUnsubscribeHeaders,
|
|
3170
|
+
MAX(CASE WHEN ns.email IS NOT NULL THEN 1 ELSE 0 END) AS isNewsletter,
|
|
3171
|
+
COALESCE(MAX(all_time.allTimeCount), COUNT(*)) AS allTimeMessageCount,
|
|
3172
|
+
COALESCE(
|
|
3173
|
+
MAX(all_time.allTimeUnreadCount),
|
|
3174
|
+
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END)
|
|
3175
|
+
) AS allTimeUnreadCount
|
|
3176
|
+
FROM emails AS e
|
|
3177
|
+
LEFT JOIN newsletter_senders AS ns
|
|
3178
|
+
ON LOWER(ns.email) = LOWER(e.from_address)
|
|
3179
|
+
LEFT JOIN (
|
|
3180
|
+
SELECT
|
|
3181
|
+
LOWER(from_address) AS senderKey,
|
|
3182
|
+
COUNT(*) AS allTimeCount,
|
|
3183
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS allTimeUnreadCount
|
|
3184
|
+
FROM emails
|
|
3185
|
+
WHERE from_address IS NOT NULL
|
|
3186
|
+
AND TRIM(from_address) <> ''
|
|
3187
|
+
GROUP BY LOWER(from_address)
|
|
3188
|
+
) AS all_time
|
|
3189
|
+
ON all_time.senderKey = LOWER(e.from_address)
|
|
3190
|
+
WHERE e.from_address IS NOT NULL
|
|
3191
|
+
AND TRIM(e.from_address) <> ''
|
|
3192
|
+
AND COALESCE(e.date, 0) >= ?
|
|
3193
|
+
GROUP BY LOWER(e.from_address)
|
|
3194
|
+
`
|
|
3195
|
+
).all(activeSince);
|
|
3196
|
+
const senders = rows.map((row) => {
|
|
3197
|
+
const unreadRate = roundPercent(row.unreadCount, row.messageCount);
|
|
3198
|
+
const allTimeMessageCount = row.allTimeMessageCount ?? row.messageCount;
|
|
3199
|
+
const allTimeUnreadCount = row.allTimeUnreadCount ?? row.unreadCount;
|
|
3200
|
+
const allTimeUnreadRate = roundPercent(allTimeUnreadCount, allTimeMessageCount);
|
|
3201
|
+
const noiseScore = roundNoiseScore(row.messageCount, unreadRate);
|
|
3202
|
+
const allTimeNoiseScore = roundNoiseScore(allTimeMessageCount, allTimeUnreadRate);
|
|
3203
|
+
const unsubscribe2 = resolveUnsubscribeTarget(
|
|
3204
|
+
row.newsletterUnsubscribeLink,
|
|
3205
|
+
row.emailUnsubscribeHeaders
|
|
3206
|
+
);
|
|
3207
|
+
return {
|
|
3208
|
+
email: row.email,
|
|
3209
|
+
name: row.name?.trim() || row.email,
|
|
3210
|
+
messageCount: row.messageCount,
|
|
3211
|
+
allTimeMessageCount,
|
|
3212
|
+
unreadCount: row.unreadCount,
|
|
3213
|
+
unreadRate,
|
|
3214
|
+
noiseScore,
|
|
3215
|
+
allTimeNoiseScore,
|
|
3216
|
+
lastSeen: toIsoString2(row.lastSeen),
|
|
3217
|
+
isNewsletter: row.isNewsletter === 1,
|
|
3218
|
+
hasUnsubscribeLink: Boolean(unsubscribe2.unsubscribeLink),
|
|
3219
|
+
unsubscribeLink: unsubscribe2.unsubscribeLink,
|
|
3220
|
+
suggestedCategory: getSuggestedCategory(row.email, row.name?.trim() || row.email)
|
|
3221
|
+
};
|
|
3222
|
+
}).filter((sender) => sender.noiseScore >= minNoiseScore).sort(compareNoiseSenders(sortBy)).slice(0, limit);
|
|
3223
|
+
return { senders };
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
// src/core/stats/query.ts
|
|
3227
|
+
import { z as z2 } from "zod";
|
|
3228
|
+
var QUERY_EMAIL_GROUP_BY_VALUES = [
|
|
3229
|
+
"sender",
|
|
3230
|
+
"domain",
|
|
3231
|
+
"label",
|
|
3232
|
+
"year_month",
|
|
3233
|
+
"year_week",
|
|
3234
|
+
"day_of_week",
|
|
3235
|
+
"is_read",
|
|
3236
|
+
"is_newsletter"
|
|
3237
|
+
];
|
|
3238
|
+
var QUERY_EMAIL_AGGREGATE_VALUES = [
|
|
3239
|
+
"count",
|
|
3240
|
+
"unread_count",
|
|
3241
|
+
"read_count",
|
|
3242
|
+
"unread_rate",
|
|
3243
|
+
"oldest",
|
|
3244
|
+
"newest",
|
|
3245
|
+
"sender_count"
|
|
3246
|
+
];
|
|
3247
|
+
var QUERY_EMAIL_HAVING_FIELDS = [
|
|
3248
|
+
"count",
|
|
3249
|
+
"unread_count",
|
|
3250
|
+
"unread_rate",
|
|
3251
|
+
"sender_count"
|
|
3252
|
+
];
|
|
3253
|
+
var CATEGORY_LABEL_LIKE_PATTERN = `${CATEGORY_LABEL_PREFIX.replace(/_/g, "\\_")}%`;
|
|
3254
|
+
var SYSTEM_LABEL_SQL = SYSTEM_LABEL_IDS.map((label) => `'${label}'`).join(", ");
|
|
3255
|
+
var DOMAIN_SQL = `
|
|
3256
|
+
LOWER(
|
|
3257
|
+
CASE
|
|
3258
|
+
WHEN INSTR(COALESCE(e.from_address, ''), '@') > 0
|
|
3259
|
+
THEN SUBSTR(e.from_address, INSTR(e.from_address, '@') + 1)
|
|
3260
|
+
ELSE ''
|
|
3261
|
+
END
|
|
3262
|
+
)
|
|
3263
|
+
`;
|
|
3264
|
+
var queryEmailsFiltersSchema = z2.object({
|
|
3265
|
+
from: z2.string().optional(),
|
|
3266
|
+
from_contains: z2.string().optional(),
|
|
3267
|
+
domain: z2.string().optional(),
|
|
3268
|
+
domain_contains: z2.string().optional(),
|
|
3269
|
+
subject_contains: z2.string().optional(),
|
|
3270
|
+
date_after: z2.string().optional(),
|
|
3271
|
+
date_before: z2.string().optional(),
|
|
3272
|
+
is_read: z2.boolean().optional(),
|
|
3273
|
+
is_newsletter: z2.boolean().optional(),
|
|
3274
|
+
has_label: z2.boolean().optional(),
|
|
3275
|
+
label: z2.string().optional(),
|
|
3276
|
+
has_unsubscribe: z2.boolean().optional(),
|
|
3277
|
+
min_sender_messages: z2.number().int().positive().optional()
|
|
3278
|
+
}).strict();
|
|
3279
|
+
var havingConditionSchema = z2.object({
|
|
3280
|
+
gte: z2.number().optional(),
|
|
3281
|
+
lte: z2.number().optional()
|
|
3282
|
+
}).strict().refine(
|
|
3283
|
+
(value) => value.gte !== void 0 || value.lte !== void 0,
|
|
3284
|
+
{ message: "Provide at least one of gte or lte." }
|
|
3285
|
+
);
|
|
3286
|
+
var queryEmailsHavingSchema = z2.object({
|
|
3287
|
+
count: havingConditionSchema.optional(),
|
|
3288
|
+
unread_count: havingConditionSchema.optional(),
|
|
3289
|
+
unread_rate: havingConditionSchema.optional(),
|
|
3290
|
+
sender_count: havingConditionSchema.optional()
|
|
3291
|
+
}).strict();
|
|
3292
|
+
var queryEmailsInputSchema = z2.object({
|
|
3293
|
+
filters: queryEmailsFiltersSchema.optional(),
|
|
3294
|
+
group_by: z2.enum(QUERY_EMAIL_GROUP_BY_VALUES).optional(),
|
|
3295
|
+
aggregates: z2.array(z2.enum(QUERY_EMAIL_AGGREGATE_VALUES)).min(1).optional(),
|
|
3296
|
+
having: queryEmailsHavingSchema.optional(),
|
|
3297
|
+
order_by: z2.string().optional(),
|
|
3298
|
+
limit: z2.number().int().positive().max(500).optional()
|
|
3299
|
+
}).strict();
|
|
3300
|
+
var QUERY_EMAILS_FIELD_SCHEMA = {
|
|
3301
|
+
description: "Available fields for the query_emails tool.",
|
|
3302
|
+
filters: {
|
|
3303
|
+
from: { type: "string", description: "Exact sender email (case-insensitive)" },
|
|
3304
|
+
from_contains: { type: "string", description: "Partial match on sender email" },
|
|
3305
|
+
domain: { type: "string", description: "Exact sender domain" },
|
|
3306
|
+
domain_contains: { type: "string", description: "Partial match on sender domain" },
|
|
3307
|
+
subject_contains: { type: "string", description: "Partial match on subject line" },
|
|
3308
|
+
date_after: { type: "string", description: "ISO date \u2014 emails after this date" },
|
|
3309
|
+
date_before: { type: "string", description: "ISO date \u2014 emails before this date" },
|
|
3310
|
+
is_read: { type: "boolean", description: "Filter by read/unread state" },
|
|
3311
|
+
is_newsletter: { type: "boolean", description: "Sender detected as newsletter" },
|
|
3312
|
+
has_label: { type: "boolean", description: "Has any user-applied label" },
|
|
3313
|
+
label: { type: "string", description: "Has this specific label" },
|
|
3314
|
+
has_unsubscribe: { type: "boolean", description: "Has List-Unsubscribe header" },
|
|
3315
|
+
min_sender_messages: { type: "integer", description: "Sender has at least this many total emails" }
|
|
3316
|
+
},
|
|
3317
|
+
group_by: [
|
|
3318
|
+
{ value: "sender", description: "Group by sender email address" },
|
|
3319
|
+
{ value: "domain", description: "Group by sender domain" },
|
|
3320
|
+
{ value: "label", description: "Group by applied label (expands multi-label emails)" },
|
|
3321
|
+
{ value: "year_month", description: "Group by month (YYYY-MM)" },
|
|
3322
|
+
{ value: "year_week", description: "Group by week (YYYY-WNN)" },
|
|
3323
|
+
{ value: "day_of_week", description: "Group by day of week (0=Sunday)" },
|
|
3324
|
+
{ value: "is_read", description: "Group by read/unread state" },
|
|
3325
|
+
{ value: "is_newsletter", description: "Group by newsletter detection" }
|
|
3326
|
+
],
|
|
3327
|
+
aggregates: [
|
|
3328
|
+
{ value: "count", description: "Number of emails" },
|
|
3329
|
+
{ value: "unread_count", description: "Number of unread emails" },
|
|
3330
|
+
{ value: "read_count", description: "Number of read emails" },
|
|
3331
|
+
{ value: "unread_rate", description: "Percentage of emails that are unread" },
|
|
3332
|
+
{ value: "oldest", description: "Earliest email date (ISO string)" },
|
|
3333
|
+
{ value: "newest", description: "Latest email date (ISO string)" },
|
|
3334
|
+
{ value: "sender_count", description: "Count of distinct senders" }
|
|
3335
|
+
],
|
|
3336
|
+
having_fields: [...QUERY_EMAIL_HAVING_FIELDS],
|
|
3337
|
+
example_queries: [
|
|
3338
|
+
{
|
|
3339
|
+
description: "Monthly volume trend for Amazon",
|
|
3340
|
+
query: {
|
|
3341
|
+
filters: { domain_contains: "amazon" },
|
|
3342
|
+
group_by: "year_month",
|
|
3343
|
+
aggregates: ["count", "unread_rate"],
|
|
3344
|
+
order_by: "year_month asc"
|
|
3345
|
+
}
|
|
3346
|
+
},
|
|
3347
|
+
{
|
|
3348
|
+
description: "Domains with 95%+ unread rate and 50+ emails",
|
|
3349
|
+
query: {
|
|
3350
|
+
group_by: "domain",
|
|
3351
|
+
aggregates: ["count", "unread_rate"],
|
|
3352
|
+
having: { count: { gte: 50 }, unread_rate: { gte: 95 } }
|
|
3353
|
+
}
|
|
3354
|
+
},
|
|
3355
|
+
{
|
|
3356
|
+
description: "What day of the week gets the most email?",
|
|
3357
|
+
query: {
|
|
3358
|
+
group_by: "day_of_week",
|
|
3359
|
+
aggregates: ["count", "sender_count"]
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
]
|
|
3363
|
+
};
|
|
3364
|
+
var GROUP_BY_SQL_MAP = {
|
|
3365
|
+
sender: "LOWER(COALESCE(e.from_address, ''))",
|
|
3366
|
+
domain: DOMAIN_SQL,
|
|
3367
|
+
label: "CAST(grouped_label.value AS TEXT)",
|
|
3368
|
+
year_month: "STRFTIME('%Y-%m', e.date / 1000, 'unixepoch')",
|
|
3369
|
+
year_week: "STRFTIME('%Y-W%W', e.date / 1000, 'unixepoch')",
|
|
3370
|
+
day_of_week: "CAST(STRFTIME('%w', e.date / 1000, 'unixepoch') AS INTEGER)",
|
|
3371
|
+
is_read: "COALESCE(e.is_read, 0)",
|
|
3372
|
+
is_newsletter: "CASE WHEN ns.email IS NOT NULL THEN 1 ELSE 0 END"
|
|
3373
|
+
};
|
|
3374
|
+
var AGGREGATE_SQL_MAP = {
|
|
3375
|
+
count: "COUNT(*)",
|
|
3376
|
+
unread_count: "SUM(CASE WHEN COALESCE(e.is_read, 0) = 0 THEN 1 ELSE 0 END)",
|
|
3377
|
+
read_count: "SUM(CASE WHEN COALESCE(e.is_read, 0) = 1 THEN 1 ELSE 0 END)",
|
|
3378
|
+
unread_rate: `
|
|
3379
|
+
ROUND(
|
|
3380
|
+
CASE
|
|
3381
|
+
WHEN COUNT(*) = 0 THEN 0
|
|
3382
|
+
ELSE 100.0 * SUM(CASE WHEN COALESCE(e.is_read, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*)
|
|
3383
|
+
END,
|
|
3384
|
+
1
|
|
3385
|
+
)
|
|
3386
|
+
`,
|
|
3387
|
+
oldest: "MIN(e.date)",
|
|
3388
|
+
newest: "MAX(e.date)",
|
|
3389
|
+
sender_count: `
|
|
3390
|
+
COUNT(
|
|
3391
|
+
DISTINCT CASE
|
|
3392
|
+
WHEN e.from_address IS NOT NULL AND TRIM(e.from_address) <> ''
|
|
3393
|
+
THEN LOWER(e.from_address)
|
|
3394
|
+
ELSE NULL
|
|
3395
|
+
END
|
|
3396
|
+
)
|
|
3397
|
+
`
|
|
3398
|
+
};
|
|
3399
|
+
function userLabelPredicate(column) {
|
|
3400
|
+
return `
|
|
3401
|
+
${column} IS NOT NULL
|
|
3402
|
+
AND TRIM(CAST(${column} AS TEXT)) <> ''
|
|
3403
|
+
AND CAST(${column} AS TEXT) NOT IN (${SYSTEM_LABEL_SQL})
|
|
3404
|
+
AND CAST(${column} AS TEXT) NOT LIKE '${CATEGORY_LABEL_LIKE_PATTERN}' ESCAPE '\\'
|
|
3405
|
+
`;
|
|
3406
|
+
}
|
|
3407
|
+
function toIsoString3(value) {
|
|
3408
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
3409
|
+
return null;
|
|
3410
|
+
}
|
|
3411
|
+
return new Date(value).toISOString();
|
|
3412
|
+
}
|
|
3413
|
+
function normalizeGroupValue(groupBy, value) {
|
|
3414
|
+
switch (groupBy) {
|
|
3415
|
+
case "is_read":
|
|
3416
|
+
case "is_newsletter":
|
|
3417
|
+
return Number(value ?? 0) === 1;
|
|
3418
|
+
case "day_of_week":
|
|
3419
|
+
return Number(value ?? 0);
|
|
3420
|
+
case "sender":
|
|
3421
|
+
case "domain":
|
|
3422
|
+
case "label":
|
|
3423
|
+
case "year_month":
|
|
3424
|
+
case "year_week":
|
|
3425
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
function normalizeAggregateValue(aggregate, value) {
|
|
3429
|
+
switch (aggregate) {
|
|
3430
|
+
case "oldest":
|
|
3431
|
+
case "newest":
|
|
3432
|
+
return toIsoString3(value);
|
|
3433
|
+
case "count":
|
|
3434
|
+
case "unread_count":
|
|
3435
|
+
case "read_count":
|
|
3436
|
+
case "sender_count":
|
|
3437
|
+
return Number(value ?? 0);
|
|
3438
|
+
case "unread_rate":
|
|
3439
|
+
return Number(value ?? 0);
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
function parseDateFilter(field, value) {
|
|
3443
|
+
const timestamp = Date.parse(value);
|
|
3444
|
+
if (Number.isNaN(timestamp)) {
|
|
3445
|
+
throw new Error(`Invalid ${field} value: ${value}`);
|
|
3446
|
+
}
|
|
3447
|
+
return timestamp;
|
|
3448
|
+
}
|
|
3449
|
+
function resolveAggregates(aggregates) {
|
|
3450
|
+
return Array.from(new Set(aggregates && aggregates.length > 0 ? aggregates : ["count"]));
|
|
3451
|
+
}
|
|
3452
|
+
function resolveOrderBy(orderBy, groupBy, aggregates) {
|
|
3453
|
+
const defaultField = aggregates.includes("count") ? "count" : aggregates[0];
|
|
3454
|
+
const rawValue = (orderBy || `${defaultField} desc`).trim();
|
|
3455
|
+
const match = rawValue.match(/^([a-z_]+)\s+(asc|desc)$/i);
|
|
3456
|
+
if (!match) {
|
|
3457
|
+
throw new Error(`Invalid order_by value: ${rawValue}`);
|
|
3458
|
+
}
|
|
3459
|
+
const [, field, direction] = match;
|
|
3460
|
+
const allowedFields = new Set(aggregates);
|
|
3461
|
+
if (groupBy) {
|
|
3462
|
+
allowedFields.add(groupBy);
|
|
3463
|
+
}
|
|
3464
|
+
if (!allowedFields.has(field)) {
|
|
3465
|
+
throw new Error(`Invalid order_by field: ${field}`);
|
|
3466
|
+
}
|
|
3467
|
+
return `${field} ${direction.toLowerCase()}`;
|
|
3468
|
+
}
|
|
3469
|
+
function buildWhereClauses(filters, groupBy) {
|
|
3470
|
+
const whereParts = [];
|
|
3471
|
+
const params = [];
|
|
3472
|
+
if (filters.from !== void 0) {
|
|
3473
|
+
whereParts.push("LOWER(COALESCE(e.from_address, '')) = LOWER(?)");
|
|
3474
|
+
params.push(filters.from);
|
|
3475
|
+
}
|
|
3476
|
+
if (filters.from_contains !== void 0) {
|
|
3477
|
+
whereParts.push("LOWER(COALESCE(e.from_address, '')) LIKE '%' || LOWER(?) || '%'");
|
|
3478
|
+
params.push(filters.from_contains);
|
|
3479
|
+
}
|
|
3480
|
+
if (filters.domain !== void 0) {
|
|
3481
|
+
whereParts.push(`${DOMAIN_SQL} = LOWER(?)`);
|
|
3482
|
+
params.push(filters.domain);
|
|
3483
|
+
}
|
|
3484
|
+
if (filters.domain_contains !== void 0) {
|
|
3485
|
+
whereParts.push(`${DOMAIN_SQL} LIKE '%' || LOWER(?) || '%'`);
|
|
3486
|
+
params.push(filters.domain_contains);
|
|
3487
|
+
}
|
|
3488
|
+
if (filters.subject_contains !== void 0) {
|
|
3489
|
+
whereParts.push("LOWER(COALESCE(e.subject, '')) LIKE '%' || LOWER(?) || '%'");
|
|
3490
|
+
params.push(filters.subject_contains);
|
|
3491
|
+
}
|
|
3492
|
+
if (filters.date_after !== void 0) {
|
|
3493
|
+
whereParts.push("COALESCE(e.date, 0) >= ?");
|
|
3494
|
+
params.push(parseDateFilter("date_after", filters.date_after));
|
|
3495
|
+
}
|
|
3496
|
+
if (filters.date_before !== void 0) {
|
|
3497
|
+
whereParts.push("COALESCE(e.date, 0) <= ?");
|
|
3498
|
+
params.push(parseDateFilter("date_before", filters.date_before));
|
|
3499
|
+
}
|
|
3500
|
+
if (filters.is_read !== void 0) {
|
|
3501
|
+
whereParts.push("COALESCE(e.is_read, 0) = ?");
|
|
3502
|
+
params.push(filters.is_read ? 1 : 0);
|
|
3503
|
+
}
|
|
3504
|
+
if (filters.is_newsletter !== void 0) {
|
|
3505
|
+
whereParts.push(filters.is_newsletter ? "ns.email IS NOT NULL" : "ns.email IS NULL");
|
|
3506
|
+
}
|
|
3507
|
+
if (filters.has_label !== void 0) {
|
|
3508
|
+
whereParts.push(
|
|
3509
|
+
filters.has_label ? `EXISTS (
|
|
3510
|
+
SELECT 1
|
|
3511
|
+
FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
|
|
3512
|
+
WHERE ${userLabelPredicate("label_filter.value")}
|
|
3513
|
+
)` : `NOT EXISTS (
|
|
3514
|
+
SELECT 1
|
|
3515
|
+
FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
|
|
3516
|
+
WHERE ${userLabelPredicate("label_filter.value")}
|
|
3517
|
+
)`
|
|
3518
|
+
);
|
|
3519
|
+
}
|
|
3520
|
+
if (filters.label !== void 0) {
|
|
3521
|
+
whereParts.push(`
|
|
3522
|
+
EXISTS (
|
|
3523
|
+
SELECT 1
|
|
3524
|
+
FROM json_each(COALESCE(e.label_ids, '[]')) AS label_filter
|
|
3525
|
+
WHERE LOWER(CAST(label_filter.value AS TEXT)) = LOWER(?)
|
|
3526
|
+
)
|
|
3527
|
+
`);
|
|
3528
|
+
params.push(filters.label);
|
|
3529
|
+
}
|
|
3530
|
+
if (filters.has_unsubscribe !== void 0) {
|
|
3531
|
+
whereParts.push(
|
|
3532
|
+
filters.has_unsubscribe ? "NULLIF(TRIM(e.list_unsubscribe), '') IS NOT NULL" : "(e.list_unsubscribe IS NULL OR TRIM(e.list_unsubscribe) = '')"
|
|
3533
|
+
);
|
|
3534
|
+
}
|
|
3535
|
+
if (filters.min_sender_messages !== void 0) {
|
|
3536
|
+
whereParts.push("COALESCE(sender_stats.totalFromSender, 0) >= ?");
|
|
3537
|
+
params.push(filters.min_sender_messages);
|
|
3538
|
+
}
|
|
3539
|
+
if (groupBy === "label") {
|
|
3540
|
+
whereParts.push(userLabelPredicate("grouped_label.value"));
|
|
3541
|
+
}
|
|
3542
|
+
return {
|
|
3543
|
+
sql: whereParts.length > 0 ? `WHERE ${whereParts.join("\n AND ")}` : "",
|
|
3544
|
+
params
|
|
3545
|
+
};
|
|
3546
|
+
}
|
|
3547
|
+
function buildHavingClause(having) {
|
|
3548
|
+
const parts = [];
|
|
3549
|
+
for (const field of QUERY_EMAIL_HAVING_FIELDS) {
|
|
3550
|
+
const condition = having[field];
|
|
3551
|
+
if (!condition) {
|
|
3552
|
+
continue;
|
|
3553
|
+
}
|
|
3554
|
+
const expression = AGGREGATE_SQL_MAP[field];
|
|
3555
|
+
if (condition.gte !== void 0) {
|
|
3556
|
+
parts.push(`${expression} >= ${condition.gte}`);
|
|
3557
|
+
}
|
|
3558
|
+
if (condition.lte !== void 0) {
|
|
3559
|
+
parts.push(`${expression} <= ${condition.lte}`);
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
return parts.length > 0 ? `HAVING ${parts.join("\n AND ")}` : "";
|
|
3563
|
+
}
|
|
3564
|
+
function normalizeRow(row, groupBy, aggregates) {
|
|
3565
|
+
const normalized = {};
|
|
3566
|
+
if (groupBy) {
|
|
3567
|
+
normalized[groupBy] = normalizeGroupValue(groupBy, row[groupBy]);
|
|
3568
|
+
}
|
|
3569
|
+
for (const aggregate of aggregates) {
|
|
3570
|
+
normalized[aggregate] = normalizeAggregateValue(aggregate, row[aggregate]);
|
|
3571
|
+
}
|
|
3572
|
+
return normalized;
|
|
3573
|
+
}
|
|
3574
|
+
async function queryEmails(options = {}) {
|
|
3575
|
+
const parsed = queryEmailsInputSchema.parse(options);
|
|
3576
|
+
await detectNewsletters();
|
|
3577
|
+
const sqlite = getStatsSqlite();
|
|
3578
|
+
const filters = parsed.filters ?? {};
|
|
3579
|
+
const groupBy = parsed.group_by;
|
|
3580
|
+
const aggregates = resolveAggregates(parsed.aggregates);
|
|
3581
|
+
const having = parsed.having ?? {};
|
|
3582
|
+
const orderBy = resolveOrderBy(parsed.order_by, groupBy, aggregates);
|
|
3583
|
+
const limit = Math.min(500, normalizeLimit(parsed.limit, 50));
|
|
3584
|
+
const { sql: whereSql, params } = buildWhereClauses(filters, groupBy);
|
|
3585
|
+
const havingSql = buildHavingClause(having);
|
|
3586
|
+
const fromSql = [
|
|
3587
|
+
"FROM emails AS e",
|
|
3588
|
+
"LEFT JOIN newsletter_senders AS ns ON LOWER(ns.email) = LOWER(e.from_address)",
|
|
3589
|
+
`LEFT JOIN (
|
|
3590
|
+
SELECT
|
|
3591
|
+
LOWER(from_address) AS senderKey,
|
|
3592
|
+
COUNT(*) AS totalFromSender
|
|
3593
|
+
FROM emails
|
|
3594
|
+
WHERE from_address IS NOT NULL
|
|
3595
|
+
AND TRIM(from_address) <> ''
|
|
3596
|
+
GROUP BY LOWER(from_address)
|
|
3597
|
+
) AS sender_stats ON sender_stats.senderKey = LOWER(e.from_address)`,
|
|
3598
|
+
groupBy === "label" ? "JOIN json_each(COALESCE(e.label_ids, '[]')) AS grouped_label" : ""
|
|
3599
|
+
].filter(Boolean).join("\n");
|
|
3600
|
+
const selectParts = [];
|
|
3601
|
+
if (groupBy) {
|
|
3602
|
+
selectParts.push(`${GROUP_BY_SQL_MAP[groupBy]} AS ${groupBy}`);
|
|
3603
|
+
}
|
|
3604
|
+
for (const aggregate of aggregates) {
|
|
3605
|
+
selectParts.push(`${AGGREGATE_SQL_MAP[aggregate]} AS ${aggregate}`);
|
|
3606
|
+
}
|
|
3607
|
+
const groupBySql = groupBy ? `GROUP BY ${GROUP_BY_SQL_MAP[groupBy]}` : "";
|
|
3608
|
+
const orderBySql = `ORDER BY ${orderBy.split(" ")[0]} ${orderBy.split(" ")[1].toUpperCase()}`;
|
|
3609
|
+
const totalRow = groupBy ? sqlite.prepare(
|
|
3610
|
+
`
|
|
3611
|
+
SELECT COUNT(*) AS totalRows
|
|
3612
|
+
FROM (
|
|
3613
|
+
SELECT 1
|
|
3614
|
+
${fromSql}
|
|
3615
|
+
${whereSql}
|
|
3616
|
+
${groupBySql}
|
|
3617
|
+
${havingSql}
|
|
3618
|
+
) AS grouped_rows
|
|
3619
|
+
`
|
|
3620
|
+
).get(...params) : void 0;
|
|
3621
|
+
const rows = sqlite.prepare(
|
|
3622
|
+
`
|
|
3623
|
+
SELECT
|
|
3624
|
+
${selectParts.join(",\n ")}
|
|
3625
|
+
${fromSql}
|
|
3626
|
+
${whereSql}
|
|
3627
|
+
${groupBySql}
|
|
3628
|
+
${havingSql}
|
|
3629
|
+
${orderBySql}
|
|
3630
|
+
LIMIT ?
|
|
3631
|
+
`
|
|
3632
|
+
).all(...params, limit);
|
|
3633
|
+
return {
|
|
3634
|
+
rows: rows.map((row) => normalizeRow(row, groupBy, aggregates)),
|
|
3635
|
+
totalRows: groupBy ? totalRow?.totalRows ?? 0 : rows.length,
|
|
3636
|
+
query: {
|
|
3637
|
+
filters,
|
|
3638
|
+
group_by: groupBy ?? null,
|
|
3639
|
+
aggregates,
|
|
3640
|
+
having,
|
|
3641
|
+
order_by: orderBy,
|
|
3642
|
+
limit
|
|
3643
|
+
}
|
|
3644
|
+
};
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
// src/core/stats/sender.ts
|
|
3648
|
+
function buildSenderWhereClause(period) {
|
|
3649
|
+
const whereParts = [
|
|
3650
|
+
"from_address IS NOT NULL",
|
|
3651
|
+
"TRIM(from_address) <> ''"
|
|
3652
|
+
];
|
|
3653
|
+
const params = [];
|
|
3654
|
+
const periodStart = getPeriodStart(period);
|
|
2329
3655
|
if (periodStart !== null) {
|
|
2330
3656
|
whereParts.push("date >= ?");
|
|
2331
3657
|
params.push(periodStart);
|
|
@@ -2476,6 +3802,270 @@ async function getSenderStats(emailOrDomain) {
|
|
|
2476
3802
|
};
|
|
2477
3803
|
}
|
|
2478
3804
|
|
|
3805
|
+
// src/core/stats/uncategorized.ts
|
|
3806
|
+
var SYSTEM_LABEL_IDS2 = [
|
|
3807
|
+
"INBOX",
|
|
3808
|
+
"UNREAD",
|
|
3809
|
+
"IMPORTANT",
|
|
3810
|
+
"SENT",
|
|
3811
|
+
"DRAFT",
|
|
3812
|
+
"SPAM",
|
|
3813
|
+
"TRASH",
|
|
3814
|
+
"STARRED"
|
|
3815
|
+
];
|
|
3816
|
+
var CATEGORY_LABEL_PATTERN = "CATEGORY\\_%";
|
|
3817
|
+
function toIsoString4(value) {
|
|
3818
|
+
if (!value) {
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
return new Date(value).toISOString();
|
|
3822
|
+
}
|
|
3823
|
+
function parseJsonArray4(raw) {
|
|
3824
|
+
if (!raw) {
|
|
3825
|
+
return [];
|
|
3826
|
+
}
|
|
3827
|
+
try {
|
|
3828
|
+
const parsed = JSON.parse(raw);
|
|
3829
|
+
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
|
|
3830
|
+
} catch {
|
|
3831
|
+
return [];
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
function resolveSinceTimestamp2(since) {
|
|
3835
|
+
if (!since) {
|
|
3836
|
+
return null;
|
|
3837
|
+
}
|
|
3838
|
+
const parsed = Date.parse(since);
|
|
3839
|
+
if (Number.isNaN(parsed)) {
|
|
3840
|
+
throw new Error(`Invalid since value: ${since}`);
|
|
3841
|
+
}
|
|
3842
|
+
return parsed;
|
|
3843
|
+
}
|
|
3844
|
+
function computeConfidence(row) {
|
|
3845
|
+
const signals = [];
|
|
3846
|
+
let score = 0;
|
|
3847
|
+
const hasDefinitiveNewsletterSignal = Boolean(row.listUnsubscribe && row.listUnsubscribe.trim()) || Boolean(row.detectionReason?.includes("list_unsubscribe"));
|
|
3848
|
+
if (row.listUnsubscribe && row.listUnsubscribe.trim()) {
|
|
3849
|
+
signals.push("list_unsubscribe_header");
|
|
3850
|
+
score += 3;
|
|
3851
|
+
}
|
|
3852
|
+
if (row.detectionReason?.includes("list_unsubscribe")) {
|
|
3853
|
+
signals.push("newsletter_list_header");
|
|
3854
|
+
score += 2;
|
|
3855
|
+
}
|
|
3856
|
+
if ((row.totalFromSender ?? 0) >= 20) {
|
|
3857
|
+
signals.push("high_volume_sender");
|
|
3858
|
+
score += 2;
|
|
3859
|
+
} else if ((row.totalFromSender ?? 0) >= 5) {
|
|
3860
|
+
signals.push("moderate_volume_sender");
|
|
3861
|
+
score += 1;
|
|
3862
|
+
}
|
|
3863
|
+
if (row.detectionReason?.includes("known_sender_pattern")) {
|
|
3864
|
+
signals.push("automated_sender_pattern");
|
|
3865
|
+
score += 1;
|
|
3866
|
+
}
|
|
3867
|
+
if (row.detectionReason?.includes("bulk_sender_pattern")) {
|
|
3868
|
+
signals.push("bulk_sender_pattern");
|
|
3869
|
+
score += 1;
|
|
3870
|
+
}
|
|
3871
|
+
if ((row.totalFromSender ?? 0) <= 2 && !hasDefinitiveNewsletterSignal) {
|
|
3872
|
+
signals.push("rare_sender");
|
|
3873
|
+
score -= 3;
|
|
3874
|
+
}
|
|
3875
|
+
if (!row.detectionReason) {
|
|
3876
|
+
signals.push("no_newsletter_signals");
|
|
3877
|
+
score -= 2;
|
|
3878
|
+
}
|
|
3879
|
+
if (!row.detectionReason && !isLikelyAutomatedSenderAddress(row.sender || "")) {
|
|
3880
|
+
signals.push("personal_sender_address");
|
|
3881
|
+
score -= 2;
|
|
3882
|
+
}
|
|
3883
|
+
return {
|
|
3884
|
+
confidence: score >= 3 ? "high" : score >= 0 ? "medium" : "low",
|
|
3885
|
+
signals
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3888
|
+
function buildWhereClause(options) {
|
|
3889
|
+
const whereParts = [
|
|
3890
|
+
`
|
|
3891
|
+
NOT EXISTS (
|
|
3892
|
+
SELECT 1
|
|
3893
|
+
FROM json_each(COALESCE(e.label_ids, '[]')) AS label
|
|
3894
|
+
WHERE label.value IS NOT NULL
|
|
3895
|
+
AND TRIM(CAST(label.value AS TEXT)) <> ''
|
|
3896
|
+
AND label.value NOT IN (${SYSTEM_LABEL_IDS2.map(() => "?").join(", ")})
|
|
3897
|
+
AND label.value NOT LIKE ? ESCAPE '\\'
|
|
3898
|
+
)
|
|
3899
|
+
`
|
|
3900
|
+
];
|
|
3901
|
+
const params = [...SYSTEM_LABEL_IDS2, CATEGORY_LABEL_PATTERN];
|
|
3902
|
+
if (options.unreadOnly) {
|
|
3903
|
+
whereParts.push("COALESCE(e.is_read, 0) = 0");
|
|
3904
|
+
}
|
|
3905
|
+
if (options.sinceTimestamp !== null) {
|
|
3906
|
+
whereParts.push("COALESCE(e.date, 0) >= ?");
|
|
3907
|
+
params.push(options.sinceTimestamp);
|
|
3908
|
+
}
|
|
3909
|
+
return {
|
|
3910
|
+
clause: whereParts.join(" AND "),
|
|
3911
|
+
params
|
|
3912
|
+
};
|
|
3913
|
+
}
|
|
3914
|
+
async function getUncategorizedEmails(options = {}) {
|
|
3915
|
+
await detectNewsletters();
|
|
3916
|
+
const sqlite = getStatsSqlite();
|
|
3917
|
+
const limit = Math.min(1e3, normalizeLimit(options.limit, 50));
|
|
3918
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
3919
|
+
const sinceTimestamp = resolveSinceTimestamp2(options.since);
|
|
3920
|
+
const { clause, params } = buildWhereClause({
|
|
3921
|
+
sinceTimestamp,
|
|
3922
|
+
unreadOnly: options.unreadOnly ?? false
|
|
3923
|
+
});
|
|
3924
|
+
const totalRow = sqlite.prepare(
|
|
3925
|
+
`
|
|
3926
|
+
SELECT COUNT(*) AS total
|
|
3927
|
+
FROM emails AS e
|
|
3928
|
+
WHERE ${clause}
|
|
3929
|
+
`
|
|
3930
|
+
).get(...params);
|
|
3931
|
+
const rows = sqlite.prepare(
|
|
3932
|
+
`
|
|
3933
|
+
SELECT
|
|
3934
|
+
e.id AS id,
|
|
3935
|
+
e.thread_id AS threadId,
|
|
3936
|
+
e.from_address AS sender,
|
|
3937
|
+
e.subject AS subject,
|
|
3938
|
+
e.date AS date,
|
|
3939
|
+
e.snippet AS snippet,
|
|
3940
|
+
e.label_ids AS labelIds,
|
|
3941
|
+
e.is_read AS isRead,
|
|
3942
|
+
sender_stats.totalFromSender AS totalFromSender,
|
|
3943
|
+
sender_stats.unreadFromSender AS unreadFromSender,
|
|
3944
|
+
ns.detection_reason AS detectionReason,
|
|
3945
|
+
e.list_unsubscribe AS listUnsubscribe
|
|
3946
|
+
FROM emails AS e
|
|
3947
|
+
LEFT JOIN (
|
|
3948
|
+
SELECT
|
|
3949
|
+
LOWER(from_address) AS senderKey,
|
|
3950
|
+
COUNT(*) AS totalFromSender,
|
|
3951
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unreadFromSender
|
|
3952
|
+
FROM emails
|
|
3953
|
+
WHERE from_address IS NOT NULL
|
|
3954
|
+
AND TRIM(from_address) <> ''
|
|
3955
|
+
GROUP BY LOWER(from_address)
|
|
3956
|
+
) AS sender_stats
|
|
3957
|
+
ON sender_stats.senderKey = LOWER(e.from_address)
|
|
3958
|
+
LEFT JOIN newsletter_senders AS ns
|
|
3959
|
+
ON LOWER(ns.email) = LOWER(e.from_address)
|
|
3960
|
+
WHERE ${clause}
|
|
3961
|
+
ORDER BY COALESCE(e.date, 0) DESC, e.id ASC
|
|
3962
|
+
LIMIT ?
|
|
3963
|
+
OFFSET ?
|
|
3964
|
+
`
|
|
3965
|
+
).all(...params, limit, offset);
|
|
3966
|
+
const emails2 = rows.map((row) => {
|
|
3967
|
+
const totalFromSender = row.totalFromSender ?? 0;
|
|
3968
|
+
const unreadFromSender = row.unreadFromSender ?? 0;
|
|
3969
|
+
const confidence = computeConfidence(row);
|
|
3970
|
+
return {
|
|
3971
|
+
id: row.id,
|
|
3972
|
+
threadId: row.threadId || "",
|
|
3973
|
+
from: row.sender || "",
|
|
3974
|
+
subject: row.subject || "",
|
|
3975
|
+
date: toIsoString4(row.date),
|
|
3976
|
+
snippet: row.snippet || "",
|
|
3977
|
+
labels: parseJsonArray4(row.labelIds),
|
|
3978
|
+
isRead: row.isRead === 1,
|
|
3979
|
+
senderContext: {
|
|
3980
|
+
totalFromSender,
|
|
3981
|
+
unreadRate: roundPercent(unreadFromSender, totalFromSender),
|
|
3982
|
+
isNewsletter: Boolean(row.detectionReason),
|
|
3983
|
+
detectionReason: row.detectionReason,
|
|
3984
|
+
confidence: confidence.confidence,
|
|
3985
|
+
signals: confidence.signals
|
|
3986
|
+
}
|
|
3987
|
+
};
|
|
3988
|
+
});
|
|
3989
|
+
return {
|
|
3990
|
+
totalUncategorized: totalRow?.total ?? 0,
|
|
3991
|
+
returned: emails2.length,
|
|
3992
|
+
offset,
|
|
3993
|
+
hasMore: offset + emails2.length < (totalRow?.total ?? 0),
|
|
3994
|
+
emails: emails2
|
|
3995
|
+
};
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
// src/core/stats/unsubscribe.ts
|
|
3999
|
+
function toIsoString5(value) {
|
|
4000
|
+
if (!value) {
|
|
4001
|
+
return null;
|
|
4002
|
+
}
|
|
4003
|
+
return new Date(value).toISOString();
|
|
4004
|
+
}
|
|
4005
|
+
function roundImpactScore(messageCount, unreadRate) {
|
|
4006
|
+
return Math.round(messageCount * unreadRate * 10 / 100) / 10;
|
|
4007
|
+
}
|
|
4008
|
+
async function getUnsubscribeSuggestions(options = {}) {
|
|
4009
|
+
await detectNewsletters();
|
|
4010
|
+
const sqlite = getStatsSqlite();
|
|
4011
|
+
const limit = Math.min(50, normalizeLimit(options.limit, 20));
|
|
4012
|
+
const minMessages = normalizeLimit(options.minMessages, 5);
|
|
4013
|
+
const rows = sqlite.prepare(
|
|
4014
|
+
`
|
|
4015
|
+
SELECT
|
|
4016
|
+
e.from_address AS email,
|
|
4017
|
+
COALESCE(MAX(NULLIF(TRIM(e.from_name), '')), e.from_address) AS name,
|
|
4018
|
+
COUNT(*) AS messageCount,
|
|
4019
|
+
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
|
|
4020
|
+
MAX(CASE WHEN e.is_read = 1 THEN e.date ELSE NULL END) AS lastRead,
|
|
4021
|
+
MAX(e.date) AS lastReceived,
|
|
4022
|
+
MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
|
|
4023
|
+
GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
|
|
4024
|
+
') AS emailUnsubscribeHeaders
|
|
4025
|
+
FROM emails AS e
|
|
4026
|
+
LEFT JOIN newsletter_senders AS ns
|
|
4027
|
+
ON LOWER(ns.email) = LOWER(e.from_address)
|
|
4028
|
+
WHERE e.from_address IS NOT NULL
|
|
4029
|
+
AND TRIM(e.from_address) <> ''
|
|
4030
|
+
GROUP BY LOWER(e.from_address)
|
|
4031
|
+
HAVING COUNT(*) >= ?
|
|
4032
|
+
`
|
|
4033
|
+
).all(minMessages);
|
|
4034
|
+
const suggestions = rows.map((row) => {
|
|
4035
|
+
const unsubscribe2 = resolveUnsubscribeTarget(
|
|
4036
|
+
row.newsletterUnsubscribeLink,
|
|
4037
|
+
row.emailUnsubscribeHeaders
|
|
4038
|
+
);
|
|
4039
|
+
if (!unsubscribe2.unsubscribeLink || !unsubscribe2.unsubscribeMethod) {
|
|
4040
|
+
return null;
|
|
4041
|
+
}
|
|
4042
|
+
const unreadRate = roundPercent(row.unreadCount, row.messageCount);
|
|
4043
|
+
const readRate = roundPercent(row.messageCount - row.unreadCount, row.messageCount);
|
|
4044
|
+
return {
|
|
4045
|
+
email: row.email,
|
|
4046
|
+
name: row.name?.trim() || row.email,
|
|
4047
|
+
allTimeMessageCount: row.messageCount,
|
|
4048
|
+
unreadCount: row.unreadCount,
|
|
4049
|
+
unreadRate,
|
|
4050
|
+
readRate,
|
|
4051
|
+
lastRead: toIsoString5(row.lastRead),
|
|
4052
|
+
lastReceived: toIsoString5(row.lastReceived),
|
|
4053
|
+
unsubscribeLink: unsubscribe2.unsubscribeLink,
|
|
4054
|
+
unsubscribeMethod: unsubscribe2.unsubscribeMethod,
|
|
4055
|
+
impactScore: roundImpactScore(row.messageCount, unreadRate),
|
|
4056
|
+
reason: buildUnsubscribeReason(unreadRate, row.messageCount)
|
|
4057
|
+
};
|
|
4058
|
+
}).filter((suggestion) => suggestion !== null).filter(
|
|
4059
|
+
(suggestion) => options.unreadOnlySenders ? suggestion.unreadCount === suggestion.allTimeMessageCount : true
|
|
4060
|
+
).sort(
|
|
4061
|
+
(left, right) => right.impactScore - left.impactScore || right.allTimeMessageCount - left.allTimeMessageCount || right.unreadRate - left.unreadRate || (right.lastReceived || "").localeCompare(left.lastReceived || "") || left.email.localeCompare(right.email)
|
|
4062
|
+
);
|
|
4063
|
+
return {
|
|
4064
|
+
suggestions: suggestions.slice(0, limit),
|
|
4065
|
+
totalWithUnsubscribeLinks: suggestions.length
|
|
4066
|
+
};
|
|
4067
|
+
}
|
|
4068
|
+
|
|
2479
4069
|
// src/core/stats/volume.ts
|
|
2480
4070
|
function getBucketExpression(granularity) {
|
|
2481
4071
|
switch (granularity) {
|
|
@@ -2588,6 +4178,18 @@ async function getExecutionHistory(ruleId, limit = 20) {
|
|
|
2588
4178
|
const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(limit);
|
|
2589
4179
|
return runs.slice(0, limit);
|
|
2590
4180
|
}
|
|
4181
|
+
async function getExecutionStats(ruleId) {
|
|
4182
|
+
const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(1e4);
|
|
4183
|
+
return {
|
|
4184
|
+
totalRuns: runs.length,
|
|
4185
|
+
plannedRuns: runs.filter((run) => run.status === "planned").length,
|
|
4186
|
+
appliedRuns: runs.filter((run) => run.status === "applied").length,
|
|
4187
|
+
partialRuns: runs.filter((run) => run.status === "partial").length,
|
|
4188
|
+
errorRuns: runs.filter((run) => run.status === "error").length,
|
|
4189
|
+
undoneRuns: runs.filter((run) => run.status === "undone").length,
|
|
4190
|
+
lastExecutionAt: runs[0]?.createdAt ?? null
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
2591
4193
|
|
|
2592
4194
|
// src/core/rules/deploy.ts
|
|
2593
4195
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
@@ -2599,67 +4201,67 @@ import { join as join2 } from "path";
|
|
|
2599
4201
|
import YAML from "yaml";
|
|
2600
4202
|
|
|
2601
4203
|
// src/core/rules/types.ts
|
|
2602
|
-
import { z } from "zod";
|
|
2603
|
-
var RuleNameSchema =
|
|
4204
|
+
import { z as z3 } from "zod";
|
|
4205
|
+
var RuleNameSchema = z3.string().min(1, "Rule name is required").regex(
|
|
2604
4206
|
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
2605
4207
|
"Rule name must be kebab-case (lowercase letters, numbers, and single hyphens)"
|
|
2606
4208
|
);
|
|
2607
|
-
var RuleFieldSchema =
|
|
2608
|
-
var RegexStringSchema =
|
|
4209
|
+
var RuleFieldSchema = z3.enum(["from", "to", "subject", "snippet", "labels"]);
|
|
4210
|
+
var RegexStringSchema = z3.string().min(1, "Pattern must not be empty").superRefine((value, ctx) => {
|
|
2609
4211
|
try {
|
|
2610
4212
|
new RegExp(value);
|
|
2611
4213
|
} catch (error) {
|
|
2612
4214
|
ctx.addIssue({
|
|
2613
|
-
code:
|
|
4215
|
+
code: z3.ZodIssueCode.custom,
|
|
2614
4216
|
message: `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`
|
|
2615
4217
|
});
|
|
2616
4218
|
}
|
|
2617
4219
|
});
|
|
2618
|
-
var MatcherSchema =
|
|
4220
|
+
var MatcherSchema = z3.object({
|
|
2619
4221
|
// `snippet` is the only cached free-text matcher in MVP.
|
|
2620
4222
|
field: RuleFieldSchema,
|
|
2621
4223
|
pattern: RegexStringSchema.optional(),
|
|
2622
|
-
contains:
|
|
2623
|
-
values:
|
|
2624
|
-
exclude:
|
|
4224
|
+
contains: z3.array(z3.string().min(1)).min(1).optional(),
|
|
4225
|
+
values: z3.array(z3.string().min(1)).min(1).optional(),
|
|
4226
|
+
exclude: z3.boolean().default(false)
|
|
2625
4227
|
}).strict().superRefine((value, ctx) => {
|
|
2626
4228
|
if (!value.pattern && !value.contains && !value.values) {
|
|
2627
4229
|
ctx.addIssue({
|
|
2628
|
-
code:
|
|
4230
|
+
code: z3.ZodIssueCode.custom,
|
|
2629
4231
|
message: "Matcher must provide at least one of pattern, contains, or values",
|
|
2630
4232
|
path: ["pattern"]
|
|
2631
4233
|
});
|
|
2632
4234
|
}
|
|
2633
4235
|
});
|
|
2634
|
-
var ConditionsSchema =
|
|
2635
|
-
operator:
|
|
2636
|
-
matchers:
|
|
4236
|
+
var ConditionsSchema = z3.object({
|
|
4237
|
+
operator: z3.enum(["AND", "OR"]),
|
|
4238
|
+
matchers: z3.array(MatcherSchema).min(1, "At least one matcher is required")
|
|
2637
4239
|
}).strict();
|
|
2638
|
-
var LabelActionSchema =
|
|
2639
|
-
type:
|
|
2640
|
-
label:
|
|
4240
|
+
var LabelActionSchema = z3.object({
|
|
4241
|
+
type: z3.literal("label"),
|
|
4242
|
+
label: z3.string().min(1, "Label name is required")
|
|
2641
4243
|
});
|
|
2642
|
-
var ArchiveActionSchema =
|
|
2643
|
-
var MarkReadActionSchema =
|
|
2644
|
-
var ForwardActionSchema =
|
|
2645
|
-
type:
|
|
2646
|
-
to:
|
|
4244
|
+
var ArchiveActionSchema = z3.object({ type: z3.literal("archive") });
|
|
4245
|
+
var MarkReadActionSchema = z3.object({ type: z3.literal("mark_read") });
|
|
4246
|
+
var ForwardActionSchema = z3.object({
|
|
4247
|
+
type: z3.literal("forward"),
|
|
4248
|
+
to: z3.string().email("Forward destination must be a valid email address")
|
|
2647
4249
|
});
|
|
2648
|
-
var MarkSpamActionSchema =
|
|
2649
|
-
var ActionSchema =
|
|
4250
|
+
var MarkSpamActionSchema = z3.object({ type: z3.literal("mark_spam") });
|
|
4251
|
+
var ActionSchema = z3.discriminatedUnion("type", [
|
|
2650
4252
|
LabelActionSchema,
|
|
2651
4253
|
ArchiveActionSchema,
|
|
2652
4254
|
MarkReadActionSchema,
|
|
2653
4255
|
ForwardActionSchema,
|
|
2654
4256
|
MarkSpamActionSchema
|
|
2655
4257
|
]);
|
|
2656
|
-
var RuleSchema =
|
|
4258
|
+
var RuleSchema = z3.object({
|
|
2657
4259
|
name: RuleNameSchema,
|
|
2658
|
-
description:
|
|
2659
|
-
enabled:
|
|
2660
|
-
priority:
|
|
4260
|
+
description: z3.string(),
|
|
4261
|
+
enabled: z3.boolean().default(true),
|
|
4262
|
+
priority: z3.number().int().min(0).default(50),
|
|
2661
4263
|
conditions: ConditionsSchema,
|
|
2662
|
-
actions:
|
|
4264
|
+
actions: z3.array(ActionSchema).min(1, "At least one action is required")
|
|
2663
4265
|
}).strict();
|
|
2664
4266
|
|
|
2665
4267
|
// src/core/rules/loader.ts
|
|
@@ -2989,7 +4591,7 @@ function getDatabase4() {
|
|
|
2989
4591
|
const config = loadConfig();
|
|
2990
4592
|
return getSqlite(config.dbPath);
|
|
2991
4593
|
}
|
|
2992
|
-
function
|
|
4594
|
+
function parseJsonArray5(value) {
|
|
2993
4595
|
if (!value) {
|
|
2994
4596
|
return [];
|
|
2995
4597
|
}
|
|
@@ -3000,19 +4602,19 @@ function parseJsonArray2(value) {
|
|
|
3000
4602
|
return [];
|
|
3001
4603
|
}
|
|
3002
4604
|
}
|
|
3003
|
-
function
|
|
4605
|
+
function rowToEmail3(row) {
|
|
3004
4606
|
return {
|
|
3005
4607
|
id: row.id,
|
|
3006
4608
|
threadId: row.thread_id ?? "",
|
|
3007
4609
|
fromAddress: row.from_address ?? "",
|
|
3008
4610
|
fromName: row.from_name ?? "",
|
|
3009
|
-
toAddresses:
|
|
4611
|
+
toAddresses: parseJsonArray5(row.to_addresses),
|
|
3010
4612
|
subject: row.subject ?? "",
|
|
3011
4613
|
snippet: row.snippet ?? "",
|
|
3012
4614
|
date: row.date ?? 0,
|
|
3013
4615
|
isRead: row.is_read === 1,
|
|
3014
4616
|
isStarred: row.is_starred === 1,
|
|
3015
|
-
labelIds:
|
|
4617
|
+
labelIds: parseJsonArray5(row.label_ids),
|
|
3016
4618
|
sizeEstimate: row.size_estimate ?? 0,
|
|
3017
4619
|
hasAttachments: row.has_attachments === 1,
|
|
3018
4620
|
listUnsubscribe: row.list_unsubscribe
|
|
@@ -3121,7 +4723,7 @@ async function findMatchingEmails(ruleOrConditions, limit) {
|
|
|
3121
4723
|
).all();
|
|
3122
4724
|
const matches = [];
|
|
3123
4725
|
for (const row of rows) {
|
|
3124
|
-
const email =
|
|
4726
|
+
const email = rowToEmail3(row);
|
|
3125
4727
|
const result = matchEmail(email, conditions);
|
|
3126
4728
|
if (!result.matches) {
|
|
3127
4729
|
continue;
|
|
@@ -3186,7 +4788,7 @@ async function loadMatchedItems(rule, options) {
|
|
|
3186
4788
|
errorMessage: null
|
|
3187
4789
|
}));
|
|
3188
4790
|
}
|
|
3189
|
-
async function
|
|
4791
|
+
async function executeAction2(emailId, action, options) {
|
|
3190
4792
|
switch (action.type) {
|
|
3191
4793
|
case "archive":
|
|
3192
4794
|
return (await archiveEmails([emailId], options)).items[0];
|
|
@@ -3207,7 +4809,7 @@ async function applyRuleActions(item, actions, options) {
|
|
|
3207
4809
|
};
|
|
3208
4810
|
for (const action of actions) {
|
|
3209
4811
|
try {
|
|
3210
|
-
const result = await
|
|
4812
|
+
const result = await executeAction2(item.emailId, action, options);
|
|
3211
4813
|
current = {
|
|
3212
4814
|
...current,
|
|
3213
4815
|
status: result.status,
|
|
@@ -3335,7 +4937,7 @@ async function runAllRules(options) {
|
|
|
3335
4937
|
}
|
|
3336
4938
|
|
|
3337
4939
|
// src/core/gmail/filters.ts
|
|
3338
|
-
async function
|
|
4940
|
+
async function resolveContext5(options) {
|
|
3339
4941
|
const config = options?.config ?? loadConfig();
|
|
3340
4942
|
const transport = options?.transport ?? await getGmailTransport(config);
|
|
3341
4943
|
return { config, transport };
|
|
@@ -3385,7 +4987,7 @@ async function buildLabelMap(context) {
|
|
|
3385
4987
|
return map;
|
|
3386
4988
|
}
|
|
3387
4989
|
async function listFilters(options) {
|
|
3388
|
-
const context = await
|
|
4990
|
+
const context = await resolveContext5(options);
|
|
3389
4991
|
const [response, labelMap] = await Promise.all([
|
|
3390
4992
|
context.transport.listFilters(),
|
|
3391
4993
|
buildLabelMap(context)
|
|
@@ -3394,7 +4996,7 @@ async function listFilters(options) {
|
|
|
3394
4996
|
return raw.map((f) => toGmailFilter(f, labelMap)).filter((f) => f !== null);
|
|
3395
4997
|
}
|
|
3396
4998
|
async function getFilter(id, options) {
|
|
3397
|
-
const context = await
|
|
4999
|
+
const context = await resolveContext5(options);
|
|
3398
5000
|
const [raw, labelMap] = await Promise.all([
|
|
3399
5001
|
context.transport.getFilter(id),
|
|
3400
5002
|
buildLabelMap(context)
|
|
@@ -3418,7 +5020,7 @@ async function createFilter(input, options) {
|
|
|
3418
5020
|
"At least one action is required (labelName, archive, markRead, star, or forward)"
|
|
3419
5021
|
);
|
|
3420
5022
|
}
|
|
3421
|
-
const context = await
|
|
5023
|
+
const context = await resolveContext5(options);
|
|
3422
5024
|
const addLabelIds = [];
|
|
3423
5025
|
if (input.star) {
|
|
3424
5026
|
addLabelIds.push("STARRED");
|
|
@@ -3457,7 +5059,7 @@ async function createFilter(input, options) {
|
|
|
3457
5059
|
return filter;
|
|
3458
5060
|
}
|
|
3459
5061
|
async function deleteFilter(id, options) {
|
|
3460
|
-
const context = await
|
|
5062
|
+
const context = await resolveContext5(options);
|
|
3461
5063
|
await context.transport.deleteFilter(id);
|
|
3462
5064
|
}
|
|
3463
5065
|
|
|
@@ -3949,8 +5551,8 @@ async function getSyncStatus() {
|
|
|
3949
5551
|
}
|
|
3950
5552
|
|
|
3951
5553
|
// src/mcp/server.ts
|
|
3952
|
-
var
|
|
3953
|
-
var MCP_VERSION = "0.
|
|
5554
|
+
var DAY_MS4 = 24 * 60 * 60 * 1e3;
|
|
5555
|
+
var MCP_VERSION = "0.3.0";
|
|
3954
5556
|
var MCP_TOOLS = [
|
|
3955
5557
|
"search_emails",
|
|
3956
5558
|
"get_email",
|
|
@@ -3959,6 +5561,7 @@ var MCP_TOOLS = [
|
|
|
3959
5561
|
"archive_emails",
|
|
3960
5562
|
"label_emails",
|
|
3961
5563
|
"mark_read",
|
|
5564
|
+
"batch_apply_actions",
|
|
3962
5565
|
"forward_email",
|
|
3963
5566
|
"undo_run",
|
|
3964
5567
|
"get_labels",
|
|
@@ -3967,6 +5570,12 @@ var MCP_TOOLS = [
|
|
|
3967
5570
|
"get_top_senders",
|
|
3968
5571
|
"get_sender_stats",
|
|
3969
5572
|
"get_newsletter_senders",
|
|
5573
|
+
"get_uncategorized_emails",
|
|
5574
|
+
"review_categorized",
|
|
5575
|
+
"query_emails",
|
|
5576
|
+
"get_noise_senders",
|
|
5577
|
+
"get_unsubscribe_suggestions",
|
|
5578
|
+
"unsubscribe",
|
|
3970
5579
|
"deploy_rule",
|
|
3971
5580
|
"list_rules",
|
|
3972
5581
|
"run_rule",
|
|
@@ -3980,6 +5589,8 @@ var MCP_TOOLS = [
|
|
|
3980
5589
|
var MCP_RESOURCES = [
|
|
3981
5590
|
"inbox://recent",
|
|
3982
5591
|
"inbox://summary",
|
|
5592
|
+
"inbox://action-log",
|
|
5593
|
+
"schema://query-fields",
|
|
3983
5594
|
"rules://deployed",
|
|
3984
5595
|
"rules://history",
|
|
3985
5596
|
"stats://senders",
|
|
@@ -3990,7 +5601,8 @@ var MCP_PROMPTS = [
|
|
|
3990
5601
|
"review-senders",
|
|
3991
5602
|
"find-newsletters",
|
|
3992
5603
|
"suggest-rules",
|
|
3993
|
-
"triage-inbox"
|
|
5604
|
+
"triage-inbox",
|
|
5605
|
+
"categorize-emails"
|
|
3994
5606
|
];
|
|
3995
5607
|
function toTextResult(value) {
|
|
3996
5608
|
return {
|
|
@@ -4064,12 +5676,22 @@ function buildSearchQuery(query, label) {
|
|
|
4064
5676
|
}
|
|
4065
5677
|
return trimmedQuery;
|
|
4066
5678
|
}
|
|
4067
|
-
function
|
|
5679
|
+
function uniqueStrings3(values) {
|
|
4068
5680
|
return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
|
|
4069
5681
|
}
|
|
4070
5682
|
function resolveResourceUri(uri, fallback) {
|
|
4071
5683
|
return typeof uri === "string" ? uri : fallback;
|
|
4072
5684
|
}
|
|
5685
|
+
function formatActionSummary(action) {
|
|
5686
|
+
switch (action.type) {
|
|
5687
|
+
case "label":
|
|
5688
|
+
return action.label ? `label:${action.label}` : "label";
|
|
5689
|
+
case "forward":
|
|
5690
|
+
return action.to ? `forward:${action.to}` : "forward";
|
|
5691
|
+
default:
|
|
5692
|
+
return action.type;
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
4073
5695
|
async function buildStartupWarnings() {
|
|
4074
5696
|
const config = loadConfig();
|
|
4075
5697
|
initializeDb(config.dbPath);
|
|
@@ -4090,7 +5712,7 @@ async function buildStartupWarnings() {
|
|
|
4090
5712
|
}
|
|
4091
5713
|
if (!latestSync) {
|
|
4092
5714
|
warnings.push("Inbox cache has not been synced yet. Stats and resources will be empty until `sync_inbox` runs.");
|
|
4093
|
-
} else if (Date.now() - latestSync >
|
|
5715
|
+
} else if (Date.now() - latestSync > DAY_MS4) {
|
|
4094
5716
|
warnings.push("Inbox cache appears stale (last sync older than 24 hours). Call `sync_inbox` if freshness matters.");
|
|
4095
5717
|
}
|
|
4096
5718
|
return warnings;
|
|
@@ -4101,7 +5723,7 @@ async function buildStatsOverview() {
|
|
|
4101
5723
|
topSenders: await getTopSenders({ limit: 10 }),
|
|
4102
5724
|
labelDistribution: (await getLabelDistribution()).slice(0, 10),
|
|
4103
5725
|
dailyVolume: await getVolumeByPeriod("day", {
|
|
4104
|
-
start: Date.now() - 30 *
|
|
5726
|
+
start: Date.now() - 30 * DAY_MS4,
|
|
4105
5727
|
end: Date.now()
|
|
4106
5728
|
})
|
|
4107
5729
|
};
|
|
@@ -4113,6 +5735,23 @@ async function buildRuleHistory() {
|
|
|
4113
5735
|
recentRuns: await getRecentRuns(20)
|
|
4114
5736
|
};
|
|
4115
5737
|
}
|
|
5738
|
+
async function buildActionLog() {
|
|
5739
|
+
const recentRuns = await getRecentRuns(10);
|
|
5740
|
+
const stats = await getExecutionStats();
|
|
5741
|
+
return {
|
|
5742
|
+
recentRuns: recentRuns.map((run) => ({
|
|
5743
|
+
runId: run.id,
|
|
5744
|
+
createdAt: new Date(run.createdAt).toISOString(),
|
|
5745
|
+
sourceType: run.sourceType,
|
|
5746
|
+
dryRun: run.dryRun,
|
|
5747
|
+
status: run.status,
|
|
5748
|
+
emailCount: run.itemCount,
|
|
5749
|
+
actions: run.requestedActions.map(formatActionSummary),
|
|
5750
|
+
undoAvailable: !run.dryRun && run.undoneAt === null && run.status !== "planned" && run.status !== "undone" && run.itemCount > 0
|
|
5751
|
+
})),
|
|
5752
|
+
totalRuns: stats.totalRuns
|
|
5753
|
+
};
|
|
5754
|
+
}
|
|
4116
5755
|
async function createMcpServer() {
|
|
4117
5756
|
const warnings = await buildStartupWarnings();
|
|
4118
5757
|
const server = new McpServer({
|
|
@@ -4124,9 +5763,9 @@ async function createMcpServer() {
|
|
|
4124
5763
|
{
|
|
4125
5764
|
description: "Search Gmail using Gmail query syntax and return matching email metadata.",
|
|
4126
5765
|
inputSchema: {
|
|
4127
|
-
query:
|
|
4128
|
-
max_results:
|
|
4129
|
-
label:
|
|
5766
|
+
query: z4.string().min(1),
|
|
5767
|
+
max_results: z4.number().int().positive().max(100).optional(),
|
|
5768
|
+
label: z4.string().min(1).optional()
|
|
4130
5769
|
},
|
|
4131
5770
|
annotations: {
|
|
4132
5771
|
readOnlyHint: true
|
|
@@ -4141,7 +5780,7 @@ async function createMcpServer() {
|
|
|
4141
5780
|
{
|
|
4142
5781
|
description: "Fetch a single email with full content by Gmail message ID.",
|
|
4143
5782
|
inputSchema: {
|
|
4144
|
-
email_id:
|
|
5783
|
+
email_id: z4.string().min(1)
|
|
4145
5784
|
},
|
|
4146
5785
|
annotations: {
|
|
4147
5786
|
readOnlyHint: true
|
|
@@ -4154,7 +5793,7 @@ async function createMcpServer() {
|
|
|
4154
5793
|
{
|
|
4155
5794
|
description: "Fetch a full Gmail thread by thread ID.",
|
|
4156
5795
|
inputSchema: {
|
|
4157
|
-
thread_id:
|
|
5796
|
+
thread_id: z4.string().min(1)
|
|
4158
5797
|
},
|
|
4159
5798
|
annotations: {
|
|
4160
5799
|
readOnlyHint: true
|
|
@@ -4167,7 +5806,7 @@ async function createMcpServer() {
|
|
|
4167
5806
|
{
|
|
4168
5807
|
description: "Run inbox sync. Uses incremental sync by default and full sync when requested.",
|
|
4169
5808
|
inputSchema: {
|
|
4170
|
-
full:
|
|
5809
|
+
full: z4.boolean().optional()
|
|
4171
5810
|
},
|
|
4172
5811
|
annotations: {
|
|
4173
5812
|
readOnlyHint: false,
|
|
@@ -4181,23 +5820,23 @@ async function createMcpServer() {
|
|
|
4181
5820
|
{
|
|
4182
5821
|
description: "Archive one or more Gmail messages by removing the INBOX label.",
|
|
4183
5822
|
inputSchema: {
|
|
4184
|
-
email_ids:
|
|
5823
|
+
email_ids: z4.array(z4.string().min(1)).min(1)
|
|
4185
5824
|
},
|
|
4186
5825
|
annotations: {
|
|
4187
5826
|
readOnlyHint: false,
|
|
4188
5827
|
destructiveHint: false
|
|
4189
5828
|
}
|
|
4190
5829
|
},
|
|
4191
|
-
toolHandler(async ({ email_ids }) => archiveEmails(
|
|
5830
|
+
toolHandler(async ({ email_ids }) => archiveEmails(uniqueStrings3(email_ids)))
|
|
4192
5831
|
);
|
|
4193
5832
|
server.registerTool(
|
|
4194
5833
|
"label_emails",
|
|
4195
5834
|
{
|
|
4196
5835
|
description: "Add and/or remove Gmail labels on one or more messages.",
|
|
4197
5836
|
inputSchema: {
|
|
4198
|
-
email_ids:
|
|
4199
|
-
add_labels:
|
|
4200
|
-
remove_labels:
|
|
5837
|
+
email_ids: z4.array(z4.string().min(1)).min(1),
|
|
5838
|
+
add_labels: z4.array(z4.string().min(1)).optional(),
|
|
5839
|
+
remove_labels: z4.array(z4.string().min(1)).optional()
|
|
4201
5840
|
},
|
|
4202
5841
|
annotations: {
|
|
4203
5842
|
readOnlyHint: false,
|
|
@@ -4205,9 +5844,9 @@ async function createMcpServer() {
|
|
|
4205
5844
|
}
|
|
4206
5845
|
},
|
|
4207
5846
|
toolHandler(async ({ email_ids, add_labels, remove_labels }) => {
|
|
4208
|
-
const ids =
|
|
4209
|
-
const addLabels =
|
|
4210
|
-
const removeLabels =
|
|
5847
|
+
const ids = uniqueStrings3(email_ids);
|
|
5848
|
+
const addLabels = uniqueStrings3(add_labels);
|
|
5849
|
+
const removeLabels = uniqueStrings3(remove_labels);
|
|
4211
5850
|
if (addLabels.length === 0 && removeLabels.length === 0) {
|
|
4212
5851
|
throw new Error("Provide at least one label to add or remove.");
|
|
4213
5852
|
}
|
|
@@ -4231,8 +5870,8 @@ async function createMcpServer() {
|
|
|
4231
5870
|
{
|
|
4232
5871
|
description: "Mark one or more Gmail messages as read or unread.",
|
|
4233
5872
|
inputSchema: {
|
|
4234
|
-
email_ids:
|
|
4235
|
-
read:
|
|
5873
|
+
email_ids: z4.array(z4.string().min(1)).min(1),
|
|
5874
|
+
read: z4.boolean()
|
|
4236
5875
|
},
|
|
4237
5876
|
annotations: {
|
|
4238
5877
|
readOnlyHint: false,
|
|
@@ -4240,17 +5879,53 @@ async function createMcpServer() {
|
|
|
4240
5879
|
}
|
|
4241
5880
|
},
|
|
4242
5881
|
toolHandler(async ({ email_ids, read }) => {
|
|
4243
|
-
const ids =
|
|
5882
|
+
const ids = uniqueStrings3(email_ids);
|
|
4244
5883
|
return read ? markRead(ids) : markUnread(ids);
|
|
4245
5884
|
})
|
|
4246
5885
|
);
|
|
5886
|
+
server.registerTool(
|
|
5887
|
+
"batch_apply_actions",
|
|
5888
|
+
{
|
|
5889
|
+
description: "Apply grouped inbox actions in one call for faster AI-driven triage and categorization.",
|
|
5890
|
+
inputSchema: {
|
|
5891
|
+
groups: z4.array(
|
|
5892
|
+
z4.object({
|
|
5893
|
+
email_ids: z4.array(z4.string().min(1)).min(1).max(500),
|
|
5894
|
+
actions: z4.array(
|
|
5895
|
+
z4.discriminatedUnion("type", [
|
|
5896
|
+
z4.object({
|
|
5897
|
+
type: z4.literal("label"),
|
|
5898
|
+
label: z4.string().min(1)
|
|
5899
|
+
}),
|
|
5900
|
+
z4.object({ type: z4.literal("archive") }),
|
|
5901
|
+
z4.object({ type: z4.literal("mark_read") }),
|
|
5902
|
+
z4.object({ type: z4.literal("mark_spam") })
|
|
5903
|
+
])
|
|
5904
|
+
).min(1).max(5)
|
|
5905
|
+
})
|
|
5906
|
+
).min(1).max(20),
|
|
5907
|
+
dry_run: z4.boolean().optional()
|
|
5908
|
+
},
|
|
5909
|
+
annotations: {
|
|
5910
|
+
readOnlyHint: false,
|
|
5911
|
+
destructiveHint: false
|
|
5912
|
+
}
|
|
5913
|
+
},
|
|
5914
|
+
toolHandler(async ({ groups, dry_run }) => batchApplyActions({
|
|
5915
|
+
groups: groups.map((group) => ({
|
|
5916
|
+
emailIds: uniqueStrings3(group.email_ids),
|
|
5917
|
+
actions: group.actions
|
|
5918
|
+
})),
|
|
5919
|
+
dryRun: dry_run
|
|
5920
|
+
}))
|
|
5921
|
+
);
|
|
4247
5922
|
server.registerTool(
|
|
4248
5923
|
"forward_email",
|
|
4249
5924
|
{
|
|
4250
5925
|
description: "Forward a Gmail message to another address.",
|
|
4251
5926
|
inputSchema: {
|
|
4252
|
-
email_id:
|
|
4253
|
-
to:
|
|
5927
|
+
email_id: z4.string().min(1),
|
|
5928
|
+
to: z4.string().email()
|
|
4254
5929
|
},
|
|
4255
5930
|
annotations: {
|
|
4256
5931
|
readOnlyHint: false,
|
|
@@ -4264,7 +5939,7 @@ async function createMcpServer() {
|
|
|
4264
5939
|
{
|
|
4265
5940
|
description: "Undo a prior inboxctl action run when the underlying Gmail mutations are reversible.",
|
|
4266
5941
|
inputSchema: {
|
|
4267
|
-
run_id:
|
|
5942
|
+
run_id: z4.string().min(1)
|
|
4268
5943
|
},
|
|
4269
5944
|
annotations: {
|
|
4270
5945
|
readOnlyHint: false,
|
|
@@ -4288,8 +5963,8 @@ async function createMcpServer() {
|
|
|
4288
5963
|
{
|
|
4289
5964
|
description: "Create a Gmail label if it does not already exist.",
|
|
4290
5965
|
inputSchema: {
|
|
4291
|
-
name:
|
|
4292
|
-
color:
|
|
5966
|
+
name: z4.string().min(1),
|
|
5967
|
+
color: z4.string().min(1).optional()
|
|
4293
5968
|
},
|
|
4294
5969
|
annotations: {
|
|
4295
5970
|
readOnlyHint: false,
|
|
@@ -4321,9 +5996,9 @@ async function createMcpServer() {
|
|
|
4321
5996
|
{
|
|
4322
5997
|
description: "Return top senders ranked by cached email volume.",
|
|
4323
5998
|
inputSchema: {
|
|
4324
|
-
limit:
|
|
4325
|
-
min_unread_rate:
|
|
4326
|
-
period:
|
|
5999
|
+
limit: z4.number().int().positive().max(100).optional(),
|
|
6000
|
+
min_unread_rate: z4.number().min(0).max(100).optional(),
|
|
6001
|
+
period: z4.enum(["day", "week", "month", "year", "all"]).optional()
|
|
4327
6002
|
},
|
|
4328
6003
|
annotations: {
|
|
4329
6004
|
readOnlyHint: true
|
|
@@ -4340,7 +6015,7 @@ async function createMcpServer() {
|
|
|
4340
6015
|
{
|
|
4341
6016
|
description: "Return detailed stats for a sender email address or an @domain aggregate.",
|
|
4342
6017
|
inputSchema: {
|
|
4343
|
-
email_or_domain:
|
|
6018
|
+
email_or_domain: z4.string().min(1)
|
|
4344
6019
|
},
|
|
4345
6020
|
annotations: {
|
|
4346
6021
|
readOnlyHint: true
|
|
@@ -4360,8 +6035,8 @@ async function createMcpServer() {
|
|
|
4360
6035
|
{
|
|
4361
6036
|
description: "Return senders that look like newsletters or mailing lists based on cached heuristics.",
|
|
4362
6037
|
inputSchema: {
|
|
4363
|
-
min_messages:
|
|
4364
|
-
min_unread_rate:
|
|
6038
|
+
min_messages: z4.number().int().positive().optional(),
|
|
6039
|
+
min_unread_rate: z4.number().min(0).max(100).optional()
|
|
4365
6040
|
},
|
|
4366
6041
|
annotations: {
|
|
4367
6042
|
readOnlyHint: true
|
|
@@ -4372,12 +6047,115 @@ async function createMcpServer() {
|
|
|
4372
6047
|
minUnreadRate: min_unread_rate
|
|
4373
6048
|
}))
|
|
4374
6049
|
);
|
|
6050
|
+
server.registerTool(
|
|
6051
|
+
"get_uncategorized_emails",
|
|
6052
|
+
{
|
|
6053
|
+
description: "Return cached emails that have only Gmail system labels and no user-applied organization.",
|
|
6054
|
+
inputSchema: {
|
|
6055
|
+
limit: z4.number().int().positive().max(1e3).optional().describe("Max emails to return per page. Default 50. AI clients should start with 50-100 and paginate."),
|
|
6056
|
+
offset: z4.number().int().min(0).optional().describe("Number of results to skip for pagination. Use with totalUncategorized and hasMore."),
|
|
6057
|
+
unread_only: z4.boolean().optional(),
|
|
6058
|
+
since: z4.string().min(1).optional()
|
|
6059
|
+
},
|
|
6060
|
+
annotations: {
|
|
6061
|
+
readOnlyHint: true
|
|
6062
|
+
}
|
|
6063
|
+
},
|
|
6064
|
+
toolHandler(async ({ limit, offset, unread_only, since }) => getUncategorizedEmails({
|
|
6065
|
+
limit,
|
|
6066
|
+
offset,
|
|
6067
|
+
unreadOnly: unread_only,
|
|
6068
|
+
since
|
|
6069
|
+
}))
|
|
6070
|
+
);
|
|
6071
|
+
server.registerTool(
|
|
6072
|
+
"review_categorized",
|
|
6073
|
+
{
|
|
6074
|
+
description: "Scan recently categorized emails for anomalies that suggest a misclassification or over-aggressive archive.",
|
|
6075
|
+
inputSchema: reviewCategorizedInputSchema.shape,
|
|
6076
|
+
annotations: {
|
|
6077
|
+
readOnlyHint: true
|
|
6078
|
+
}
|
|
6079
|
+
},
|
|
6080
|
+
toolHandler(async (args) => reviewCategorized(args))
|
|
6081
|
+
);
|
|
6082
|
+
server.registerTool(
|
|
6083
|
+
"query_emails",
|
|
6084
|
+
{
|
|
6085
|
+
description: "Run structured analytics queries over the cached email dataset using fixed filters, groupings, and aggregates.",
|
|
6086
|
+
inputSchema: queryEmailsInputSchema.shape,
|
|
6087
|
+
annotations: {
|
|
6088
|
+
readOnlyHint: true
|
|
6089
|
+
}
|
|
6090
|
+
},
|
|
6091
|
+
toolHandler(async (args) => queryEmails(args))
|
|
6092
|
+
);
|
|
6093
|
+
server.registerTool(
|
|
6094
|
+
"get_noise_senders",
|
|
6095
|
+
{
|
|
6096
|
+
description: "Return a focused list of active, high-noise senders worth categorizing, filtering, or unsubscribing.",
|
|
6097
|
+
inputSchema: {
|
|
6098
|
+
limit: z4.number().int().positive().max(50).optional(),
|
|
6099
|
+
min_noise_score: z4.number().min(0).optional(),
|
|
6100
|
+
active_days: z4.number().int().positive().optional(),
|
|
6101
|
+
sort_by: z4.enum(["noise_score", "all_time_noise_score", "message_count", "unread_rate"]).optional().describe("Sort order. Default: noise_score. Use all_time_noise_score for lifetime perspective.")
|
|
6102
|
+
},
|
|
6103
|
+
annotations: {
|
|
6104
|
+
readOnlyHint: true
|
|
6105
|
+
}
|
|
6106
|
+
},
|
|
6107
|
+
toolHandler(async ({ limit, min_noise_score, active_days, sort_by }) => getNoiseSenders({
|
|
6108
|
+
limit,
|
|
6109
|
+
minNoiseScore: min_noise_score,
|
|
6110
|
+
activeDays: active_days,
|
|
6111
|
+
sortBy: sort_by
|
|
6112
|
+
}))
|
|
6113
|
+
);
|
|
6114
|
+
server.registerTool(
|
|
6115
|
+
"get_unsubscribe_suggestions",
|
|
6116
|
+
{
|
|
6117
|
+
description: "Return ranked senders with unsubscribe links, sorted by how much inbox noise unsubscribing would remove.",
|
|
6118
|
+
inputSchema: {
|
|
6119
|
+
limit: z4.number().int().positive().max(50).optional(),
|
|
6120
|
+
min_messages: z4.number().int().positive().optional(),
|
|
6121
|
+
unread_only_senders: z4.boolean().optional()
|
|
6122
|
+
},
|
|
6123
|
+
annotations: {
|
|
6124
|
+
readOnlyHint: true
|
|
6125
|
+
}
|
|
6126
|
+
},
|
|
6127
|
+
toolHandler(async ({ limit, min_messages, unread_only_senders }) => getUnsubscribeSuggestions({
|
|
6128
|
+
limit,
|
|
6129
|
+
minMessages: min_messages,
|
|
6130
|
+
unreadOnlySenders: unread_only_senders
|
|
6131
|
+
}))
|
|
6132
|
+
);
|
|
6133
|
+
server.registerTool(
|
|
6134
|
+
"unsubscribe",
|
|
6135
|
+
{
|
|
6136
|
+
description: "Return the unsubscribe target for a sender and optionally label/archive existing emails in one undoable run.",
|
|
6137
|
+
inputSchema: {
|
|
6138
|
+
sender_email: z4.string().min(1),
|
|
6139
|
+
also_archive: z4.boolean().optional(),
|
|
6140
|
+
also_label: z4.string().min(1).optional()
|
|
6141
|
+
},
|
|
6142
|
+
annotations: {
|
|
6143
|
+
readOnlyHint: false,
|
|
6144
|
+
destructiveHint: false
|
|
6145
|
+
}
|
|
6146
|
+
},
|
|
6147
|
+
toolHandler(async ({ sender_email, also_archive, also_label }) => unsubscribe({
|
|
6148
|
+
senderEmail: sender_email,
|
|
6149
|
+
alsoArchive: also_archive,
|
|
6150
|
+
alsoLabel: also_label
|
|
6151
|
+
}))
|
|
6152
|
+
);
|
|
4375
6153
|
server.registerTool(
|
|
4376
6154
|
"deploy_rule",
|
|
4377
6155
|
{
|
|
4378
6156
|
description: "Validate and deploy a rule directly from YAML content.",
|
|
4379
6157
|
inputSchema: {
|
|
4380
|
-
yaml_content:
|
|
6158
|
+
yaml_content: z4.string().min(1)
|
|
4381
6159
|
},
|
|
4382
6160
|
annotations: {
|
|
4383
6161
|
readOnlyHint: false,
|
|
@@ -4394,7 +6172,7 @@ async function createMcpServer() {
|
|
|
4394
6172
|
{
|
|
4395
6173
|
description: "List deployed inboxctl rules and their execution status.",
|
|
4396
6174
|
inputSchema: {
|
|
4397
|
-
enabled_only:
|
|
6175
|
+
enabled_only: z4.boolean().optional()
|
|
4398
6176
|
},
|
|
4399
6177
|
annotations: {
|
|
4400
6178
|
readOnlyHint: true
|
|
@@ -4410,9 +6188,9 @@ async function createMcpServer() {
|
|
|
4410
6188
|
{
|
|
4411
6189
|
description: "Run a deployed rule in dry-run mode by default, or apply it when dry_run is false.",
|
|
4412
6190
|
inputSchema: {
|
|
4413
|
-
rule_name:
|
|
4414
|
-
dry_run:
|
|
4415
|
-
max_emails:
|
|
6191
|
+
rule_name: z4.string().min(1),
|
|
6192
|
+
dry_run: z4.boolean().optional(),
|
|
6193
|
+
max_emails: z4.number().int().positive().max(1e3).optional()
|
|
4416
6194
|
},
|
|
4417
6195
|
annotations: {
|
|
4418
6196
|
readOnlyHint: false,
|
|
@@ -4429,7 +6207,7 @@ async function createMcpServer() {
|
|
|
4429
6207
|
{
|
|
4430
6208
|
description: "Enable a deployed rule by name.",
|
|
4431
6209
|
inputSchema: {
|
|
4432
|
-
rule_name:
|
|
6210
|
+
rule_name: z4.string().min(1)
|
|
4433
6211
|
},
|
|
4434
6212
|
annotations: {
|
|
4435
6213
|
readOnlyHint: false,
|
|
@@ -4443,7 +6221,7 @@ async function createMcpServer() {
|
|
|
4443
6221
|
{
|
|
4444
6222
|
description: "Disable a deployed rule by name.",
|
|
4445
6223
|
inputSchema: {
|
|
4446
|
-
rule_name:
|
|
6224
|
+
rule_name: z4.string().min(1)
|
|
4447
6225
|
},
|
|
4448
6226
|
annotations: {
|
|
4449
6227
|
readOnlyHint: false,
|
|
@@ -4470,6 +6248,24 @@ async function createMcpServer() {
|
|
|
4470
6248
|
},
|
|
4471
6249
|
async (uri) => resourceText(resolveResourceUri(uri, "inbox://summary"), await getInboxOverview())
|
|
4472
6250
|
);
|
|
6251
|
+
server.registerResource(
|
|
6252
|
+
"inbox-action-log",
|
|
6253
|
+
"inbox://action-log",
|
|
6254
|
+
{
|
|
6255
|
+
description: "Recent action history showing what inboxctl already did and whether undo is still available.",
|
|
6256
|
+
mimeType: "application/json"
|
|
6257
|
+
},
|
|
6258
|
+
async (uri) => resourceText(resolveResourceUri(uri, "inbox://action-log"), await buildActionLog())
|
|
6259
|
+
);
|
|
6260
|
+
server.registerResource(
|
|
6261
|
+
"query-fields",
|
|
6262
|
+
"schema://query-fields",
|
|
6263
|
+
{
|
|
6264
|
+
description: "Field vocabulary, aggregates, and examples for the query_emails analytics tool.",
|
|
6265
|
+
mimeType: "application/json"
|
|
6266
|
+
},
|
|
6267
|
+
async (uri) => resourceText(resolveResourceUri(uri, "schema://query-fields"), QUERY_EMAILS_FIELD_SCHEMA)
|
|
6268
|
+
);
|
|
4473
6269
|
server.registerResource(
|
|
4474
6270
|
"deployed-rules",
|
|
4475
6271
|
"rules://deployed",
|
|
@@ -4529,10 +6325,28 @@ async function createMcpServer() {
|
|
|
4529
6325
|
async () => promptResult(
|
|
4530
6326
|
"Review top senders and recommend cleanup actions.",
|
|
4531
6327
|
[
|
|
4532
|
-
"
|
|
4533
|
-
"
|
|
4534
|
-
"
|
|
4535
|
-
"
|
|
6328
|
+
"Step 0 \u2014 Check for past mistakes:",
|
|
6329
|
+
" Call `review_categorized` to see if any recent categorisations look incorrect.",
|
|
6330
|
+
" If anomalies are found, present them first \u2014 fixing past mistakes takes priority over reviewing new senders.",
|
|
6331
|
+
"",
|
|
6332
|
+
"Step 1 \u2014 Gather data:",
|
|
6333
|
+
" Use `get_noise_senders` for the most actionable noisy senders.",
|
|
6334
|
+
" Use `rules://deployed` to check for existing rules covering these senders.",
|
|
6335
|
+
" Use `get_unsubscribe_suggestions` for senders you can unsubscribe from.",
|
|
6336
|
+
"",
|
|
6337
|
+
"Step 2 \u2014 For each noisy sender, recommend one of:",
|
|
6338
|
+
" KEEP \u2014 important, reduce noise with a label rule",
|
|
6339
|
+
" RULE \u2014 create a rule to auto-label + mark read (or archive)",
|
|
6340
|
+
" UNSUBSCRIBE \u2014 stop receiving entirely (has unsubscribe link, high unread rate)",
|
|
6341
|
+
"",
|
|
6342
|
+
"Step 3 \u2014 Present as a table:",
|
|
6343
|
+
" Sender | Messages | Unread% | Noise Score | Has Unsub | Recommendation | Reason",
|
|
6344
|
+
"",
|
|
6345
|
+
"Step 4 \u2014 Offer to act:",
|
|
6346
|
+
" For senders marked RULE, offer to generate YAML using the rule schema.",
|
|
6347
|
+
" Group similar senders (e.g. all shipping senders) into one rule.",
|
|
6348
|
+
" Present YAML for review before deploying with `deploy_rule`.",
|
|
6349
|
+
" For senders marked UNSUBSCRIBE, use `unsubscribe` with `also_archive: true` and return the link for the user to follow."
|
|
4536
6350
|
].join("\n")
|
|
4537
6351
|
)
|
|
4538
6352
|
);
|
|
@@ -4559,10 +6373,37 @@ async function createMcpServer() {
|
|
|
4559
6373
|
async () => promptResult(
|
|
4560
6374
|
"Analyze inbox patterns and propose valid inboxctl rule YAML.",
|
|
4561
6375
|
[
|
|
4562
|
-
"
|
|
4563
|
-
"
|
|
4564
|
-
"
|
|
4565
|
-
"
|
|
6376
|
+
"First, inspect these data sources:",
|
|
6377
|
+
"- `rules://deployed` \u2014 existing rules (avoid duplicates)",
|
|
6378
|
+
"- `get_noise_senders` \u2014 high-volume low-read senders",
|
|
6379
|
+
"- `get_newsletter_senders` \u2014 detected newsletters and mailing lists",
|
|
6380
|
+
"",
|
|
6381
|
+
"For each recommendation, generate complete YAML using this schema:",
|
|
6382
|
+
"",
|
|
6383
|
+
" name: kebab-case-name # lowercase, hyphens only",
|
|
6384
|
+
" description: What this rule does",
|
|
6385
|
+
" enabled: true",
|
|
6386
|
+
" priority: 50 # 0-100, lower = runs first",
|
|
6387
|
+
" conditions:",
|
|
6388
|
+
" operator: AND # AND or OR",
|
|
6389
|
+
" matchers:",
|
|
6390
|
+
" - field: from # from | to | subject | snippet | labels",
|
|
6391
|
+
" contains: # OR use: values (exact), pattern (regex)",
|
|
6392
|
+
' - "@example.com"',
|
|
6393
|
+
" exclude: false # true to negate the match",
|
|
6394
|
+
" actions:",
|
|
6395
|
+
" - type: label # label | archive | mark_read | forward | mark_spam",
|
|
6396
|
+
' label: "Category/Name"',
|
|
6397
|
+
" - type: mark_read",
|
|
6398
|
+
" - type: archive",
|
|
6399
|
+
"",
|
|
6400
|
+
"Matcher fields: `from`, `to`, `subject`, `snippet`, `labels`.",
|
|
6401
|
+
"Match modes (provide exactly one per matcher): `values` (exact), `contains` (substring), `pattern` (regex).",
|
|
6402
|
+
"Action types: `label` (requires `label` field), `archive`, `mark_read`, `forward` (requires `to` field), `mark_spam`.",
|
|
6403
|
+
"",
|
|
6404
|
+
"Group related senders into a single rule where possible (e.g. all shipping notifications in one rule).",
|
|
6405
|
+
"Explain why each rule is safe. Default to `mark_read` + `label` over `archive` unless evidence is strong.",
|
|
6406
|
+
"Present the YAML so the user can review before deploying with `deploy_rule`."
|
|
4566
6407
|
].join("\n")
|
|
4567
6408
|
)
|
|
4568
6409
|
);
|
|
@@ -4574,10 +6415,96 @@ async function createMcpServer() {
|
|
|
4574
6415
|
async () => promptResult(
|
|
4575
6416
|
"Triage unread mail using inboxctl data sources.",
|
|
4576
6417
|
[
|
|
4577
|
-
"
|
|
4578
|
-
"
|
|
4579
|
-
"
|
|
4580
|
-
"
|
|
6418
|
+
"Step 1 \u2014 Gather data:",
|
|
6419
|
+
" Use `get_uncategorized_emails` with `unread_only: true` for uncategorised unread mail.",
|
|
6420
|
+
" Use `inbox://summary` for overall counts.",
|
|
6421
|
+
" If totalUncategorized is large, process in pages rather than all at once.",
|
|
6422
|
+
" If more context is needed on a specific email, use `get_email` or `get_thread`.",
|
|
6423
|
+
"",
|
|
6424
|
+
"Step 2 \u2014 Categorise each email into one of:",
|
|
6425
|
+
" ACTION REQUIRED \u2014 needs a response or decision from the user",
|
|
6426
|
+
" FYI \u2014 worth knowing about but no action needed",
|
|
6427
|
+
" NOISE \u2014 bulk, promotional, or irrelevant",
|
|
6428
|
+
"",
|
|
6429
|
+
"Step 2.5 \u2014 Flag low-confidence items:",
|
|
6430
|
+
' For any email with `confidence: "low"` in `senderContext`, always categorise it as ACTION REQUIRED.',
|
|
6431
|
+
" Better to surface a false positive than bury a real personal or work email.",
|
|
6432
|
+
"",
|
|
6433
|
+
"Step 3 \u2014 Present findings:",
|
|
6434
|
+
" List emails grouped by category with: sender, subject, and one-line reason.",
|
|
6435
|
+
" For NOISE, suggest a label and whether to archive.",
|
|
6436
|
+
" For FYI, suggest a label.",
|
|
6437
|
+
" For ACTION REQUIRED, summarise what action seems needed.",
|
|
6438
|
+
"",
|
|
6439
|
+
"Step 4 \u2014 Offer to apply:",
|
|
6440
|
+
" If the user approves, use `batch_apply_actions` to apply all decisions in one call.",
|
|
6441
|
+
" Group emails by their action set (e.g. all `label:Receipts + mark_read` together).",
|
|
6442
|
+
"",
|
|
6443
|
+
"Step 5 \u2014 Offer noise reduction:",
|
|
6444
|
+
" If NOISE senders appear repeatedly, suggest a rule or `unsubscribe` when a link is available."
|
|
6445
|
+
].join("\n")
|
|
6446
|
+
)
|
|
6447
|
+
);
|
|
6448
|
+
server.registerPrompt(
|
|
6449
|
+
"categorize-emails",
|
|
6450
|
+
{
|
|
6451
|
+
description: "Systematically categorise uncategorised emails using sender patterns, content, and inbox analytics."
|
|
6452
|
+
},
|
|
6453
|
+
async () => promptResult(
|
|
6454
|
+
"Categorise uncategorised emails in the user's inbox.",
|
|
6455
|
+
[
|
|
6456
|
+
"Step 1 \u2014 Gather data:",
|
|
6457
|
+
" Use `get_uncategorized_emails` (start with limit 100).",
|
|
6458
|
+
" If totalUncategorized is more than 500, ask whether to process the recent batch or paginate through the full backlog.",
|
|
6459
|
+
" Use `get_noise_senders` for sender context.",
|
|
6460
|
+
" Use `get_unsubscribe_suggestions` for likely unsubscribe candidates.",
|
|
6461
|
+
" Use `get_labels` to see what labels already exist.",
|
|
6462
|
+
" Use `rules://deployed` to avoid duplicating existing automation.",
|
|
6463
|
+
"",
|
|
6464
|
+
"Step 2 \u2014 Assign each email a category:",
|
|
6465
|
+
" Receipts \u2014 purchase confirmations, invoices, payment notifications",
|
|
6466
|
+
" Shipping \u2014 delivery tracking, dispatch notices, shipping updates",
|
|
6467
|
+
" Newsletters \u2014 editorial content, digests, weekly roundups",
|
|
6468
|
+
" Promotions \u2014 marketing, sales, deals, coupons",
|
|
6469
|
+
" Social \u2014 social network notifications (LinkedIn, Facebook, etc.)",
|
|
6470
|
+
" Notifications \u2014 automated alerts, system notifications, service updates",
|
|
6471
|
+
" Finance \u2014 bank statements, investment updates, tax documents",
|
|
6472
|
+
" Travel \u2014 bookings, itineraries, check-in reminders",
|
|
6473
|
+
" Important \u2014 personal or work email requiring attention",
|
|
6474
|
+
"",
|
|
6475
|
+
"Step 3 \u2014 Present the categorisation plan:",
|
|
6476
|
+
" Group emails by assigned category.",
|
|
6477
|
+
" For each group show: count, senders involved, sample subjects.",
|
|
6478
|
+
" Note confidence level: HIGH (clear pattern), MEDIUM (reasonable guess), LOW (uncertain).",
|
|
6479
|
+
" Flag any LOW confidence items for the user to decide.",
|
|
6480
|
+
" Present the confidence breakdown: X HIGH (auto-apply), Y MEDIUM (label only), Z LOW (review queue).",
|
|
6481
|
+
" If any LOW confidence emails are present, note why they were flagged from the `signals` array.",
|
|
6482
|
+
"",
|
|
6483
|
+
"Step 3.5 \u2014 Apply confidence gating:",
|
|
6484
|
+
" HIGH confidence \u2014 safe to apply directly (label, mark_read, archive as appropriate).",
|
|
6485
|
+
" MEDIUM confidence \u2014 apply the category label only. Do not archive. Keep the email visible in the inbox.",
|
|
6486
|
+
" LOW confidence \u2014 apply only the label `inboxctl/Review`. Do not archive or mark read.",
|
|
6487
|
+
" These emails need human review before any further action.",
|
|
6488
|
+
"",
|
|
6489
|
+
"Step 4 \u2014 Apply with user approval:",
|
|
6490
|
+
" Create labels for any new categories (use `create_label`).",
|
|
6491
|
+
" Use `batch_apply_actions` to apply labels in one call.",
|
|
6492
|
+
" For Newsletters and Promotions with high unread rates, suggest mark_read + archive or `unsubscribe` when a link is available.",
|
|
6493
|
+
" For Receipts/Shipping/Notifications, suggest mark_read only (keep in inbox).",
|
|
6494
|
+
" For Important, do not mark read or archive.",
|
|
6495
|
+
"",
|
|
6496
|
+
"Step 5 \u2014 Paginate if needed:",
|
|
6497
|
+
" If hasMore is true, ask whether to continue with the next page using offset.",
|
|
6498
|
+
" Reuse the same sender categorisations on later pages instead of re-evaluating known senders.",
|
|
6499
|
+
"",
|
|
6500
|
+
"Step 6 \u2014 Suggest ongoing rules:",
|
|
6501
|
+
" For any category with 3+ emails from the same sender, suggest a YAML rule.",
|
|
6502
|
+
" This prevents the same categorisation from being needed again.",
|
|
6503
|
+
" Use `deploy_rule` after user reviews the YAML.",
|
|
6504
|
+
"",
|
|
6505
|
+
"Step 7 \u2014 Post-categorisation audit:",
|
|
6506
|
+
" After applying actions, call `review_categorized` to check for anomalies.",
|
|
6507
|
+
" If anomalies are found, present them with the option to undo the relevant run."
|
|
4581
6508
|
].join("\n")
|
|
4582
6509
|
)
|
|
4583
6510
|
);
|
|
@@ -4593,7 +6520,7 @@ async function createMcpServer() {
|
|
|
4593
6520
|
{
|
|
4594
6521
|
description: "Get the details of a specific Gmail server-side filter by ID.",
|
|
4595
6522
|
inputSchema: {
|
|
4596
|
-
filter_id:
|
|
6523
|
+
filter_id: z4.string().min(1).describe("Gmail filter ID")
|
|
4597
6524
|
}
|
|
4598
6525
|
},
|
|
4599
6526
|
toolHandler(async ({ filter_id }) => getFilter(filter_id))
|
|
@@ -4603,20 +6530,20 @@ async function createMcpServer() {
|
|
|
4603
6530
|
{
|
|
4604
6531
|
description: "Create a Gmail server-side filter that applies automatically to all future incoming mail. Useful for simple, always-on rules (e.g. 'label all mail from newsletter@x.com and archive it'). At least one criteria field and one action field are required. Gmail does not support updating filters \u2014 to change one, delete it and create a new one. For regex matching, OR conditions, snippet matching, or processing existing mail, use YAML rules instead.",
|
|
4605
6532
|
inputSchema: {
|
|
4606
|
-
from:
|
|
4607
|
-
to:
|
|
4608
|
-
subject:
|
|
4609
|
-
query:
|
|
4610
|
-
negated_query:
|
|
4611
|
-
has_attachment:
|
|
4612
|
-
exclude_chats:
|
|
4613
|
-
size:
|
|
4614
|
-
size_comparison:
|
|
4615
|
-
label:
|
|
4616
|
-
archive:
|
|
4617
|
-
mark_read:
|
|
4618
|
-
star:
|
|
4619
|
-
forward:
|
|
6533
|
+
from: z4.string().optional().describe("Match emails from this address"),
|
|
6534
|
+
to: z4.string().optional().describe("Match emails sent to this address"),
|
|
6535
|
+
subject: z4.string().optional().describe("Match emails with this text in the subject"),
|
|
6536
|
+
query: z4.string().optional().describe("Match using Gmail search syntax (e.g. 'has:attachment')"),
|
|
6537
|
+
negated_query: z4.string().optional().describe("Exclude emails matching this Gmail query"),
|
|
6538
|
+
has_attachment: z4.boolean().optional().describe("Match emails with attachments"),
|
|
6539
|
+
exclude_chats: z4.boolean().optional().describe("Exclude chat messages from matches"),
|
|
6540
|
+
size: z4.number().int().positive().optional().describe("Size threshold in bytes"),
|
|
6541
|
+
size_comparison: z4.enum(["larger", "smaller"]).optional().describe("Use with size: match emails larger or smaller than the threshold"),
|
|
6542
|
+
label: z4.string().optional().describe("Apply this label to matching emails (auto-created if it does not exist)"),
|
|
6543
|
+
archive: z4.boolean().optional().describe("Archive matching emails (remove from inbox)"),
|
|
6544
|
+
mark_read: z4.boolean().optional().describe("Mark matching emails as read"),
|
|
6545
|
+
star: z4.boolean().optional().describe("Star matching emails"),
|
|
6546
|
+
forward: z4.string().email().optional().describe("Forward matching emails to this address (address must be verified in Gmail settings)")
|
|
4620
6547
|
}
|
|
4621
6548
|
},
|
|
4622
6549
|
toolHandler(
|
|
@@ -4643,7 +6570,7 @@ async function createMcpServer() {
|
|
|
4643
6570
|
{
|
|
4644
6571
|
description: "Delete a Gmail server-side filter by ID. The filter stops processing future mail immediately. Already-processed mail is not affected. Use list_filters to find filter IDs.",
|
|
4645
6572
|
inputSchema: {
|
|
4646
|
-
filter_id:
|
|
6573
|
+
filter_id: z4.string().min(1).describe("Gmail filter ID to delete")
|
|
4647
6574
|
}
|
|
4648
6575
|
},
|
|
4649
6576
|
toolHandler(async ({ filter_id }) => {
|
|
@@ -4741,4 +6668,4 @@ export {
|
|
|
4741
6668
|
createMcpServer,
|
|
4742
6669
|
startMcpServer
|
|
4743
6670
|
};
|
|
4744
|
-
//# sourceMappingURL=chunk-
|
|
6671
|
+
//# sourceMappingURL=chunk-OLL3OA5B.js.map
|