inboxctl 0.1.2 → 0.2.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-NUN2WRBN.js} +1147 -36
- package/dist/chunk-NUN2WRBN.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/mcp.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-EY6VV43S.js.map +0 -1
|
@@ -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,6 +2371,184 @@ 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
|
+
|
|
2058
2552
|
// src/core/stats/common.ts
|
|
2059
2553
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2060
2554
|
var SYSTEM_LABEL_NAMES = /* @__PURE__ */ new Map([
|
|
@@ -2318,6 +2812,124 @@ async function getNewsletters(options = {}) {
|
|
|
2318
2812
|
return rows.map(mapNewsletterRow);
|
|
2319
2813
|
}
|
|
2320
2814
|
|
|
2815
|
+
// src/core/stats/noise.ts
|
|
2816
|
+
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
2817
|
+
var SUGGESTED_CATEGORY_RULES = [
|
|
2818
|
+
{ category: "Receipts", keywords: ["receipt", "invoice", "payment", "order"] },
|
|
2819
|
+
{ category: "Shipping", keywords: ["shipping", "tracking", "delivery", "dispatch"] },
|
|
2820
|
+
{ category: "Newsletters", keywords: ["newsletter", "digest", "weekly", "update"] },
|
|
2821
|
+
{ category: "Notifications", keywords: ["noreply", "notification", "alert"] },
|
|
2822
|
+
{ category: "Promotions", keywords: ["promo", "offer", "deal", "sale", "marketing"] },
|
|
2823
|
+
{ category: "Social", keywords: ["linkedin", "facebook", "twitter", "social"] }
|
|
2824
|
+
];
|
|
2825
|
+
function toIsoString(value) {
|
|
2826
|
+
if (!value) {
|
|
2827
|
+
return null;
|
|
2828
|
+
}
|
|
2829
|
+
return new Date(value).toISOString();
|
|
2830
|
+
}
|
|
2831
|
+
function roundNoiseScore(messageCount, unreadRate) {
|
|
2832
|
+
return Math.round(messageCount * unreadRate * 10 / 100) / 10;
|
|
2833
|
+
}
|
|
2834
|
+
function getSuggestedCategory(email, name) {
|
|
2835
|
+
const haystack = `${email} ${name}`.toLowerCase();
|
|
2836
|
+
for (const rule of SUGGESTED_CATEGORY_RULES) {
|
|
2837
|
+
if (rule.keywords.some((keyword) => haystack.includes(keyword))) {
|
|
2838
|
+
return rule.category;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
return "Other";
|
|
2842
|
+
}
|
|
2843
|
+
function compareNoiseSenders(sortBy) {
|
|
2844
|
+
return (left, right) => {
|
|
2845
|
+
switch (sortBy) {
|
|
2846
|
+
case "all_time_noise_score":
|
|
2847
|
+
return right.allTimeNoiseScore - left.allTimeNoiseScore || right.noiseScore - left.noiseScore || right.allTimeMessageCount - left.allTimeMessageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
|
|
2848
|
+
case "message_count":
|
|
2849
|
+
return right.messageCount - left.messageCount || right.noiseScore - left.noiseScore || right.allTimeMessageCount - left.allTimeMessageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
|
|
2850
|
+
case "unread_rate":
|
|
2851
|
+
return right.unreadRate - left.unreadRate || right.noiseScore - left.noiseScore || right.messageCount - left.messageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
|
|
2852
|
+
case "noise_score":
|
|
2853
|
+
default:
|
|
2854
|
+
return right.noiseScore - left.noiseScore || right.allTimeNoiseScore - left.allTimeNoiseScore || right.messageCount - left.messageCount || (right.lastSeen || "").localeCompare(left.lastSeen || "") || left.email.localeCompare(right.email);
|
|
2855
|
+
}
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
async function getNoiseSenders(options = {}) {
|
|
2859
|
+
await detectNewsletters();
|
|
2860
|
+
const sqlite = getStatsSqlite();
|
|
2861
|
+
const limit = Math.min(50, normalizeLimit(options.limit, 20));
|
|
2862
|
+
const minNoiseScore = options.minNoiseScore ?? 5;
|
|
2863
|
+
const activeDays = Math.max(1, Math.floor(options.activeDays ?? 90));
|
|
2864
|
+
const activeSince = Date.now() - activeDays * DAY_MS2;
|
|
2865
|
+
const sortBy = options.sortBy ?? "noise_score";
|
|
2866
|
+
const rows = sqlite.prepare(
|
|
2867
|
+
`
|
|
2868
|
+
SELECT
|
|
2869
|
+
e.from_address AS email,
|
|
2870
|
+
COALESCE(MAX(NULLIF(TRIM(e.from_name), '')), e.from_address) AS name,
|
|
2871
|
+
COUNT(*) AS messageCount,
|
|
2872
|
+
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
|
|
2873
|
+
MAX(e.date) AS lastSeen,
|
|
2874
|
+
MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
|
|
2875
|
+
GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
|
|
2876
|
+
') AS emailUnsubscribeHeaders,
|
|
2877
|
+
MAX(CASE WHEN ns.email IS NOT NULL THEN 1 ELSE 0 END) AS isNewsletter,
|
|
2878
|
+
COALESCE(MAX(all_time.allTimeCount), COUNT(*)) AS allTimeMessageCount,
|
|
2879
|
+
COALESCE(
|
|
2880
|
+
MAX(all_time.allTimeUnreadCount),
|
|
2881
|
+
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END)
|
|
2882
|
+
) AS allTimeUnreadCount
|
|
2883
|
+
FROM emails AS e
|
|
2884
|
+
LEFT JOIN newsletter_senders AS ns
|
|
2885
|
+
ON LOWER(ns.email) = LOWER(e.from_address)
|
|
2886
|
+
LEFT JOIN (
|
|
2887
|
+
SELECT
|
|
2888
|
+
LOWER(from_address) AS senderKey,
|
|
2889
|
+
COUNT(*) AS allTimeCount,
|
|
2890
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS allTimeUnreadCount
|
|
2891
|
+
FROM emails
|
|
2892
|
+
WHERE from_address IS NOT NULL
|
|
2893
|
+
AND TRIM(from_address) <> ''
|
|
2894
|
+
GROUP BY LOWER(from_address)
|
|
2895
|
+
) AS all_time
|
|
2896
|
+
ON all_time.senderKey = LOWER(e.from_address)
|
|
2897
|
+
WHERE e.from_address IS NOT NULL
|
|
2898
|
+
AND TRIM(e.from_address) <> ''
|
|
2899
|
+
AND COALESCE(e.date, 0) >= ?
|
|
2900
|
+
GROUP BY LOWER(e.from_address)
|
|
2901
|
+
`
|
|
2902
|
+
).all(activeSince);
|
|
2903
|
+
const senders = rows.map((row) => {
|
|
2904
|
+
const unreadRate = roundPercent(row.unreadCount, row.messageCount);
|
|
2905
|
+
const allTimeMessageCount = row.allTimeMessageCount ?? row.messageCount;
|
|
2906
|
+
const allTimeUnreadCount = row.allTimeUnreadCount ?? row.unreadCount;
|
|
2907
|
+
const allTimeUnreadRate = roundPercent(allTimeUnreadCount, allTimeMessageCount);
|
|
2908
|
+
const noiseScore = roundNoiseScore(row.messageCount, unreadRate);
|
|
2909
|
+
const allTimeNoiseScore = roundNoiseScore(allTimeMessageCount, allTimeUnreadRate);
|
|
2910
|
+
const unsubscribe2 = resolveUnsubscribeTarget(
|
|
2911
|
+
row.newsletterUnsubscribeLink,
|
|
2912
|
+
row.emailUnsubscribeHeaders
|
|
2913
|
+
);
|
|
2914
|
+
return {
|
|
2915
|
+
email: row.email,
|
|
2916
|
+
name: row.name?.trim() || row.email,
|
|
2917
|
+
messageCount: row.messageCount,
|
|
2918
|
+
allTimeMessageCount,
|
|
2919
|
+
unreadCount: row.unreadCount,
|
|
2920
|
+
unreadRate,
|
|
2921
|
+
noiseScore,
|
|
2922
|
+
allTimeNoiseScore,
|
|
2923
|
+
lastSeen: toIsoString(row.lastSeen),
|
|
2924
|
+
isNewsletter: row.isNewsletter === 1,
|
|
2925
|
+
hasUnsubscribeLink: Boolean(unsubscribe2.unsubscribeLink),
|
|
2926
|
+
unsubscribeLink: unsubscribe2.unsubscribeLink,
|
|
2927
|
+
suggestedCategory: getSuggestedCategory(row.email, row.name?.trim() || row.email)
|
|
2928
|
+
};
|
|
2929
|
+
}).filter((sender) => sender.noiseScore >= minNoiseScore).sort(compareNoiseSenders(sortBy)).slice(0, limit);
|
|
2930
|
+
return { senders };
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2321
2933
|
// src/core/stats/sender.ts
|
|
2322
2934
|
function buildSenderWhereClause(period) {
|
|
2323
2935
|
const whereParts = [
|
|
@@ -2476,6 +3088,222 @@ async function getSenderStats(emailOrDomain) {
|
|
|
2476
3088
|
};
|
|
2477
3089
|
}
|
|
2478
3090
|
|
|
3091
|
+
// src/core/stats/uncategorized.ts
|
|
3092
|
+
var SYSTEM_LABEL_IDS = [
|
|
3093
|
+
"INBOX",
|
|
3094
|
+
"UNREAD",
|
|
3095
|
+
"IMPORTANT",
|
|
3096
|
+
"SENT",
|
|
3097
|
+
"DRAFT",
|
|
3098
|
+
"SPAM",
|
|
3099
|
+
"TRASH",
|
|
3100
|
+
"STARRED"
|
|
3101
|
+
];
|
|
3102
|
+
var CATEGORY_LABEL_PATTERN = "CATEGORY\\_%";
|
|
3103
|
+
function toIsoString2(value) {
|
|
3104
|
+
if (!value) {
|
|
3105
|
+
return null;
|
|
3106
|
+
}
|
|
3107
|
+
return new Date(value).toISOString();
|
|
3108
|
+
}
|
|
3109
|
+
function parseJsonArray3(raw) {
|
|
3110
|
+
if (!raw) {
|
|
3111
|
+
return [];
|
|
3112
|
+
}
|
|
3113
|
+
try {
|
|
3114
|
+
const parsed = JSON.parse(raw);
|
|
3115
|
+
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
|
|
3116
|
+
} catch {
|
|
3117
|
+
return [];
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
function resolveSinceTimestamp(since) {
|
|
3121
|
+
if (!since) {
|
|
3122
|
+
return null;
|
|
3123
|
+
}
|
|
3124
|
+
const parsed = Date.parse(since);
|
|
3125
|
+
if (Number.isNaN(parsed)) {
|
|
3126
|
+
throw new Error(`Invalid since value: ${since}`);
|
|
3127
|
+
}
|
|
3128
|
+
return parsed;
|
|
3129
|
+
}
|
|
3130
|
+
function buildWhereClause(options) {
|
|
3131
|
+
const whereParts = [
|
|
3132
|
+
`
|
|
3133
|
+
NOT EXISTS (
|
|
3134
|
+
SELECT 1
|
|
3135
|
+
FROM json_each(COALESCE(e.label_ids, '[]')) AS label
|
|
3136
|
+
WHERE label.value IS NOT NULL
|
|
3137
|
+
AND TRIM(CAST(label.value AS TEXT)) <> ''
|
|
3138
|
+
AND label.value NOT IN (${SYSTEM_LABEL_IDS.map(() => "?").join(", ")})
|
|
3139
|
+
AND label.value NOT LIKE ? ESCAPE '\\'
|
|
3140
|
+
)
|
|
3141
|
+
`
|
|
3142
|
+
];
|
|
3143
|
+
const params = [...SYSTEM_LABEL_IDS, CATEGORY_LABEL_PATTERN];
|
|
3144
|
+
if (options.unreadOnly) {
|
|
3145
|
+
whereParts.push("COALESCE(e.is_read, 0) = 0");
|
|
3146
|
+
}
|
|
3147
|
+
if (options.sinceTimestamp !== null) {
|
|
3148
|
+
whereParts.push("COALESCE(e.date, 0) >= ?");
|
|
3149
|
+
params.push(options.sinceTimestamp);
|
|
3150
|
+
}
|
|
3151
|
+
return {
|
|
3152
|
+
clause: whereParts.join(" AND "),
|
|
3153
|
+
params
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
async function getUncategorizedEmails(options = {}) {
|
|
3157
|
+
await detectNewsletters();
|
|
3158
|
+
const sqlite = getStatsSqlite();
|
|
3159
|
+
const limit = Math.min(1e3, normalizeLimit(options.limit, 50));
|
|
3160
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
3161
|
+
const sinceTimestamp = resolveSinceTimestamp(options.since);
|
|
3162
|
+
const { clause, params } = buildWhereClause({
|
|
3163
|
+
sinceTimestamp,
|
|
3164
|
+
unreadOnly: options.unreadOnly ?? false
|
|
3165
|
+
});
|
|
3166
|
+
const totalRow = sqlite.prepare(
|
|
3167
|
+
`
|
|
3168
|
+
SELECT COUNT(*) AS total
|
|
3169
|
+
FROM emails AS e
|
|
3170
|
+
WHERE ${clause}
|
|
3171
|
+
`
|
|
3172
|
+
).get(...params);
|
|
3173
|
+
const rows = sqlite.prepare(
|
|
3174
|
+
`
|
|
3175
|
+
SELECT
|
|
3176
|
+
e.id AS id,
|
|
3177
|
+
e.thread_id AS threadId,
|
|
3178
|
+
e.from_address AS sender,
|
|
3179
|
+
e.subject AS subject,
|
|
3180
|
+
e.date AS date,
|
|
3181
|
+
e.snippet AS snippet,
|
|
3182
|
+
e.label_ids AS labelIds,
|
|
3183
|
+
e.is_read AS isRead,
|
|
3184
|
+
sender_stats.totalFromSender AS totalFromSender,
|
|
3185
|
+
sender_stats.unreadFromSender AS unreadFromSender,
|
|
3186
|
+
ns.detection_reason AS detectionReason
|
|
3187
|
+
FROM emails AS e
|
|
3188
|
+
LEFT JOIN (
|
|
3189
|
+
SELECT
|
|
3190
|
+
LOWER(from_address) AS senderKey,
|
|
3191
|
+
COUNT(*) AS totalFromSender,
|
|
3192
|
+
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unreadFromSender
|
|
3193
|
+
FROM emails
|
|
3194
|
+
WHERE from_address IS NOT NULL
|
|
3195
|
+
AND TRIM(from_address) <> ''
|
|
3196
|
+
GROUP BY LOWER(from_address)
|
|
3197
|
+
) AS sender_stats
|
|
3198
|
+
ON sender_stats.senderKey = LOWER(e.from_address)
|
|
3199
|
+
LEFT JOIN newsletter_senders AS ns
|
|
3200
|
+
ON LOWER(ns.email) = LOWER(e.from_address)
|
|
3201
|
+
WHERE ${clause}
|
|
3202
|
+
ORDER BY COALESCE(e.date, 0) DESC, e.id ASC
|
|
3203
|
+
LIMIT ?
|
|
3204
|
+
OFFSET ?
|
|
3205
|
+
`
|
|
3206
|
+
).all(...params, limit, offset);
|
|
3207
|
+
const emails2 = rows.map((row) => {
|
|
3208
|
+
const totalFromSender = row.totalFromSender ?? 0;
|
|
3209
|
+
const unreadFromSender = row.unreadFromSender ?? 0;
|
|
3210
|
+
return {
|
|
3211
|
+
id: row.id,
|
|
3212
|
+
threadId: row.threadId || "",
|
|
3213
|
+
from: row.sender || "",
|
|
3214
|
+
subject: row.subject || "",
|
|
3215
|
+
date: toIsoString2(row.date),
|
|
3216
|
+
snippet: row.snippet || "",
|
|
3217
|
+
labels: parseJsonArray3(row.labelIds),
|
|
3218
|
+
isRead: row.isRead === 1,
|
|
3219
|
+
senderContext: {
|
|
3220
|
+
totalFromSender,
|
|
3221
|
+
unreadRate: roundPercent(unreadFromSender, totalFromSender),
|
|
3222
|
+
isNewsletter: Boolean(row.detectionReason),
|
|
3223
|
+
detectionReason: row.detectionReason
|
|
3224
|
+
}
|
|
3225
|
+
};
|
|
3226
|
+
});
|
|
3227
|
+
return {
|
|
3228
|
+
totalUncategorized: totalRow?.total ?? 0,
|
|
3229
|
+
returned: emails2.length,
|
|
3230
|
+
offset,
|
|
3231
|
+
hasMore: offset + emails2.length < (totalRow?.total ?? 0),
|
|
3232
|
+
emails: emails2
|
|
3233
|
+
};
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// src/core/stats/unsubscribe.ts
|
|
3237
|
+
function toIsoString3(value) {
|
|
3238
|
+
if (!value) {
|
|
3239
|
+
return null;
|
|
3240
|
+
}
|
|
3241
|
+
return new Date(value).toISOString();
|
|
3242
|
+
}
|
|
3243
|
+
function roundImpactScore(messageCount, unreadRate) {
|
|
3244
|
+
return Math.round(messageCount * unreadRate * 10 / 100) / 10;
|
|
3245
|
+
}
|
|
3246
|
+
async function getUnsubscribeSuggestions(options = {}) {
|
|
3247
|
+
await detectNewsletters();
|
|
3248
|
+
const sqlite = getStatsSqlite();
|
|
3249
|
+
const limit = Math.min(50, normalizeLimit(options.limit, 20));
|
|
3250
|
+
const minMessages = normalizeLimit(options.minMessages, 5);
|
|
3251
|
+
const rows = sqlite.prepare(
|
|
3252
|
+
`
|
|
3253
|
+
SELECT
|
|
3254
|
+
e.from_address AS email,
|
|
3255
|
+
COALESCE(MAX(NULLIF(TRIM(e.from_name), '')), e.from_address) AS name,
|
|
3256
|
+
COUNT(*) AS messageCount,
|
|
3257
|
+
SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unreadCount,
|
|
3258
|
+
MAX(CASE WHEN e.is_read = 1 THEN e.date ELSE NULL END) AS lastRead,
|
|
3259
|
+
MAX(e.date) AS lastReceived,
|
|
3260
|
+
MAX(NULLIF(TRIM(ns.unsubscribe_link), '')) AS newsletterUnsubscribeLink,
|
|
3261
|
+
GROUP_CONCAT(NULLIF(TRIM(e.list_unsubscribe), ''), '
|
|
3262
|
+
') AS emailUnsubscribeHeaders
|
|
3263
|
+
FROM emails AS e
|
|
3264
|
+
LEFT JOIN newsletter_senders AS ns
|
|
3265
|
+
ON LOWER(ns.email) = LOWER(e.from_address)
|
|
3266
|
+
WHERE e.from_address IS NOT NULL
|
|
3267
|
+
AND TRIM(e.from_address) <> ''
|
|
3268
|
+
GROUP BY LOWER(e.from_address)
|
|
3269
|
+
HAVING COUNT(*) >= ?
|
|
3270
|
+
`
|
|
3271
|
+
).all(minMessages);
|
|
3272
|
+
const suggestions = rows.map((row) => {
|
|
3273
|
+
const unsubscribe2 = resolveUnsubscribeTarget(
|
|
3274
|
+
row.newsletterUnsubscribeLink,
|
|
3275
|
+
row.emailUnsubscribeHeaders
|
|
3276
|
+
);
|
|
3277
|
+
if (!unsubscribe2.unsubscribeLink || !unsubscribe2.unsubscribeMethod) {
|
|
3278
|
+
return null;
|
|
3279
|
+
}
|
|
3280
|
+
const unreadRate = roundPercent(row.unreadCount, row.messageCount);
|
|
3281
|
+
const readRate = roundPercent(row.messageCount - row.unreadCount, row.messageCount);
|
|
3282
|
+
return {
|
|
3283
|
+
email: row.email,
|
|
3284
|
+
name: row.name?.trim() || row.email,
|
|
3285
|
+
allTimeMessageCount: row.messageCount,
|
|
3286
|
+
unreadCount: row.unreadCount,
|
|
3287
|
+
unreadRate,
|
|
3288
|
+
readRate,
|
|
3289
|
+
lastRead: toIsoString3(row.lastRead),
|
|
3290
|
+
lastReceived: toIsoString3(row.lastReceived),
|
|
3291
|
+
unsubscribeLink: unsubscribe2.unsubscribeLink,
|
|
3292
|
+
unsubscribeMethod: unsubscribe2.unsubscribeMethod,
|
|
3293
|
+
impactScore: roundImpactScore(row.messageCount, unreadRate),
|
|
3294
|
+
reason: buildUnsubscribeReason(unreadRate, row.messageCount)
|
|
3295
|
+
};
|
|
3296
|
+
}).filter((suggestion) => suggestion !== null).filter(
|
|
3297
|
+
(suggestion) => options.unreadOnlySenders ? suggestion.unreadCount === suggestion.allTimeMessageCount : true
|
|
3298
|
+
).sort(
|
|
3299
|
+
(left, right) => right.impactScore - left.impactScore || right.allTimeMessageCount - left.allTimeMessageCount || right.unreadRate - left.unreadRate || (right.lastReceived || "").localeCompare(left.lastReceived || "") || left.email.localeCompare(right.email)
|
|
3300
|
+
);
|
|
3301
|
+
return {
|
|
3302
|
+
suggestions: suggestions.slice(0, limit),
|
|
3303
|
+
totalWithUnsubscribeLinks: suggestions.length
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
|
|
2479
3307
|
// src/core/stats/volume.ts
|
|
2480
3308
|
function getBucketExpression(granularity) {
|
|
2481
3309
|
switch (granularity) {
|
|
@@ -2588,6 +3416,18 @@ async function getExecutionHistory(ruleId, limit = 20) {
|
|
|
2588
3416
|
const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(limit);
|
|
2589
3417
|
return runs.slice(0, limit);
|
|
2590
3418
|
}
|
|
3419
|
+
async function getExecutionStats(ruleId) {
|
|
3420
|
+
const runs = ruleId ? await getRunsByRule(ruleId) : await getRecentRuns(1e4);
|
|
3421
|
+
return {
|
|
3422
|
+
totalRuns: runs.length,
|
|
3423
|
+
plannedRuns: runs.filter((run) => run.status === "planned").length,
|
|
3424
|
+
appliedRuns: runs.filter((run) => run.status === "applied").length,
|
|
3425
|
+
partialRuns: runs.filter((run) => run.status === "partial").length,
|
|
3426
|
+
errorRuns: runs.filter((run) => run.status === "error").length,
|
|
3427
|
+
undoneRuns: runs.filter((run) => run.status === "undone").length,
|
|
3428
|
+
lastExecutionAt: runs[0]?.createdAt ?? null
|
|
3429
|
+
};
|
|
3430
|
+
}
|
|
2591
3431
|
|
|
2592
3432
|
// src/core/rules/deploy.ts
|
|
2593
3433
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
@@ -2989,7 +3829,7 @@ function getDatabase4() {
|
|
|
2989
3829
|
const config = loadConfig();
|
|
2990
3830
|
return getSqlite(config.dbPath);
|
|
2991
3831
|
}
|
|
2992
|
-
function
|
|
3832
|
+
function parseJsonArray4(value) {
|
|
2993
3833
|
if (!value) {
|
|
2994
3834
|
return [];
|
|
2995
3835
|
}
|
|
@@ -3000,19 +3840,19 @@ function parseJsonArray2(value) {
|
|
|
3000
3840
|
return [];
|
|
3001
3841
|
}
|
|
3002
3842
|
}
|
|
3003
|
-
function
|
|
3843
|
+
function rowToEmail3(row) {
|
|
3004
3844
|
return {
|
|
3005
3845
|
id: row.id,
|
|
3006
3846
|
threadId: row.thread_id ?? "",
|
|
3007
3847
|
fromAddress: row.from_address ?? "",
|
|
3008
3848
|
fromName: row.from_name ?? "",
|
|
3009
|
-
toAddresses:
|
|
3849
|
+
toAddresses: parseJsonArray4(row.to_addresses),
|
|
3010
3850
|
subject: row.subject ?? "",
|
|
3011
3851
|
snippet: row.snippet ?? "",
|
|
3012
3852
|
date: row.date ?? 0,
|
|
3013
3853
|
isRead: row.is_read === 1,
|
|
3014
3854
|
isStarred: row.is_starred === 1,
|
|
3015
|
-
labelIds:
|
|
3855
|
+
labelIds: parseJsonArray4(row.label_ids),
|
|
3016
3856
|
sizeEstimate: row.size_estimate ?? 0,
|
|
3017
3857
|
hasAttachments: row.has_attachments === 1,
|
|
3018
3858
|
listUnsubscribe: row.list_unsubscribe
|
|
@@ -3121,7 +3961,7 @@ async function findMatchingEmails(ruleOrConditions, limit) {
|
|
|
3121
3961
|
).all();
|
|
3122
3962
|
const matches = [];
|
|
3123
3963
|
for (const row of rows) {
|
|
3124
|
-
const email =
|
|
3964
|
+
const email = rowToEmail3(row);
|
|
3125
3965
|
const result = matchEmail(email, conditions);
|
|
3126
3966
|
if (!result.matches) {
|
|
3127
3967
|
continue;
|
|
@@ -3186,7 +4026,7 @@ async function loadMatchedItems(rule, options) {
|
|
|
3186
4026
|
errorMessage: null
|
|
3187
4027
|
}));
|
|
3188
4028
|
}
|
|
3189
|
-
async function
|
|
4029
|
+
async function executeAction2(emailId, action, options) {
|
|
3190
4030
|
switch (action.type) {
|
|
3191
4031
|
case "archive":
|
|
3192
4032
|
return (await archiveEmails([emailId], options)).items[0];
|
|
@@ -3207,7 +4047,7 @@ async function applyRuleActions(item, actions, options) {
|
|
|
3207
4047
|
};
|
|
3208
4048
|
for (const action of actions) {
|
|
3209
4049
|
try {
|
|
3210
|
-
const result = await
|
|
4050
|
+
const result = await executeAction2(item.emailId, action, options);
|
|
3211
4051
|
current = {
|
|
3212
4052
|
...current,
|
|
3213
4053
|
status: result.status,
|
|
@@ -3335,7 +4175,7 @@ async function runAllRules(options) {
|
|
|
3335
4175
|
}
|
|
3336
4176
|
|
|
3337
4177
|
// src/core/gmail/filters.ts
|
|
3338
|
-
async function
|
|
4178
|
+
async function resolveContext5(options) {
|
|
3339
4179
|
const config = options?.config ?? loadConfig();
|
|
3340
4180
|
const transport = options?.transport ?? await getGmailTransport(config);
|
|
3341
4181
|
return { config, transport };
|
|
@@ -3385,7 +4225,7 @@ async function buildLabelMap(context) {
|
|
|
3385
4225
|
return map;
|
|
3386
4226
|
}
|
|
3387
4227
|
async function listFilters(options) {
|
|
3388
|
-
const context = await
|
|
4228
|
+
const context = await resolveContext5(options);
|
|
3389
4229
|
const [response, labelMap] = await Promise.all([
|
|
3390
4230
|
context.transport.listFilters(),
|
|
3391
4231
|
buildLabelMap(context)
|
|
@@ -3394,7 +4234,7 @@ async function listFilters(options) {
|
|
|
3394
4234
|
return raw.map((f) => toGmailFilter(f, labelMap)).filter((f) => f !== null);
|
|
3395
4235
|
}
|
|
3396
4236
|
async function getFilter(id, options) {
|
|
3397
|
-
const context = await
|
|
4237
|
+
const context = await resolveContext5(options);
|
|
3398
4238
|
const [raw, labelMap] = await Promise.all([
|
|
3399
4239
|
context.transport.getFilter(id),
|
|
3400
4240
|
buildLabelMap(context)
|
|
@@ -3418,7 +4258,7 @@ async function createFilter(input, options) {
|
|
|
3418
4258
|
"At least one action is required (labelName, archive, markRead, star, or forward)"
|
|
3419
4259
|
);
|
|
3420
4260
|
}
|
|
3421
|
-
const context = await
|
|
4261
|
+
const context = await resolveContext5(options);
|
|
3422
4262
|
const addLabelIds = [];
|
|
3423
4263
|
if (input.star) {
|
|
3424
4264
|
addLabelIds.push("STARRED");
|
|
@@ -3457,7 +4297,7 @@ async function createFilter(input, options) {
|
|
|
3457
4297
|
return filter;
|
|
3458
4298
|
}
|
|
3459
4299
|
async function deleteFilter(id, options) {
|
|
3460
|
-
const context = await
|
|
4300
|
+
const context = await resolveContext5(options);
|
|
3461
4301
|
await context.transport.deleteFilter(id);
|
|
3462
4302
|
}
|
|
3463
4303
|
|
|
@@ -3949,7 +4789,7 @@ async function getSyncStatus() {
|
|
|
3949
4789
|
}
|
|
3950
4790
|
|
|
3951
4791
|
// src/mcp/server.ts
|
|
3952
|
-
var
|
|
4792
|
+
var DAY_MS3 = 24 * 60 * 60 * 1e3;
|
|
3953
4793
|
var MCP_VERSION = "0.1.0";
|
|
3954
4794
|
var MCP_TOOLS = [
|
|
3955
4795
|
"search_emails",
|
|
@@ -3959,6 +4799,7 @@ var MCP_TOOLS = [
|
|
|
3959
4799
|
"archive_emails",
|
|
3960
4800
|
"label_emails",
|
|
3961
4801
|
"mark_read",
|
|
4802
|
+
"batch_apply_actions",
|
|
3962
4803
|
"forward_email",
|
|
3963
4804
|
"undo_run",
|
|
3964
4805
|
"get_labels",
|
|
@@ -3967,6 +4808,10 @@ var MCP_TOOLS = [
|
|
|
3967
4808
|
"get_top_senders",
|
|
3968
4809
|
"get_sender_stats",
|
|
3969
4810
|
"get_newsletter_senders",
|
|
4811
|
+
"get_uncategorized_emails",
|
|
4812
|
+
"get_noise_senders",
|
|
4813
|
+
"get_unsubscribe_suggestions",
|
|
4814
|
+
"unsubscribe",
|
|
3970
4815
|
"deploy_rule",
|
|
3971
4816
|
"list_rules",
|
|
3972
4817
|
"run_rule",
|
|
@@ -3980,6 +4825,7 @@ var MCP_TOOLS = [
|
|
|
3980
4825
|
var MCP_RESOURCES = [
|
|
3981
4826
|
"inbox://recent",
|
|
3982
4827
|
"inbox://summary",
|
|
4828
|
+
"inbox://action-log",
|
|
3983
4829
|
"rules://deployed",
|
|
3984
4830
|
"rules://history",
|
|
3985
4831
|
"stats://senders",
|
|
@@ -3990,7 +4836,8 @@ var MCP_PROMPTS = [
|
|
|
3990
4836
|
"review-senders",
|
|
3991
4837
|
"find-newsletters",
|
|
3992
4838
|
"suggest-rules",
|
|
3993
|
-
"triage-inbox"
|
|
4839
|
+
"triage-inbox",
|
|
4840
|
+
"categorize-emails"
|
|
3994
4841
|
];
|
|
3995
4842
|
function toTextResult(value) {
|
|
3996
4843
|
return {
|
|
@@ -4064,12 +4911,22 @@ function buildSearchQuery(query, label) {
|
|
|
4064
4911
|
}
|
|
4065
4912
|
return trimmedQuery;
|
|
4066
4913
|
}
|
|
4067
|
-
function
|
|
4914
|
+
function uniqueStrings3(values) {
|
|
4068
4915
|
return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
|
|
4069
4916
|
}
|
|
4070
4917
|
function resolveResourceUri(uri, fallback) {
|
|
4071
4918
|
return typeof uri === "string" ? uri : fallback;
|
|
4072
4919
|
}
|
|
4920
|
+
function formatActionSummary(action) {
|
|
4921
|
+
switch (action.type) {
|
|
4922
|
+
case "label":
|
|
4923
|
+
return action.label ? `label:${action.label}` : "label";
|
|
4924
|
+
case "forward":
|
|
4925
|
+
return action.to ? `forward:${action.to}` : "forward";
|
|
4926
|
+
default:
|
|
4927
|
+
return action.type;
|
|
4928
|
+
}
|
|
4929
|
+
}
|
|
4073
4930
|
async function buildStartupWarnings() {
|
|
4074
4931
|
const config = loadConfig();
|
|
4075
4932
|
initializeDb(config.dbPath);
|
|
@@ -4090,7 +4947,7 @@ async function buildStartupWarnings() {
|
|
|
4090
4947
|
}
|
|
4091
4948
|
if (!latestSync) {
|
|
4092
4949
|
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 >
|
|
4950
|
+
} else if (Date.now() - latestSync > DAY_MS3) {
|
|
4094
4951
|
warnings.push("Inbox cache appears stale (last sync older than 24 hours). Call `sync_inbox` if freshness matters.");
|
|
4095
4952
|
}
|
|
4096
4953
|
return warnings;
|
|
@@ -4101,7 +4958,7 @@ async function buildStatsOverview() {
|
|
|
4101
4958
|
topSenders: await getTopSenders({ limit: 10 }),
|
|
4102
4959
|
labelDistribution: (await getLabelDistribution()).slice(0, 10),
|
|
4103
4960
|
dailyVolume: await getVolumeByPeriod("day", {
|
|
4104
|
-
start: Date.now() - 30 *
|
|
4961
|
+
start: Date.now() - 30 * DAY_MS3,
|
|
4105
4962
|
end: Date.now()
|
|
4106
4963
|
})
|
|
4107
4964
|
};
|
|
@@ -4113,6 +4970,23 @@ async function buildRuleHistory() {
|
|
|
4113
4970
|
recentRuns: await getRecentRuns(20)
|
|
4114
4971
|
};
|
|
4115
4972
|
}
|
|
4973
|
+
async function buildActionLog() {
|
|
4974
|
+
const recentRuns = await getRecentRuns(10);
|
|
4975
|
+
const stats = await getExecutionStats();
|
|
4976
|
+
return {
|
|
4977
|
+
recentRuns: recentRuns.map((run) => ({
|
|
4978
|
+
runId: run.id,
|
|
4979
|
+
createdAt: new Date(run.createdAt).toISOString(),
|
|
4980
|
+
sourceType: run.sourceType,
|
|
4981
|
+
dryRun: run.dryRun,
|
|
4982
|
+
status: run.status,
|
|
4983
|
+
emailCount: run.itemCount,
|
|
4984
|
+
actions: run.requestedActions.map(formatActionSummary),
|
|
4985
|
+
undoAvailable: !run.dryRun && run.undoneAt === null && run.status !== "planned" && run.status !== "undone" && run.itemCount > 0
|
|
4986
|
+
})),
|
|
4987
|
+
totalRuns: stats.totalRuns
|
|
4988
|
+
};
|
|
4989
|
+
}
|
|
4116
4990
|
async function createMcpServer() {
|
|
4117
4991
|
const warnings = await buildStartupWarnings();
|
|
4118
4992
|
const server = new McpServer({
|
|
@@ -4188,7 +5062,7 @@ async function createMcpServer() {
|
|
|
4188
5062
|
destructiveHint: false
|
|
4189
5063
|
}
|
|
4190
5064
|
},
|
|
4191
|
-
toolHandler(async ({ email_ids }) => archiveEmails(
|
|
5065
|
+
toolHandler(async ({ email_ids }) => archiveEmails(uniqueStrings3(email_ids)))
|
|
4192
5066
|
);
|
|
4193
5067
|
server.registerTool(
|
|
4194
5068
|
"label_emails",
|
|
@@ -4205,9 +5079,9 @@ async function createMcpServer() {
|
|
|
4205
5079
|
}
|
|
4206
5080
|
},
|
|
4207
5081
|
toolHandler(async ({ email_ids, add_labels, remove_labels }) => {
|
|
4208
|
-
const ids =
|
|
4209
|
-
const addLabels =
|
|
4210
|
-
const removeLabels =
|
|
5082
|
+
const ids = uniqueStrings3(email_ids);
|
|
5083
|
+
const addLabels = uniqueStrings3(add_labels);
|
|
5084
|
+
const removeLabels = uniqueStrings3(remove_labels);
|
|
4211
5085
|
if (addLabels.length === 0 && removeLabels.length === 0) {
|
|
4212
5086
|
throw new Error("Provide at least one label to add or remove.");
|
|
4213
5087
|
}
|
|
@@ -4240,10 +5114,46 @@ async function createMcpServer() {
|
|
|
4240
5114
|
}
|
|
4241
5115
|
},
|
|
4242
5116
|
toolHandler(async ({ email_ids, read }) => {
|
|
4243
|
-
const ids =
|
|
5117
|
+
const ids = uniqueStrings3(email_ids);
|
|
4244
5118
|
return read ? markRead(ids) : markUnread(ids);
|
|
4245
5119
|
})
|
|
4246
5120
|
);
|
|
5121
|
+
server.registerTool(
|
|
5122
|
+
"batch_apply_actions",
|
|
5123
|
+
{
|
|
5124
|
+
description: "Apply grouped inbox actions in one call for faster AI-driven triage and categorization.",
|
|
5125
|
+
inputSchema: {
|
|
5126
|
+
groups: z2.array(
|
|
5127
|
+
z2.object({
|
|
5128
|
+
email_ids: z2.array(z2.string().min(1)).min(1).max(500),
|
|
5129
|
+
actions: z2.array(
|
|
5130
|
+
z2.discriminatedUnion("type", [
|
|
5131
|
+
z2.object({
|
|
5132
|
+
type: z2.literal("label"),
|
|
5133
|
+
label: z2.string().min(1)
|
|
5134
|
+
}),
|
|
5135
|
+
z2.object({ type: z2.literal("archive") }),
|
|
5136
|
+
z2.object({ type: z2.literal("mark_read") }),
|
|
5137
|
+
z2.object({ type: z2.literal("mark_spam") })
|
|
5138
|
+
])
|
|
5139
|
+
).min(1).max(5)
|
|
5140
|
+
})
|
|
5141
|
+
).min(1).max(20),
|
|
5142
|
+
dry_run: z2.boolean().optional()
|
|
5143
|
+
},
|
|
5144
|
+
annotations: {
|
|
5145
|
+
readOnlyHint: false,
|
|
5146
|
+
destructiveHint: false
|
|
5147
|
+
}
|
|
5148
|
+
},
|
|
5149
|
+
toolHandler(async ({ groups, dry_run }) => batchApplyActions({
|
|
5150
|
+
groups: groups.map((group) => ({
|
|
5151
|
+
emailIds: uniqueStrings3(group.email_ids),
|
|
5152
|
+
actions: group.actions
|
|
5153
|
+
})),
|
|
5154
|
+
dryRun: dry_run
|
|
5155
|
+
}))
|
|
5156
|
+
);
|
|
4247
5157
|
server.registerTool(
|
|
4248
5158
|
"forward_email",
|
|
4249
5159
|
{
|
|
@@ -4372,6 +5282,87 @@ async function createMcpServer() {
|
|
|
4372
5282
|
minUnreadRate: min_unread_rate
|
|
4373
5283
|
}))
|
|
4374
5284
|
);
|
|
5285
|
+
server.registerTool(
|
|
5286
|
+
"get_uncategorized_emails",
|
|
5287
|
+
{
|
|
5288
|
+
description: "Return cached emails that have only Gmail system labels and no user-applied organization.",
|
|
5289
|
+
inputSchema: {
|
|
5290
|
+
limit: z2.number().int().positive().max(1e3).optional().describe("Max emails to return per page. Default 50. AI clients should start with 50-100 and paginate."),
|
|
5291
|
+
offset: z2.number().int().min(0).optional().describe("Number of results to skip for pagination. Use with totalUncategorized and hasMore."),
|
|
5292
|
+
unread_only: z2.boolean().optional(),
|
|
5293
|
+
since: z2.string().min(1).optional()
|
|
5294
|
+
},
|
|
5295
|
+
annotations: {
|
|
5296
|
+
readOnlyHint: true
|
|
5297
|
+
}
|
|
5298
|
+
},
|
|
5299
|
+
toolHandler(async ({ limit, offset, unread_only, since }) => getUncategorizedEmails({
|
|
5300
|
+
limit,
|
|
5301
|
+
offset,
|
|
5302
|
+
unreadOnly: unread_only,
|
|
5303
|
+
since
|
|
5304
|
+
}))
|
|
5305
|
+
);
|
|
5306
|
+
server.registerTool(
|
|
5307
|
+
"get_noise_senders",
|
|
5308
|
+
{
|
|
5309
|
+
description: "Return a focused list of active, high-noise senders worth categorizing, filtering, or unsubscribing.",
|
|
5310
|
+
inputSchema: {
|
|
5311
|
+
limit: z2.number().int().positive().max(50).optional(),
|
|
5312
|
+
min_noise_score: z2.number().min(0).optional(),
|
|
5313
|
+
active_days: z2.number().int().positive().optional(),
|
|
5314
|
+
sort_by: z2.enum(["noise_score", "all_time_noise_score", "message_count", "unread_rate"]).optional().describe("Sort order. Default: noise_score. Use all_time_noise_score for lifetime perspective.")
|
|
5315
|
+
},
|
|
5316
|
+
annotations: {
|
|
5317
|
+
readOnlyHint: true
|
|
5318
|
+
}
|
|
5319
|
+
},
|
|
5320
|
+
toolHandler(async ({ limit, min_noise_score, active_days, sort_by }) => getNoiseSenders({
|
|
5321
|
+
limit,
|
|
5322
|
+
minNoiseScore: min_noise_score,
|
|
5323
|
+
activeDays: active_days,
|
|
5324
|
+
sortBy: sort_by
|
|
5325
|
+
}))
|
|
5326
|
+
);
|
|
5327
|
+
server.registerTool(
|
|
5328
|
+
"get_unsubscribe_suggestions",
|
|
5329
|
+
{
|
|
5330
|
+
description: "Return ranked senders with unsubscribe links, sorted by how much inbox noise unsubscribing would remove.",
|
|
5331
|
+
inputSchema: {
|
|
5332
|
+
limit: z2.number().int().positive().max(50).optional(),
|
|
5333
|
+
min_messages: z2.number().int().positive().optional(),
|
|
5334
|
+
unread_only_senders: z2.boolean().optional()
|
|
5335
|
+
},
|
|
5336
|
+
annotations: {
|
|
5337
|
+
readOnlyHint: true
|
|
5338
|
+
}
|
|
5339
|
+
},
|
|
5340
|
+
toolHandler(async ({ limit, min_messages, unread_only_senders }) => getUnsubscribeSuggestions({
|
|
5341
|
+
limit,
|
|
5342
|
+
minMessages: min_messages,
|
|
5343
|
+
unreadOnlySenders: unread_only_senders
|
|
5344
|
+
}))
|
|
5345
|
+
);
|
|
5346
|
+
server.registerTool(
|
|
5347
|
+
"unsubscribe",
|
|
5348
|
+
{
|
|
5349
|
+
description: "Return the unsubscribe target for a sender and optionally label/archive existing emails in one undoable run.",
|
|
5350
|
+
inputSchema: {
|
|
5351
|
+
sender_email: z2.string().min(1),
|
|
5352
|
+
also_archive: z2.boolean().optional(),
|
|
5353
|
+
also_label: z2.string().min(1).optional()
|
|
5354
|
+
},
|
|
5355
|
+
annotations: {
|
|
5356
|
+
readOnlyHint: false,
|
|
5357
|
+
destructiveHint: false
|
|
5358
|
+
}
|
|
5359
|
+
},
|
|
5360
|
+
toolHandler(async ({ sender_email, also_archive, also_label }) => unsubscribe({
|
|
5361
|
+
senderEmail: sender_email,
|
|
5362
|
+
alsoArchive: also_archive,
|
|
5363
|
+
alsoLabel: also_label
|
|
5364
|
+
}))
|
|
5365
|
+
);
|
|
4375
5366
|
server.registerTool(
|
|
4376
5367
|
"deploy_rule",
|
|
4377
5368
|
{
|
|
@@ -4470,6 +5461,15 @@ async function createMcpServer() {
|
|
|
4470
5461
|
},
|
|
4471
5462
|
async (uri) => resourceText(resolveResourceUri(uri, "inbox://summary"), await getInboxOverview())
|
|
4472
5463
|
);
|
|
5464
|
+
server.registerResource(
|
|
5465
|
+
"inbox-action-log",
|
|
5466
|
+
"inbox://action-log",
|
|
5467
|
+
{
|
|
5468
|
+
description: "Recent action history showing what inboxctl already did and whether undo is still available.",
|
|
5469
|
+
mimeType: "application/json"
|
|
5470
|
+
},
|
|
5471
|
+
async (uri) => resourceText(resolveResourceUri(uri, "inbox://action-log"), await buildActionLog())
|
|
5472
|
+
);
|
|
4473
5473
|
server.registerResource(
|
|
4474
5474
|
"deployed-rules",
|
|
4475
5475
|
"rules://deployed",
|
|
@@ -4529,10 +5529,24 @@ async function createMcpServer() {
|
|
|
4529
5529
|
async () => promptResult(
|
|
4530
5530
|
"Review top senders and recommend cleanup actions.",
|
|
4531
5531
|
[
|
|
4532
|
-
"
|
|
4533
|
-
"
|
|
4534
|
-
"
|
|
4535
|
-
"
|
|
5532
|
+
"Step 1 \u2014 Gather data:",
|
|
5533
|
+
" Use `get_noise_senders` for the most actionable noisy senders.",
|
|
5534
|
+
" Use `rules://deployed` to check for existing rules covering these senders.",
|
|
5535
|
+
" Use `get_unsubscribe_suggestions` for senders you can unsubscribe from.",
|
|
5536
|
+
"",
|
|
5537
|
+
"Step 2 \u2014 For each noisy sender, recommend one of:",
|
|
5538
|
+
" KEEP \u2014 important, reduce noise with a label rule",
|
|
5539
|
+
" RULE \u2014 create a rule to auto-label + mark read (or archive)",
|
|
5540
|
+
" UNSUBSCRIBE \u2014 stop receiving entirely (has unsubscribe link, high unread rate)",
|
|
5541
|
+
"",
|
|
5542
|
+
"Step 3 \u2014 Present as a table:",
|
|
5543
|
+
" Sender | Messages | Unread% | Noise Score | Has Unsub | Recommendation | Reason",
|
|
5544
|
+
"",
|
|
5545
|
+
"Step 4 \u2014 Offer to act:",
|
|
5546
|
+
" For senders marked RULE, offer to generate YAML using the rule schema.",
|
|
5547
|
+
" Group similar senders (e.g. all shipping senders) into one rule.",
|
|
5548
|
+
" Present YAML for review before deploying with `deploy_rule`.",
|
|
5549
|
+
" For senders marked UNSUBSCRIBE, use `unsubscribe` with `also_archive: true` and return the link for the user to follow."
|
|
4536
5550
|
].join("\n")
|
|
4537
5551
|
)
|
|
4538
5552
|
);
|
|
@@ -4559,10 +5573,37 @@ async function createMcpServer() {
|
|
|
4559
5573
|
async () => promptResult(
|
|
4560
5574
|
"Analyze inbox patterns and propose valid inboxctl rule YAML.",
|
|
4561
5575
|
[
|
|
4562
|
-
"
|
|
4563
|
-
"
|
|
4564
|
-
"
|
|
4565
|
-
"
|
|
5576
|
+
"First, inspect these data sources:",
|
|
5577
|
+
"- `rules://deployed` \u2014 existing rules (avoid duplicates)",
|
|
5578
|
+
"- `get_noise_senders` \u2014 high-volume low-read senders",
|
|
5579
|
+
"- `get_newsletter_senders` \u2014 detected newsletters and mailing lists",
|
|
5580
|
+
"",
|
|
5581
|
+
"For each recommendation, generate complete YAML using this schema:",
|
|
5582
|
+
"",
|
|
5583
|
+
" name: kebab-case-name # lowercase, hyphens only",
|
|
5584
|
+
" description: What this rule does",
|
|
5585
|
+
" enabled: true",
|
|
5586
|
+
" priority: 50 # 0-100, lower = runs first",
|
|
5587
|
+
" conditions:",
|
|
5588
|
+
" operator: AND # AND or OR",
|
|
5589
|
+
" matchers:",
|
|
5590
|
+
" - field: from # from | to | subject | snippet | labels",
|
|
5591
|
+
" contains: # OR use: values (exact), pattern (regex)",
|
|
5592
|
+
' - "@example.com"',
|
|
5593
|
+
" exclude: false # true to negate the match",
|
|
5594
|
+
" actions:",
|
|
5595
|
+
" - type: label # label | archive | mark_read | forward | mark_spam",
|
|
5596
|
+
' label: "Category/Name"',
|
|
5597
|
+
" - type: mark_read",
|
|
5598
|
+
" - type: archive",
|
|
5599
|
+
"",
|
|
5600
|
+
"Matcher fields: `from`, `to`, `subject`, `snippet`, `labels`.",
|
|
5601
|
+
"Match modes (provide exactly one per matcher): `values` (exact), `contains` (substring), `pattern` (regex).",
|
|
5602
|
+
"Action types: `label` (requires `label` field), `archive`, `mark_read`, `forward` (requires `to` field), `mark_spam`.",
|
|
5603
|
+
"",
|
|
5604
|
+
"Group related senders into a single rule where possible (e.g. all shipping notifications in one rule).",
|
|
5605
|
+
"Explain why each rule is safe. Default to `mark_read` + `label` over `archive` unless evidence is strong.",
|
|
5606
|
+
"Present the YAML so the user can review before deploying with `deploy_rule`."
|
|
4566
5607
|
].join("\n")
|
|
4567
5608
|
)
|
|
4568
5609
|
);
|
|
@@ -4574,10 +5615,80 @@ async function createMcpServer() {
|
|
|
4574
5615
|
async () => promptResult(
|
|
4575
5616
|
"Triage unread mail using inboxctl data sources.",
|
|
4576
5617
|
[
|
|
4577
|
-
"
|
|
4578
|
-
"
|
|
4579
|
-
"
|
|
4580
|
-
"
|
|
5618
|
+
"Step 1 \u2014 Gather data:",
|
|
5619
|
+
" Use `get_uncategorized_emails` with `unread_only: true` for uncategorised unread mail.",
|
|
5620
|
+
" Use `inbox://summary` for overall counts.",
|
|
5621
|
+
" If totalUncategorized is large, process in pages rather than all at once.",
|
|
5622
|
+
" If more context is needed on a specific email, use `get_email` or `get_thread`.",
|
|
5623
|
+
"",
|
|
5624
|
+
"Step 2 \u2014 Categorise each email into one of:",
|
|
5625
|
+
" ACTION REQUIRED \u2014 needs a response or decision from the user",
|
|
5626
|
+
" FYI \u2014 worth knowing about but no action needed",
|
|
5627
|
+
" NOISE \u2014 bulk, promotional, or irrelevant",
|
|
5628
|
+
"",
|
|
5629
|
+
"Step 3 \u2014 Present findings:",
|
|
5630
|
+
" List emails grouped by category with: sender, subject, and one-line reason.",
|
|
5631
|
+
" For NOISE, suggest a label and whether to archive.",
|
|
5632
|
+
" For FYI, suggest a label.",
|
|
5633
|
+
" For ACTION REQUIRED, summarise what action seems needed.",
|
|
5634
|
+
"",
|
|
5635
|
+
"Step 4 \u2014 Offer to apply:",
|
|
5636
|
+
" If the user approves, use `batch_apply_actions` to apply all decisions in one call.",
|
|
5637
|
+
" Group emails by their action set (e.g. all `label:Receipts + mark_read` together).",
|
|
5638
|
+
"",
|
|
5639
|
+
"Step 5 \u2014 Offer noise reduction:",
|
|
5640
|
+
" If NOISE senders appear repeatedly, suggest a rule or `unsubscribe` when a link is available."
|
|
5641
|
+
].join("\n")
|
|
5642
|
+
)
|
|
5643
|
+
);
|
|
5644
|
+
server.registerPrompt(
|
|
5645
|
+
"categorize-emails",
|
|
5646
|
+
{
|
|
5647
|
+
description: "Systematically categorise uncategorised emails using sender patterns, content, and inbox analytics."
|
|
5648
|
+
},
|
|
5649
|
+
async () => promptResult(
|
|
5650
|
+
"Categorise uncategorised emails in the user's inbox.",
|
|
5651
|
+
[
|
|
5652
|
+
"Step 1 \u2014 Gather data:",
|
|
5653
|
+
" Use `get_uncategorized_emails` (start with limit 100).",
|
|
5654
|
+
" If totalUncategorized is more than 500, ask whether to process the recent batch or paginate through the full backlog.",
|
|
5655
|
+
" Use `get_noise_senders` for sender context.",
|
|
5656
|
+
" Use `get_unsubscribe_suggestions` for likely unsubscribe candidates.",
|
|
5657
|
+
" Use `get_labels` to see what labels already exist.",
|
|
5658
|
+
" Use `rules://deployed` to avoid duplicating existing automation.",
|
|
5659
|
+
"",
|
|
5660
|
+
"Step 2 \u2014 Assign each email a category:",
|
|
5661
|
+
" Receipts \u2014 purchase confirmations, invoices, payment notifications",
|
|
5662
|
+
" Shipping \u2014 delivery tracking, dispatch notices, shipping updates",
|
|
5663
|
+
" Newsletters \u2014 editorial content, digests, weekly roundups",
|
|
5664
|
+
" Promotions \u2014 marketing, sales, deals, coupons",
|
|
5665
|
+
" Social \u2014 social network notifications (LinkedIn, Facebook, etc.)",
|
|
5666
|
+
" Notifications \u2014 automated alerts, system notifications, service updates",
|
|
5667
|
+
" Finance \u2014 bank statements, investment updates, tax documents",
|
|
5668
|
+
" Travel \u2014 bookings, itineraries, check-in reminders",
|
|
5669
|
+
" Important \u2014 personal or work email requiring attention",
|
|
5670
|
+
"",
|
|
5671
|
+
"Step 3 \u2014 Present the categorisation plan:",
|
|
5672
|
+
" Group emails by assigned category.",
|
|
5673
|
+
" For each group show: count, senders involved, sample subjects.",
|
|
5674
|
+
" Note confidence level: HIGH (clear pattern), MEDIUM (reasonable guess), LOW (uncertain).",
|
|
5675
|
+
" Flag any LOW confidence items for the user to decide.",
|
|
5676
|
+
"",
|
|
5677
|
+
"Step 4 \u2014 Apply with user approval:",
|
|
5678
|
+
" Create labels for any new categories (use `create_label`).",
|
|
5679
|
+
" Use `batch_apply_actions` to apply labels in one call.",
|
|
5680
|
+
" For Newsletters and Promotions with high unread rates, suggest mark_read + archive or `unsubscribe` when a link is available.",
|
|
5681
|
+
" For Receipts/Shipping/Notifications, suggest mark_read only (keep in inbox).",
|
|
5682
|
+
" For Important, do not mark read or archive.",
|
|
5683
|
+
"",
|
|
5684
|
+
"Step 5 \u2014 Paginate if needed:",
|
|
5685
|
+
" If hasMore is true, ask whether to continue with the next page using offset.",
|
|
5686
|
+
" Reuse the same sender categorisations on later pages instead of re-evaluating known senders.",
|
|
5687
|
+
"",
|
|
5688
|
+
"Step 6 \u2014 Suggest ongoing rules:",
|
|
5689
|
+
" For any category with 3+ emails from the same sender, suggest a YAML rule.",
|
|
5690
|
+
" This prevents the same categorisation from being needed again.",
|
|
5691
|
+
" Use `deploy_rule` after user reviews the YAML."
|
|
4581
5692
|
].join("\n")
|
|
4582
5693
|
)
|
|
4583
5694
|
);
|
|
@@ -4741,4 +5852,4 @@ export {
|
|
|
4741
5852
|
createMcpServer,
|
|
4742
5853
|
startMcpServer
|
|
4743
5854
|
};
|
|
4744
|
-
//# sourceMappingURL=chunk-
|
|
5855
|
+
//# sourceMappingURL=chunk-NUN2WRBN.js.map
|