inboxctl 0.1.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/.env.example +14 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/dist/chunk-EY6VV43S.js +4744 -0
- package/dist/chunk-EY6VV43S.js.map +1 -0
- package/dist/cli.js +4668 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp.js +16 -0
- package/dist/mcp.js.map +1 -0
- package/package.json +83 -0
- package/rules/example.yaml +29 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4668 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_GOOGLE_REDIRECT_URI,
|
|
4
|
+
GMAIL_SCOPES,
|
|
5
|
+
addExecutionItems,
|
|
6
|
+
archiveEmails,
|
|
7
|
+
clearGmailTransportOverride,
|
|
8
|
+
closeDb,
|
|
9
|
+
createExecutionRun,
|
|
10
|
+
createFilter,
|
|
11
|
+
createLabel,
|
|
12
|
+
createOAuthClient,
|
|
13
|
+
deleteFilter,
|
|
14
|
+
deployAllRules,
|
|
15
|
+
deployLoadedRule,
|
|
16
|
+
detectDrift,
|
|
17
|
+
disableRule,
|
|
18
|
+
enableRule,
|
|
19
|
+
ensureDir,
|
|
20
|
+
forwardEmail,
|
|
21
|
+
fullSync,
|
|
22
|
+
getAllRulesStatus,
|
|
23
|
+
getConfigFilePath,
|
|
24
|
+
getDefaultDataDir,
|
|
25
|
+
getExecutionHistory,
|
|
26
|
+
getFilter,
|
|
27
|
+
getGmailReadiness,
|
|
28
|
+
getGmailTransport,
|
|
29
|
+
getGoogleCredentialStatus,
|
|
30
|
+
getInboxOverview,
|
|
31
|
+
getLabelDistribution,
|
|
32
|
+
getMessage,
|
|
33
|
+
getNewsletters,
|
|
34
|
+
getOAuthReadiness,
|
|
35
|
+
getRecentEmails,
|
|
36
|
+
getRecentRuns,
|
|
37
|
+
getRuleStatus,
|
|
38
|
+
getRunsByEmail,
|
|
39
|
+
getSenderStats,
|
|
40
|
+
getSqlite,
|
|
41
|
+
getSyncStatus,
|
|
42
|
+
getTopSenders,
|
|
43
|
+
getVolumeByPeriod,
|
|
44
|
+
incrementalSync,
|
|
45
|
+
initializeDb,
|
|
46
|
+
isTokenExpired,
|
|
47
|
+
labelEmails,
|
|
48
|
+
listFilters,
|
|
49
|
+
listLabels,
|
|
50
|
+
listMessages,
|
|
51
|
+
loadConfig,
|
|
52
|
+
loadRuleFile,
|
|
53
|
+
loadTokens,
|
|
54
|
+
markRead,
|
|
55
|
+
markUnread,
|
|
56
|
+
reconcileCacheForAuthenticatedAccount,
|
|
57
|
+
runAllRules,
|
|
58
|
+
runRule,
|
|
59
|
+
saveTokens,
|
|
60
|
+
setGmailTransportOverride,
|
|
61
|
+
startMcpServer,
|
|
62
|
+
startOAuthFlow,
|
|
63
|
+
syncLabels,
|
|
64
|
+
undoRun
|
|
65
|
+
} from "./chunk-EY6VV43S.js";
|
|
66
|
+
|
|
67
|
+
// src/cli.ts
|
|
68
|
+
import { Command } from "commander";
|
|
69
|
+
import { createInterface } from "readline/promises";
|
|
70
|
+
import { stdin as input, stdout as output } from "process";
|
|
71
|
+
|
|
72
|
+
// src/core/demo/index.ts
|
|
73
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
|
74
|
+
import { tmpdir } from "os";
|
|
75
|
+
import { dirname, join } from "path";
|
|
76
|
+
import { fileURLToPath } from "url";
|
|
77
|
+
|
|
78
|
+
// src/tui/app.tsx
|
|
79
|
+
import { convert } from "html-to-text";
|
|
80
|
+
import {
|
|
81
|
+
Box,
|
|
82
|
+
Newline,
|
|
83
|
+
Spacer,
|
|
84
|
+
Text,
|
|
85
|
+
render,
|
|
86
|
+
useApp,
|
|
87
|
+
useInput,
|
|
88
|
+
useStdout
|
|
89
|
+
} from "ink";
|
|
90
|
+
import Spinner from "ink-spinner";
|
|
91
|
+
import TextInput from "ink-text-input";
|
|
92
|
+
import { useEffect, useState } from "react";
|
|
93
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
94
|
+
var PAGE_SIZE = 20;
|
|
95
|
+
var SEARCH_LIMIT = 50;
|
|
96
|
+
var MIN_CONTENT_HEIGHT = 10;
|
|
97
|
+
function stripAnsi(value) {
|
|
98
|
+
return value.replace(/\u001B\[[0-9;]*m/g, "");
|
|
99
|
+
}
|
|
100
|
+
function pad(value, width) {
|
|
101
|
+
const visible = stripAnsi(value);
|
|
102
|
+
if (visible.length >= width) {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
return `${value}${" ".repeat(width - visible.length)}`;
|
|
106
|
+
}
|
|
107
|
+
function truncate(value, width) {
|
|
108
|
+
if (width <= 0) {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
if (value.length <= width) {
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
if (width === 1) {
|
|
115
|
+
return value.slice(0, 1);
|
|
116
|
+
}
|
|
117
|
+
return `${value.slice(0, width - 1)}\u2026`;
|
|
118
|
+
}
|
|
119
|
+
function flattenWhitespace(value) {
|
|
120
|
+
return value.replace(/\s+/g, " ").trim();
|
|
121
|
+
}
|
|
122
|
+
function sanitizeInlineText(value, width) {
|
|
123
|
+
let next = value.replace(/[\r\n\t]+/g, " ");
|
|
124
|
+
if (/<[a-z][\s\S]*>/i.test(next)) {
|
|
125
|
+
next = convert(next, {
|
|
126
|
+
wordwrap: false,
|
|
127
|
+
selectors: [{ selector: "a", options: { ignoreHref: true } }]
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
next = flattenWhitespace(next);
|
|
131
|
+
return width ? truncate(next, width) : next;
|
|
132
|
+
}
|
|
133
|
+
function formatFlashText(value) {
|
|
134
|
+
return sanitizeInlineText(value, 240);
|
|
135
|
+
}
|
|
136
|
+
function formatRelativeTime(value) {
|
|
137
|
+
if (!value) {
|
|
138
|
+
return "-";
|
|
139
|
+
}
|
|
140
|
+
const timestamp = value instanceof Date ? value.getTime() : value;
|
|
141
|
+
const diff = Date.now() - timestamp;
|
|
142
|
+
if (diff < 6e4) {
|
|
143
|
+
return `${Math.max(1, Math.floor(diff / 1e3))}s ago`;
|
|
144
|
+
}
|
|
145
|
+
if (diff < 36e5) {
|
|
146
|
+
return `${Math.floor(diff / 6e4)}m ago`;
|
|
147
|
+
}
|
|
148
|
+
if (diff < 864e5) {
|
|
149
|
+
return `${Math.floor(diff / 36e5)}h ago`;
|
|
150
|
+
}
|
|
151
|
+
if (diff < 6048e5) {
|
|
152
|
+
return `${Math.floor(diff / 864e5)}d ago`;
|
|
153
|
+
}
|
|
154
|
+
return new Date(timestamp).toISOString().slice(0, 10);
|
|
155
|
+
}
|
|
156
|
+
function formatPercent(value) {
|
|
157
|
+
return `${Number.isInteger(value) ? value : Number(value.toFixed(1))}%`;
|
|
158
|
+
}
|
|
159
|
+
function formatCount(value) {
|
|
160
|
+
if (value == null) {
|
|
161
|
+
return "-";
|
|
162
|
+
}
|
|
163
|
+
return new Intl.NumberFormat("en-US").format(value);
|
|
164
|
+
}
|
|
165
|
+
function formatDurationEstimate(seconds) {
|
|
166
|
+
if (seconds < 60) {
|
|
167
|
+
return `${Math.max(1, Math.round(seconds))}s`;
|
|
168
|
+
}
|
|
169
|
+
if (seconds < 3600) {
|
|
170
|
+
const minutes2 = Math.floor(seconds / 60);
|
|
171
|
+
const remainingSeconds = Math.round(seconds % 60);
|
|
172
|
+
return remainingSeconds === 0 ? `${minutes2}m` : `${minutes2}m ${remainingSeconds}s`;
|
|
173
|
+
}
|
|
174
|
+
const hours = Math.floor(seconds / 3600);
|
|
175
|
+
const minutes = Math.round(seconds % 3600 / 60);
|
|
176
|
+
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
|
|
177
|
+
}
|
|
178
|
+
function getMinimumItemsForEta(total) {
|
|
179
|
+
if (!total || total <= 0) {
|
|
180
|
+
return 500;
|
|
181
|
+
}
|
|
182
|
+
return Math.min(2e3, Math.max(500, Math.floor(total * 0.01)));
|
|
183
|
+
}
|
|
184
|
+
function clampIndex(index, length) {
|
|
185
|
+
if (length <= 0) {
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
return Math.max(0, Math.min(index, length - 1));
|
|
189
|
+
}
|
|
190
|
+
function terminalWidth() {
|
|
191
|
+
return process.stdout.columns || 100;
|
|
192
|
+
}
|
|
193
|
+
function terminalHeight() {
|
|
194
|
+
return process.stdout.rows || 30;
|
|
195
|
+
}
|
|
196
|
+
function toneColor(tone) {
|
|
197
|
+
switch (tone) {
|
|
198
|
+
case "success":
|
|
199
|
+
return "green";
|
|
200
|
+
case "error":
|
|
201
|
+
return "red";
|
|
202
|
+
case "info":
|
|
203
|
+
return "blue";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function getBodyText(detail) {
|
|
207
|
+
if (detail.textPlain?.trim()) {
|
|
208
|
+
return detail.textPlain.trim();
|
|
209
|
+
}
|
|
210
|
+
if (detail.bodyHtml?.trim()) {
|
|
211
|
+
return convert(detail.bodyHtml, {
|
|
212
|
+
wordwrap: Math.max(terminalWidth() - 10, 40)
|
|
213
|
+
}).trim();
|
|
214
|
+
}
|
|
215
|
+
return detail.body?.trim() || detail.snippet || "";
|
|
216
|
+
}
|
|
217
|
+
function useTerminalSize() {
|
|
218
|
+
const { stdout } = useStdout();
|
|
219
|
+
const [size, setSize] = useState({
|
|
220
|
+
columns: stdout.columns || terminalWidth(),
|
|
221
|
+
rows: stdout.rows || terminalHeight()
|
|
222
|
+
});
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
function handleResize() {
|
|
225
|
+
setSize({
|
|
226
|
+
columns: stdout.columns || terminalWidth(),
|
|
227
|
+
rows: stdout.rows || terminalHeight()
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
handleResize();
|
|
231
|
+
stdout.on("resize", handleResize);
|
|
232
|
+
return () => {
|
|
233
|
+
stdout.off("resize", handleResize);
|
|
234
|
+
};
|
|
235
|
+
}, [stdout]);
|
|
236
|
+
return size;
|
|
237
|
+
}
|
|
238
|
+
function getViewportRange(length, selectedIndex, visibleCount) {
|
|
239
|
+
if (length <= visibleCount) {
|
|
240
|
+
return { start: 0, end: length };
|
|
241
|
+
}
|
|
242
|
+
const half = Math.floor(visibleCount / 2);
|
|
243
|
+
const start = Math.max(0, Math.min(selectedIndex - half, length - visibleCount));
|
|
244
|
+
return {
|
|
245
|
+
start,
|
|
246
|
+
end: Math.min(length, start + visibleCount)
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function getScreenGuide(screen, focus) {
|
|
250
|
+
const global = "q quit \u2022 s sync \u2022 / search \u2022 d stats \u2022 R rules";
|
|
251
|
+
switch (screen) {
|
|
252
|
+
case "inbox":
|
|
253
|
+
return `${global} \u2022 j/k move \u2022 Enter open \u2022 a archive \u2022 l label \u2022 r read`;
|
|
254
|
+
case "email":
|
|
255
|
+
return "Esc back \u2022 j/k scroll \u2022 a archive \u2022 l label \u2022 r toggle read";
|
|
256
|
+
case "stats":
|
|
257
|
+
return "Esc back \u2022 s senders \u2022 l labels \u2022 n newsletters";
|
|
258
|
+
case "rules":
|
|
259
|
+
return `Esc back \u2022 Tab switch ${focus === "history" ? "history" : "rules"} focus \u2022 d deploy \u2022 e toggle \u2022 r dry-run \u2022 R apply \u2022 u undo`;
|
|
260
|
+
case "search":
|
|
261
|
+
return `Esc back \u2022 Enter search \u2022 i focus input \u2022 ${focus === "input" ? "type Gmail query" : "j/k move \u2022 Enter open \u2022 a archive \u2022 l label \u2022 r read"}`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function renderProgressBar(current, total, width) {
|
|
265
|
+
if (total <= 0 || width <= 0) {
|
|
266
|
+
return "";
|
|
267
|
+
}
|
|
268
|
+
const ratio = Math.max(0, Math.min(1, current / total));
|
|
269
|
+
const filled = Math.round(ratio * width);
|
|
270
|
+
return `${"\u2588".repeat(filled)}${"\u2591".repeat(Math.max(0, width - filled))}`;
|
|
271
|
+
}
|
|
272
|
+
function Panel(props) {
|
|
273
|
+
return /* @__PURE__ */ jsxs(
|
|
274
|
+
Box,
|
|
275
|
+
{
|
|
276
|
+
flexDirection: "column",
|
|
277
|
+
borderStyle: "round",
|
|
278
|
+
borderColor: props.accent || "cyan",
|
|
279
|
+
paddingX: 1,
|
|
280
|
+
paddingY: 0,
|
|
281
|
+
width: "100%",
|
|
282
|
+
children: [
|
|
283
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
284
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: props.accent || "cyan", children: props.title }),
|
|
285
|
+
/* @__PURE__ */ jsx(Spacer, {}),
|
|
286
|
+
props.subtitle ? /* @__PURE__ */ jsx(Text, { color: "gray", children: props.subtitle }) : null
|
|
287
|
+
] }),
|
|
288
|
+
props.children
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
function Header(props) {
|
|
294
|
+
const items = [
|
|
295
|
+
{ key: "inbox", label: "Inbox" },
|
|
296
|
+
{ key: "email", label: "Email" },
|
|
297
|
+
{ key: "stats", label: "Stats" },
|
|
298
|
+
{ key: "rules", label: "Rules" },
|
|
299
|
+
{ key: "search", label: "Search" }
|
|
300
|
+
];
|
|
301
|
+
return /* @__PURE__ */ jsxs(
|
|
302
|
+
Box,
|
|
303
|
+
{
|
|
304
|
+
width: props.columns,
|
|
305
|
+
borderStyle: "round",
|
|
306
|
+
borderColor: "blue",
|
|
307
|
+
paddingX: 1,
|
|
308
|
+
paddingY: 0,
|
|
309
|
+
flexDirection: "column",
|
|
310
|
+
children: [
|
|
311
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
312
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "inboxctl" }),
|
|
313
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: " local-first Gmail cockpit" }),
|
|
314
|
+
/* @__PURE__ */ jsx(Spacer, {}),
|
|
315
|
+
/* @__PURE__ */ jsx(Text, { color: props.sync.syncing ? "yellow" : "gray", children: props.sync.syncing ? props.sync.message : "ready" })
|
|
316
|
+
] }),
|
|
317
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: items.map((item, index) => /* @__PURE__ */ jsx(Box, { marginRight: index === items.length - 1 ? 0 : 2, children: /* @__PURE__ */ jsx(Text, { color: props.screen === item.key ? "black" : "gray", backgroundColor: props.screen === item.key ? "cyan" : void 0, children: ` ${item.label} ` }) }, item.key)) }),
|
|
318
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: truncate(props.guide, Math.max(20, props.columns - 4)) }) })
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
function EmailList(props) {
|
|
324
|
+
const senderWidth = 28;
|
|
325
|
+
const dateWidth = 10;
|
|
326
|
+
const subjectWidth = Math.max(terminalWidth() - senderWidth - dateWidth - 20, 20);
|
|
327
|
+
const { start, end } = getViewportRange(props.emails.length, props.selectedIndex, props.visibleRows);
|
|
328
|
+
const visibleEmails = props.emails.slice(start, end);
|
|
329
|
+
return /* @__PURE__ */ jsxs(
|
|
330
|
+
Panel,
|
|
331
|
+
{
|
|
332
|
+
title: props.title,
|
|
333
|
+
subtitle: props.subtitle || `${props.emails.length} loaded`,
|
|
334
|
+
accent: "cyan",
|
|
335
|
+
children: [
|
|
336
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
337
|
+
pad("STATE", 6),
|
|
338
|
+
" ",
|
|
339
|
+
pad("FROM", senderWidth),
|
|
340
|
+
" ",
|
|
341
|
+
pad("DATE", dateWidth),
|
|
342
|
+
" SUBJECT"
|
|
343
|
+
] }),
|
|
344
|
+
props.loading ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
345
|
+
/* @__PURE__ */ jsx(Spinner, { type: "dots" }),
|
|
346
|
+
" Loading inbox\u2026"
|
|
347
|
+
] }) }) : props.emails.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "gray", children: props.emptyMessage }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
348
|
+
visibleEmails.map((email, index) => {
|
|
349
|
+
const absoluteIndex = start + index;
|
|
350
|
+
const selected = absoluteIndex === props.selectedIndex;
|
|
351
|
+
const sender = sanitizeInlineText(email.fromName || email.fromAddress || "(unknown)", senderWidth);
|
|
352
|
+
const subject = sanitizeInlineText(email.subject || "(no subject)", subjectWidth);
|
|
353
|
+
const state = email.isRead ? " " : "\u25CF";
|
|
354
|
+
return /* @__PURE__ */ jsxs(
|
|
355
|
+
Text,
|
|
356
|
+
{
|
|
357
|
+
backgroundColor: selected ? "white" : void 0,
|
|
358
|
+
color: selected ? "black" : !email.isRead ? "white" : "gray",
|
|
359
|
+
bold: !email.isRead,
|
|
360
|
+
children: [
|
|
361
|
+
pad(state, 6),
|
|
362
|
+
" ",
|
|
363
|
+
pad(sender, senderWidth),
|
|
364
|
+
" ",
|
|
365
|
+
pad(formatRelativeTime(email.date), dateWidth),
|
|
366
|
+
" ",
|
|
367
|
+
subject
|
|
368
|
+
]
|
|
369
|
+
},
|
|
370
|
+
email.id
|
|
371
|
+
);
|
|
372
|
+
}),
|
|
373
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
374
|
+
"showing ",
|
|
375
|
+
start + 1,
|
|
376
|
+
"-",
|
|
377
|
+
end,
|
|
378
|
+
" of ",
|
|
379
|
+
props.emails.length
|
|
380
|
+
] }) })
|
|
381
|
+
] })
|
|
382
|
+
]
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
function Table(props) {
|
|
387
|
+
const widths = props.headers.map(
|
|
388
|
+
(header, index) => Math.max(
|
|
389
|
+
header.length,
|
|
390
|
+
...props.rows.map((row) => stripAnsi(row[index] || "").length)
|
|
391
|
+
)
|
|
392
|
+
);
|
|
393
|
+
return /* @__PURE__ */ jsx(Panel, { title: props.title, accent: "magenta", children: props.rows.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "gray", children: props.emptyMessage }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
394
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: props.headers.map((header, index) => pad(header, widths[index] || header.length)).join(" ") }),
|
|
395
|
+
props.rows.map((row, index) => /* @__PURE__ */ jsx(Text, { children: row.map((cell, cellIndex) => pad(cell, widths[cellIndex] || cell.length)).join(" ") }, `${props.title}-${index}`))
|
|
396
|
+
] }) });
|
|
397
|
+
}
|
|
398
|
+
function Modal(props) {
|
|
399
|
+
return /* @__PURE__ */ jsxs(
|
|
400
|
+
Box,
|
|
401
|
+
{
|
|
402
|
+
borderStyle: "round",
|
|
403
|
+
borderColor: "cyan",
|
|
404
|
+
paddingX: 1,
|
|
405
|
+
paddingY: 0,
|
|
406
|
+
marginTop: 1,
|
|
407
|
+
flexDirection: "column",
|
|
408
|
+
children: [
|
|
409
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: props.title }),
|
|
410
|
+
props.children
|
|
411
|
+
]
|
|
412
|
+
}
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
function StatusBar(props) {
|
|
416
|
+
const lastSync = props.sync.lastSync ? formatRelativeTime(props.sync.lastSync) : "never";
|
|
417
|
+
const progressWidth = Math.max(12, Math.min(36, Math.floor((props.columns - 48) / 2)));
|
|
418
|
+
const hasProgressBar = props.sync.syncing && props.sync.progressTotal !== null && props.sync.progressTotal > 0;
|
|
419
|
+
const progressBar = hasProgressBar ? renderProgressBar(
|
|
420
|
+
props.sync.progressCurrent,
|
|
421
|
+
props.sync.progressTotal || 0,
|
|
422
|
+
progressWidth
|
|
423
|
+
) : "";
|
|
424
|
+
const progressSummary = hasProgressBar ? `${formatCount(props.sync.progressCurrent)} / ${formatCount(props.sync.progressTotal)}` : props.sync.syncing ? props.sync.progressMode === "incremental" ? "checking for changes" : "preparing sync" : `Last sync ${lastSync}`;
|
|
425
|
+
const elapsedSeconds = props.sync.startedAt ? Math.max(1, (Date.now() - props.sync.startedAt) / 1e3) : 0;
|
|
426
|
+
const remainingItems = props.sync.progressTotal !== null ? Math.max(0, props.sync.progressTotal - props.sync.progressCurrent) : 0;
|
|
427
|
+
const minimumItemsForEta = getMinimumItemsForEta(props.sync.progressTotal);
|
|
428
|
+
const canShowEta = hasProgressBar && props.sync.progressMode === "full" && props.sync.progressPhase === "fetching_messages" && props.sync.ratePerSecond !== null && props.sync.ratePerSecond > 0 && elapsedSeconds >= 15 && props.sync.progressCurrent >= minimumItemsForEta && remainingItems > 0;
|
|
429
|
+
const etaSummary = canShowEta ? `ETA ~${formatDurationEstimate(remainingItems / (props.sync.ratePerSecond || 1))}` : hasProgressBar && props.sync.progressMode === "full" && props.sync.progressPhase === "fetching_messages" && props.sync.progressCurrent > 0 && remainingItems > 0 ? "ETA calculating\u2026" : null;
|
|
430
|
+
return /* @__PURE__ */ jsxs(
|
|
431
|
+
Box,
|
|
432
|
+
{
|
|
433
|
+
borderStyle: "round",
|
|
434
|
+
borderColor: props.flash ? toneColor(props.flash.tone) : "gray",
|
|
435
|
+
paddingX: 1,
|
|
436
|
+
paddingY: 0,
|
|
437
|
+
flexDirection: "column",
|
|
438
|
+
width: "100%",
|
|
439
|
+
children: [
|
|
440
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
441
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", children: "inboxctl" }),
|
|
442
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
443
|
+
" ",
|
|
444
|
+
props.email || "not authenticated"
|
|
445
|
+
] }),
|
|
446
|
+
/* @__PURE__ */ jsx(Spacer, {}),
|
|
447
|
+
/* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
|
|
448
|
+
props.unreadCount,
|
|
449
|
+
" unread"
|
|
450
|
+
] }),
|
|
451
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: " | " }),
|
|
452
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: truncate(progressSummary, Math.max(18, props.columns - 56)) }),
|
|
453
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: " | " }),
|
|
454
|
+
/* @__PURE__ */ jsx(Text, { color: "magenta", children: props.screen })
|
|
455
|
+
] }),
|
|
456
|
+
props.sync.syncing ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
457
|
+
/* @__PURE__ */ jsx(Newline, {}),
|
|
458
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
459
|
+
/* @__PURE__ */ jsx(Text, { color: props.sync.progressMode === "full" ? "yellow" : "cyan", children: props.sync.progressMode === "full" ? "full" : "incr" }),
|
|
460
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
461
|
+
" ",
|
|
462
|
+
truncate(props.sync.message, Math.max(24, props.columns - 34))
|
|
463
|
+
] }),
|
|
464
|
+
hasProgressBar ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
465
|
+
/* @__PURE__ */ jsx(Spacer, {}),
|
|
466
|
+
etaSummary ? /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
467
|
+
etaSummary,
|
|
468
|
+
" "
|
|
469
|
+
] }) : null,
|
|
470
|
+
/* @__PURE__ */ jsx(Text, { color: "green", children: progressBar }),
|
|
471
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
472
|
+
" ",
|
|
473
|
+
formatPercent(props.sync.progressCurrent / (props.sync.progressTotal || 1) * 100)
|
|
474
|
+
] })
|
|
475
|
+
] }) : null
|
|
476
|
+
] })
|
|
477
|
+
] }) : null,
|
|
478
|
+
props.flash ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
479
|
+
/* @__PURE__ */ jsx(Newline, {}),
|
|
480
|
+
/* @__PURE__ */ jsx(Text, { color: toneColor(props.flash.tone), children: props.flash.text })
|
|
481
|
+
] }) : null
|
|
482
|
+
]
|
|
483
|
+
}
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
function App({ initialSync = true }) {
|
|
487
|
+
const { exit } = useApp();
|
|
488
|
+
const { columns, rows } = useTerminalSize();
|
|
489
|
+
const config = loadConfig();
|
|
490
|
+
const [screen, setScreen] = useState("inbox");
|
|
491
|
+
const [emailOrigin, setEmailOrigin] = useState("inbox");
|
|
492
|
+
const [flash, setFlash] = useState(null);
|
|
493
|
+
const [confirmState, setConfirmState] = useState(null);
|
|
494
|
+
const [authEmail, setAuthEmail] = useState(null);
|
|
495
|
+
const [syncState, setSyncState] = useState({
|
|
496
|
+
syncing: false,
|
|
497
|
+
message: "Syncing\u2026",
|
|
498
|
+
lastSync: null,
|
|
499
|
+
totalMessages: 0,
|
|
500
|
+
resumableProgressCurrent: 0,
|
|
501
|
+
resumableProgressTotal: null,
|
|
502
|
+
progressCurrent: 0,
|
|
503
|
+
progressTotal: null,
|
|
504
|
+
progressMode: null,
|
|
505
|
+
progressPhase: null,
|
|
506
|
+
startedAt: null,
|
|
507
|
+
progressStartedAt: null,
|
|
508
|
+
lastProgressAt: null,
|
|
509
|
+
lastProgressCurrent: 0,
|
|
510
|
+
ratePerSecond: null
|
|
511
|
+
});
|
|
512
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
513
|
+
const [inboxEmails, setInboxEmails] = useState([]);
|
|
514
|
+
const [inboxSelectedIndex, setInboxSelectedIndex] = useState(0);
|
|
515
|
+
const [inboxLoading, setInboxLoading] = useState(true);
|
|
516
|
+
const [inboxHasMore, setInboxHasMore] = useState(true);
|
|
517
|
+
const [selectedEmailId, setSelectedEmailId] = useState(null);
|
|
518
|
+
const [emailDetail, setEmailDetail] = useState(null);
|
|
519
|
+
const [emailBody, setEmailBody] = useState("");
|
|
520
|
+
const [emailLoading, setEmailLoading] = useState(false);
|
|
521
|
+
const [emailScroll, setEmailScroll] = useState(0);
|
|
522
|
+
const [statsLoading, setStatsLoading] = useState(false);
|
|
523
|
+
const [statsTab, setStatsTab] = useState("senders");
|
|
524
|
+
const [statsOverview, setStatsOverview] = useState(null);
|
|
525
|
+
const [statsSenders, setStatsSenders] = useState([]);
|
|
526
|
+
const [statsLabels, setStatsLabels] = useState([]);
|
|
527
|
+
const [statsNewsletters, setStatsNewsletters] = useState([]);
|
|
528
|
+
const [statsVolume, setStatsVolume] = useState([]);
|
|
529
|
+
const [rulesLoading, setRulesLoading] = useState(false);
|
|
530
|
+
const [rulesFocus, setRulesFocus] = useState("rules");
|
|
531
|
+
const [rules2, setRules] = useState([]);
|
|
532
|
+
const [rulesSelectedIndex, setRulesSelectedIndex] = useState(0);
|
|
533
|
+
const [ruleHistory, setRuleHistory] = useState([]);
|
|
534
|
+
const [historySelectedIndex, setHistorySelectedIndex] = useState(0);
|
|
535
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
536
|
+
const [searchFocus, setSearchFocus] = useState("input");
|
|
537
|
+
const [searchLoading, setSearchLoading] = useState(false);
|
|
538
|
+
const [searchResults, setSearchResults] = useState([]);
|
|
539
|
+
const [searchSelectedIndex, setSearchSelectedIndex] = useState(0);
|
|
540
|
+
const [labelPicker, setLabelPicker] = useState({
|
|
541
|
+
open: false,
|
|
542
|
+
loading: false,
|
|
543
|
+
labels: [],
|
|
544
|
+
selectedIndex: 0,
|
|
545
|
+
targetEmailId: null,
|
|
546
|
+
createMode: false,
|
|
547
|
+
newLabelName: ""
|
|
548
|
+
});
|
|
549
|
+
function pushFlash(tone, text2) {
|
|
550
|
+
setFlash({ tone, text: formatFlashText(text2) });
|
|
551
|
+
}
|
|
552
|
+
async function refreshStatus() {
|
|
553
|
+
const [syncStatus, overview, tokens] = await Promise.all([
|
|
554
|
+
getSyncStatus(),
|
|
555
|
+
getInboxOverview(),
|
|
556
|
+
loadTokens(config.tokensPath)
|
|
557
|
+
]);
|
|
558
|
+
setSyncState((current) => ({
|
|
559
|
+
...current,
|
|
560
|
+
lastSync: syncStatus.lastIncrementalSync ?? syncStatus.lastFullSync,
|
|
561
|
+
totalMessages: syncStatus.totalMessages,
|
|
562
|
+
resumableProgressCurrent: syncStatus.fullSyncProcessed,
|
|
563
|
+
resumableProgressTotal: syncStatus.fullSyncTotal
|
|
564
|
+
}));
|
|
565
|
+
setUnreadCount(overview.unread);
|
|
566
|
+
setAuthEmail(tokens?.email && tokens.email !== "unknown" ? tokens.email : null);
|
|
567
|
+
}
|
|
568
|
+
async function loadInbox(reset) {
|
|
569
|
+
setInboxLoading(true);
|
|
570
|
+
try {
|
|
571
|
+
const offset = reset ? 0 : inboxEmails.length;
|
|
572
|
+
const rows2 = await getRecentEmails(PAGE_SIZE, offset);
|
|
573
|
+
setInboxEmails((current) => reset ? rows2 : [...current, ...rows2]);
|
|
574
|
+
setInboxHasMore(rows2.length === PAGE_SIZE);
|
|
575
|
+
setInboxSelectedIndex((current) => clampIndex(reset ? 0 : current, reset ? rows2.length : offset + rows2.length));
|
|
576
|
+
} catch (error) {
|
|
577
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
578
|
+
} finally {
|
|
579
|
+
setInboxLoading(false);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async function loadStats() {
|
|
583
|
+
setStatsLoading(true);
|
|
584
|
+
try {
|
|
585
|
+
const [overview, senders, labels2, newsletters, volume] = await Promise.all([
|
|
586
|
+
getInboxOverview(),
|
|
587
|
+
getTopSenders({ limit: 10 }),
|
|
588
|
+
getLabelDistribution(),
|
|
589
|
+
getNewsletters({ minMessages: 1 }),
|
|
590
|
+
getVolumeByPeriod("day", { start: Date.now() - 30 * 24 * 60 * 60 * 1e3, end: Date.now() })
|
|
591
|
+
]);
|
|
592
|
+
setStatsOverview(overview);
|
|
593
|
+
setStatsSenders(senders);
|
|
594
|
+
setStatsLabels(labels2.slice(0, 10));
|
|
595
|
+
setStatsNewsletters(newsletters.slice(0, 10));
|
|
596
|
+
setStatsVolume(volume.slice(-7));
|
|
597
|
+
} catch (error) {
|
|
598
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
599
|
+
} finally {
|
|
600
|
+
setStatsLoading(false);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async function loadRules() {
|
|
604
|
+
setRulesLoading(true);
|
|
605
|
+
try {
|
|
606
|
+
const [nextRules, nextHistory] = await Promise.all([
|
|
607
|
+
getAllRulesStatus(),
|
|
608
|
+
getExecutionHistory(void 0, 10)
|
|
609
|
+
]);
|
|
610
|
+
setRules(nextRules);
|
|
611
|
+
setRuleHistory(nextHistory);
|
|
612
|
+
setRulesSelectedIndex((current) => clampIndex(current, nextRules.length));
|
|
613
|
+
setHistorySelectedIndex((current) => clampIndex(current, nextHistory.length));
|
|
614
|
+
} catch (error) {
|
|
615
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
616
|
+
} finally {
|
|
617
|
+
setRulesLoading(false);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async function loadEmailDetail(emailId) {
|
|
621
|
+
setEmailLoading(true);
|
|
622
|
+
setEmailScroll(0);
|
|
623
|
+
try {
|
|
624
|
+
const detail = await getMessage(emailId);
|
|
625
|
+
setEmailDetail(detail);
|
|
626
|
+
setEmailBody(getBodyText(detail));
|
|
627
|
+
} catch (error) {
|
|
628
|
+
setEmailDetail(null);
|
|
629
|
+
setEmailBody("");
|
|
630
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
631
|
+
} finally {
|
|
632
|
+
setEmailLoading(false);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function runSync(message) {
|
|
636
|
+
const startedAt = Date.now();
|
|
637
|
+
setSyncState((current) => ({
|
|
638
|
+
...current,
|
|
639
|
+
syncing: true,
|
|
640
|
+
message,
|
|
641
|
+
progressCurrent: current.resumableProgressCurrent,
|
|
642
|
+
progressTotal: current.resumableProgressTotal,
|
|
643
|
+
progressMode: current.resumableProgressCurrent > 0 ? "full" : null,
|
|
644
|
+
progressPhase: current.resumableProgressCurrent > 0 ? "starting" : "starting",
|
|
645
|
+
startedAt,
|
|
646
|
+
progressStartedAt: null,
|
|
647
|
+
lastProgressAt: startedAt,
|
|
648
|
+
lastProgressCurrent: current.resumableProgressCurrent,
|
|
649
|
+
ratePerSecond: null
|
|
650
|
+
}));
|
|
651
|
+
try {
|
|
652
|
+
await incrementalSync(
|
|
653
|
+
(synced, total) => {
|
|
654
|
+
setSyncState((current) => {
|
|
655
|
+
const now = Date.now();
|
|
656
|
+
const progressStartedAt = current.progressStartedAt ?? (synced > 0 ? now : null);
|
|
657
|
+
const elapsedSinceProgressStart = progressStartedAt ? (now - progressStartedAt) / 1e3 : 0;
|
|
658
|
+
const nextRate = synced > 0 && elapsedSinceProgressStart > 0 ? synced / elapsedSinceProgressStart : current.ratePerSecond;
|
|
659
|
+
return {
|
|
660
|
+
...current,
|
|
661
|
+
progressCurrent: synced,
|
|
662
|
+
progressTotal: total,
|
|
663
|
+
progressStartedAt,
|
|
664
|
+
lastProgressAt: now,
|
|
665
|
+
lastProgressCurrent: Math.max(current.lastProgressCurrent, synced),
|
|
666
|
+
ratePerSecond: nextRate
|
|
667
|
+
};
|
|
668
|
+
});
|
|
669
|
+
},
|
|
670
|
+
(event) => {
|
|
671
|
+
setSyncState((current) => ({
|
|
672
|
+
...current,
|
|
673
|
+
message: event.detail,
|
|
674
|
+
progressCurrent: event.synced,
|
|
675
|
+
progressTotal: event.total,
|
|
676
|
+
progressMode: event.mode,
|
|
677
|
+
progressPhase: event.phase
|
|
678
|
+
}));
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
await Promise.all([
|
|
682
|
+
refreshStatus(),
|
|
683
|
+
loadInbox(true),
|
|
684
|
+
screen === "stats" ? loadStats() : Promise.resolve(),
|
|
685
|
+
screen === "rules" ? loadRules() : Promise.resolve()
|
|
686
|
+
]);
|
|
687
|
+
pushFlash("success", "Inbox sync complete.");
|
|
688
|
+
} catch (error) {
|
|
689
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
690
|
+
} finally {
|
|
691
|
+
setSyncState((current) => ({
|
|
692
|
+
...current,
|
|
693
|
+
syncing: false,
|
|
694
|
+
progressCurrent: 0,
|
|
695
|
+
progressTotal: null,
|
|
696
|
+
progressMode: null,
|
|
697
|
+
progressPhase: null,
|
|
698
|
+
startedAt: null,
|
|
699
|
+
progressStartedAt: null,
|
|
700
|
+
lastProgressAt: null,
|
|
701
|
+
lastProgressCurrent: 0,
|
|
702
|
+
ratePerSecond: null
|
|
703
|
+
}));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
async function refreshAfterMutation() {
|
|
707
|
+
await Promise.all([
|
|
708
|
+
refreshStatus(),
|
|
709
|
+
loadInbox(true),
|
|
710
|
+
screen === "stats" ? loadStats() : Promise.resolve(),
|
|
711
|
+
screen === "rules" ? loadRules() : Promise.resolve(),
|
|
712
|
+
selectedEmailId && screen === "email" ? loadEmailDetail(selectedEmailId) : Promise.resolve()
|
|
713
|
+
]);
|
|
714
|
+
}
|
|
715
|
+
function currentListSelection() {
|
|
716
|
+
if (screen === "search") {
|
|
717
|
+
return searchResults[searchSelectedIndex] || null;
|
|
718
|
+
}
|
|
719
|
+
return inboxEmails[inboxSelectedIndex] || null;
|
|
720
|
+
}
|
|
721
|
+
async function archiveCurrentEmail() {
|
|
722
|
+
const email = screen === "email" ? emailDetail : currentListSelection();
|
|
723
|
+
if (!email) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
await archiveEmails([email.id]);
|
|
728
|
+
pushFlash("success", `Archived ${email.subject || email.id}.`);
|
|
729
|
+
await refreshAfterMutation();
|
|
730
|
+
} catch (error) {
|
|
731
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async function toggleReadCurrentEmail() {
|
|
735
|
+
const email = screen === "email" ? emailDetail : currentListSelection();
|
|
736
|
+
if (!email) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
if (email.isRead) {
|
|
741
|
+
await markUnread([email.id]);
|
|
742
|
+
pushFlash("success", `Marked ${email.subject || email.id} as unread.`);
|
|
743
|
+
} else {
|
|
744
|
+
await markRead([email.id]);
|
|
745
|
+
pushFlash("success", `Marked ${email.subject || email.id} as read.`);
|
|
746
|
+
}
|
|
747
|
+
await refreshAfterMutation();
|
|
748
|
+
} catch (error) {
|
|
749
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function openLabelPicker(targetEmailId) {
|
|
753
|
+
if (!targetEmailId) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
setLabelPicker({
|
|
757
|
+
open: true,
|
|
758
|
+
loading: true,
|
|
759
|
+
labels: [],
|
|
760
|
+
selectedIndex: 0,
|
|
761
|
+
targetEmailId,
|
|
762
|
+
createMode: false,
|
|
763
|
+
newLabelName: ""
|
|
764
|
+
});
|
|
765
|
+
try {
|
|
766
|
+
const labels2 = await listLabels();
|
|
767
|
+
setLabelPicker((current) => ({
|
|
768
|
+
...current,
|
|
769
|
+
loading: false,
|
|
770
|
+
labels: labels2
|
|
771
|
+
}));
|
|
772
|
+
} catch (error) {
|
|
773
|
+
setLabelPicker((current) => ({
|
|
774
|
+
...current,
|
|
775
|
+
loading: false
|
|
776
|
+
}));
|
|
777
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
async function applySelectedLabel(labelName) {
|
|
781
|
+
if (!labelPicker.targetEmailId) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
await labelEmails([labelPicker.targetEmailId], labelName);
|
|
786
|
+
setLabelPicker((current) => ({
|
|
787
|
+
...current,
|
|
788
|
+
open: false
|
|
789
|
+
}));
|
|
790
|
+
pushFlash("success", `Applied label ${labelName}.`);
|
|
791
|
+
await refreshAfterMutation();
|
|
792
|
+
} catch (error) {
|
|
793
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async function createAndApplyLabel(name) {
|
|
797
|
+
const trimmed = name.trim();
|
|
798
|
+
if (!trimmed) {
|
|
799
|
+
pushFlash("error", "Label name cannot be empty.");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
await createLabel(trimmed);
|
|
804
|
+
await applySelectedLabel(trimmed);
|
|
805
|
+
} catch (error) {
|
|
806
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async function executeSearch(query) {
|
|
810
|
+
const trimmed = query.trim();
|
|
811
|
+
if (!trimmed) {
|
|
812
|
+
pushFlash("error", "Enter a Gmail query first.");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
setSearchLoading(true);
|
|
816
|
+
try {
|
|
817
|
+
const results = await listMessages(trimmed, SEARCH_LIMIT);
|
|
818
|
+
setSearchResults(results);
|
|
819
|
+
setSearchSelectedIndex(0);
|
|
820
|
+
setSearchFocus("results");
|
|
821
|
+
pushFlash("info", `${results.length} search results loaded.`);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
824
|
+
} finally {
|
|
825
|
+
setSearchLoading(false);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
function openSelectedEmail(email, origin) {
|
|
829
|
+
setEmailOrigin(origin);
|
|
830
|
+
setSelectedEmailId(email.id);
|
|
831
|
+
setScreen("email");
|
|
832
|
+
}
|
|
833
|
+
useEffect(() => {
|
|
834
|
+
void (async () => {
|
|
835
|
+
await Promise.all([
|
|
836
|
+
refreshStatus(),
|
|
837
|
+
loadInbox(true)
|
|
838
|
+
]);
|
|
839
|
+
if (initialSync) {
|
|
840
|
+
await runSync("Syncing inbox\u2026");
|
|
841
|
+
}
|
|
842
|
+
})();
|
|
843
|
+
}, []);
|
|
844
|
+
useEffect(() => {
|
|
845
|
+
if (screen === "stats") {
|
|
846
|
+
void loadStats();
|
|
847
|
+
}
|
|
848
|
+
if (screen === "rules") {
|
|
849
|
+
void loadRules();
|
|
850
|
+
}
|
|
851
|
+
}, [screen]);
|
|
852
|
+
useEffect(() => {
|
|
853
|
+
if (screen === "email" && selectedEmailId) {
|
|
854
|
+
void loadEmailDetail(selectedEmailId);
|
|
855
|
+
}
|
|
856
|
+
}, [screen, selectedEmailId]);
|
|
857
|
+
useEffect(() => {
|
|
858
|
+
if (!flash) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const timeout = setTimeout(() => {
|
|
862
|
+
setFlash(null);
|
|
863
|
+
}, 3500);
|
|
864
|
+
return () => clearTimeout(timeout);
|
|
865
|
+
}, [flash]);
|
|
866
|
+
useInput((input2, key) => {
|
|
867
|
+
if (confirmState) {
|
|
868
|
+
if (input2 === "y" || input2 === "Y") {
|
|
869
|
+
const action = confirmState.onConfirm;
|
|
870
|
+
setConfirmState(null);
|
|
871
|
+
void action();
|
|
872
|
+
} else if (input2 === "n" || input2 === "N" || key.escape) {
|
|
873
|
+
setConfirmState(null);
|
|
874
|
+
}
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (labelPicker.open) {
|
|
878
|
+
if (labelPicker.createMode) {
|
|
879
|
+
if (key.escape) {
|
|
880
|
+
setLabelPicker((current) => ({
|
|
881
|
+
...current,
|
|
882
|
+
createMode: false,
|
|
883
|
+
newLabelName: ""
|
|
884
|
+
}));
|
|
885
|
+
}
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (key.escape) {
|
|
889
|
+
setLabelPicker((current) => ({
|
|
890
|
+
...current,
|
|
891
|
+
open: false
|
|
892
|
+
}));
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (input2 === "j" || key.downArrow) {
|
|
896
|
+
setLabelPicker((current) => ({
|
|
897
|
+
...current,
|
|
898
|
+
selectedIndex: clampIndex(current.selectedIndex + 1, current.labels.length)
|
|
899
|
+
}));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (input2 === "k" || key.upArrow) {
|
|
903
|
+
setLabelPicker((current) => ({
|
|
904
|
+
...current,
|
|
905
|
+
selectedIndex: clampIndex(current.selectedIndex - 1, current.labels.length)
|
|
906
|
+
}));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (input2 === "c") {
|
|
910
|
+
setLabelPicker((current) => ({
|
|
911
|
+
...current,
|
|
912
|
+
createMode: true,
|
|
913
|
+
newLabelName: ""
|
|
914
|
+
}));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (key.return) {
|
|
918
|
+
const label = labelPicker.labels[labelPicker.selectedIndex];
|
|
919
|
+
if (label) {
|
|
920
|
+
void applySelectedLabel(label.name);
|
|
921
|
+
}
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (screen === "search" && searchFocus === "input") {
|
|
927
|
+
if (key.escape) {
|
|
928
|
+
setScreen("inbox");
|
|
929
|
+
}
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (input2 === "q") {
|
|
933
|
+
exit();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (screen === "inbox") {
|
|
937
|
+
if (input2 === "j" || key.downArrow) {
|
|
938
|
+
if (inboxSelectedIndex === inboxEmails.length - 1 && inboxHasMore && !inboxLoading) {
|
|
939
|
+
void loadInbox(false);
|
|
940
|
+
}
|
|
941
|
+
setInboxSelectedIndex((current) => clampIndex(current + 1, inboxEmails.length));
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (input2 === "k" || key.upArrow) {
|
|
945
|
+
setInboxSelectedIndex((current) => clampIndex(current - 1, inboxEmails.length));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (key.return) {
|
|
949
|
+
const email = inboxEmails[inboxSelectedIndex];
|
|
950
|
+
if (email) {
|
|
951
|
+
openSelectedEmail(email, "inbox");
|
|
952
|
+
}
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (input2 === "a") {
|
|
956
|
+
void archiveCurrentEmail();
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (input2 === "l") {
|
|
960
|
+
void openLabelPicker(inboxEmails[inboxSelectedIndex]?.id || null);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (input2 === "r") {
|
|
964
|
+
void toggleReadCurrentEmail();
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (input2 === "/") {
|
|
968
|
+
setScreen("search");
|
|
969
|
+
setSearchFocus("input");
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (input2 === "s") {
|
|
973
|
+
void runSync("Syncing inbox\u2026");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (input2 === "d") {
|
|
977
|
+
setScreen("stats");
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (input2 === "R") {
|
|
981
|
+
setScreen("rules");
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (screen === "email") {
|
|
986
|
+
if (key.escape || key.backspace) {
|
|
987
|
+
setScreen(emailOrigin);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (input2 === "j" || key.downArrow) {
|
|
991
|
+
setEmailScroll((current) => current + 1);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (input2 === "k" || key.upArrow) {
|
|
995
|
+
setEmailScroll((current) => Math.max(0, current - 1));
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (input2 === "a") {
|
|
999
|
+
void archiveCurrentEmail();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (input2 === "l") {
|
|
1003
|
+
void openLabelPicker(selectedEmailId);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if (input2 === "r") {
|
|
1007
|
+
void toggleReadCurrentEmail();
|
|
1008
|
+
}
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (screen === "stats") {
|
|
1012
|
+
if (key.escape || key.backspace) {
|
|
1013
|
+
setScreen("inbox");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (input2 === "s") {
|
|
1017
|
+
setStatsTab("senders");
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (input2 === "l") {
|
|
1021
|
+
setStatsTab("labels");
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (input2 === "n") {
|
|
1025
|
+
setStatsTab("newsletters");
|
|
1026
|
+
}
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (screen === "rules") {
|
|
1030
|
+
if (key.escape || key.backspace) {
|
|
1031
|
+
setScreen("inbox");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (key.tab) {
|
|
1035
|
+
setRulesFocus((current) => current === "rules" ? "history" : "rules");
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (input2 === "j" || key.downArrow) {
|
|
1039
|
+
if (rulesFocus === "rules") {
|
|
1040
|
+
setRulesSelectedIndex((current) => clampIndex(current + 1, rules2.length));
|
|
1041
|
+
} else {
|
|
1042
|
+
setHistorySelectedIndex((current) => clampIndex(current + 1, ruleHistory.length));
|
|
1043
|
+
}
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (input2 === "k" || key.upArrow) {
|
|
1047
|
+
if (rulesFocus === "rules") {
|
|
1048
|
+
setRulesSelectedIndex((current) => clampIndex(current - 1, rules2.length));
|
|
1049
|
+
} else {
|
|
1050
|
+
setHistorySelectedIndex((current) => clampIndex(current - 1, ruleHistory.length));
|
|
1051
|
+
}
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (input2 === "d") {
|
|
1055
|
+
void (async () => {
|
|
1056
|
+
try {
|
|
1057
|
+
await deployAllRules(config.rulesDir);
|
|
1058
|
+
pushFlash("success", "Rules deployed from YAML.");
|
|
1059
|
+
await loadRules();
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
1062
|
+
}
|
|
1063
|
+
})();
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (input2 === "e" && rulesFocus === "rules") {
|
|
1067
|
+
const rule = rules2[rulesSelectedIndex];
|
|
1068
|
+
if (!rule) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
void (async () => {
|
|
1072
|
+
try {
|
|
1073
|
+
if (rule.enabled) {
|
|
1074
|
+
await disableRule(rule.name);
|
|
1075
|
+
pushFlash("success", `Disabled rule ${rule.name}.`);
|
|
1076
|
+
} else {
|
|
1077
|
+
await enableRule(rule.name);
|
|
1078
|
+
pushFlash("success", `Enabled rule ${rule.name}.`);
|
|
1079
|
+
}
|
|
1080
|
+
await loadRules();
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
1083
|
+
}
|
|
1084
|
+
})();
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if (input2 === "r" && rulesFocus === "rules") {
|
|
1088
|
+
const rule = rules2[rulesSelectedIndex];
|
|
1089
|
+
if (!rule) {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
void (async () => {
|
|
1093
|
+
try {
|
|
1094
|
+
await runRule(rule.name, { dryRun: true, maxEmails: 100 });
|
|
1095
|
+
pushFlash("success", `Dry-ran rule ${rule.name}.`);
|
|
1096
|
+
await loadRules();
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
1099
|
+
}
|
|
1100
|
+
})();
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (input2 === "R" && rulesFocus === "rules") {
|
|
1104
|
+
const rule = rules2[rulesSelectedIndex];
|
|
1105
|
+
if (!rule) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
setConfirmState({
|
|
1109
|
+
title: "Run Rule",
|
|
1110
|
+
message: `Apply rule ${rule.name} against cached matches?`,
|
|
1111
|
+
onConfirm: async () => {
|
|
1112
|
+
try {
|
|
1113
|
+
await runRule(rule.name, { dryRun: false, maxEmails: 100 });
|
|
1114
|
+
pushFlash("success", `Applied rule ${rule.name}.`);
|
|
1115
|
+
await refreshAfterMutation();
|
|
1116
|
+
await loadRules();
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (input2 === "u" && rulesFocus === "history") {
|
|
1125
|
+
const run = ruleHistory[historySelectedIndex];
|
|
1126
|
+
if (!run) {
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
void (async () => {
|
|
1130
|
+
try {
|
|
1131
|
+
await undoRun(run.id);
|
|
1132
|
+
pushFlash("success", `Undid run ${run.id}.`);
|
|
1133
|
+
await refreshAfterMutation();
|
|
1134
|
+
await loadRules();
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
pushFlash("error", error instanceof Error ? error.message : String(error));
|
|
1137
|
+
}
|
|
1138
|
+
})();
|
|
1139
|
+
}
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (screen === "search") {
|
|
1143
|
+
if (key.escape || key.backspace) {
|
|
1144
|
+
setScreen("inbox");
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (input2 === "i") {
|
|
1148
|
+
setSearchFocus("input");
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (input2 === "j" || key.downArrow) {
|
|
1152
|
+
setSearchSelectedIndex((current) => clampIndex(current + 1, searchResults.length));
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (input2 === "k" || key.upArrow) {
|
|
1156
|
+
setSearchSelectedIndex((current) => clampIndex(current - 1, searchResults.length));
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (key.return) {
|
|
1160
|
+
const email = searchResults[searchSelectedIndex];
|
|
1161
|
+
if (email) {
|
|
1162
|
+
openSelectedEmail(email, "search");
|
|
1163
|
+
}
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (input2 === "a") {
|
|
1167
|
+
void archiveCurrentEmail();
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (input2 === "l") {
|
|
1171
|
+
void openLabelPicker(searchResults[searchSelectedIndex]?.id || null);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (input2 === "r") {
|
|
1175
|
+
void toggleReadCurrentEmail();
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
const chromeHeight = 9;
|
|
1180
|
+
const contentHeight = Math.max(MIN_CONTENT_HEIGHT, rows - chromeHeight);
|
|
1181
|
+
const emailBodyHeight = Math.max(8, contentHeight - 8);
|
|
1182
|
+
const listVisibleRows = Math.max(8, contentHeight - 6);
|
|
1183
|
+
const emailBodyLines = emailBody.split("\n");
|
|
1184
|
+
const visibleBodyLines = emailBodyLines.slice(emailScroll, emailScroll + emailBodyHeight);
|
|
1185
|
+
const rulesVisibleCount = Math.max(5, Math.floor(contentHeight / 3));
|
|
1186
|
+
const historyVisibleCount = Math.max(4, Math.floor(contentHeight / 4));
|
|
1187
|
+
const rulesRange = getViewportRange(rules2.length, rulesSelectedIndex, rulesVisibleCount);
|
|
1188
|
+
const historyRange = getViewportRange(
|
|
1189
|
+
ruleHistory.length,
|
|
1190
|
+
historySelectedIndex,
|
|
1191
|
+
historyVisibleCount
|
|
1192
|
+
);
|
|
1193
|
+
const visibleRules = rules2.slice(rulesRange.start, rulesRange.end);
|
|
1194
|
+
const visibleHistory = ruleHistory.slice(historyRange.start, historyRange.end);
|
|
1195
|
+
const selectedRule = rules2[rulesSelectedIndex];
|
|
1196
|
+
const selectedRun = ruleHistory[historySelectedIndex];
|
|
1197
|
+
const screenGuide = getScreenGuide(
|
|
1198
|
+
screen,
|
|
1199
|
+
screen === "rules" ? rulesFocus : screen === "search" ? searchFocus : void 0
|
|
1200
|
+
);
|
|
1201
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width: columns, height: rows, paddingX: 1, paddingY: 0, children: [
|
|
1202
|
+
/* @__PURE__ */ jsx(
|
|
1203
|
+
Header,
|
|
1204
|
+
{
|
|
1205
|
+
screen,
|
|
1206
|
+
sync: syncState,
|
|
1207
|
+
columns: Math.max(40, columns - 2),
|
|
1208
|
+
guide: screenGuide
|
|
1209
|
+
}
|
|
1210
|
+
),
|
|
1211
|
+
/* @__PURE__ */ jsxs(Box, { height: contentHeight, flexDirection: "column", marginTop: 1, children: [
|
|
1212
|
+
screen === "inbox" ? /* @__PURE__ */ jsx(
|
|
1213
|
+
EmailList,
|
|
1214
|
+
{
|
|
1215
|
+
emails: inboxEmails,
|
|
1216
|
+
selectedIndex: inboxSelectedIndex,
|
|
1217
|
+
title: "Inbox",
|
|
1218
|
+
loading: inboxLoading,
|
|
1219
|
+
emptyMessage: "No cached emails yet. Sync to populate the local inbox.",
|
|
1220
|
+
visibleRows: listVisibleRows,
|
|
1221
|
+
subtitle: "Local cache first, background sync second"
|
|
1222
|
+
}
|
|
1223
|
+
) : null,
|
|
1224
|
+
screen === "email" ? /* @__PURE__ */ jsx(
|
|
1225
|
+
Panel,
|
|
1226
|
+
{
|
|
1227
|
+
title: sanitizeInlineText(emailDetail?.subject || "Email Detail", Math.max(20, columns - 12)),
|
|
1228
|
+
subtitle: "Esc back \u2022 j/k scroll \u2022 a archive \u2022 l label \u2022 r toggle read",
|
|
1229
|
+
accent: "green",
|
|
1230
|
+
children: emailLoading ? /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
1231
|
+
/* @__PURE__ */ jsx(Spinner, { type: "dots" }),
|
|
1232
|
+
" Loading full email body\u2026"
|
|
1233
|
+
] }) : emailDetail ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1234
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1235
|
+
"From: ",
|
|
1236
|
+
sanitizeInlineText(emailDetail.fromName ? `${emailDetail.fromName} <${emailDetail.fromAddress}>` : emailDetail.fromAddress)
|
|
1237
|
+
] }),
|
|
1238
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1239
|
+
"To: ",
|
|
1240
|
+
sanitizeInlineText(emailDetail.toAddresses.join(", ") || "-")
|
|
1241
|
+
] }),
|
|
1242
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1243
|
+
"Date: ",
|
|
1244
|
+
new Date(emailDetail.date).toISOString()
|
|
1245
|
+
] }),
|
|
1246
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1247
|
+
"Labels: ",
|
|
1248
|
+
sanitizeInlineText(emailDetail.labelIds.join(", ") || "-")
|
|
1249
|
+
] }),
|
|
1250
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleBodyLines.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "gray", children: "(no body content)" }) : visibleBodyLines.map((line, index) => /* @__PURE__ */ jsx(Text, { children: sanitizeInlineText(line || " ", Math.max(20, columns - 8)) }, `body-${index}`)) }),
|
|
1251
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
1252
|
+
"line ",
|
|
1253
|
+
Math.min(emailBodyLines.length, emailScroll + 1),
|
|
1254
|
+
" of ",
|
|
1255
|
+
emailBodyLines.length || 1
|
|
1256
|
+
] }) })
|
|
1257
|
+
] }) : /* @__PURE__ */ jsx(Text, { color: "gray", children: "Unable to load email detail." })
|
|
1258
|
+
}
|
|
1259
|
+
) : null,
|
|
1260
|
+
screen === "stats" ? /* @__PURE__ */ jsx(
|
|
1261
|
+
Panel,
|
|
1262
|
+
{
|
|
1263
|
+
title: "Stats Dashboard",
|
|
1264
|
+
subtitle: "s senders \u2022 l labels \u2022 n newsletters \u2022 Esc back",
|
|
1265
|
+
accent: "yellow",
|
|
1266
|
+
children: statsLoading ? /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
1267
|
+
/* @__PURE__ */ jsx(Spinner, { type: "dots" }),
|
|
1268
|
+
" Loading stats\u2026"
|
|
1269
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1270
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
|
|
1271
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1272
|
+
"Total: ",
|
|
1273
|
+
statsOverview?.total ?? 0
|
|
1274
|
+
] }),
|
|
1275
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1276
|
+
"Unread: ",
|
|
1277
|
+
statsOverview?.unread ?? 0
|
|
1278
|
+
] }),
|
|
1279
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1280
|
+
"Starred: ",
|
|
1281
|
+
statsOverview?.starred ?? 0
|
|
1282
|
+
] }),
|
|
1283
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1284
|
+
"Today: ",
|
|
1285
|
+
statsOverview?.today.received ?? 0,
|
|
1286
|
+
" received / ",
|
|
1287
|
+
statsOverview?.today.unread ?? 0,
|
|
1288
|
+
" unread"
|
|
1289
|
+
] }),
|
|
1290
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1291
|
+
"Week: ",
|
|
1292
|
+
statsOverview?.thisWeek.received ?? 0,
|
|
1293
|
+
" received / ",
|
|
1294
|
+
statsOverview?.thisWeek.unread ?? 0,
|
|
1295
|
+
" unread"
|
|
1296
|
+
] }),
|
|
1297
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1298
|
+
"Month: ",
|
|
1299
|
+
statsOverview?.thisMonth.received ?? 0,
|
|
1300
|
+
" received / ",
|
|
1301
|
+
statsOverview?.thisMonth.unread ?? 0,
|
|
1302
|
+
" unread"
|
|
1303
|
+
] })
|
|
1304
|
+
] }),
|
|
1305
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
|
|
1306
|
+
/* @__PURE__ */ jsx(Text, { color: statsTab === "senders" ? "cyan" : "gray", children: "[Senders]" }),
|
|
1307
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
1308
|
+
/* @__PURE__ */ jsx(Text, { color: statsTab === "labels" ? "cyan" : "gray", children: "[Labels]" }),
|
|
1309
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
1310
|
+
/* @__PURE__ */ jsx(Text, { color: statsTab === "newsletters" ? "cyan" : "gray", children: "[Newsletters]" })
|
|
1311
|
+
] }),
|
|
1312
|
+
statsTab === "senders" ? /* @__PURE__ */ jsx(
|
|
1313
|
+
Table,
|
|
1314
|
+
{
|
|
1315
|
+
title: "Top Senders",
|
|
1316
|
+
headers: ["SENDER", "TOTAL", "UNREAD%"],
|
|
1317
|
+
rows: statsSenders.map((sender) => [
|
|
1318
|
+
truncate(sender.name || sender.email, 28),
|
|
1319
|
+
String(sender.totalMessages),
|
|
1320
|
+
formatPercent(sender.unreadRate)
|
|
1321
|
+
]),
|
|
1322
|
+
emptyMessage: "No sender stats available."
|
|
1323
|
+
}
|
|
1324
|
+
) : null,
|
|
1325
|
+
statsTab === "labels" ? /* @__PURE__ */ jsx(
|
|
1326
|
+
Table,
|
|
1327
|
+
{
|
|
1328
|
+
title: "Top Labels",
|
|
1329
|
+
headers: ["LABEL", "TOTAL", "UNREAD"],
|
|
1330
|
+
rows: statsLabels.map((label) => [
|
|
1331
|
+
truncate(label.labelName, 24),
|
|
1332
|
+
String(label.totalMessages),
|
|
1333
|
+
String(label.unreadMessages)
|
|
1334
|
+
]),
|
|
1335
|
+
emptyMessage: "No label stats available."
|
|
1336
|
+
}
|
|
1337
|
+
) : null,
|
|
1338
|
+
statsTab === "newsletters" ? /* @__PURE__ */ jsx(
|
|
1339
|
+
Table,
|
|
1340
|
+
{
|
|
1341
|
+
title: "Newsletters",
|
|
1342
|
+
headers: ["SENDER", "TOTAL", "UNREAD%", "STATUS"],
|
|
1343
|
+
rows: statsNewsletters.map((newsletter) => [
|
|
1344
|
+
truncate(newsletter.name || newsletter.email, 24),
|
|
1345
|
+
String(newsletter.messageCount),
|
|
1346
|
+
formatPercent(newsletter.unreadRate),
|
|
1347
|
+
newsletter.status
|
|
1348
|
+
]),
|
|
1349
|
+
emptyMessage: "No newsletters detected."
|
|
1350
|
+
}
|
|
1351
|
+
) : null,
|
|
1352
|
+
/* @__PURE__ */ jsx(
|
|
1353
|
+
Table,
|
|
1354
|
+
{
|
|
1355
|
+
title: "Recent Volume",
|
|
1356
|
+
headers: ["DAY", "RECEIVED", "UNREAD"],
|
|
1357
|
+
rows: statsVolume.map((point) => [
|
|
1358
|
+
point.period,
|
|
1359
|
+
String(point.received),
|
|
1360
|
+
String(point.unread)
|
|
1361
|
+
]),
|
|
1362
|
+
emptyMessage: "No cached volume data."
|
|
1363
|
+
}
|
|
1364
|
+
)
|
|
1365
|
+
] })
|
|
1366
|
+
}
|
|
1367
|
+
) : null,
|
|
1368
|
+
screen === "rules" ? /* @__PURE__ */ jsx(
|
|
1369
|
+
Panel,
|
|
1370
|
+
{
|
|
1371
|
+
title: "Rules Control",
|
|
1372
|
+
subtitle: "Tab switches focus \u2022 d deploy \u2022 e toggle \u2022 r dry-run \u2022 R apply \u2022 u undo",
|
|
1373
|
+
accent: "magenta",
|
|
1374
|
+
children: rulesLoading ? /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
1375
|
+
/* @__PURE__ */ jsx(Spinner, { type: "dots" }),
|
|
1376
|
+
" Loading rules\u2026"
|
|
1377
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1378
|
+
/* @__PURE__ */ jsx(Text, { color: rulesFocus === "rules" ? "cyan" : "gray", children: "Rules" }),
|
|
1379
|
+
rules2.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "gray", children: "No deployed rules." }) : visibleRules.map((rule, index) => {
|
|
1380
|
+
const absoluteIndex = rulesRange.start + index;
|
|
1381
|
+
return /* @__PURE__ */ jsxs(
|
|
1382
|
+
Text,
|
|
1383
|
+
{
|
|
1384
|
+
backgroundColor: rulesFocus === "rules" && absoluteIndex === rulesSelectedIndex ? "magenta" : void 0,
|
|
1385
|
+
color: rulesFocus === "rules" && absoluteIndex === rulesSelectedIndex ? "black" : void 0,
|
|
1386
|
+
children: [
|
|
1387
|
+
rule.enabled ? "\u2713" : "\xB7",
|
|
1388
|
+
" ",
|
|
1389
|
+
rule.name,
|
|
1390
|
+
" (",
|
|
1391
|
+
rule.totalRuns,
|
|
1392
|
+
" runs, last ",
|
|
1393
|
+
formatRelativeTime(rule.lastExecutionAt),
|
|
1394
|
+
")"
|
|
1395
|
+
]
|
|
1396
|
+
},
|
|
1397
|
+
rule.id
|
|
1398
|
+
);
|
|
1399
|
+
}),
|
|
1400
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
|
|
1401
|
+
/* @__PURE__ */ jsx(Text, { color: rulesFocus === "history" ? "cyan" : "gray", children: "Recent History" }),
|
|
1402
|
+
ruleHistory.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "gray", children: "No execution history yet." }) : visibleHistory.map((run, index) => {
|
|
1403
|
+
const absoluteIndex = historyRange.start + index;
|
|
1404
|
+
return /* @__PURE__ */ jsxs(
|
|
1405
|
+
Text,
|
|
1406
|
+
{
|
|
1407
|
+
backgroundColor: rulesFocus === "history" && absoluteIndex === historySelectedIndex ? "white" : void 0,
|
|
1408
|
+
color: rulesFocus === "history" && absoluteIndex === historySelectedIndex ? "black" : void 0,
|
|
1409
|
+
children: [
|
|
1410
|
+
run.status,
|
|
1411
|
+
" ",
|
|
1412
|
+
run.id,
|
|
1413
|
+
" (",
|
|
1414
|
+
run.itemCount,
|
|
1415
|
+
" items, ",
|
|
1416
|
+
formatRelativeTime(run.createdAt),
|
|
1417
|
+
")"
|
|
1418
|
+
]
|
|
1419
|
+
},
|
|
1420
|
+
run.id
|
|
1421
|
+
);
|
|
1422
|
+
})
|
|
1423
|
+
] }),
|
|
1424
|
+
selectedRule && rulesFocus === "rules" ? /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
|
|
1425
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
1426
|
+
"Selected rule: ",
|
|
1427
|
+
selectedRule.name
|
|
1428
|
+
] }),
|
|
1429
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
1430
|
+
"Actions: ",
|
|
1431
|
+
selectedRule.actions.map((action) => action.type).join(", ") || "-"
|
|
1432
|
+
] })
|
|
1433
|
+
] }) : null,
|
|
1434
|
+
selectedRun && rulesFocus === "history" ? /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
1435
|
+
"Selected run: ",
|
|
1436
|
+
selectedRun.id
|
|
1437
|
+
] }) : null
|
|
1438
|
+
] })
|
|
1439
|
+
}
|
|
1440
|
+
) : null,
|
|
1441
|
+
screen === "search" ? /* @__PURE__ */ jsxs(
|
|
1442
|
+
Panel,
|
|
1443
|
+
{
|
|
1444
|
+
title: "Search",
|
|
1445
|
+
subtitle: "Enter Gmail query syntax \u2022 Enter search \u2022 i focus input \u2022 Esc back",
|
|
1446
|
+
accent: "cyan",
|
|
1447
|
+
children: [
|
|
1448
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
1449
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", children: "query: " }),
|
|
1450
|
+
/* @__PURE__ */ jsx(
|
|
1451
|
+
TextInput,
|
|
1452
|
+
{
|
|
1453
|
+
value: searchQuery,
|
|
1454
|
+
onChange: setSearchQuery,
|
|
1455
|
+
onSubmit: (value) => {
|
|
1456
|
+
void executeSearch(value);
|
|
1457
|
+
},
|
|
1458
|
+
focus: searchFocus === "input"
|
|
1459
|
+
}
|
|
1460
|
+
)
|
|
1461
|
+
] }),
|
|
1462
|
+
searchLoading ? /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
1463
|
+
/* @__PURE__ */ jsx(Spinner, { type: "dots" }),
|
|
1464
|
+
" Searching Gmail\u2026"
|
|
1465
|
+
] }) : /* @__PURE__ */ jsx(
|
|
1466
|
+
EmailList,
|
|
1467
|
+
{
|
|
1468
|
+
emails: searchResults,
|
|
1469
|
+
selectedIndex: searchSelectedIndex,
|
|
1470
|
+
title: "Results",
|
|
1471
|
+
emptyMessage: "No results yet.",
|
|
1472
|
+
visibleRows: Math.max(6, listVisibleRows - 4)
|
|
1473
|
+
}
|
|
1474
|
+
)
|
|
1475
|
+
]
|
|
1476
|
+
}
|
|
1477
|
+
) : null
|
|
1478
|
+
] }),
|
|
1479
|
+
labelPicker.open ? /* @__PURE__ */ jsx(Modal, { title: "Label Picker", children: labelPicker.loading ? /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
1480
|
+
/* @__PURE__ */ jsx(Spinner, { type: "dots" }),
|
|
1481
|
+
" Loading labels\u2026"
|
|
1482
|
+
] }) : labelPicker.createMode ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1483
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: "Enter new label name, then press Enter. Esc cancels." }),
|
|
1484
|
+
/* @__PURE__ */ jsx(
|
|
1485
|
+
TextInput,
|
|
1486
|
+
{
|
|
1487
|
+
value: labelPicker.newLabelName,
|
|
1488
|
+
onChange: (value) => {
|
|
1489
|
+
setLabelPicker((current) => ({
|
|
1490
|
+
...current,
|
|
1491
|
+
newLabelName: value
|
|
1492
|
+
}));
|
|
1493
|
+
},
|
|
1494
|
+
onSubmit: (value) => {
|
|
1495
|
+
void createAndApplyLabel(value);
|
|
1496
|
+
},
|
|
1497
|
+
focus: true
|
|
1498
|
+
}
|
|
1499
|
+
)
|
|
1500
|
+
] }) : labelPicker.labels.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "gray", children: "No labels available. Press c to create one." }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1501
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: "j/k navigate, Enter apply, c create label, Esc cancel" }),
|
|
1502
|
+
labelPicker.labels.map((label, index) => /* @__PURE__ */ jsx(Text, { inverse: index === labelPicker.selectedIndex, children: label.name }, label.id))
|
|
1503
|
+
] }) }) : null,
|
|
1504
|
+
confirmState ? /* @__PURE__ */ jsxs(Modal, { title: confirmState.title, children: [
|
|
1505
|
+
/* @__PURE__ */ jsx(Text, { children: confirmState.message }),
|
|
1506
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: "Press y to continue or n to cancel." })
|
|
1507
|
+
] }) : null,
|
|
1508
|
+
/* @__PURE__ */ jsx(
|
|
1509
|
+
StatusBar,
|
|
1510
|
+
{
|
|
1511
|
+
screen,
|
|
1512
|
+
email: authEmail,
|
|
1513
|
+
unreadCount,
|
|
1514
|
+
sync: syncState,
|
|
1515
|
+
flash,
|
|
1516
|
+
columns
|
|
1517
|
+
}
|
|
1518
|
+
)
|
|
1519
|
+
] });
|
|
1520
|
+
}
|
|
1521
|
+
async function startTuiApp(options) {
|
|
1522
|
+
if (process.stdout.isTTY) {
|
|
1523
|
+
process.stdout.write("\x1B[?1049h\x1B[2J\x1B[H");
|
|
1524
|
+
}
|
|
1525
|
+
try {
|
|
1526
|
+
const instance = render(/* @__PURE__ */ jsx(App, { initialSync: !options?.noSync }));
|
|
1527
|
+
await instance.waitUntilExit();
|
|
1528
|
+
} finally {
|
|
1529
|
+
if (process.stdout.isTTY) {
|
|
1530
|
+
process.stdout.write("\x1B[?1049l");
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// src/core/demo/demo-transport.ts
|
|
1536
|
+
function copyMessage(message) {
|
|
1537
|
+
return JSON.parse(JSON.stringify(message));
|
|
1538
|
+
}
|
|
1539
|
+
function copyLabel(label) {
|
|
1540
|
+
return { ...label };
|
|
1541
|
+
}
|
|
1542
|
+
function copyFilter(filter) {
|
|
1543
|
+
return JSON.parse(JSON.stringify(filter));
|
|
1544
|
+
}
|
|
1545
|
+
function getSearchTokens(query) {
|
|
1546
|
+
return query.match(/"[^"]+"|\S+/g) || [];
|
|
1547
|
+
}
|
|
1548
|
+
function normalize(value) {
|
|
1549
|
+
return (value || "").trim().toLowerCase();
|
|
1550
|
+
}
|
|
1551
|
+
function includesLabel(labelIds, rawLabels, expected) {
|
|
1552
|
+
return labelIds.some((labelId) => {
|
|
1553
|
+
const label = rawLabels.get(labelId);
|
|
1554
|
+
return normalize(labelId) === expected || normalize(label?.name) === expected || normalize(label?.name).replace(/\s+/g, "-") === expected;
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
function matchesQuery(entry, query, rawLabels) {
|
|
1558
|
+
const trimmed = query.trim();
|
|
1559
|
+
if (!trimmed) {
|
|
1560
|
+
return true;
|
|
1561
|
+
}
|
|
1562
|
+
const searchableText = normalize(
|
|
1563
|
+
[
|
|
1564
|
+
entry.message.fromAddress,
|
|
1565
|
+
entry.message.fromName,
|
|
1566
|
+
entry.message.subject,
|
|
1567
|
+
entry.message.snippet,
|
|
1568
|
+
...entry.message.toAddresses
|
|
1569
|
+
].join(" ")
|
|
1570
|
+
);
|
|
1571
|
+
return getSearchTokens(trimmed).every((token) => {
|
|
1572
|
+
const cleaned = token.replace(/^"|"$/g, "");
|
|
1573
|
+
const separator = cleaned.indexOf(":");
|
|
1574
|
+
if (separator === -1) {
|
|
1575
|
+
return searchableText.includes(normalize(cleaned));
|
|
1576
|
+
}
|
|
1577
|
+
const key = normalize(cleaned.slice(0, separator));
|
|
1578
|
+
const value = normalize(cleaned.slice(separator + 1));
|
|
1579
|
+
switch (key) {
|
|
1580
|
+
case "from":
|
|
1581
|
+
return normalize(entry.message.fromAddress).includes(value) || normalize(entry.message.fromName).includes(value);
|
|
1582
|
+
case "to":
|
|
1583
|
+
return entry.message.toAddresses.some((address) => normalize(address).includes(value));
|
|
1584
|
+
case "subject":
|
|
1585
|
+
return normalize(entry.message.subject).includes(value);
|
|
1586
|
+
case "label":
|
|
1587
|
+
return includesLabel(entry.message.labelIds, rawLabels, value);
|
|
1588
|
+
case "is":
|
|
1589
|
+
if (value === "unread") return !entry.message.isRead;
|
|
1590
|
+
if (value === "read") return entry.message.isRead;
|
|
1591
|
+
if (value === "starred") return entry.message.isStarred;
|
|
1592
|
+
return true;
|
|
1593
|
+
case "has":
|
|
1594
|
+
if (value === "attachment") return entry.message.hasAttachments;
|
|
1595
|
+
return true;
|
|
1596
|
+
default:
|
|
1597
|
+
return searchableText.includes(normalize(cleaned));
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
function applyLabelMutation(labelIds, addLabelIds = [], removeLabelIds = []) {
|
|
1602
|
+
const next = labelIds.filter((labelId) => !removeLabelIds.includes(labelId));
|
|
1603
|
+
for (const labelId of addLabelIds) {
|
|
1604
|
+
if (!next.includes(labelId)) {
|
|
1605
|
+
next.push(labelId);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
return next;
|
|
1609
|
+
}
|
|
1610
|
+
var DemoTransport = class {
|
|
1611
|
+
constructor(dataset) {
|
|
1612
|
+
this.dataset = dataset;
|
|
1613
|
+
this.historyCounter = Number.parseInt(dataset.historyId, 10) || 12345678;
|
|
1614
|
+
for (const label of dataset.labels) {
|
|
1615
|
+
if (label.id) {
|
|
1616
|
+
this.labels.set(label.id, copyLabel(label));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
for (const message of dataset.messages) {
|
|
1620
|
+
this.messages.set(message.message.id, {
|
|
1621
|
+
...message,
|
|
1622
|
+
message: {
|
|
1623
|
+
...message.message,
|
|
1624
|
+
labelIds: [...message.message.labelIds]
|
|
1625
|
+
},
|
|
1626
|
+
rawMessage: copyMessage(message.rawMessage)
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
for (const filter of dataset.filters) {
|
|
1630
|
+
if (filter.id) {
|
|
1631
|
+
this.filters.set(filter.id, copyFilter(filter));
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
dataset;
|
|
1636
|
+
kind = "rest";
|
|
1637
|
+
labels = /* @__PURE__ */ new Map();
|
|
1638
|
+
messages = /* @__PURE__ */ new Map();
|
|
1639
|
+
filters = /* @__PURE__ */ new Map();
|
|
1640
|
+
historyCounter;
|
|
1641
|
+
async getProfile() {
|
|
1642
|
+
return {
|
|
1643
|
+
emailAddress: this.dataset.accountEmail,
|
|
1644
|
+
historyId: String(this.historyCounter),
|
|
1645
|
+
messagesTotal: this.messages.size,
|
|
1646
|
+
threadsTotal: new Set(
|
|
1647
|
+
[...this.messages.values()].map((entry) => entry.message.threadId)
|
|
1648
|
+
).size
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
buildLabelDetails(label) {
|
|
1652
|
+
const id = label.id || label.name || "";
|
|
1653
|
+
const matching = [...this.messages.values()].filter(
|
|
1654
|
+
(entry) => entry.message.labelIds.includes(id)
|
|
1655
|
+
);
|
|
1656
|
+
return {
|
|
1657
|
+
...copyLabel(label),
|
|
1658
|
+
messagesTotal: matching.length,
|
|
1659
|
+
messagesUnread: matching.filter((entry) => !entry.message.isRead).length,
|
|
1660
|
+
threadsTotal: new Set(matching.map((entry) => entry.message.threadId)).size,
|
|
1661
|
+
threadsUnread: new Set(
|
|
1662
|
+
matching.filter((entry) => !entry.message.isRead).map((entry) => entry.message.threadId)
|
|
1663
|
+
).size
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
async listLabels() {
|
|
1667
|
+
return {
|
|
1668
|
+
labels: [...this.labels.values()].map((label) => this.buildLabelDetails(label))
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
async getLabel(id) {
|
|
1672
|
+
const label = this.labels.get(id);
|
|
1673
|
+
if (!label) {
|
|
1674
|
+
throw new Error(`Demo label not found: ${id}`);
|
|
1675
|
+
}
|
|
1676
|
+
return this.buildLabelDetails(label);
|
|
1677
|
+
}
|
|
1678
|
+
async createLabel(input2) {
|
|
1679
|
+
const existing = [...this.labels.values()].find(
|
|
1680
|
+
(label) => normalize(label.name) === normalize(input2.name)
|
|
1681
|
+
);
|
|
1682
|
+
if (existing) {
|
|
1683
|
+
return this.buildLabelDetails(existing);
|
|
1684
|
+
}
|
|
1685
|
+
const nextUserLabelCount = [...this.labels.values()].filter((label) => label.type === "user").length + 1;
|
|
1686
|
+
const created = {
|
|
1687
|
+
id: `Label_${nextUserLabelCount}`,
|
|
1688
|
+
name: input2.name.trim(),
|
|
1689
|
+
type: "user",
|
|
1690
|
+
color: input2.color || null
|
|
1691
|
+
};
|
|
1692
|
+
this.labels.set(created.id, created);
|
|
1693
|
+
return this.buildLabelDetails(created);
|
|
1694
|
+
}
|
|
1695
|
+
async batchModifyMessages(input2) {
|
|
1696
|
+
for (const id of input2.ids) {
|
|
1697
|
+
const entry = this.messages.get(id);
|
|
1698
|
+
if (!entry) {
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
const nextLabelIds = applyLabelMutation(
|
|
1702
|
+
entry.message.labelIds,
|
|
1703
|
+
input2.addLabelIds,
|
|
1704
|
+
input2.removeLabelIds
|
|
1705
|
+
);
|
|
1706
|
+
entry.message.labelIds = nextLabelIds;
|
|
1707
|
+
entry.message.isRead = !nextLabelIds.includes("UNREAD");
|
|
1708
|
+
entry.message.isStarred = nextLabelIds.includes("STARRED");
|
|
1709
|
+
entry.rawMessage.labelIds = [...nextLabelIds];
|
|
1710
|
+
}
|
|
1711
|
+
this.historyCounter += 1;
|
|
1712
|
+
}
|
|
1713
|
+
async sendMessage() {
|
|
1714
|
+
this.historyCounter += 1;
|
|
1715
|
+
return {
|
|
1716
|
+
id: `sent-demo-${this.historyCounter}`,
|
|
1717
|
+
threadId: `sent-thread-${this.historyCounter}`,
|
|
1718
|
+
labelIds: ["SENT"]
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
async listMessages(options) {
|
|
1722
|
+
const matching = [...this.messages.values()].filter((entry) => matchesQuery(entry, options.query || "", this.labels)).sort((left, right) => right.message.date - left.message.date);
|
|
1723
|
+
const offset = options.pageToken ? Number.parseInt(options.pageToken, 10) : 0;
|
|
1724
|
+
const limit = options.maxResults || 20;
|
|
1725
|
+
const slice = matching.slice(offset, offset + limit);
|
|
1726
|
+
const nextOffset = offset + slice.length;
|
|
1727
|
+
return {
|
|
1728
|
+
messages: slice.map((entry) => ({
|
|
1729
|
+
id: entry.message.id,
|
|
1730
|
+
threadId: entry.message.threadId
|
|
1731
|
+
})),
|
|
1732
|
+
nextPageToken: nextOffset < matching.length ? String(nextOffset) : void 0,
|
|
1733
|
+
resultSizeEstimate: matching.length
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
async getMessage(options) {
|
|
1737
|
+
const entry = this.messages.get(options.id);
|
|
1738
|
+
if (!entry) {
|
|
1739
|
+
throw new Error(`Demo message not found: ${options.id}`);
|
|
1740
|
+
}
|
|
1741
|
+
return copyMessage(entry.rawMessage);
|
|
1742
|
+
}
|
|
1743
|
+
async getThread(id) {
|
|
1744
|
+
const messages = [...this.messages.values()].filter((entry) => entry.message.threadId === id).sort((left, right) => left.message.date - right.message.date).map((entry) => copyMessage(entry.rawMessage));
|
|
1745
|
+
return {
|
|
1746
|
+
id,
|
|
1747
|
+
messages
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
async listHistory() {
|
|
1751
|
+
return {
|
|
1752
|
+
history: [],
|
|
1753
|
+
historyId: String(this.historyCounter)
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
async listFilters() {
|
|
1757
|
+
return {
|
|
1758
|
+
filter: [...this.filters.values()].map((filter) => copyFilter(filter))
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
async getFilter(id) {
|
|
1762
|
+
const filter = this.filters.get(id);
|
|
1763
|
+
if (!filter) {
|
|
1764
|
+
throw new Error(`Demo filter not found: ${id}`);
|
|
1765
|
+
}
|
|
1766
|
+
return copyFilter(filter);
|
|
1767
|
+
}
|
|
1768
|
+
async createFilter(filter) {
|
|
1769
|
+
const created = {
|
|
1770
|
+
id: `filter-demo-${this.filters.size + 1}`,
|
|
1771
|
+
criteria: filter.criteria,
|
|
1772
|
+
action: filter.action
|
|
1773
|
+
};
|
|
1774
|
+
this.filters.set(created.id, created);
|
|
1775
|
+
return copyFilter(created);
|
|
1776
|
+
}
|
|
1777
|
+
async deleteFilter(id) {
|
|
1778
|
+
if (!this.filters.delete(id)) {
|
|
1779
|
+
throw new Error(`Demo filter not found: ${id}`);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
function createDemoTransport(dataset) {
|
|
1784
|
+
return new DemoTransport(dataset);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// src/core/demo/seed.ts
|
|
1788
|
+
var DEMO_ACCOUNT_EMAIL = "demo@example.com";
|
|
1789
|
+
var USER_LABELS = {
|
|
1790
|
+
receipts: "Label_1",
|
|
1791
|
+
awsAlerts: "Label_2",
|
|
1792
|
+
important: "Label_3"
|
|
1793
|
+
};
|
|
1794
|
+
var SYSTEM_LABELS = [
|
|
1795
|
+
{ id: "INBOX", name: "Inbox", type: "system" },
|
|
1796
|
+
{ id: "UNREAD", name: "Unread", type: "system" },
|
|
1797
|
+
{ id: "STARRED", name: "Starred", type: "system" },
|
|
1798
|
+
{ id: "SENT", name: "Sent", type: "system" },
|
|
1799
|
+
{ id: "DRAFT", name: "Drafts", type: "system" },
|
|
1800
|
+
{ id: "SPAM", name: "Spam", type: "system" },
|
|
1801
|
+
{ id: "TRASH", name: "Trash", type: "system" },
|
|
1802
|
+
{ id: "CATEGORY_UPDATES", name: "Updates", type: "system" },
|
|
1803
|
+
{ id: "CATEGORY_PROMOTIONS", name: "Promotions", type: "system" }
|
|
1804
|
+
];
|
|
1805
|
+
var DEMO_LABELS = [
|
|
1806
|
+
...SYSTEM_LABELS,
|
|
1807
|
+
{ id: USER_LABELS.receipts, name: "Receipts", type: "user" },
|
|
1808
|
+
{ id: USER_LABELS.awsAlerts, name: "AWS-Alerts", type: "user" },
|
|
1809
|
+
{ id: USER_LABELS.important, name: "Important", type: "user" }
|
|
1810
|
+
];
|
|
1811
|
+
var SENDERS = [
|
|
1812
|
+
{ key: "github", name: "GitHub", email: "notifications@github.com", count: 25, unread: 8 },
|
|
1813
|
+
{ key: "stripe", name: "Stripe", email: "receipts@stripe.com", count: 12, unread: 0 },
|
|
1814
|
+
{ key: "aws", name: "AWS Notifications", email: "no-reply@amazonaws.com", count: 18, unread: 14 },
|
|
1815
|
+
{ key: "vercel", name: "Vercel", email: "notifications@vercel.com", count: 10, unread: 7 },
|
|
1816
|
+
{ key: "linear", name: "Linear", email: "notifications@linear.app", count: 15, unread: 3 },
|
|
1817
|
+
{ key: "alice", name: "Alice Chen", email: "alice.chen@example.com", count: 8, unread: 2 },
|
|
1818
|
+
{ key: "bob", name: "Bob Martinez", email: "bob.martinez@example.com", count: 6, unread: 1 },
|
|
1819
|
+
{ key: "newsletter", name: "Newsletter Weekly", email: "digest@newsletterweekly.com", count: 14, unread: 12, hasListUnsubscribe: true },
|
|
1820
|
+
{ key: "hn", name: "Hacker News Digest", email: "hn@newsletters.hackernews.com", count: 12, unread: 10, hasListUnsubscribe: true },
|
|
1821
|
+
{ key: "paypal", name: "PayPal", email: "service@paypal.com", count: 8, unread: 0 },
|
|
1822
|
+
{ key: "shopify", name: "Shopify", email: "no-reply@shopify.com", count: 6, unread: 0 },
|
|
1823
|
+
{ key: "docker", name: "Docker Hub", email: "noreply@docker.com", count: 5, unread: 4 },
|
|
1824
|
+
{ key: "sarah", name: "Sarah Kim", email: "sarah.kim@example.com", count: 4, unread: 1 },
|
|
1825
|
+
{ key: "npm", name: "npm", email: "support@npmjs.com", count: 4, unread: 3 },
|
|
1826
|
+
{ key: "slack", name: "Slack", email: "notification@slack.com", count: 3, unread: 2 }
|
|
1827
|
+
];
|
|
1828
|
+
var PRIORITY_SEQUENCE = [
|
|
1829
|
+
"github",
|
|
1830
|
+
"aws",
|
|
1831
|
+
"stripe",
|
|
1832
|
+
"alice",
|
|
1833
|
+
"linear",
|
|
1834
|
+
"github",
|
|
1835
|
+
"newsletter",
|
|
1836
|
+
"stripe",
|
|
1837
|
+
"vercel",
|
|
1838
|
+
"hn",
|
|
1839
|
+
"bob",
|
|
1840
|
+
"github"
|
|
1841
|
+
];
|
|
1842
|
+
function createRng(seed) {
|
|
1843
|
+
let state = seed >>> 0;
|
|
1844
|
+
return () => {
|
|
1845
|
+
state = state * 1664525 + 1013904223 >>> 0;
|
|
1846
|
+
return state / 4294967296;
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
function encodeBase64Url(value) {
|
|
1850
|
+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
1851
|
+
}
|
|
1852
|
+
function buildTimestamps(now, total) {
|
|
1853
|
+
const timestamps = [];
|
|
1854
|
+
const blocks = [
|
|
1855
|
+
{ count: 10, startHours: 0.5, endHours: 23 },
|
|
1856
|
+
{ count: 15, startHours: 26, endHours: 72 },
|
|
1857
|
+
{ count: 30, startHours: 74, endHours: 168 },
|
|
1858
|
+
{ count: total - 55, startHours: 170, endHours: 720 }
|
|
1859
|
+
];
|
|
1860
|
+
for (const block of blocks) {
|
|
1861
|
+
for (let index = 0; index < block.count; index += 1) {
|
|
1862
|
+
const ratio = block.count === 1 ? 0 : index / (block.count - 1);
|
|
1863
|
+
const offsetHours = block.startHours + ratio * (block.endHours - block.startHours);
|
|
1864
|
+
timestamps.push(now - Math.round(offsetHours * 60 * 60 * 1e3));
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return timestamps.sort((a, b) => b - a);
|
|
1868
|
+
}
|
|
1869
|
+
function chooseSender(slotIndex, remaining, rng) {
|
|
1870
|
+
const preferred = PRIORITY_SEQUENCE[slotIndex];
|
|
1871
|
+
if (preferred && (remaining.get(preferred) || 0) > 0) {
|
|
1872
|
+
remaining.set(preferred, (remaining.get(preferred) || 0) - 1);
|
|
1873
|
+
return preferred;
|
|
1874
|
+
}
|
|
1875
|
+
const weighted = [...remaining.entries()].filter(([, count]) => count > 0);
|
|
1876
|
+
const totalWeight = weighted.reduce((sum, [, count]) => sum + count, 0);
|
|
1877
|
+
let cursor = rng() * totalWeight;
|
|
1878
|
+
for (const [key, count] of weighted) {
|
|
1879
|
+
cursor -= count;
|
|
1880
|
+
if (cursor <= 0) {
|
|
1881
|
+
remaining.set(key, count - 1);
|
|
1882
|
+
return key;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
const fallback = weighted[weighted.length - 1]?.[0];
|
|
1886
|
+
if (!fallback) {
|
|
1887
|
+
throw new Error("Demo sender pool exhausted unexpectedly.");
|
|
1888
|
+
}
|
|
1889
|
+
remaining.set(fallback, (remaining.get(fallback) || 1) - 1);
|
|
1890
|
+
return fallback;
|
|
1891
|
+
}
|
|
1892
|
+
function getSubject(senderKey, sequence) {
|
|
1893
|
+
switch (senderKey) {
|
|
1894
|
+
case "github":
|
|
1895
|
+
return [
|
|
1896
|
+
`Re: [acme/inboxctl] Fix race condition in worker pool (#12${sequence + 1})`,
|
|
1897
|
+
`[acme/inboxctl] New issue: Memory leak in cache layer (#23${sequence + 1})`,
|
|
1898
|
+
`[acme/platform] Review requested on PR #45${sequence + 1}`,
|
|
1899
|
+
`Re: [acme/inboxctl] TUI search pagination follow-up (#34${sequence + 1})`
|
|
1900
|
+
][sequence % 4];
|
|
1901
|
+
case "stripe":
|
|
1902
|
+
return [
|
|
1903
|
+
"Your receipt from Acme Corp - $49.00",
|
|
1904
|
+
"Payment to DigitalOcean - $12.00",
|
|
1905
|
+
"Your receipt from Render, Inc. - $29.00",
|
|
1906
|
+
"Invoice paid: GitHub Team - $9.00"
|
|
1907
|
+
][sequence % 4];
|
|
1908
|
+
case "aws":
|
|
1909
|
+
return [
|
|
1910
|
+
"AWS Billing Alert: Your estimated charges exceed $50",
|
|
1911
|
+
"Amazon EC2 Maintenance Notification",
|
|
1912
|
+
"AWS Trusted Advisor weekly summary",
|
|
1913
|
+
"Amazon RDS performance insights available"
|
|
1914
|
+
][sequence % 4];
|
|
1915
|
+
case "vercel":
|
|
1916
|
+
return [
|
|
1917
|
+
"Deployment completed for inboxctl-web",
|
|
1918
|
+
"Build failed for docs-preview",
|
|
1919
|
+
"Preview ready for PR #184",
|
|
1920
|
+
"Your project has a new domain alias"
|
|
1921
|
+
][sequence % 4];
|
|
1922
|
+
case "linear":
|
|
1923
|
+
return [
|
|
1924
|
+
"Issue assigned: PROJ-142 Implement retry logic",
|
|
1925
|
+
"Comment on PROJ-138",
|
|
1926
|
+
"Cycle planning notes are ready",
|
|
1927
|
+
"Issue updated: PROJ-155 Improve inbox search"
|
|
1928
|
+
][sequence % 4];
|
|
1929
|
+
case "alice":
|
|
1930
|
+
return [
|
|
1931
|
+
"Re: Architecture review notes",
|
|
1932
|
+
"Quick question about the deploy pipeline",
|
|
1933
|
+
"Lunch tomorrow?",
|
|
1934
|
+
"Follow-up on MCP prompt defaults"
|
|
1935
|
+
][sequence % 4];
|
|
1936
|
+
case "bob":
|
|
1937
|
+
return [
|
|
1938
|
+
"Re: Sprint planning agenda",
|
|
1939
|
+
"Can you sanity-check the release notes?",
|
|
1940
|
+
"Quick update on the billing incident"
|
|
1941
|
+
][sequence % 3];
|
|
1942
|
+
case "newsletter":
|
|
1943
|
+
return [
|
|
1944
|
+
"Weekly systems brief: incidents, launches, and lessons learned",
|
|
1945
|
+
"This week in developer tools",
|
|
1946
|
+
"Five workflows worth stealing this week"
|
|
1947
|
+
][sequence % 3];
|
|
1948
|
+
case "hn":
|
|
1949
|
+
return [
|
|
1950
|
+
"Hacker News Digest: Top stories this week",
|
|
1951
|
+
"HN Digest: AI tooling, terminals, and SQLite",
|
|
1952
|
+
"Your weekly Hacker News roundup"
|
|
1953
|
+
][sequence % 3];
|
|
1954
|
+
case "paypal":
|
|
1955
|
+
return [
|
|
1956
|
+
"Receipt for your payment to Figma",
|
|
1957
|
+
"You sent a payment to Notion Labs",
|
|
1958
|
+
"Transaction receipt from OpenAI"
|
|
1959
|
+
][sequence % 3];
|
|
1960
|
+
case "shopify":
|
|
1961
|
+
return [
|
|
1962
|
+
"Order confirmation from Acme Supply",
|
|
1963
|
+
"Receipt from Shopify Billing",
|
|
1964
|
+
"Your Shopify payment receipt"
|
|
1965
|
+
][sequence % 3];
|
|
1966
|
+
case "docker":
|
|
1967
|
+
return [
|
|
1968
|
+
"Docker Hub autobuild completed",
|
|
1969
|
+
"New image vulnerability summary",
|
|
1970
|
+
"Usage summary for your Docker plan"
|
|
1971
|
+
][sequence % 3];
|
|
1972
|
+
case "sarah":
|
|
1973
|
+
return [
|
|
1974
|
+
"Re: Copy review for the setup guide",
|
|
1975
|
+
"Notes from today's product sync",
|
|
1976
|
+
"Small wording suggestion for the README"
|
|
1977
|
+
][sequence % 3];
|
|
1978
|
+
case "npm":
|
|
1979
|
+
return [
|
|
1980
|
+
"Security notice for one of your dependencies",
|
|
1981
|
+
"Your npm access token was used",
|
|
1982
|
+
"Package advisory summary"
|
|
1983
|
+
][sequence % 3];
|
|
1984
|
+
case "slack":
|
|
1985
|
+
return [
|
|
1986
|
+
"You have unread mentions in #engineering",
|
|
1987
|
+
"Slack summary for yesterday",
|
|
1988
|
+
"Reminder: action requested in #launches"
|
|
1989
|
+
][sequence % 3];
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
function buildBody(sender, subject, sequence) {
|
|
1993
|
+
switch (sender.key) {
|
|
1994
|
+
case "github":
|
|
1995
|
+
return [
|
|
1996
|
+
subject,
|
|
1997
|
+
"",
|
|
1998
|
+
"A reviewer left feedback on your pull request.",
|
|
1999
|
+
"",
|
|
2000
|
+
"File: src/core/rules/executor.ts",
|
|
2001
|
+
"",
|
|
2002
|
+
"```ts",
|
|
2003
|
+
'if (result.status === "warning") {',
|
|
2004
|
+
" retryQueue.push(item);",
|
|
2005
|
+
"}",
|
|
2006
|
+
"```",
|
|
2007
|
+
"",
|
|
2008
|
+
"Comment:",
|
|
2009
|
+
"The warning path looks good, but we may still emit duplicate audit items",
|
|
2010
|
+
"when the retry branch is hit after a partial apply. Can we move the",
|
|
2011
|
+
"audit append behind the final status resolution?",
|
|
2012
|
+
"",
|
|
2013
|
+
"Suggested follow-up:",
|
|
2014
|
+
"- add a regression test around repeated partial retries",
|
|
2015
|
+
"- confirm undo still sees a single logical execution item",
|
|
2016
|
+
"",
|
|
2017
|
+
"View pull request",
|
|
2018
|
+
"Reply to comment"
|
|
2019
|
+
].join("\n");
|
|
2020
|
+
case "stripe":
|
|
2021
|
+
return [
|
|
2022
|
+
subject,
|
|
2023
|
+
"",
|
|
2024
|
+
"Receipt summary",
|
|
2025
|
+
"----------------------------------------",
|
|
2026
|
+
`Receipt number: rcpt_demo_${sequence + 1}`,
|
|
2027
|
+
"Paid with: Visa ending in 4242",
|
|
2028
|
+
"Billing contact: demo@example.com",
|
|
2029
|
+
"",
|
|
2030
|
+
"Line items",
|
|
2031
|
+
"- Team plan........................ $29.00",
|
|
2032
|
+
"- Usage overage................... $20.00",
|
|
2033
|
+
"",
|
|
2034
|
+
"Subtotal.......................... $49.00",
|
|
2035
|
+
"Tax............................... $0.00",
|
|
2036
|
+
"Total............................. $49.00",
|
|
2037
|
+
"",
|
|
2038
|
+
"If you have questions about this charge, reply to this email and include",
|
|
2039
|
+
"the receipt number above."
|
|
2040
|
+
].join("\n");
|
|
2041
|
+
case "aws":
|
|
2042
|
+
return [
|
|
2043
|
+
subject,
|
|
2044
|
+
"",
|
|
2045
|
+
"Account: demo-platform",
|
|
2046
|
+
"Region highlights:",
|
|
2047
|
+
"- us-east-1 EC2 running hours increased by 14%",
|
|
2048
|
+
"- ap-southeast-2 RDS backup storage exceeded forecast",
|
|
2049
|
+
"- CloudWatch logs ingestion steady week over week",
|
|
2050
|
+
"",
|
|
2051
|
+
"Estimated charges",
|
|
2052
|
+
"- EC2.............................. $28.40",
|
|
2053
|
+
"- RDS.............................. $13.10",
|
|
2054
|
+
"- CloudWatch....................... $7.92",
|
|
2055
|
+
"- Data transfer.................... $3.61",
|
|
2056
|
+
"",
|
|
2057
|
+
"Recommended actions",
|
|
2058
|
+
"- review orphaned volumes",
|
|
2059
|
+
"- confirm dev database retention",
|
|
2060
|
+
"- validate expected scale-up window"
|
|
2061
|
+
].join("\n");
|
|
2062
|
+
case "newsletter":
|
|
2063
|
+
case "hn":
|
|
2064
|
+
return [
|
|
2065
|
+
subject,
|
|
2066
|
+
"",
|
|
2067
|
+
"Good morning.",
|
|
2068
|
+
"",
|
|
2069
|
+
"Here is your roundup of the links and ideas people kept passing around",
|
|
2070
|
+
"this week, from terminal UX to pricing models to practical SQLite tips.",
|
|
2071
|
+
"",
|
|
2072
|
+
"Featured reads",
|
|
2073
|
+
"- A better way to review notification-heavy inboxes",
|
|
2074
|
+
"- Why local-first tools feel faster and safer",
|
|
2075
|
+
"- The hidden costs of brittle automation",
|
|
2076
|
+
"",
|
|
2077
|
+
"Worth a skim if you missed them",
|
|
2078
|
+
"- Building humane CLIs",
|
|
2079
|
+
"- Debugging MCP integrations in practice",
|
|
2080
|
+
"- Shipping small tools without creating trust debt",
|
|
2081
|
+
"",
|
|
2082
|
+
"Read online",
|
|
2083
|
+
"Manage subscription"
|
|
2084
|
+
].join("\n");
|
|
2085
|
+
case "alice":
|
|
2086
|
+
case "bob":
|
|
2087
|
+
case "sarah":
|
|
2088
|
+
return [
|
|
2089
|
+
subject,
|
|
2090
|
+
"",
|
|
2091
|
+
`Hey team,`,
|
|
2092
|
+
"",
|
|
2093
|
+
"A couple of quick notes from my side:",
|
|
2094
|
+
"- the current flow feels solid once the first sync is done",
|
|
2095
|
+
"- the setup copy could be a little shorter",
|
|
2096
|
+
"- I like that the TUI keeps the audit story visible",
|
|
2097
|
+
"",
|
|
2098
|
+
"Can you take a look when you get a chance?",
|
|
2099
|
+
"",
|
|
2100
|
+
"Thanks,",
|
|
2101
|
+
sender.name
|
|
2102
|
+
].join("\n");
|
|
2103
|
+
default:
|
|
2104
|
+
return [
|
|
2105
|
+
subject,
|
|
2106
|
+
"",
|
|
2107
|
+
`${sender.name} generated this notification for the demo mailbox.`,
|
|
2108
|
+
"",
|
|
2109
|
+
"This message exists so the TUI detail view, sender stats, and search",
|
|
2110
|
+
"screens all have realistic data to work with."
|
|
2111
|
+
].join("\n");
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
function getBaseLabelIds(senderKey, unread, sequence) {
|
|
2115
|
+
const labels2 = ["INBOX"];
|
|
2116
|
+
if (unread) {
|
|
2117
|
+
labels2.push("UNREAD");
|
|
2118
|
+
}
|
|
2119
|
+
switch (senderKey) {
|
|
2120
|
+
case "github":
|
|
2121
|
+
case "linear":
|
|
2122
|
+
labels2.push("CATEGORY_UPDATES");
|
|
2123
|
+
break;
|
|
2124
|
+
case "stripe":
|
|
2125
|
+
case "paypal":
|
|
2126
|
+
case "shopify":
|
|
2127
|
+
labels2.push(USER_LABELS.receipts);
|
|
2128
|
+
break;
|
|
2129
|
+
case "alice":
|
|
2130
|
+
case "bob":
|
|
2131
|
+
case "sarah":
|
|
2132
|
+
if (sequence === 0) {
|
|
2133
|
+
labels2.push(USER_LABELS.important);
|
|
2134
|
+
}
|
|
2135
|
+
break;
|
|
2136
|
+
case "aws":
|
|
2137
|
+
if (sequence < 2) {
|
|
2138
|
+
labels2.push(USER_LABELS.awsAlerts);
|
|
2139
|
+
}
|
|
2140
|
+
break;
|
|
2141
|
+
case "npm":
|
|
2142
|
+
labels2.push(USER_LABELS.important);
|
|
2143
|
+
break;
|
|
2144
|
+
}
|
|
2145
|
+
return Array.from(new Set(labels2));
|
|
2146
|
+
}
|
|
2147
|
+
function buildRawMessage(record, bodyText) {
|
|
2148
|
+
return {
|
|
2149
|
+
id: record.id,
|
|
2150
|
+
threadId: record.threadId,
|
|
2151
|
+
snippet: record.snippet,
|
|
2152
|
+
internalDate: String(record.date),
|
|
2153
|
+
labelIds: record.labelIds,
|
|
2154
|
+
sizeEstimate: record.sizeEstimate,
|
|
2155
|
+
payload: {
|
|
2156
|
+
mimeType: "multipart/alternative",
|
|
2157
|
+
headers: [
|
|
2158
|
+
{ name: "From", value: `${record.fromName} <${record.fromAddress}>` },
|
|
2159
|
+
{ name: "To", value: record.toAddresses.join(", ") },
|
|
2160
|
+
{ name: "Subject", value: record.subject },
|
|
2161
|
+
{ name: "Date", value: new Date(record.date).toUTCString() },
|
|
2162
|
+
...record.listUnsubscribe ? [{ name: "List-Unsubscribe", value: record.listUnsubscribe }] : []
|
|
2163
|
+
],
|
|
2164
|
+
parts: [
|
|
2165
|
+
{
|
|
2166
|
+
mimeType: "text/plain",
|
|
2167
|
+
filename: "",
|
|
2168
|
+
body: {
|
|
2169
|
+
data: encodeBase64Url(bodyText)
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
]
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
function buildMessage(sender, sequence, date) {
|
|
2177
|
+
const subject = getSubject(sender.key, sequence);
|
|
2178
|
+
const bodyText = buildBody(sender, subject, sequence);
|
|
2179
|
+
const unread = sequence < sender.unread;
|
|
2180
|
+
const labelIds = getBaseLabelIds(sender.key, unread, sequence);
|
|
2181
|
+
const message = {
|
|
2182
|
+
id: `demo-${sender.key}-${String(sequence + 1).padStart(2, "0")}`,
|
|
2183
|
+
threadId: `thread-${sender.key}-${Math.floor(sequence / 2) + 1}`,
|
|
2184
|
+
fromAddress: sender.email,
|
|
2185
|
+
fromName: sender.name,
|
|
2186
|
+
toAddresses: [DEMO_ACCOUNT_EMAIL],
|
|
2187
|
+
subject,
|
|
2188
|
+
snippet: bodyText.split("\n").slice(0, 3).join(" ").slice(0, 160),
|
|
2189
|
+
date,
|
|
2190
|
+
isRead: !unread,
|
|
2191
|
+
isStarred: sender.key === "alice" && sequence === 0 || sender.key === "stripe" && sequence === 0 || sender.key === "github" && sequence === 0,
|
|
2192
|
+
labelIds,
|
|
2193
|
+
sizeEstimate: 1024 + sequence * 17,
|
|
2194
|
+
hasAttachments: sender.key === "stripe" || sender.key === "aws",
|
|
2195
|
+
listUnsubscribe: sender.hasListUnsubscribe ? `<mailto:unsubscribe@${sender.email.split("@")[1]}>` : null
|
|
2196
|
+
};
|
|
2197
|
+
return {
|
|
2198
|
+
senderKey: sender.key,
|
|
2199
|
+
message,
|
|
2200
|
+
rawMessage: buildRawMessage(message, bodyText),
|
|
2201
|
+
bodyText
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
function makeRuleRecords(now) {
|
|
2205
|
+
return [
|
|
2206
|
+
{
|
|
2207
|
+
id: "rule-label-receipts",
|
|
2208
|
+
name: "label-receipts",
|
|
2209
|
+
description: "Label emails that look like receipts",
|
|
2210
|
+
enabled: true,
|
|
2211
|
+
yamlHash: "demo-hash-label-receipts",
|
|
2212
|
+
conditions: {
|
|
2213
|
+
operator: "OR",
|
|
2214
|
+
matchers: [
|
|
2215
|
+
{
|
|
2216
|
+
field: "from",
|
|
2217
|
+
values: ["receipts@stripe.com", "service@paypal.com", "no-reply@shopify.com"]
|
|
2218
|
+
},
|
|
2219
|
+
{
|
|
2220
|
+
field: "subject",
|
|
2221
|
+
contains: ["receipt", "invoice", "order confirmation"]
|
|
2222
|
+
}
|
|
2223
|
+
]
|
|
2224
|
+
},
|
|
2225
|
+
actions: [{ type: "label", label: "Receipts" }],
|
|
2226
|
+
priority: 30,
|
|
2227
|
+
deployedAt: now - 6 * 24 * 60 * 60 * 1e3,
|
|
2228
|
+
createdAt: now - 6 * 24 * 60 * 60 * 1e3
|
|
2229
|
+
},
|
|
2230
|
+
{
|
|
2231
|
+
id: "rule-archive-newsletters",
|
|
2232
|
+
name: "archive-newsletters",
|
|
2233
|
+
description: "Archive low-engagement newsletters",
|
|
2234
|
+
enabled: true,
|
|
2235
|
+
yamlHash: "demo-hash-archive-newsletters",
|
|
2236
|
+
conditions: {
|
|
2237
|
+
operator: "AND",
|
|
2238
|
+
matchers: [
|
|
2239
|
+
{
|
|
2240
|
+
field: "from",
|
|
2241
|
+
values: ["digest@newsletterweekly.com", "hn@newsletters.hackernews.com"]
|
|
2242
|
+
}
|
|
2243
|
+
]
|
|
2244
|
+
},
|
|
2245
|
+
actions: [{ type: "archive" }, { type: "mark_read" }],
|
|
2246
|
+
priority: 50,
|
|
2247
|
+
deployedAt: now - 5 * 24 * 60 * 60 * 1e3,
|
|
2248
|
+
createdAt: now - 5 * 24 * 60 * 60 * 1e3
|
|
2249
|
+
},
|
|
2250
|
+
{
|
|
2251
|
+
id: "rule-flag-aws-alerts",
|
|
2252
|
+
name: "flag-aws-alerts",
|
|
2253
|
+
description: "Label AWS billing and maintenance alerts",
|
|
2254
|
+
enabled: false,
|
|
2255
|
+
yamlHash: "demo-hash-flag-aws-alerts",
|
|
2256
|
+
conditions: {
|
|
2257
|
+
operator: "OR",
|
|
2258
|
+
matchers: [
|
|
2259
|
+
{
|
|
2260
|
+
field: "from",
|
|
2261
|
+
values: ["no-reply@amazonaws.com"]
|
|
2262
|
+
},
|
|
2263
|
+
{
|
|
2264
|
+
field: "subject",
|
|
2265
|
+
contains: ["billing", "maintenance"]
|
|
2266
|
+
}
|
|
2267
|
+
]
|
|
2268
|
+
},
|
|
2269
|
+
actions: [{ type: "label", label: "AWS-Alerts" }],
|
|
2270
|
+
priority: 40,
|
|
2271
|
+
deployedAt: now - 4 * 24 * 60 * 60 * 1e3,
|
|
2272
|
+
createdAt: now - 4 * 24 * 60 * 60 * 1e3
|
|
2273
|
+
}
|
|
2274
|
+
];
|
|
2275
|
+
}
|
|
2276
|
+
function pickIds(messages, senderKey, count) {
|
|
2277
|
+
return messages.filter((entry) => entry.senderKey === senderKey).slice(0, count).map((entry) => entry.message.id);
|
|
2278
|
+
}
|
|
2279
|
+
function makeExecutionRecords(now, messages) {
|
|
2280
|
+
const receiptIds = [
|
|
2281
|
+
...pickIds(messages, "stripe", 2),
|
|
2282
|
+
...pickIds(messages, "paypal", 1),
|
|
2283
|
+
...pickIds(messages, "shopify", 1)
|
|
2284
|
+
];
|
|
2285
|
+
const newsletterIds = [
|
|
2286
|
+
...pickIds(messages, "newsletter", 2),
|
|
2287
|
+
...pickIds(messages, "hn", 2)
|
|
2288
|
+
];
|
|
2289
|
+
const manualIds = [
|
|
2290
|
+
...pickIds(messages, "alice", 1),
|
|
2291
|
+
...pickIds(messages, "bob", 1),
|
|
2292
|
+
...pickIds(messages, "github", 1)
|
|
2293
|
+
];
|
|
2294
|
+
const executionRuns = [
|
|
2295
|
+
{
|
|
2296
|
+
id: "run-receipts-apply",
|
|
2297
|
+
sourceType: "rule",
|
|
2298
|
+
ruleId: "rule-label-receipts",
|
|
2299
|
+
dryRun: false,
|
|
2300
|
+
requestedActions: [{ type: "label", label: "Receipts" }],
|
|
2301
|
+
query: null,
|
|
2302
|
+
status: "applied",
|
|
2303
|
+
createdAt: now - 3 * 24 * 60 * 60 * 1e3,
|
|
2304
|
+
undoneAt: null
|
|
2305
|
+
},
|
|
2306
|
+
{
|
|
2307
|
+
id: "run-receipts-plan",
|
|
2308
|
+
sourceType: "rule",
|
|
2309
|
+
ruleId: "rule-label-receipts",
|
|
2310
|
+
dryRun: true,
|
|
2311
|
+
requestedActions: [{ type: "label", label: "Receipts" }],
|
|
2312
|
+
query: null,
|
|
2313
|
+
status: "planned",
|
|
2314
|
+
createdAt: now - 3 * 24 * 60 * 60 * 1e3 + 20 * 60 * 1e3,
|
|
2315
|
+
undoneAt: null
|
|
2316
|
+
},
|
|
2317
|
+
{
|
|
2318
|
+
id: "run-newsletters-apply",
|
|
2319
|
+
sourceType: "rule",
|
|
2320
|
+
ruleId: "rule-archive-newsletters",
|
|
2321
|
+
dryRun: false,
|
|
2322
|
+
requestedActions: [{ type: "archive" }, { type: "mark_read" }],
|
|
2323
|
+
query: null,
|
|
2324
|
+
status: "applied",
|
|
2325
|
+
createdAt: now - 24 * 60 * 60 * 1e3,
|
|
2326
|
+
undoneAt: null
|
|
2327
|
+
},
|
|
2328
|
+
{
|
|
2329
|
+
id: "run-manual-read",
|
|
2330
|
+
sourceType: "manual",
|
|
2331
|
+
ruleId: null,
|
|
2332
|
+
dryRun: false,
|
|
2333
|
+
requestedActions: [{ type: "mark_read" }],
|
|
2334
|
+
query: "label:UNREAD older_than:2d",
|
|
2335
|
+
status: "applied",
|
|
2336
|
+
createdAt: now - 6 * 60 * 60 * 1e3,
|
|
2337
|
+
undoneAt: null
|
|
2338
|
+
},
|
|
2339
|
+
{
|
|
2340
|
+
id: "run-newsletters-plan",
|
|
2341
|
+
sourceType: "rule",
|
|
2342
|
+
ruleId: "rule-archive-newsletters",
|
|
2343
|
+
dryRun: true,
|
|
2344
|
+
requestedActions: [{ type: "archive" }, { type: "mark_read" }],
|
|
2345
|
+
query: null,
|
|
2346
|
+
status: "planned",
|
|
2347
|
+
createdAt: now - 2 * 60 * 60 * 1e3,
|
|
2348
|
+
undoneAt: null
|
|
2349
|
+
}
|
|
2350
|
+
];
|
|
2351
|
+
const executionItems = [
|
|
2352
|
+
...receiptIds.map((emailId, index) => ({
|
|
2353
|
+
id: `item-receipts-apply-${index + 1}`,
|
|
2354
|
+
runId: "run-receipts-apply",
|
|
2355
|
+
emailId,
|
|
2356
|
+
status: "applied",
|
|
2357
|
+
appliedActions: [{ type: "label", label: "Receipts" }],
|
|
2358
|
+
beforeLabelIds: ["INBOX"],
|
|
2359
|
+
afterLabelIds: ["INBOX", USER_LABELS.receipts],
|
|
2360
|
+
errorMessage: null,
|
|
2361
|
+
executedAt: now - 3 * 24 * 60 * 60 * 1e3 + index * 60 * 1e3,
|
|
2362
|
+
undoneAt: null
|
|
2363
|
+
})),
|
|
2364
|
+
...receiptIds.map((emailId, index) => ({
|
|
2365
|
+
id: `item-receipts-plan-${index + 1}`,
|
|
2366
|
+
runId: "run-receipts-plan",
|
|
2367
|
+
emailId,
|
|
2368
|
+
status: "planned",
|
|
2369
|
+
appliedActions: [{ type: "label", label: "Receipts" }],
|
|
2370
|
+
beforeLabelIds: ["INBOX"],
|
|
2371
|
+
afterLabelIds: ["INBOX", USER_LABELS.receipts],
|
|
2372
|
+
errorMessage: null,
|
|
2373
|
+
executedAt: now - 3 * 24 * 60 * 60 * 1e3 + 20 * 60 * 1e3 + index * 60 * 1e3,
|
|
2374
|
+
undoneAt: null
|
|
2375
|
+
})),
|
|
2376
|
+
...newsletterIds.map((emailId, index) => ({
|
|
2377
|
+
id: `item-newsletters-apply-${index + 1}`,
|
|
2378
|
+
runId: "run-newsletters-apply",
|
|
2379
|
+
emailId,
|
|
2380
|
+
status: "applied",
|
|
2381
|
+
appliedActions: [{ type: "archive" }, { type: "mark_read" }],
|
|
2382
|
+
beforeLabelIds: ["INBOX", "UNREAD"],
|
|
2383
|
+
afterLabelIds: [],
|
|
2384
|
+
errorMessage: null,
|
|
2385
|
+
executedAt: now - 24 * 60 * 60 * 1e3 + index * 60 * 1e3,
|
|
2386
|
+
undoneAt: null
|
|
2387
|
+
})),
|
|
2388
|
+
...manualIds.map((emailId, index) => ({
|
|
2389
|
+
id: `item-manual-read-${index + 1}`,
|
|
2390
|
+
runId: "run-manual-read",
|
|
2391
|
+
emailId,
|
|
2392
|
+
status: "applied",
|
|
2393
|
+
appliedActions: [{ type: "mark_read" }],
|
|
2394
|
+
beforeLabelIds: ["INBOX", "UNREAD"],
|
|
2395
|
+
afterLabelIds: ["INBOX"],
|
|
2396
|
+
errorMessage: null,
|
|
2397
|
+
executedAt: now - 6 * 60 * 60 * 1e3 + index * 60 * 1e3,
|
|
2398
|
+
undoneAt: null
|
|
2399
|
+
})),
|
|
2400
|
+
...newsletterIds.map((emailId, index) => ({
|
|
2401
|
+
id: `item-newsletters-plan-${index + 1}`,
|
|
2402
|
+
runId: "run-newsletters-plan",
|
|
2403
|
+
emailId,
|
|
2404
|
+
status: "planned",
|
|
2405
|
+
appliedActions: [{ type: "archive" }, { type: "mark_read" }],
|
|
2406
|
+
beforeLabelIds: ["INBOX", "UNREAD"],
|
|
2407
|
+
afterLabelIds: [],
|
|
2408
|
+
errorMessage: null,
|
|
2409
|
+
executedAt: now - 2 * 60 * 60 * 1e3 + index * 60 * 1e3,
|
|
2410
|
+
undoneAt: null
|
|
2411
|
+
}))
|
|
2412
|
+
];
|
|
2413
|
+
return { executionRuns, executionItems };
|
|
2414
|
+
}
|
|
2415
|
+
function makeNewsletterRecords(messages) {
|
|
2416
|
+
const senders = [
|
|
2417
|
+
{
|
|
2418
|
+
email: "digest@newsletterweekly.com",
|
|
2419
|
+
name: "Newsletter Weekly",
|
|
2420
|
+
reason: "list-unsubscribe,high-unread"
|
|
2421
|
+
},
|
|
2422
|
+
{
|
|
2423
|
+
email: "hn@newsletters.hackernews.com",
|
|
2424
|
+
name: "Hacker News Digest",
|
|
2425
|
+
reason: "list-unsubscribe,high-unread"
|
|
2426
|
+
},
|
|
2427
|
+
{
|
|
2428
|
+
email: "no-reply@amazonaws.com",
|
|
2429
|
+
name: "AWS Notifications",
|
|
2430
|
+
reason: "noreply-pattern,high-unread"
|
|
2431
|
+
},
|
|
2432
|
+
{
|
|
2433
|
+
email: "noreply@docker.com",
|
|
2434
|
+
name: "Docker Hub",
|
|
2435
|
+
reason: "noreply-pattern"
|
|
2436
|
+
},
|
|
2437
|
+
{
|
|
2438
|
+
email: "notifications@github.com",
|
|
2439
|
+
name: "GitHub",
|
|
2440
|
+
reason: "high-volume"
|
|
2441
|
+
},
|
|
2442
|
+
{
|
|
2443
|
+
email: "notification@slack.com",
|
|
2444
|
+
name: "Slack",
|
|
2445
|
+
reason: "noreply-pattern"
|
|
2446
|
+
}
|
|
2447
|
+
];
|
|
2448
|
+
return senders.map((sender, index) => {
|
|
2449
|
+
const matching = messages.filter((entry) => entry.message.fromAddress === sender.email);
|
|
2450
|
+
return {
|
|
2451
|
+
id: `newsletter-${index + 1}`,
|
|
2452
|
+
email: sender.email,
|
|
2453
|
+
name: sender.name,
|
|
2454
|
+
messageCount: matching.length,
|
|
2455
|
+
unreadCount: matching.filter((entry) => !entry.message.isRead).length,
|
|
2456
|
+
status: "active",
|
|
2457
|
+
unsubscribeLink: matching[0]?.message.listUnsubscribe || null,
|
|
2458
|
+
detectionReason: sender.reason,
|
|
2459
|
+
firstSeen: matching[matching.length - 1]?.message.date || Date.now(),
|
|
2460
|
+
lastSeen: matching[0]?.message.date || Date.now()
|
|
2461
|
+
};
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
function buildDemoDataset(referenceNow = Date.now()) {
|
|
2465
|
+
const timestamps = buildTimestamps(referenceNow, 150);
|
|
2466
|
+
const remaining = new Map(
|
|
2467
|
+
SENDERS.map((sender) => [sender.key, sender.count])
|
|
2468
|
+
);
|
|
2469
|
+
const sequenceBySender = /* @__PURE__ */ new Map();
|
|
2470
|
+
const senderByKey = new Map(SENDERS.map((sender) => [sender.key, sender]));
|
|
2471
|
+
const rng = createRng(42);
|
|
2472
|
+
const messages = [];
|
|
2473
|
+
for (let index = 0; index < timestamps.length; index += 1) {
|
|
2474
|
+
const senderKey = chooseSender(index, remaining, rng);
|
|
2475
|
+
const sender = senderByKey.get(senderKey);
|
|
2476
|
+
if (!sender) {
|
|
2477
|
+
throw new Error(`Missing demo sender spec for ${senderKey}`);
|
|
2478
|
+
}
|
|
2479
|
+
const sequence = sequenceBySender.get(senderKey) || 0;
|
|
2480
|
+
messages.push(buildMessage(sender, sequence, timestamps[index] || referenceNow));
|
|
2481
|
+
sequenceBySender.set(senderKey, sequence + 1);
|
|
2482
|
+
}
|
|
2483
|
+
messages.sort((left, right) => right.message.date - left.message.date);
|
|
2484
|
+
const rules2 = makeRuleRecords(referenceNow);
|
|
2485
|
+
const { executionRuns, executionItems } = makeExecutionRecords(referenceNow, messages);
|
|
2486
|
+
return {
|
|
2487
|
+
accountEmail: DEMO_ACCOUNT_EMAIL,
|
|
2488
|
+
historyId: "12345678",
|
|
2489
|
+
labels: DEMO_LABELS.map((label) => ({ ...label })),
|
|
2490
|
+
messages,
|
|
2491
|
+
rules: rules2,
|
|
2492
|
+
executionRuns,
|
|
2493
|
+
executionItems,
|
|
2494
|
+
newsletters: makeNewsletterRecords(messages),
|
|
2495
|
+
filters: []
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
function seedDemoData(sqlite, referenceNow = Date.now()) {
|
|
2499
|
+
const dataset = buildDemoDataset(referenceNow);
|
|
2500
|
+
sqlite.exec(`
|
|
2501
|
+
DELETE FROM execution_items;
|
|
2502
|
+
DELETE FROM execution_runs;
|
|
2503
|
+
DELETE FROM rules;
|
|
2504
|
+
DELETE FROM newsletter_senders;
|
|
2505
|
+
DELETE FROM emails;
|
|
2506
|
+
`);
|
|
2507
|
+
const insertEmail = sqlite.prepare(`
|
|
2508
|
+
INSERT INTO emails (
|
|
2509
|
+
id, thread_id, from_address, from_name, to_addresses, subject, snippet, date,
|
|
2510
|
+
is_read, is_starred, label_ids, size_estimate, has_attachments, list_unsubscribe, synced_at
|
|
2511
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2512
|
+
`);
|
|
2513
|
+
const insertRule = sqlite.prepare(`
|
|
2514
|
+
INSERT INTO rules (
|
|
2515
|
+
id, name, description, enabled, yaml_hash, conditions, actions, priority, deployed_at, created_at
|
|
2516
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2517
|
+
`);
|
|
2518
|
+
const insertRun = sqlite.prepare(`
|
|
2519
|
+
INSERT INTO execution_runs (
|
|
2520
|
+
id, source_type, rule_id, dry_run, requested_actions, query, status, created_at, undone_at
|
|
2521
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2522
|
+
`);
|
|
2523
|
+
const insertItem = sqlite.prepare(`
|
|
2524
|
+
INSERT INTO execution_items (
|
|
2525
|
+
id, run_id, email_id, status, applied_actions, before_label_ids, after_label_ids, error_message, executed_at, undone_at
|
|
2526
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2527
|
+
`);
|
|
2528
|
+
const insertNewsletter = sqlite.prepare(`
|
|
2529
|
+
INSERT INTO newsletter_senders (
|
|
2530
|
+
id, email, name, message_count, unread_count, status, unsubscribe_link, detection_reason, first_seen, last_seen
|
|
2531
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2532
|
+
`);
|
|
2533
|
+
const transaction = sqlite.transaction(() => {
|
|
2534
|
+
for (const entry of dataset.messages) {
|
|
2535
|
+
insertEmail.run(
|
|
2536
|
+
entry.message.id,
|
|
2537
|
+
entry.message.threadId,
|
|
2538
|
+
entry.message.fromAddress,
|
|
2539
|
+
entry.message.fromName,
|
|
2540
|
+
JSON.stringify(entry.message.toAddresses),
|
|
2541
|
+
entry.message.subject,
|
|
2542
|
+
entry.message.snippet,
|
|
2543
|
+
entry.message.date,
|
|
2544
|
+
entry.message.isRead ? 1 : 0,
|
|
2545
|
+
entry.message.isStarred ? 1 : 0,
|
|
2546
|
+
JSON.stringify(entry.message.labelIds),
|
|
2547
|
+
entry.message.sizeEstimate,
|
|
2548
|
+
entry.message.hasAttachments ? 1 : 0,
|
|
2549
|
+
entry.message.listUnsubscribe,
|
|
2550
|
+
referenceNow
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
for (const rule of dataset.rules) {
|
|
2554
|
+
insertRule.run(
|
|
2555
|
+
rule.id,
|
|
2556
|
+
rule.name,
|
|
2557
|
+
rule.description,
|
|
2558
|
+
rule.enabled ? 1 : 0,
|
|
2559
|
+
rule.yamlHash,
|
|
2560
|
+
JSON.stringify(rule.conditions),
|
|
2561
|
+
JSON.stringify(rule.actions),
|
|
2562
|
+
rule.priority,
|
|
2563
|
+
rule.deployedAt,
|
|
2564
|
+
rule.createdAt
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
for (const run of dataset.executionRuns) {
|
|
2568
|
+
insertRun.run(
|
|
2569
|
+
run.id,
|
|
2570
|
+
run.sourceType,
|
|
2571
|
+
run.ruleId,
|
|
2572
|
+
run.dryRun ? 1 : 0,
|
|
2573
|
+
JSON.stringify(run.requestedActions),
|
|
2574
|
+
run.query,
|
|
2575
|
+
run.status,
|
|
2576
|
+
run.createdAt,
|
|
2577
|
+
run.undoneAt
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
for (const item of dataset.executionItems) {
|
|
2581
|
+
insertItem.run(
|
|
2582
|
+
item.id,
|
|
2583
|
+
item.runId,
|
|
2584
|
+
item.emailId,
|
|
2585
|
+
item.status,
|
|
2586
|
+
JSON.stringify(item.appliedActions),
|
|
2587
|
+
JSON.stringify(item.beforeLabelIds),
|
|
2588
|
+
JSON.stringify(item.afterLabelIds),
|
|
2589
|
+
item.errorMessage,
|
|
2590
|
+
item.executedAt,
|
|
2591
|
+
item.undoneAt
|
|
2592
|
+
);
|
|
2593
|
+
}
|
|
2594
|
+
for (const newsletter of dataset.newsletters) {
|
|
2595
|
+
insertNewsletter.run(
|
|
2596
|
+
newsletter.id,
|
|
2597
|
+
newsletter.email,
|
|
2598
|
+
newsletter.name,
|
|
2599
|
+
newsletter.messageCount,
|
|
2600
|
+
newsletter.unreadCount,
|
|
2601
|
+
newsletter.status,
|
|
2602
|
+
newsletter.unsubscribeLink,
|
|
2603
|
+
newsletter.detectionReason,
|
|
2604
|
+
newsletter.firstSeen,
|
|
2605
|
+
newsletter.lastSeen
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
sqlite.prepare(
|
|
2609
|
+
`
|
|
2610
|
+
UPDATE sync_state
|
|
2611
|
+
SET account_email = ?,
|
|
2612
|
+
history_id = ?,
|
|
2613
|
+
last_full_sync = ?,
|
|
2614
|
+
last_incremental_sync = ?,
|
|
2615
|
+
total_messages = ?,
|
|
2616
|
+
full_sync_cursor = NULL,
|
|
2617
|
+
full_sync_processed = 0,
|
|
2618
|
+
full_sync_total = 0
|
|
2619
|
+
WHERE id = 1
|
|
2620
|
+
`
|
|
2621
|
+
).run(
|
|
2622
|
+
dataset.accountEmail,
|
|
2623
|
+
dataset.historyId,
|
|
2624
|
+
referenceNow - 2 * 60 * 60 * 1e3,
|
|
2625
|
+
referenceNow - 5 * 60 * 1e3,
|
|
2626
|
+
dataset.messages.length
|
|
2627
|
+
);
|
|
2628
|
+
});
|
|
2629
|
+
transaction();
|
|
2630
|
+
return dataset;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
// src/core/demo/index.ts
|
|
2634
|
+
var DEMO_ENV_KEYS = [
|
|
2635
|
+
"INBOXCTL_DATA_DIR",
|
|
2636
|
+
"INBOXCTL_DB_PATH",
|
|
2637
|
+
"INBOXCTL_RULES_DIR",
|
|
2638
|
+
"INBOXCTL_TOKENS_PATH",
|
|
2639
|
+
"GOOGLE_CLIENT_ID",
|
|
2640
|
+
"GOOGLE_CLIENT_SECRET",
|
|
2641
|
+
"GOOGLE_REDIRECT_URI"
|
|
2642
|
+
];
|
|
2643
|
+
async function seedDemoRulesDirectory(rulesDir) {
|
|
2644
|
+
const sourcePath = fileURLToPath(
|
|
2645
|
+
new URL("../rules/example.yaml", import.meta.url)
|
|
2646
|
+
);
|
|
2647
|
+
const targetPath = join(rulesDir, "example.yaml");
|
|
2648
|
+
await mkdir(rulesDir, { recursive: true });
|
|
2649
|
+
await writeFile(targetPath, await readFile(sourcePath, "utf8"), "utf8");
|
|
2650
|
+
}
|
|
2651
|
+
async function runDemoSession() {
|
|
2652
|
+
const previousEnv = Object.fromEntries(
|
|
2653
|
+
DEMO_ENV_KEYS.map((key) => [key, process.env[key]])
|
|
2654
|
+
);
|
|
2655
|
+
const tempDir = await mkdtemp(join(tmpdir(), "inboxctl-demo-"));
|
|
2656
|
+
const dbPath = join(tempDir, "demo.db");
|
|
2657
|
+
const rulesDir = join(tempDir, "rules");
|
|
2658
|
+
const tokensPath = join(tempDir, "tokens.json");
|
|
2659
|
+
const referenceNow = Date.now();
|
|
2660
|
+
process.env.INBOXCTL_DATA_DIR = tempDir;
|
|
2661
|
+
process.env.INBOXCTL_DB_PATH = dbPath;
|
|
2662
|
+
process.env.INBOXCTL_RULES_DIR = rulesDir;
|
|
2663
|
+
process.env.INBOXCTL_TOKENS_PATH = tokensPath;
|
|
2664
|
+
process.env.GOOGLE_CLIENT_ID = "demo-client-id";
|
|
2665
|
+
process.env.GOOGLE_CLIENT_SECRET = "demo-client-secret";
|
|
2666
|
+
process.env.GOOGLE_REDIRECT_URI = "http://127.0.0.1:3456/callback";
|
|
2667
|
+
try {
|
|
2668
|
+
await mkdir(dirname(tokensPath), { recursive: true });
|
|
2669
|
+
await seedDemoRulesDirectory(rulesDir);
|
|
2670
|
+
initializeDb(dbPath);
|
|
2671
|
+
const sqlite = getSqlite(dbPath);
|
|
2672
|
+
const dataset = seedDemoData(sqlite, referenceNow);
|
|
2673
|
+
await saveTokens(tokensPath, {
|
|
2674
|
+
accessToken: "demo-access-token",
|
|
2675
|
+
refreshToken: "demo-refresh-token",
|
|
2676
|
+
expiryDate: referenceNow + 365 * 24 * 60 * 60 * 1e3,
|
|
2677
|
+
email: DEMO_ACCOUNT_EMAIL
|
|
2678
|
+
});
|
|
2679
|
+
const config = loadConfig();
|
|
2680
|
+
setGmailTransportOverride(config.dataDir, createDemoTransport(dataset));
|
|
2681
|
+
await syncLabels({ config, forceRefresh: true });
|
|
2682
|
+
await startTuiApp({ noSync: true });
|
|
2683
|
+
} finally {
|
|
2684
|
+
try {
|
|
2685
|
+
clearGmailTransportOverride(tempDir);
|
|
2686
|
+
closeDb(dbPath);
|
|
2687
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
2688
|
+
} finally {
|
|
2689
|
+
for (const key of DEMO_ENV_KEYS) {
|
|
2690
|
+
const value = previousEnv[key];
|
|
2691
|
+
if (value === void 0) {
|
|
2692
|
+
delete process.env[key];
|
|
2693
|
+
} else {
|
|
2694
|
+
process.env[key] = value;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// src/core/setup/setup.ts
|
|
2702
|
+
import {
|
|
2703
|
+
cancel,
|
|
2704
|
+
confirm,
|
|
2705
|
+
intro,
|
|
2706
|
+
isCancel,
|
|
2707
|
+
log,
|
|
2708
|
+
note,
|
|
2709
|
+
outro,
|
|
2710
|
+
password,
|
|
2711
|
+
spinner,
|
|
2712
|
+
text
|
|
2713
|
+
} from "@clack/prompts";
|
|
2714
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2715
|
+
import { resolve } from "path";
|
|
2716
|
+
|
|
2717
|
+
// src/core/setup/gcloud.ts
|
|
2718
|
+
import { spawnSync } from "child_process";
|
|
2719
|
+
import open from "open";
|
|
2720
|
+
function runGcloud(args, inheritStdio = false) {
|
|
2721
|
+
const result = spawnSync("gcloud", args, {
|
|
2722
|
+
encoding: "utf8",
|
|
2723
|
+
stdio: inheritStdio ? "inherit" : "pipe"
|
|
2724
|
+
});
|
|
2725
|
+
if (result.error) {
|
|
2726
|
+
return {
|
|
2727
|
+
success: false,
|
|
2728
|
+
stdout: "",
|
|
2729
|
+
stderr: "",
|
|
2730
|
+
error: result.error.message
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
return {
|
|
2734
|
+
success: result.status === 0,
|
|
2735
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
2736
|
+
stderr: typeof result.stderr === "string" ? result.stderr : ""
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
function normalizeValue(value) {
|
|
2740
|
+
const trimmed = value.trim();
|
|
2741
|
+
if (!trimmed || trimmed === "(unset)") {
|
|
2742
|
+
return null;
|
|
2743
|
+
}
|
|
2744
|
+
return trimmed;
|
|
2745
|
+
}
|
|
2746
|
+
function checkGcloudInstalled() {
|
|
2747
|
+
return runGcloud(["--version"]).success;
|
|
2748
|
+
}
|
|
2749
|
+
function getGcloudActiveAccount() {
|
|
2750
|
+
const result = runGcloud([
|
|
2751
|
+
"auth",
|
|
2752
|
+
"list",
|
|
2753
|
+
"--filter=status:ACTIVE",
|
|
2754
|
+
"--format=value(account)"
|
|
2755
|
+
]);
|
|
2756
|
+
if (!result.success) {
|
|
2757
|
+
return null;
|
|
2758
|
+
}
|
|
2759
|
+
return normalizeValue(result.stdout);
|
|
2760
|
+
}
|
|
2761
|
+
function checkGcloudAuthenticated() {
|
|
2762
|
+
return getGcloudActiveAccount() !== null;
|
|
2763
|
+
}
|
|
2764
|
+
function runGcloudAuthLogin() {
|
|
2765
|
+
const result = runGcloud(["auth", "login"], true);
|
|
2766
|
+
return result.success ? { success: true } : { success: false, error: result.error || result.stderr || "gcloud auth login failed." };
|
|
2767
|
+
}
|
|
2768
|
+
function getGcloudProject() {
|
|
2769
|
+
const result = runGcloud(["config", "get-value", "project", "--quiet"]);
|
|
2770
|
+
if (!result.success) {
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
return normalizeValue(result.stdout);
|
|
2774
|
+
}
|
|
2775
|
+
function enableApi(projectId, api) {
|
|
2776
|
+
const result = runGcloud(["services", "enable", api, "--project", projectId]);
|
|
2777
|
+
if (result.success) {
|
|
2778
|
+
return { success: true };
|
|
2779
|
+
}
|
|
2780
|
+
return {
|
|
2781
|
+
success: false,
|
|
2782
|
+
error: normalizeValue(result.stderr) || normalizeValue(result.stdout) || result.error || `Failed to enable ${api}.`
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
function openBrowser(url) {
|
|
2786
|
+
void open(url, {
|
|
2787
|
+
wait: false,
|
|
2788
|
+
newInstance: false
|
|
2789
|
+
}).catch(() => {
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// src/core/setup/credentials.ts
|
|
2794
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2795
|
+
import { dirname as dirname2 } from "path";
|
|
2796
|
+
function readStoredConfig(configPath) {
|
|
2797
|
+
if (!existsSync(configPath)) {
|
|
2798
|
+
return {};
|
|
2799
|
+
}
|
|
2800
|
+
const raw = readFileSync(configPath, "utf8");
|
|
2801
|
+
const parsed = JSON.parse(raw);
|
|
2802
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2803
|
+
throw new Error(`Invalid config file at ${configPath}: expected JSON object`);
|
|
2804
|
+
}
|
|
2805
|
+
return parsed;
|
|
2806
|
+
}
|
|
2807
|
+
function writeGoogleCredentials(credentials, configPath = getConfigFilePath(getDefaultDataDir())) {
|
|
2808
|
+
const current = readStoredConfig(configPath);
|
|
2809
|
+
ensureDir(dirname2(configPath));
|
|
2810
|
+
const next = {
|
|
2811
|
+
...current,
|
|
2812
|
+
google: {
|
|
2813
|
+
...current.google || {},
|
|
2814
|
+
clientId: credentials.clientId,
|
|
2815
|
+
clientSecret: credentials.clientSecret,
|
|
2816
|
+
redirectUri: credentials.redirectUri || current.google?.redirectUri || DEFAULT_GOOGLE_REDIRECT_URI
|
|
2817
|
+
}
|
|
2818
|
+
};
|
|
2819
|
+
writeFileSync(configPath, `${JSON.stringify(next, null, 2)}
|
|
2820
|
+
`, "utf8");
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// src/core/setup/setup.ts
|
|
2824
|
+
var GMAIL_API = "gmail.googleapis.com";
|
|
2825
|
+
var AUDIENCE_URL = "https://console.cloud.google.com/auth/audience";
|
|
2826
|
+
var CREDENTIALS_URL = "https://console.cloud.google.com/apis/credentials";
|
|
2827
|
+
function withProject(url, projectId) {
|
|
2828
|
+
return projectId ? `${url}?project=${encodeURIComponent(projectId)}` : url;
|
|
2829
|
+
}
|
|
2830
|
+
function validateProjectId(value) {
|
|
2831
|
+
if (!value?.trim()) {
|
|
2832
|
+
return "Enter a Google Cloud project ID.";
|
|
2833
|
+
}
|
|
2834
|
+
return void 0;
|
|
2835
|
+
}
|
|
2836
|
+
function validateClientId(value) {
|
|
2837
|
+
if (!value?.trim()) {
|
|
2838
|
+
return "Enter a Google OAuth client ID.";
|
|
2839
|
+
}
|
|
2840
|
+
if (!value.includes(".apps.googleusercontent.com")) {
|
|
2841
|
+
return "Google client IDs usually end with .apps.googleusercontent.com.";
|
|
2842
|
+
}
|
|
2843
|
+
return void 0;
|
|
2844
|
+
}
|
|
2845
|
+
function validateClientSecret(value) {
|
|
2846
|
+
if (!value?.trim()) {
|
|
2847
|
+
return "Enter a Google OAuth client secret.";
|
|
2848
|
+
}
|
|
2849
|
+
return void 0;
|
|
2850
|
+
}
|
|
2851
|
+
function validateRedirectUri(value) {
|
|
2852
|
+
if (!value?.trim()) {
|
|
2853
|
+
return "Enter a redirect URI.";
|
|
2854
|
+
}
|
|
2855
|
+
try {
|
|
2856
|
+
const parsed = new URL(value);
|
|
2857
|
+
if (!parsed.protocol.startsWith("http")) {
|
|
2858
|
+
return "Redirect URI must start with http:// or https://.";
|
|
2859
|
+
}
|
|
2860
|
+
} catch {
|
|
2861
|
+
return "Enter a valid redirect URI.";
|
|
2862
|
+
}
|
|
2863
|
+
return void 0;
|
|
2864
|
+
}
|
|
2865
|
+
async function promptValue(promise, cancelMessage) {
|
|
2866
|
+
const value = await promise;
|
|
2867
|
+
if (isCancel(value)) {
|
|
2868
|
+
cancel(cancelMessage);
|
|
2869
|
+
return null;
|
|
2870
|
+
}
|
|
2871
|
+
return value;
|
|
2872
|
+
}
|
|
2873
|
+
function printConsentScreenHelp(projectId) {
|
|
2874
|
+
note(
|
|
2875
|
+
[
|
|
2876
|
+
"In Google Cloud Console, go to Google Auth Platform > Audience.",
|
|
2877
|
+
"",
|
|
2878
|
+
"1. Set User type:",
|
|
2879
|
+
" External \u2014 required for personal Gmail (@gmail.com).",
|
|
2880
|
+
" Internal \u2014 only if your account is part of a Google Workspace org.",
|
|
2881
|
+
" Personal Gmail accounts cannot use Internal.",
|
|
2882
|
+
"",
|
|
2883
|
+
"2. Complete the Branding section (app name, support email, etc.)",
|
|
2884
|
+
"",
|
|
2885
|
+
'3. Publishing status should be "Testing" (this is the default).',
|
|
2886
|
+
"",
|
|
2887
|
+
"4. IMPORTANT: Scroll down to Test Users and click + Add Users.",
|
|
2888
|
+
" Add your Gmail address here. Without this you will get",
|
|
2889
|
+
' "Error 403: access_denied" when signing in.',
|
|
2890
|
+
"",
|
|
2891
|
+
`Console: ${withProject(AUDIENCE_URL, projectId)}`
|
|
2892
|
+
].join("\n"),
|
|
2893
|
+
"OAuth Consent Screen (Audience)"
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
2896
|
+
function printCredentialHelp(projectId, redirectUri) {
|
|
2897
|
+
note(
|
|
2898
|
+
[
|
|
2899
|
+
"Create an OAuth 2.0 client with these settings:",
|
|
2900
|
+
"",
|
|
2901
|
+
'1. Application type: "Web application"',
|
|
2902
|
+
'2. Name: "inboxctl"',
|
|
2903
|
+
`3. Authorized redirect URI: ${redirectUri}`,
|
|
2904
|
+
"4. Click Create and copy the Client ID and Client Secret",
|
|
2905
|
+
"",
|
|
2906
|
+
`Console: ${withProject(CREDENTIALS_URL, projectId)}`
|
|
2907
|
+
].join("\n"),
|
|
2908
|
+
"OAuth Credentials"
|
|
2909
|
+
);
|
|
2910
|
+
}
|
|
2911
|
+
function findEnvFile() {
|
|
2912
|
+
const candidates = [
|
|
2913
|
+
resolve(process.cwd(), ".env"),
|
|
2914
|
+
resolve(process.cwd(), ".env.local")
|
|
2915
|
+
];
|
|
2916
|
+
return candidates.find((p) => existsSync2(p)) || null;
|
|
2917
|
+
}
|
|
2918
|
+
function updateEnvFile(envPath, credentials) {
|
|
2919
|
+
let content = readFileSync2(envPath, "utf8");
|
|
2920
|
+
const replacements = [
|
|
2921
|
+
["GOOGLE_CLIENT_ID", credentials.clientId],
|
|
2922
|
+
["GOOGLE_CLIENT_SECRET", credentials.clientSecret],
|
|
2923
|
+
["GOOGLE_REDIRECT_URI", credentials.redirectUri]
|
|
2924
|
+
];
|
|
2925
|
+
for (const [key, value] of replacements) {
|
|
2926
|
+
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
2927
|
+
if (regex.test(content)) {
|
|
2928
|
+
content = content.replace(regex, `${key}=${value}`);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
writeFileSync2(envPath, content, "utf8");
|
|
2932
|
+
}
|
|
2933
|
+
async function handleEnvironmentOverrides(credentials) {
|
|
2934
|
+
const overrides = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REDIRECT_URI"].filter((key) => Boolean(process.env[key]));
|
|
2935
|
+
if (overrides.length === 0) {
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
const envPath = findEnvFile();
|
|
2939
|
+
if (envPath && credentials) {
|
|
2940
|
+
log.warn(
|
|
2941
|
+
`Your .env file (${envPath}) has credentials that override config.json.`
|
|
2942
|
+
);
|
|
2943
|
+
const shouldUpdate = await promptValue(
|
|
2944
|
+
confirm({
|
|
2945
|
+
message: "Update .env with the new credentials too?",
|
|
2946
|
+
initialValue: true
|
|
2947
|
+
}),
|
|
2948
|
+
"Setup cancelled."
|
|
2949
|
+
);
|
|
2950
|
+
if (shouldUpdate) {
|
|
2951
|
+
updateEnvFile(envPath, credentials);
|
|
2952
|
+
process.env.GOOGLE_CLIENT_ID = credentials.clientId;
|
|
2953
|
+
process.env.GOOGLE_CLIENT_SECRET = credentials.clientSecret;
|
|
2954
|
+
process.env.GOOGLE_REDIRECT_URI = credentials.redirectUri;
|
|
2955
|
+
log.success(`.env updated at ${envPath}`);
|
|
2956
|
+
} else {
|
|
2957
|
+
note(
|
|
2958
|
+
`Your .env values still override config.json for: ${overrides.join(", ")}.
|
|
2959
|
+
If auth fails, update or remove those overrides in your .env file.`,
|
|
2960
|
+
"Environment Overrides"
|
|
2961
|
+
);
|
|
2962
|
+
}
|
|
2963
|
+
} else {
|
|
2964
|
+
note(
|
|
2965
|
+
`Environment variables override config.json for: ${overrides.join(", ")}.
|
|
2966
|
+
If the wizard's saved credentials do not take effect, update or remove those overrides first.`,
|
|
2967
|
+
"Environment Overrides"
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
function verifyOAuthSetup() {
|
|
2972
|
+
const config = loadConfig();
|
|
2973
|
+
const readiness = getOAuthReadiness(config);
|
|
2974
|
+
if (!readiness.ready) {
|
|
2975
|
+
throw new Error(`OAuth credentials are still incomplete: ${readiness.missing.join(", ")}`);
|
|
2976
|
+
}
|
|
2977
|
+
const client = createOAuthClient(config);
|
|
2978
|
+
client.generateAuthUrl({
|
|
2979
|
+
access_type: "offline",
|
|
2980
|
+
scope: GMAIL_SCOPES
|
|
2981
|
+
});
|
|
2982
|
+
}
|
|
2983
|
+
async function runSetupWizard(options = {}) {
|
|
2984
|
+
intro("inboxctl Setup Wizard");
|
|
2985
|
+
const initialConfig = loadConfig();
|
|
2986
|
+
const configPath = getConfigFilePath(initialConfig.dataDir);
|
|
2987
|
+
const existingGoogleStatus = getGoogleCredentialStatus(initialConfig);
|
|
2988
|
+
let useGcloud = !options.skipGcloud;
|
|
2989
|
+
let projectId = options.project || null;
|
|
2990
|
+
let credentialsUpdated = false;
|
|
2991
|
+
let gmailApiEnabled = false;
|
|
2992
|
+
let authenticatedEmail = null;
|
|
2993
|
+
if (useGcloud) {
|
|
2994
|
+
const gcloudSpinner = spinner();
|
|
2995
|
+
gcloudSpinner.start("Checking gcloud CLI...");
|
|
2996
|
+
if (!checkGcloudInstalled()) {
|
|
2997
|
+
gcloudSpinner.error("gcloud CLI not found.");
|
|
2998
|
+
note(
|
|
2999
|
+
"Install the Google Cloud CLI to let inboxctl enable the Gmail API for you.\nYou can still continue with the manual credential flow if you prefer.",
|
|
3000
|
+
"gcloud CLI"
|
|
3001
|
+
);
|
|
3002
|
+
const continueManual = await promptValue(
|
|
3003
|
+
confirm({
|
|
3004
|
+
message: "Continue with manual setup only?",
|
|
3005
|
+
initialValue: true
|
|
3006
|
+
}),
|
|
3007
|
+
"Setup cancelled."
|
|
3008
|
+
);
|
|
3009
|
+
if (!continueManual) {
|
|
3010
|
+
cancel("Setup cancelled.");
|
|
3011
|
+
return {
|
|
3012
|
+
completed: false,
|
|
3013
|
+
configPath,
|
|
3014
|
+
projectId,
|
|
3015
|
+
credentialsUpdated,
|
|
3016
|
+
usedGcloud: false,
|
|
3017
|
+
authenticatedEmail
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
useGcloud = false;
|
|
3021
|
+
} else {
|
|
3022
|
+
const account = getGcloudActiveAccount();
|
|
3023
|
+
gcloudSpinner.stop(account ? `gcloud CLI found (${account})` : "gcloud CLI found");
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
if (useGcloud && !checkGcloudAuthenticated()) {
|
|
3027
|
+
log.warn("gcloud is installed but not authenticated.");
|
|
3028
|
+
const shouldLogin = await promptValue(
|
|
3029
|
+
confirm({
|
|
3030
|
+
message: "Run `gcloud auth login` now?",
|
|
3031
|
+
initialValue: true
|
|
3032
|
+
}),
|
|
3033
|
+
"Setup cancelled."
|
|
3034
|
+
);
|
|
3035
|
+
if (!shouldLogin) {
|
|
3036
|
+
const continueManual = await promptValue(
|
|
3037
|
+
confirm({
|
|
3038
|
+
message: "Continue without gcloud and finish setup manually?",
|
|
3039
|
+
initialValue: true
|
|
3040
|
+
}),
|
|
3041
|
+
"Setup cancelled."
|
|
3042
|
+
);
|
|
3043
|
+
if (!continueManual) {
|
|
3044
|
+
cancel("Setup cancelled.");
|
|
3045
|
+
return {
|
|
3046
|
+
completed: false,
|
|
3047
|
+
configPath,
|
|
3048
|
+
projectId,
|
|
3049
|
+
credentialsUpdated,
|
|
3050
|
+
usedGcloud: false,
|
|
3051
|
+
authenticatedEmail
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
useGcloud = false;
|
|
3055
|
+
} else {
|
|
3056
|
+
const loginResult = runGcloudAuthLogin();
|
|
3057
|
+
if (!loginResult.success || !checkGcloudAuthenticated()) {
|
|
3058
|
+
throw new Error(loginResult.error || "gcloud authentication did not complete successfully.");
|
|
3059
|
+
}
|
|
3060
|
+
const account = getGcloudActiveAccount();
|
|
3061
|
+
log.success(account ? `Authenticated as ${account}` : "gcloud authentication complete.");
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
if (useGcloud) {
|
|
3065
|
+
projectId = projectId || getGcloudProject();
|
|
3066
|
+
if (!projectId) {
|
|
3067
|
+
projectId = await promptValue(
|
|
3068
|
+
text({
|
|
3069
|
+
message: "Google Cloud project ID",
|
|
3070
|
+
placeholder: "my-project-123",
|
|
3071
|
+
validate: validateProjectId
|
|
3072
|
+
}),
|
|
3073
|
+
"Setup cancelled."
|
|
3074
|
+
);
|
|
3075
|
+
} else {
|
|
3076
|
+
log.step(`Using project: ${projectId}`);
|
|
3077
|
+
}
|
|
3078
|
+
if (!projectId) {
|
|
3079
|
+
cancel("Setup cancelled.");
|
|
3080
|
+
return {
|
|
3081
|
+
completed: false,
|
|
3082
|
+
configPath,
|
|
3083
|
+
projectId: null,
|
|
3084
|
+
credentialsUpdated,
|
|
3085
|
+
usedGcloud: true,
|
|
3086
|
+
authenticatedEmail
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
const enableSpinner = spinner();
|
|
3090
|
+
enableSpinner.start("Enabling Gmail API...");
|
|
3091
|
+
const enabled = enableApi(projectId, GMAIL_API);
|
|
3092
|
+
if (!enabled.success) {
|
|
3093
|
+
enableSpinner.error(`Failed to enable Gmail API: ${enabled.error}`);
|
|
3094
|
+
const continueManual = await promptValue(
|
|
3095
|
+
confirm({
|
|
3096
|
+
message: "Continue anyway and finish the remaining setup steps manually?",
|
|
3097
|
+
initialValue: false
|
|
3098
|
+
}),
|
|
3099
|
+
"Setup cancelled."
|
|
3100
|
+
);
|
|
3101
|
+
if (!continueManual) {
|
|
3102
|
+
cancel("Setup cancelled.");
|
|
3103
|
+
return {
|
|
3104
|
+
completed: false,
|
|
3105
|
+
configPath,
|
|
3106
|
+
projectId,
|
|
3107
|
+
credentialsUpdated,
|
|
3108
|
+
usedGcloud: true,
|
|
3109
|
+
authenticatedEmail
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
} else {
|
|
3113
|
+
enableSpinner.stop("Gmail API enabled.");
|
|
3114
|
+
gmailApiEnabled = true;
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
const consentAlreadyConfigured = existingGoogleStatus.configured ? await promptValue(
|
|
3118
|
+
confirm({
|
|
3119
|
+
message: "Is the OAuth consent screen already configured for this project?",
|
|
3120
|
+
initialValue: false
|
|
3121
|
+
}),
|
|
3122
|
+
"Setup cancelled."
|
|
3123
|
+
) : false;
|
|
3124
|
+
if (consentAlreadyConfigured === null) {
|
|
3125
|
+
return {
|
|
3126
|
+
completed: false,
|
|
3127
|
+
configPath,
|
|
3128
|
+
projectId,
|
|
3129
|
+
credentialsUpdated,
|
|
3130
|
+
usedGcloud: useGcloud,
|
|
3131
|
+
authenticatedEmail
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
if (!consentAlreadyConfigured) {
|
|
3135
|
+
printConsentScreenHelp(projectId);
|
|
3136
|
+
log.step(`Opening ${withProject(AUDIENCE_URL, projectId)}`);
|
|
3137
|
+
openBrowser(withProject(AUDIENCE_URL, projectId));
|
|
3138
|
+
const completedConsent = await promptValue(
|
|
3139
|
+
confirm({
|
|
3140
|
+
message: "Have you completed the consent screen setup?",
|
|
3141
|
+
initialValue: false
|
|
3142
|
+
}),
|
|
3143
|
+
"Setup cancelled."
|
|
3144
|
+
);
|
|
3145
|
+
if (!completedConsent) {
|
|
3146
|
+
cancel("Finish the consent screen setup, then rerun `inboxctl setup`.");
|
|
3147
|
+
return {
|
|
3148
|
+
completed: false,
|
|
3149
|
+
configPath,
|
|
3150
|
+
projectId,
|
|
3151
|
+
credentialsUpdated,
|
|
3152
|
+
usedGcloud: useGcloud,
|
|
3153
|
+
authenticatedEmail
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
note(
|
|
3157
|
+
`When you sign in, you may see "This app isn't verified".
|
|
3158
|
+
This is normal for personal/testing apps. Click "Advanced", then
|
|
3159
|
+
"Go to inboxctl (unsafe)" to continue.`,
|
|
3160
|
+
"Unverified App Warning"
|
|
3161
|
+
);
|
|
3162
|
+
} else {
|
|
3163
|
+
note(
|
|
3164
|
+
`Quick check \u2014 verify these in Google Auth Platform > Audience:
|
|
3165
|
+
|
|
3166
|
+
- User type is External (required for personal Gmail)
|
|
3167
|
+
- Your Gmail address is listed under Test Users
|
|
3168
|
+
(without this you will get "Error 403: access_denied")
|
|
3169
|
+
|
|
3170
|
+
Verify at: ${withProject(AUDIENCE_URL, projectId)}`,
|
|
3171
|
+
"Consent Screen Checklist"
|
|
3172
|
+
);
|
|
3173
|
+
note(
|
|
3174
|
+
`When you sign in, you may see "This app isn't verified".
|
|
3175
|
+
This is normal for personal/testing apps. Click "Advanced", then
|
|
3176
|
+
"Go to inboxctl (unsafe)" to continue.`,
|
|
3177
|
+
"Unverified App Warning"
|
|
3178
|
+
);
|
|
3179
|
+
}
|
|
3180
|
+
const shouldReplaceCredentials = existingGoogleStatus.configured ? await promptValue(
|
|
3181
|
+
confirm({
|
|
3182
|
+
message: "Google OAuth credentials already exist. Replace them?",
|
|
3183
|
+
initialValue: false
|
|
3184
|
+
}),
|
|
3185
|
+
"Setup cancelled."
|
|
3186
|
+
) : true;
|
|
3187
|
+
if (shouldReplaceCredentials === null) {
|
|
3188
|
+
return {
|
|
3189
|
+
completed: false,
|
|
3190
|
+
configPath,
|
|
3191
|
+
projectId,
|
|
3192
|
+
credentialsUpdated,
|
|
3193
|
+
usedGcloud: useGcloud,
|
|
3194
|
+
authenticatedEmail
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
const redirectUri = initialConfig.google.redirectUri || DEFAULT_GOOGLE_REDIRECT_URI;
|
|
3198
|
+
if (shouldReplaceCredentials) {
|
|
3199
|
+
printCredentialHelp(projectId, redirectUri);
|
|
3200
|
+
log.step(`Opening ${withProject(CREDENTIALS_URL, projectId)}`);
|
|
3201
|
+
openBrowser(withProject(CREDENTIALS_URL, projectId));
|
|
3202
|
+
const clientId = await promptValue(
|
|
3203
|
+
text({
|
|
3204
|
+
message: "Paste your Google Client ID",
|
|
3205
|
+
placeholder: "123456789.apps.googleusercontent.com",
|
|
3206
|
+
validate: validateClientId
|
|
3207
|
+
}),
|
|
3208
|
+
"Setup cancelled."
|
|
3209
|
+
);
|
|
3210
|
+
if (!clientId) {
|
|
3211
|
+
return {
|
|
3212
|
+
completed: false,
|
|
3213
|
+
configPath,
|
|
3214
|
+
projectId,
|
|
3215
|
+
credentialsUpdated,
|
|
3216
|
+
usedGcloud: useGcloud,
|
|
3217
|
+
authenticatedEmail
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
const clientSecret = await promptValue(
|
|
3221
|
+
password({
|
|
3222
|
+
message: "Paste your Google Client Secret",
|
|
3223
|
+
validate: validateClientSecret
|
|
3224
|
+
}),
|
|
3225
|
+
"Setup cancelled."
|
|
3226
|
+
);
|
|
3227
|
+
if (!clientSecret) {
|
|
3228
|
+
return {
|
|
3229
|
+
completed: false,
|
|
3230
|
+
configPath,
|
|
3231
|
+
projectId,
|
|
3232
|
+
credentialsUpdated,
|
|
3233
|
+
usedGcloud: useGcloud,
|
|
3234
|
+
authenticatedEmail
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
3237
|
+
const selectedRedirectUri = await promptValue(
|
|
3238
|
+
text({
|
|
3239
|
+
message: "Redirect URI",
|
|
3240
|
+
initialValue: redirectUri,
|
|
3241
|
+
validate: validateRedirectUri
|
|
3242
|
+
}),
|
|
3243
|
+
"Setup cancelled."
|
|
3244
|
+
);
|
|
3245
|
+
if (!selectedRedirectUri) {
|
|
3246
|
+
return {
|
|
3247
|
+
completed: false,
|
|
3248
|
+
configPath,
|
|
3249
|
+
projectId,
|
|
3250
|
+
credentialsUpdated,
|
|
3251
|
+
usedGcloud: useGcloud,
|
|
3252
|
+
authenticatedEmail
|
|
3253
|
+
};
|
|
3254
|
+
}
|
|
3255
|
+
const saveSpinner = spinner();
|
|
3256
|
+
saveSpinner.start(`Saving credentials to ${configPath}...`);
|
|
3257
|
+
writeGoogleCredentials({
|
|
3258
|
+
clientId,
|
|
3259
|
+
clientSecret,
|
|
3260
|
+
redirectUri: selectedRedirectUri
|
|
3261
|
+
}, configPath);
|
|
3262
|
+
saveSpinner.stop("Credentials saved.");
|
|
3263
|
+
credentialsUpdated = true;
|
|
3264
|
+
await handleEnvironmentOverrides({
|
|
3265
|
+
clientId,
|
|
3266
|
+
clientSecret,
|
|
3267
|
+
redirectUri: selectedRedirectUri
|
|
3268
|
+
});
|
|
3269
|
+
} else {
|
|
3270
|
+
log.step("Keeping existing Google OAuth credentials from config.json.");
|
|
3271
|
+
await handleEnvironmentOverrides(null);
|
|
3272
|
+
}
|
|
3273
|
+
const verifySpinner = spinner();
|
|
3274
|
+
verifySpinner.start("Verifying setup...");
|
|
3275
|
+
verifyOAuthSetup();
|
|
3276
|
+
verifySpinner.stop("OAuth credentials look valid.");
|
|
3277
|
+
const shouldAuthenticateNow = await promptValue(
|
|
3278
|
+
confirm({
|
|
3279
|
+
message: "Authenticate a Gmail account now?",
|
|
3280
|
+
initialValue: true
|
|
3281
|
+
}),
|
|
3282
|
+
"Setup cancelled."
|
|
3283
|
+
);
|
|
3284
|
+
if (shouldAuthenticateNow === null) {
|
|
3285
|
+
return {
|
|
3286
|
+
completed: false,
|
|
3287
|
+
configPath,
|
|
3288
|
+
projectId,
|
|
3289
|
+
credentialsUpdated,
|
|
3290
|
+
usedGcloud: useGcloud,
|
|
3291
|
+
authenticatedEmail
|
|
3292
|
+
};
|
|
3293
|
+
}
|
|
3294
|
+
if (shouldAuthenticateNow) {
|
|
3295
|
+
log.step("Starting Gmail sign-in in your browser...");
|
|
3296
|
+
try {
|
|
3297
|
+
const activeConfig = loadConfig();
|
|
3298
|
+
const authResult = await startOAuthFlow(activeConfig);
|
|
3299
|
+
authenticatedEmail = authResult.email;
|
|
3300
|
+
const reconciliation = reconcileCacheForAuthenticatedAccount(
|
|
3301
|
+
activeConfig.dbPath,
|
|
3302
|
+
authenticatedEmail,
|
|
3303
|
+
{ clearLegacyUnscoped: true }
|
|
3304
|
+
);
|
|
3305
|
+
log.success(
|
|
3306
|
+
authenticatedEmail && authenticatedEmail !== "unknown" ? `Authenticated Gmail account: ${authenticatedEmail}` : "Gmail authentication complete."
|
|
3307
|
+
);
|
|
3308
|
+
if (reconciliation.cleared) {
|
|
3309
|
+
log.step("Local cache reset to avoid mixing data from another Gmail account.");
|
|
3310
|
+
}
|
|
3311
|
+
} catch (error) {
|
|
3312
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3313
|
+
if (message.includes("access_denied") || message.includes("access denied")) {
|
|
3314
|
+
log.error("Google blocked the sign-in request.");
|
|
3315
|
+
note(
|
|
3316
|
+
"This usually means one of:\n- Your Gmail address is not added as a test user (for External apps)\n- You chose Internal but are using a personal Gmail account\n\nFix it in Google Auth Platform > Audience, then retry with: inboxctl auth login",
|
|
3317
|
+
"Access Denied"
|
|
3318
|
+
);
|
|
3319
|
+
} else {
|
|
3320
|
+
throw error;
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
note(
|
|
3325
|
+
[
|
|
3326
|
+
gmailApiEnabled && projectId ? `\u2713 Gmail API enabled for project ${projectId}` : "\u2713 Gmail API step reviewed",
|
|
3327
|
+
"\u2713 OAuth consent screen reviewed",
|
|
3328
|
+
existingGoogleStatus.configured && !credentialsUpdated ? "\u2713 Existing OAuth credentials kept" : `\u2713 OAuth credentials saved to ${configPath}`,
|
|
3329
|
+
authenticatedEmail ? `\u2713 Gmail authenticated as ${authenticatedEmail}` : "\u2713 Gmail authentication ready",
|
|
3330
|
+
"",
|
|
3331
|
+
authenticatedEmail ? "You can start using inboxctl now." : "Next: run `inboxctl auth login` to authenticate your Gmail account."
|
|
3332
|
+
].join("\n"),
|
|
3333
|
+
"Setup Complete"
|
|
3334
|
+
);
|
|
3335
|
+
outro("Setup complete.");
|
|
3336
|
+
return {
|
|
3337
|
+
completed: true,
|
|
3338
|
+
configPath,
|
|
3339
|
+
projectId,
|
|
3340
|
+
credentialsUpdated,
|
|
3341
|
+
usedGcloud: useGcloud,
|
|
3342
|
+
authenticatedEmail
|
|
3343
|
+
};
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
// src/cli.ts
|
|
3347
|
+
var program = new Command();
|
|
3348
|
+
var colorEnabled = output.isTTY && process.env.NO_COLOR === void 0;
|
|
3349
|
+
function ansi(code, value) {
|
|
3350
|
+
return colorEnabled ? `\x1B[${code}m${value}\x1B[0m` : value;
|
|
3351
|
+
}
|
|
3352
|
+
var ui = {
|
|
3353
|
+
bold: (value) => ansi(1, value),
|
|
3354
|
+
dim: (value) => ansi(2, value),
|
|
3355
|
+
cyan: (value) => ansi(36, value),
|
|
3356
|
+
green: (value) => ansi(32, value),
|
|
3357
|
+
yellow: (value) => ansi(33, value),
|
|
3358
|
+
red: (value) => ansi(31, value),
|
|
3359
|
+
magenta: (value) => ansi(35, value)
|
|
3360
|
+
};
|
|
3361
|
+
function stripAnsi2(value) {
|
|
3362
|
+
return value.replace(/\u001B\[[0-9;]*m/g, "");
|
|
3363
|
+
}
|
|
3364
|
+
function truncate2(value, width) {
|
|
3365
|
+
if (width <= 0) {
|
|
3366
|
+
return "";
|
|
3367
|
+
}
|
|
3368
|
+
if (value.length <= width) {
|
|
3369
|
+
return value;
|
|
3370
|
+
}
|
|
3371
|
+
if (width <= 1) {
|
|
3372
|
+
return value.slice(0, width);
|
|
3373
|
+
}
|
|
3374
|
+
return `${value.slice(0, width - 1)}\u2026`;
|
|
3375
|
+
}
|
|
3376
|
+
function pad2(value, width) {
|
|
3377
|
+
const visible = stripAnsi2(value);
|
|
3378
|
+
if (visible.length >= width) {
|
|
3379
|
+
return value;
|
|
3380
|
+
}
|
|
3381
|
+
return `${value}${" ".repeat(width - visible.length)}`;
|
|
3382
|
+
}
|
|
3383
|
+
function printSection(title) {
|
|
3384
|
+
console.log(ui.bold(title));
|
|
3385
|
+
}
|
|
3386
|
+
function printKeyValue(label, value) {
|
|
3387
|
+
console.log(`${ui.dim(`${label}:`)} ${value}`);
|
|
3388
|
+
}
|
|
3389
|
+
function formatStatus(status) {
|
|
3390
|
+
const upper = status.toUpperCase();
|
|
3391
|
+
switch (status) {
|
|
3392
|
+
case "applied":
|
|
3393
|
+
case "undone":
|
|
3394
|
+
return ui.green(upper);
|
|
3395
|
+
case "warning":
|
|
3396
|
+
case "partial":
|
|
3397
|
+
case "noop":
|
|
3398
|
+
return ui.yellow(upper);
|
|
3399
|
+
case "error":
|
|
3400
|
+
return ui.red(upper);
|
|
3401
|
+
case "planned":
|
|
3402
|
+
return ui.cyan(upper);
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
function hasLabelChange(item) {
|
|
3406
|
+
return item.beforeLabelIds.join("\0") !== item.afterLabelIds.join("\0");
|
|
3407
|
+
}
|
|
3408
|
+
function formatList(values) {
|
|
3409
|
+
return values.length > 0 ? values.join(", ") : "none";
|
|
3410
|
+
}
|
|
3411
|
+
async function loadRuntimeStatus() {
|
|
3412
|
+
const config = loadConfig();
|
|
3413
|
+
initializeDb(config.dbPath);
|
|
3414
|
+
const tokens = await loadTokens(config.tokensPath);
|
|
3415
|
+
const googleStatus = getGoogleCredentialStatus(config);
|
|
3416
|
+
const gmailReadiness = getGmailReadiness(config, tokens);
|
|
3417
|
+
return {
|
|
3418
|
+
config,
|
|
3419
|
+
tokensPresent: tokens !== null,
|
|
3420
|
+
tokenExpired: tokens ? isTokenExpired(tokens) : null,
|
|
3421
|
+
googleConfigured: googleStatus.configured,
|
|
3422
|
+
googleMissing: googleStatus.missing,
|
|
3423
|
+
gmailReady: gmailReadiness.ready
|
|
3424
|
+
};
|
|
3425
|
+
}
|
|
3426
|
+
function printCheckpointSummary(status) {
|
|
3427
|
+
printSection("Environment");
|
|
3428
|
+
printKeyValue("dataDir", status.config.dataDir);
|
|
3429
|
+
printKeyValue("dbPath", status.config.dbPath);
|
|
3430
|
+
printKeyValue("rulesDir", status.config.rulesDir);
|
|
3431
|
+
printKeyValue("tokensPath", status.config.tokensPath);
|
|
3432
|
+
printKeyValue(
|
|
3433
|
+
"googleCredentials",
|
|
3434
|
+
status.googleConfigured ? ui.green("configured") : ui.red("missing")
|
|
3435
|
+
);
|
|
3436
|
+
printKeyValue("missingGoogleCredentials", formatList(status.googleMissing));
|
|
3437
|
+
printKeyValue("tokens", status.tokensPresent ? ui.green("present") : ui.red("missing"));
|
|
3438
|
+
printKeyValue(
|
|
3439
|
+
"tokenExpired",
|
|
3440
|
+
status.tokenExpired === null ? ui.dim("unknown (no tokens yet)") : status.tokenExpired ? ui.red("yes") : ui.green("no")
|
|
3441
|
+
);
|
|
3442
|
+
printKeyValue("gmailReady", status.gmailReady ? ui.green("yes") : ui.red("no"));
|
|
3443
|
+
}
|
|
3444
|
+
function printGoogleCheckpointInstructions(status) {
|
|
3445
|
+
console.log("");
|
|
3446
|
+
console.log("Gmail access is not ready yet.");
|
|
3447
|
+
if (!status.googleConfigured) {
|
|
3448
|
+
console.log("Still needed:");
|
|
3449
|
+
console.log(`- GOOGLE_CLIENT_ID${status.googleMissing.includes("GOOGLE_CLIENT_ID") ? " (missing)" : ""}`);
|
|
3450
|
+
console.log(
|
|
3451
|
+
`- GOOGLE_CLIENT_SECRET${status.googleMissing.includes("GOOGLE_CLIENT_SECRET") ? " (missing)" : ""}`
|
|
3452
|
+
);
|
|
3453
|
+
console.log("- OAuth consent screen configured in Google Cloud");
|
|
3454
|
+
console.log("- Localhost redirect URI allowed for the OAuth client");
|
|
3455
|
+
console.log("- Run `inboxctl setup` to configure this interactively");
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
if (!status.tokensPresent) {
|
|
3459
|
+
console.log("Google OAuth credentials are configured, but no Gmail account is authenticated yet.");
|
|
3460
|
+
console.log("- Run `inboxctl auth login` to sign in");
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
3463
|
+
if (status.tokenExpired) {
|
|
3464
|
+
console.log("A Gmail account was authenticated before, but the saved token is expired or unusable.");
|
|
3465
|
+
console.log("- Run `inboxctl auth login` again");
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
console.log("Google OAuth is configured, but Gmail readiness still failed.");
|
|
3469
|
+
console.log("- Re-run `inboxctl auth login`");
|
|
3470
|
+
}
|
|
3471
|
+
async function requireLiveGmailReadiness(commandName) {
|
|
3472
|
+
const status = await loadRuntimeStatus();
|
|
3473
|
+
if (!status.gmailReady) {
|
|
3474
|
+
console.log(`${commandName} requires authenticated Gmail access.`);
|
|
3475
|
+
printCheckpointSummary(status);
|
|
3476
|
+
printGoogleCheckpointInstructions(status);
|
|
3477
|
+
process.exitCode = 1;
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
async function resolveAuthenticatedEmail(config) {
|
|
3482
|
+
const tokens = await loadTokens(config.tokensPath);
|
|
3483
|
+
if (!tokens) {
|
|
3484
|
+
return null;
|
|
3485
|
+
}
|
|
3486
|
+
if (tokens.email && tokens.email !== "unknown") {
|
|
3487
|
+
return tokens.email;
|
|
3488
|
+
}
|
|
3489
|
+
const transport = await getGmailTransport(config);
|
|
3490
|
+
const profile = await transport.getProfile();
|
|
3491
|
+
if (!profile.emailAddress) {
|
|
3492
|
+
return null;
|
|
3493
|
+
}
|
|
3494
|
+
await saveTokens(config.tokensPath, {
|
|
3495
|
+
...tokens,
|
|
3496
|
+
email: profile.emailAddress
|
|
3497
|
+
});
|
|
3498
|
+
return profile.emailAddress;
|
|
3499
|
+
}
|
|
3500
|
+
function formatEmailRow(email) {
|
|
3501
|
+
const state = pad2(email.isRead ? ui.dim("read") : ui.yellow("unread"), 6);
|
|
3502
|
+
const date = ui.dim(new Date(email.date).toISOString().slice(0, 10));
|
|
3503
|
+
const sender = pad2(truncate2(email.fromAddress, 30), 30);
|
|
3504
|
+
const subject = truncate2(email.subject, 58);
|
|
3505
|
+
const id = ui.dim(email.id);
|
|
3506
|
+
return `${state} ${date} ${sender} ${subject} ${id}`;
|
|
3507
|
+
}
|
|
3508
|
+
function printEmailTableHeader() {
|
|
3509
|
+
console.log(
|
|
3510
|
+
[
|
|
3511
|
+
pad2(ui.dim("STATE"), 6),
|
|
3512
|
+
ui.dim("DATE"),
|
|
3513
|
+
pad2(ui.dim("FROM"), 30),
|
|
3514
|
+
ui.dim("SUBJECT"),
|
|
3515
|
+
ui.dim("ID")
|
|
3516
|
+
].join(" ")
|
|
3517
|
+
);
|
|
3518
|
+
}
|
|
3519
|
+
function formatTimestamp(value) {
|
|
3520
|
+
if (!value) {
|
|
3521
|
+
return "-";
|
|
3522
|
+
}
|
|
3523
|
+
return new Date(value).toISOString();
|
|
3524
|
+
}
|
|
3525
|
+
function formatDate(value) {
|
|
3526
|
+
if (!value) {
|
|
3527
|
+
return "-";
|
|
3528
|
+
}
|
|
3529
|
+
const timestamp = value instanceof Date ? value.getTime() : value;
|
|
3530
|
+
return new Date(timestamp).toISOString().slice(0, 10);
|
|
3531
|
+
}
|
|
3532
|
+
function formatRelativeTime2(value) {
|
|
3533
|
+
if (!value) {
|
|
3534
|
+
return "-";
|
|
3535
|
+
}
|
|
3536
|
+
const timestamp = value instanceof Date ? value.getTime() : value;
|
|
3537
|
+
const diff = Date.now() - timestamp;
|
|
3538
|
+
if (diff < 0) {
|
|
3539
|
+
return formatDate(timestamp);
|
|
3540
|
+
}
|
|
3541
|
+
if (diff < 6e4) {
|
|
3542
|
+
return `${Math.max(1, Math.floor(diff / 1e3))}s ago`;
|
|
3543
|
+
}
|
|
3544
|
+
if (diff < 36e5) {
|
|
3545
|
+
return `${Math.floor(diff / 6e4)}m ago`;
|
|
3546
|
+
}
|
|
3547
|
+
if (diff < 864e5) {
|
|
3548
|
+
return `${Math.floor(diff / 36e5)}h ago`;
|
|
3549
|
+
}
|
|
3550
|
+
if (diff < 6048e5) {
|
|
3551
|
+
return `${Math.floor(diff / 864e5)}d ago`;
|
|
3552
|
+
}
|
|
3553
|
+
if (diff < 2592e6) {
|
|
3554
|
+
return `${Math.floor(diff / 6048e5)}w ago`;
|
|
3555
|
+
}
|
|
3556
|
+
return formatDate(timestamp);
|
|
3557
|
+
}
|
|
3558
|
+
function formatPercent2(value) {
|
|
3559
|
+
const normalized = Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, "");
|
|
3560
|
+
return `${normalized}%`;
|
|
3561
|
+
}
|
|
3562
|
+
function formatSenderIdentity(name, email, width) {
|
|
3563
|
+
const identity = name && name !== email ? `${name} <${email}>` : email;
|
|
3564
|
+
return pad2(truncate2(identity, width), width);
|
|
3565
|
+
}
|
|
3566
|
+
function parseIntegerOption(value, label, min = 1) {
|
|
3567
|
+
const parsed = Number(value);
|
|
3568
|
+
if (!Number.isInteger(parsed) || parsed < min) {
|
|
3569
|
+
throw new Error(`${label} must be an integer greater than or equal to ${min}.`);
|
|
3570
|
+
}
|
|
3571
|
+
return parsed;
|
|
3572
|
+
}
|
|
3573
|
+
function parsePercentOption(value, label) {
|
|
3574
|
+
if (value === void 0) {
|
|
3575
|
+
return void 0;
|
|
3576
|
+
}
|
|
3577
|
+
const parsed = Number(value);
|
|
3578
|
+
if (Number.isNaN(parsed) || parsed < 0 || parsed > 100) {
|
|
3579
|
+
throw new Error(`${label} must be between 0 and 100.`);
|
|
3580
|
+
}
|
|
3581
|
+
return parsed;
|
|
3582
|
+
}
|
|
3583
|
+
function printInboxOverview(overview) {
|
|
3584
|
+
printSection("Inbox Overview");
|
|
3585
|
+
printKeyValue("total", String(overview.total));
|
|
3586
|
+
printKeyValue("unread", overview.unread > 0 ? ui.yellow(String(overview.unread)) : ui.dim("0"));
|
|
3587
|
+
printKeyValue("starred", overview.starred > 0 ? ui.magenta(String(overview.starred)) : ui.dim("0"));
|
|
3588
|
+
printKeyValue(
|
|
3589
|
+
"today",
|
|
3590
|
+
`${overview.today.received} received, ${overview.today.unread} unread`
|
|
3591
|
+
);
|
|
3592
|
+
printKeyValue(
|
|
3593
|
+
"thisWeek",
|
|
3594
|
+
`${overview.thisWeek.received} received, ${overview.thisWeek.unread} unread`
|
|
3595
|
+
);
|
|
3596
|
+
printKeyValue(
|
|
3597
|
+
"thisMonth",
|
|
3598
|
+
`${overview.thisMonth.received} received, ${overview.thisMonth.unread} unread`
|
|
3599
|
+
);
|
|
3600
|
+
printKeyValue(
|
|
3601
|
+
"oldestUnread",
|
|
3602
|
+
overview.oldestUnread ? `${formatRelativeTime2(overview.oldestUnread)} (${formatDate(overview.oldestUnread)})` : "-"
|
|
3603
|
+
);
|
|
3604
|
+
}
|
|
3605
|
+
function printTopSendersTable(label, senders) {
|
|
3606
|
+
printSection(`Top Senders (${label})`);
|
|
3607
|
+
console.log(
|
|
3608
|
+
[
|
|
3609
|
+
pad2(ui.dim("SENDER"), 42),
|
|
3610
|
+
pad2(ui.dim("TOTAL"), 7),
|
|
3611
|
+
pad2(ui.dim("UNREAD"), 8),
|
|
3612
|
+
pad2(ui.dim("UNREAD%"), 8),
|
|
3613
|
+
ui.dim("LAST EMAIL")
|
|
3614
|
+
].join(" ")
|
|
3615
|
+
);
|
|
3616
|
+
for (const sender of senders) {
|
|
3617
|
+
console.log(
|
|
3618
|
+
[
|
|
3619
|
+
formatSenderIdentity(sender.name, sender.email, 42),
|
|
3620
|
+
pad2(String(sender.totalMessages), 7),
|
|
3621
|
+
pad2(String(sender.unreadMessages), 8),
|
|
3622
|
+
pad2(formatPercent2(sender.unreadRate), 8),
|
|
3623
|
+
formatRelativeTime2(sender.lastEmailDate)
|
|
3624
|
+
].join(" ")
|
|
3625
|
+
);
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
function printNewslettersTable(newsletters) {
|
|
3629
|
+
printSection("Newsletters");
|
|
3630
|
+
console.log(
|
|
3631
|
+
[
|
|
3632
|
+
pad2(ui.dim("SENDER"), 36),
|
|
3633
|
+
pad2(ui.dim("TOTAL"), 7),
|
|
3634
|
+
pad2(ui.dim("UNREAD"), 8),
|
|
3635
|
+
pad2(ui.dim("UNREAD%"), 8),
|
|
3636
|
+
pad2(ui.dim("STATUS"), 14),
|
|
3637
|
+
pad2(ui.dim("LAST EMAIL"), 11),
|
|
3638
|
+
ui.dim("REASON")
|
|
3639
|
+
].join(" ")
|
|
3640
|
+
);
|
|
3641
|
+
for (const sender of newsletters) {
|
|
3642
|
+
const statusColor = sender.status === "active" ? ui.green(sender.status) : sender.status === "archived" ? ui.dim(sender.status) : ui.yellow(sender.status);
|
|
3643
|
+
console.log(
|
|
3644
|
+
[
|
|
3645
|
+
formatSenderIdentity(sender.name, sender.email, 36),
|
|
3646
|
+
pad2(String(sender.messageCount), 7),
|
|
3647
|
+
pad2(String(sender.unreadCount), 8),
|
|
3648
|
+
pad2(formatPercent2(sender.unreadRate), 8),
|
|
3649
|
+
pad2(statusColor, 14),
|
|
3650
|
+
pad2(formatRelativeTime2(sender.lastSeen), 11),
|
|
3651
|
+
truncate2(sender.detectionReason, 32)
|
|
3652
|
+
].join(" ")
|
|
3653
|
+
);
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
function printLabelDistributionTable(labels2) {
|
|
3657
|
+
printSection("Label Distribution");
|
|
3658
|
+
console.log(
|
|
3659
|
+
[
|
|
3660
|
+
pad2(ui.dim("LABEL"), 22),
|
|
3661
|
+
pad2(ui.dim("TOTAL"), 7),
|
|
3662
|
+
pad2(ui.dim("UNREAD"), 8),
|
|
3663
|
+
ui.dim("ID")
|
|
3664
|
+
].join(" ")
|
|
3665
|
+
);
|
|
3666
|
+
for (const label of labels2) {
|
|
3667
|
+
console.log(
|
|
3668
|
+
[
|
|
3669
|
+
pad2(truncate2(label.labelName, 22), 22),
|
|
3670
|
+
pad2(String(label.totalMessages), 7),
|
|
3671
|
+
pad2(String(label.unreadMessages), 8),
|
|
3672
|
+
ui.dim(label.labelId)
|
|
3673
|
+
].join(" ")
|
|
3674
|
+
);
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
function printVolumeTable(label, points) {
|
|
3678
|
+
printSection(`Volume (${label})`);
|
|
3679
|
+
console.log(
|
|
3680
|
+
[
|
|
3681
|
+
pad2(ui.dim("PERIOD"), 12),
|
|
3682
|
+
pad2(ui.dim("RECEIVED"), 10),
|
|
3683
|
+
pad2(ui.dim("READ"), 7),
|
|
3684
|
+
pad2(ui.dim("UNREAD"), 8),
|
|
3685
|
+
ui.dim("ARCHIVED")
|
|
3686
|
+
].join(" ")
|
|
3687
|
+
);
|
|
3688
|
+
for (const point of points) {
|
|
3689
|
+
console.log(
|
|
3690
|
+
[
|
|
3691
|
+
pad2(point.period, 12),
|
|
3692
|
+
pad2(String(point.received), 10),
|
|
3693
|
+
pad2(String(point.read), 7),
|
|
3694
|
+
pad2(String(point.unread), 8),
|
|
3695
|
+
String(point.archived)
|
|
3696
|
+
].join(" ")
|
|
3697
|
+
);
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
function printSenderDetail(detail) {
|
|
3701
|
+
printSection(detail.query);
|
|
3702
|
+
printKeyValue("type", detail.type);
|
|
3703
|
+
printKeyValue("name", detail.name);
|
|
3704
|
+
printKeyValue("messages", String(detail.totalMessages));
|
|
3705
|
+
printKeyValue("unread", `${detail.unreadMessages} (${formatPercent2(detail.unreadRate)})`);
|
|
3706
|
+
printKeyValue("firstSeen", formatDate(detail.firstEmailDate));
|
|
3707
|
+
printKeyValue(
|
|
3708
|
+
"lastSeen",
|
|
3709
|
+
`${formatRelativeTime2(detail.lastEmailDate)} (${formatDate(detail.lastEmailDate)})`
|
|
3710
|
+
);
|
|
3711
|
+
printKeyValue("labels", detail.labels.join(", ") || "-");
|
|
3712
|
+
if (detail.type === "domain") {
|
|
3713
|
+
printKeyValue("matchedSenders", String(detail.matchingSenders.length));
|
|
3714
|
+
for (const sender of detail.matchingSenders) {
|
|
3715
|
+
console.log(`- ${sender}`);
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
if (detail.recentEmails.length === 0) {
|
|
3719
|
+
return;
|
|
3720
|
+
}
|
|
3721
|
+
console.log("");
|
|
3722
|
+
printSection("Recent Emails");
|
|
3723
|
+
console.log(
|
|
3724
|
+
[
|
|
3725
|
+
pad2(ui.dim("STATE"), 6),
|
|
3726
|
+
pad2(ui.dim("DATE"), 10),
|
|
3727
|
+
pad2(ui.dim("FROM"), 30),
|
|
3728
|
+
ui.dim("SUBJECT")
|
|
3729
|
+
].join(" ")
|
|
3730
|
+
);
|
|
3731
|
+
for (const email of detail.recentEmails) {
|
|
3732
|
+
console.log(
|
|
3733
|
+
[
|
|
3734
|
+
pad2(email.isRead ? ui.dim("read") : ui.yellow("unread"), 6),
|
|
3735
|
+
pad2(formatDate(email.date), 10),
|
|
3736
|
+
pad2(truncate2(email.fromAddress, 30), 30),
|
|
3737
|
+
truncate2(email.subject || "(no subject)", 60)
|
|
3738
|
+
].join(" ")
|
|
3739
|
+
);
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
function getVolumeRange(period) {
|
|
3743
|
+
const end = Date.now();
|
|
3744
|
+
const dayMs = 24 * 60 * 60 * 1e3;
|
|
3745
|
+
switch (period) {
|
|
3746
|
+
case "day":
|
|
3747
|
+
return { start: end - 30 * dayMs, end };
|
|
3748
|
+
case "week":
|
|
3749
|
+
return { start: end - 26 * 7 * dayMs, end };
|
|
3750
|
+
case "month":
|
|
3751
|
+
return { start: end - 365 * dayMs, end };
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
function formatActions(actions) {
|
|
3755
|
+
if (actions.length === 0) {
|
|
3756
|
+
return "none";
|
|
3757
|
+
}
|
|
3758
|
+
return actions.map((action) => {
|
|
3759
|
+
switch (action.type) {
|
|
3760
|
+
case "label":
|
|
3761
|
+
return `label:${action.label}`;
|
|
3762
|
+
case "forward":
|
|
3763
|
+
return `forward:${action.to}`;
|
|
3764
|
+
case "archive":
|
|
3765
|
+
return "archive";
|
|
3766
|
+
case "mark_read":
|
|
3767
|
+
return "mark_read";
|
|
3768
|
+
case "mark_spam":
|
|
3769
|
+
return "mark_spam";
|
|
3770
|
+
}
|
|
3771
|
+
}).join(", ");
|
|
3772
|
+
}
|
|
3773
|
+
async function promptForConfirmation(message) {
|
|
3774
|
+
const rl = createInterface({ input, output });
|
|
3775
|
+
try {
|
|
3776
|
+
const answer = await rl.question(`${message} [y/N] `);
|
|
3777
|
+
const normalized = answer.trim().toLowerCase();
|
|
3778
|
+
return normalized === "y" || normalized === "yes";
|
|
3779
|
+
} finally {
|
|
3780
|
+
rl.close();
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
async function collectMessageIdsForQuery(query) {
|
|
3784
|
+
const config = loadConfig();
|
|
3785
|
+
const transport = await getGmailTransport(config);
|
|
3786
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3787
|
+
let pageToken;
|
|
3788
|
+
do {
|
|
3789
|
+
const response = await transport.listMessages({
|
|
3790
|
+
query,
|
|
3791
|
+
maxResults: 500,
|
|
3792
|
+
pageToken
|
|
3793
|
+
});
|
|
3794
|
+
for (const message of response.messages || []) {
|
|
3795
|
+
if (message.id) {
|
|
3796
|
+
ids.add(message.id);
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
pageToken = response.nextPageToken || void 0;
|
|
3800
|
+
} while (pageToken);
|
|
3801
|
+
return [...ids];
|
|
3802
|
+
}
|
|
3803
|
+
async function resolveMessageIds(id, query) {
|
|
3804
|
+
if (query) {
|
|
3805
|
+
return collectMessageIdsForQuery(query);
|
|
3806
|
+
}
|
|
3807
|
+
if (!id) {
|
|
3808
|
+
throw new Error("Provide an email ID or use --query to target matching Gmail messages.");
|
|
3809
|
+
}
|
|
3810
|
+
return [id];
|
|
3811
|
+
}
|
|
3812
|
+
async function confirmBatchIfNeeded(query, ids, verb) {
|
|
3813
|
+
if (!query || ids.length <= 1) {
|
|
3814
|
+
return;
|
|
3815
|
+
}
|
|
3816
|
+
const confirmed = await promptForConfirmation(
|
|
3817
|
+
`Found ${ids.length} emails matching "${query}"
|
|
3818
|
+
${verb} all ${ids.length} emails?`
|
|
3819
|
+
);
|
|
3820
|
+
if (!confirmed) {
|
|
3821
|
+
throw new Error("Cancelled.");
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
async function recordManualRun(options) {
|
|
3825
|
+
const run = await createExecutionRun({
|
|
3826
|
+
sourceType: "manual",
|
|
3827
|
+
ruleId: null,
|
|
3828
|
+
dryRun: false,
|
|
3829
|
+
requestedActions: options.requestedActions,
|
|
3830
|
+
query: options.query || null,
|
|
3831
|
+
status: options.result.items.every((item) => item.status === "applied") ? "applied" : options.result.items.some((item) => item.status === "applied" || item.status === "warning") ? "partial" : "error"
|
|
3832
|
+
});
|
|
3833
|
+
await addExecutionItems(
|
|
3834
|
+
run.id,
|
|
3835
|
+
options.result.items.map((item) => ({
|
|
3836
|
+
emailId: item.emailId,
|
|
3837
|
+
status: item.status,
|
|
3838
|
+
appliedActions: options.requestedActions,
|
|
3839
|
+
beforeLabelIds: item.beforeLabelIds,
|
|
3840
|
+
afterLabelIds: item.afterLabelIds,
|
|
3841
|
+
errorMessage: item.errorMessage || null
|
|
3842
|
+
}))
|
|
3843
|
+
);
|
|
3844
|
+
return run.id;
|
|
3845
|
+
}
|
|
3846
|
+
function printMutationSummary(result, runId) {
|
|
3847
|
+
const applied = result.items.filter((item) => item.status === "applied").length;
|
|
3848
|
+
const warnings = result.items.filter((item) => item.status === "warning").length;
|
|
3849
|
+
const errors = result.items.filter((item) => item.status === "error").length;
|
|
3850
|
+
const noops = result.items.filter((item) => item.status === "applied" && !hasLabelChange(item)).length;
|
|
3851
|
+
printSection(`Run ${ui.dim(runId)}`);
|
|
3852
|
+
printKeyValue("total", String(result.items.length));
|
|
3853
|
+
printKeyValue("applied", ui.green(String(applied)));
|
|
3854
|
+
printKeyValue("noChange", noops > 0 ? ui.yellow(String(noops)) : ui.dim("0"));
|
|
3855
|
+
printKeyValue("warnings", warnings > 0 ? ui.yellow(String(warnings)) : ui.dim("0"));
|
|
3856
|
+
printKeyValue("errors", errors > 0 ? ui.red(String(errors)) : ui.dim("0"));
|
|
3857
|
+
console.log("");
|
|
3858
|
+
for (const item of result.items) {
|
|
3859
|
+
const displayStatus = item.status === "applied" && !hasLabelChange(item) ? "noop" : item.status;
|
|
3860
|
+
const line = [
|
|
3861
|
+
pad2(formatStatus(displayStatus), 9),
|
|
3862
|
+
ui.dim(item.emailId),
|
|
3863
|
+
`${ui.dim("before")} ${item.beforeLabelIds.join(",") || "-"}`,
|
|
3864
|
+
`${ui.dim("after")} ${item.afterLabelIds.join(",") || "-"}`
|
|
3865
|
+
].join(" ");
|
|
3866
|
+
console.log(line);
|
|
3867
|
+
if (item.errorMessage) {
|
|
3868
|
+
console.log(` ${ui.yellow("note")} ${item.errorMessage}`);
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
function printHistoryTable(runs) {
|
|
3873
|
+
printSection("History");
|
|
3874
|
+
console.log(
|
|
3875
|
+
[
|
|
3876
|
+
pad2(ui.dim("STATUS"), 10),
|
|
3877
|
+
pad2(ui.dim("ACTION"), 20),
|
|
3878
|
+
pad2(ui.dim("WHEN"), 22),
|
|
3879
|
+
pad2(ui.dim("RUN ID"), 38),
|
|
3880
|
+
ui.dim("QUERY")
|
|
3881
|
+
].join(" ")
|
|
3882
|
+
);
|
|
3883
|
+
for (const run of runs) {
|
|
3884
|
+
console.log(
|
|
3885
|
+
[
|
|
3886
|
+
pad2(formatStatus(run.status), 10),
|
|
3887
|
+
pad2(truncate2(formatActions(run.requestedActions), 20), 20),
|
|
3888
|
+
pad2(ui.dim(formatTimestamp(run.createdAt)), 22),
|
|
3889
|
+
pad2(ui.dim(run.id), 38),
|
|
3890
|
+
truncate2(run.query || "-", 60)
|
|
3891
|
+
].join(" ")
|
|
3892
|
+
);
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
function formatEnabled(enabled) {
|
|
3896
|
+
return enabled ? ui.green("enabled") : ui.yellow("disabled");
|
|
3897
|
+
}
|
|
3898
|
+
function printRuleDeploySummary(result) {
|
|
3899
|
+
const results = Array.isArray(result) ? result : [result];
|
|
3900
|
+
printSection("Rules Deployed");
|
|
3901
|
+
console.log(
|
|
3902
|
+
[
|
|
3903
|
+
pad2(ui.dim("STATUS"), 10),
|
|
3904
|
+
pad2(ui.dim("NAME"), 28),
|
|
3905
|
+
pad2(ui.dim("PRIORITY"), 8),
|
|
3906
|
+
pad2(ui.dim("ENABLED"), 10),
|
|
3907
|
+
ui.dim("FILE")
|
|
3908
|
+
].join(" ")
|
|
3909
|
+
);
|
|
3910
|
+
for (const entry of results) {
|
|
3911
|
+
const statusColor = entry.status === "created" ? ui.green(entry.status.toUpperCase()) : entry.status === "updated" ? ui.yellow(entry.status.toUpperCase()) : ui.dim(entry.status.toUpperCase());
|
|
3912
|
+
console.log(
|
|
3913
|
+
[
|
|
3914
|
+
pad2(statusColor, 10),
|
|
3915
|
+
pad2(entry.name, 28),
|
|
3916
|
+
pad2(String(entry.priority), 8),
|
|
3917
|
+
pad2(formatEnabled(entry.enabled), 10),
|
|
3918
|
+
truncate2("-", 60)
|
|
3919
|
+
].join(" ")
|
|
3920
|
+
);
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
function printRuleStatusTable(rules2) {
|
|
3924
|
+
printSection("Rules");
|
|
3925
|
+
console.log(
|
|
3926
|
+
[
|
|
3927
|
+
pad2(ui.dim("NAME"), 28),
|
|
3928
|
+
pad2(ui.dim("STATE"), 10),
|
|
3929
|
+
pad2(ui.dim("PRIORITY"), 8),
|
|
3930
|
+
pad2(ui.dim("RUNS"), 6),
|
|
3931
|
+
pad2(ui.dim("LAST RUN"), 22),
|
|
3932
|
+
ui.dim("ACTIONS")
|
|
3933
|
+
].join(" ")
|
|
3934
|
+
);
|
|
3935
|
+
for (const rule of rules2) {
|
|
3936
|
+
console.log(
|
|
3937
|
+
[
|
|
3938
|
+
pad2(truncate2(rule.name, 28), 28),
|
|
3939
|
+
pad2(formatEnabled(rule.enabled), 10),
|
|
3940
|
+
pad2(String(rule.priority), 8),
|
|
3941
|
+
pad2(String(rule.totalRuns), 6),
|
|
3942
|
+
pad2(ui.dim(formatTimestamp(rule.lastExecutionAt)), 22),
|
|
3943
|
+
truncate2(formatActions(rule.actions), 40)
|
|
3944
|
+
].join(" ")
|
|
3945
|
+
);
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
function printRuleStatusDetail(rule) {
|
|
3949
|
+
printSection(rule.name);
|
|
3950
|
+
printKeyValue("description", rule.description || "-");
|
|
3951
|
+
printKeyValue("enabled", formatEnabled(rule.enabled));
|
|
3952
|
+
printKeyValue("priority", String(rule.priority));
|
|
3953
|
+
printKeyValue("actions", formatActions(rule.actions));
|
|
3954
|
+
printKeyValue("totalRuns", String(rule.totalRuns));
|
|
3955
|
+
printKeyValue("appliedRuns", String(rule.appliedRuns));
|
|
3956
|
+
printKeyValue("partialRuns", String(rule.partialRuns));
|
|
3957
|
+
printKeyValue("errorRuns", String(rule.errorRuns));
|
|
3958
|
+
printKeyValue("undoneRuns", String(rule.undoneRuns));
|
|
3959
|
+
printKeyValue("lastRunId", rule.lastRunId ? ui.dim(rule.lastRunId) : "-");
|
|
3960
|
+
printKeyValue("lastExecutionAt", ui.dim(formatTimestamp(rule.lastExecutionAt)));
|
|
3961
|
+
}
|
|
3962
|
+
function printRuleRunResult(result) {
|
|
3963
|
+
printSection(`Rule ${result.rule.name}`);
|
|
3964
|
+
printKeyValue("mode", result.dryRun ? ui.cyan("dry-run") : ui.green("apply"));
|
|
3965
|
+
printKeyValue("runId", ui.dim(result.runId));
|
|
3966
|
+
printKeyValue("status", formatStatus(result.status));
|
|
3967
|
+
printKeyValue("matched", String(result.matchedCount));
|
|
3968
|
+
printKeyValue("actions", formatActions(result.rule.actions));
|
|
3969
|
+
console.log("");
|
|
3970
|
+
if (result.items.length === 0) {
|
|
3971
|
+
console.log("No matching cached emails.");
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
for (const item of result.items) {
|
|
3975
|
+
const subject = truncate2(item.subject || "(no subject)", 50);
|
|
3976
|
+
const from = truncate2(item.fromAddress || "-", 28);
|
|
3977
|
+
const date = ui.dim(new Date(item.date).toISOString().slice(0, 10));
|
|
3978
|
+
console.log(
|
|
3979
|
+
[
|
|
3980
|
+
pad2(formatStatus(item.status), 9),
|
|
3981
|
+
pad2(from, 28),
|
|
3982
|
+
pad2(date, 12),
|
|
3983
|
+
subject
|
|
3984
|
+
].join(" ")
|
|
3985
|
+
);
|
|
3986
|
+
console.log(` ${ui.dim(item.emailId)} matched=${item.matchedFields.join(",") || "-"}`);
|
|
3987
|
+
if (item.errorMessage) {
|
|
3988
|
+
console.log(` ${ui.yellow("note")} ${item.errorMessage}`);
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
if (result.dryRun) {
|
|
3992
|
+
console.log("");
|
|
3993
|
+
console.log("Run again with `--apply` to execute these actions.");
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
function printRunAllRulesResult(result) {
|
|
3997
|
+
if (result.results.length === 0) {
|
|
3998
|
+
console.log("No enabled rules are currently deployed.");
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
for (const ruleResult of result.results) {
|
|
4002
|
+
printRuleRunResult(ruleResult);
|
|
4003
|
+
console.log("");
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
function printDriftReport(result) {
|
|
4007
|
+
printSection("Rule Drift");
|
|
4008
|
+
console.log(
|
|
4009
|
+
[
|
|
4010
|
+
pad2(ui.dim("STATUS"), 14),
|
|
4011
|
+
pad2(ui.dim("NAME"), 28),
|
|
4012
|
+
pad2(ui.dim("DEPLOYED"), 16),
|
|
4013
|
+
pad2(ui.dim("FILE"), 16),
|
|
4014
|
+
ui.dim("PATH")
|
|
4015
|
+
].join(" ")
|
|
4016
|
+
);
|
|
4017
|
+
for (const entry of result.entries) {
|
|
4018
|
+
const label = entry.status === "in_sync" ? ui.green("IN_SYNC") : entry.status === "changed" ? ui.yellow("CHANGED") : ui.red(entry.status.toUpperCase());
|
|
4019
|
+
console.log(
|
|
4020
|
+
[
|
|
4021
|
+
pad2(label, 14),
|
|
4022
|
+
pad2(truncate2(entry.name, 28), 28),
|
|
4023
|
+
pad2(truncate2(entry.deployedHash || "-", 16), 16),
|
|
4024
|
+
pad2(truncate2(entry.fileHash || "-", 16), 16),
|
|
4025
|
+
truncate2(entry.filePath || "-", 60)
|
|
4026
|
+
].join(" ")
|
|
4027
|
+
);
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
program.name("inboxctl").description("CLI email management with MCP server, rules-as-code, and TUI").version("0.1.0").option("--demo", "Launch the seeded demo mailbox").option("--no-sync", "Launch the TUI without running the initial background sync");
|
|
4031
|
+
program.command("setup").description("Run the interactive Google Cloud and OAuth setup wizard").option("--skip-gcloud", "Skip gcloud detection and API enablement").option("--project <id>", "Pre-set the Google Cloud project ID").action(async (options) => {
|
|
4032
|
+
try {
|
|
4033
|
+
await runSetupWizard({
|
|
4034
|
+
skipGcloud: options.skipGcloud,
|
|
4035
|
+
project: options.project
|
|
4036
|
+
});
|
|
4037
|
+
} catch (error) {
|
|
4038
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4039
|
+
process.exitCode = 1;
|
|
4040
|
+
}
|
|
4041
|
+
});
|
|
4042
|
+
var auth = program.command("auth").description("Gmail authentication");
|
|
4043
|
+
auth.command("login").description("Authenticate with Gmail via OAuth2").action(async () => {
|
|
4044
|
+
const config = loadConfig();
|
|
4045
|
+
try {
|
|
4046
|
+
const result = await startOAuthFlow(config);
|
|
4047
|
+
const reconciliation = reconcileCacheForAuthenticatedAccount(
|
|
4048
|
+
config.dbPath,
|
|
4049
|
+
result.email,
|
|
4050
|
+
{ clearLegacyUnscoped: true }
|
|
4051
|
+
);
|
|
4052
|
+
console.log(`Authenticated Gmail account: ${result.email}`);
|
|
4053
|
+
console.log(`Redirect URI used: ${result.redirectUri}`);
|
|
4054
|
+
if (reconciliation.cleared) {
|
|
4055
|
+
console.log("Local cache reset to avoid mixing data from another Gmail account.");
|
|
4056
|
+
}
|
|
4057
|
+
} catch (error) {
|
|
4058
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4059
|
+
const status = await loadRuntimeStatus();
|
|
4060
|
+
printCheckpointSummary(status);
|
|
4061
|
+
printGoogleCheckpointInstructions(status);
|
|
4062
|
+
process.exitCode = 1;
|
|
4063
|
+
}
|
|
4064
|
+
});
|
|
4065
|
+
auth.command("status").description("Show authentication status").action(async () => {
|
|
4066
|
+
const status = await loadRuntimeStatus();
|
|
4067
|
+
printCheckpointSummary(status);
|
|
4068
|
+
if (status.tokensPresent) {
|
|
4069
|
+
try {
|
|
4070
|
+
const email = await resolveAuthenticatedEmail(status.config);
|
|
4071
|
+
if (email) {
|
|
4072
|
+
console.log(`authenticatedEmail: ${email}`);
|
|
4073
|
+
}
|
|
4074
|
+
} catch (error) {
|
|
4075
|
+
console.log(
|
|
4076
|
+
`authenticatedEmail: unavailable (${error instanceof Error ? error.message : String(error)})`
|
|
4077
|
+
);
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
4080
|
+
if (!status.gmailReady) {
|
|
4081
|
+
printGoogleCheckpointInstructions(status);
|
|
4082
|
+
}
|
|
4083
|
+
});
|
|
4084
|
+
program.command("sync").description("Sync emails from Gmail").option("--full", "Force full sync (ignore incremental)").action(async (options) => {
|
|
4085
|
+
const status = await loadRuntimeStatus();
|
|
4086
|
+
if (!status.gmailReady) {
|
|
4087
|
+
await requireLiveGmailReadiness("sync");
|
|
4088
|
+
return;
|
|
4089
|
+
}
|
|
4090
|
+
const result = options.full ? await fullSync((synced, total) => {
|
|
4091
|
+
console.log(`synced=${synced}${total ? ` total=${total}` : ""}`);
|
|
4092
|
+
}) : await incrementalSync();
|
|
4093
|
+
console.log(`mode: ${result.mode}`);
|
|
4094
|
+
console.log(`messagesProcessed: ${result.messagesProcessed}`);
|
|
4095
|
+
console.log(`historyId: ${result.historyId}`);
|
|
4096
|
+
console.log(`usedHistoryFallback: ${result.usedHistoryFallback ? "yes" : "no"}`);
|
|
4097
|
+
const syncStatus = await getSyncStatus();
|
|
4098
|
+
console.log(`cacheTotalMessages: ${syncStatus.totalMessages}`);
|
|
4099
|
+
});
|
|
4100
|
+
program.command("inbox").description("List recent inbox emails").option("-n, --count <number>", "Number of emails to show", "20").action(async (options) => {
|
|
4101
|
+
const emails = await getRecentEmails(Number(options.count));
|
|
4102
|
+
if (emails.length === 0) {
|
|
4103
|
+
console.log("No cached emails yet. Run `inboxctl sync` first.");
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
4106
|
+
printSection("Inbox");
|
|
4107
|
+
printEmailTableHeader();
|
|
4108
|
+
for (const email of emails) {
|
|
4109
|
+
console.log(formatEmailRow(email));
|
|
4110
|
+
}
|
|
4111
|
+
});
|
|
4112
|
+
program.command("search <query>").description("Search emails using Gmail query syntax").option("-n, --count <number>", "Max results", "20").action(async (query, options) => {
|
|
4113
|
+
const status = await loadRuntimeStatus();
|
|
4114
|
+
if (!status.gmailReady) {
|
|
4115
|
+
await requireLiveGmailReadiness("search");
|
|
4116
|
+
return;
|
|
4117
|
+
}
|
|
4118
|
+
const emails = await listMessages(query, Number(options.count));
|
|
4119
|
+
if (emails.length === 0) {
|
|
4120
|
+
console.log("No matching Gmail messages.");
|
|
4121
|
+
return;
|
|
4122
|
+
}
|
|
4123
|
+
printSection(`Search ${ui.dim(query)}`);
|
|
4124
|
+
printEmailTableHeader();
|
|
4125
|
+
for (const email of emails) {
|
|
4126
|
+
console.log(formatEmailRow(email));
|
|
4127
|
+
}
|
|
4128
|
+
});
|
|
4129
|
+
program.command("email <id>").description("View a single email").action(async (id) => {
|
|
4130
|
+
const status = await loadRuntimeStatus();
|
|
4131
|
+
if (!status.gmailReady) {
|
|
4132
|
+
await requireLiveGmailReadiness("email");
|
|
4133
|
+
return;
|
|
4134
|
+
}
|
|
4135
|
+
const email = await getMessage(id);
|
|
4136
|
+
printSection(email.subject || "Email");
|
|
4137
|
+
printKeyValue("from", email.fromAddress);
|
|
4138
|
+
printKeyValue("to", email.toAddresses.join(", "));
|
|
4139
|
+
printKeyValue("date", new Date(email.date).toISOString());
|
|
4140
|
+
printKeyValue("labels", email.labelIds.join(", ") || "-");
|
|
4141
|
+
console.log("");
|
|
4142
|
+
console.log(email.textPlain || email.body || email.snippet);
|
|
4143
|
+
});
|
|
4144
|
+
program.command("archive [id]").description("Archive email(s)").option("-q, --query <query>", "Archive all emails matching query").action(async (id, options) => {
|
|
4145
|
+
const status = await loadRuntimeStatus();
|
|
4146
|
+
if (!status.gmailReady) {
|
|
4147
|
+
await requireLiveGmailReadiness("archive");
|
|
4148
|
+
return;
|
|
4149
|
+
}
|
|
4150
|
+
try {
|
|
4151
|
+
const ids = await resolveMessageIds(id, options.query);
|
|
4152
|
+
if (ids.length === 0) {
|
|
4153
|
+
console.log("No matching Gmail messages.");
|
|
4154
|
+
return;
|
|
4155
|
+
}
|
|
4156
|
+
await confirmBatchIfNeeded(options.query, ids, "Archive");
|
|
4157
|
+
const result = await archiveEmails(ids);
|
|
4158
|
+
const runId = await recordManualRun({
|
|
4159
|
+
query: options.query,
|
|
4160
|
+
requestedActions: [{ type: "archive" }],
|
|
4161
|
+
result
|
|
4162
|
+
});
|
|
4163
|
+
printMutationSummary(result, runId);
|
|
4164
|
+
} catch (error) {
|
|
4165
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4166
|
+
process.exitCode = 1;
|
|
4167
|
+
}
|
|
4168
|
+
});
|
|
4169
|
+
program.command("label <idOrLabel> [label]").description("Apply label to email(s)").option("-q, --query <query>", "Label all emails matching query").action(async (idOrLabel, label, options) => {
|
|
4170
|
+
const status = await loadRuntimeStatus();
|
|
4171
|
+
if (!status.gmailReady) {
|
|
4172
|
+
await requireLiveGmailReadiness("label");
|
|
4173
|
+
return;
|
|
4174
|
+
}
|
|
4175
|
+
const labelName = options.query ? label || idOrLabel : label;
|
|
4176
|
+
const id = options.query ? void 0 : idOrLabel;
|
|
4177
|
+
if (!labelName) {
|
|
4178
|
+
console.log("Provide a label name.");
|
|
4179
|
+
process.exitCode = 1;
|
|
4180
|
+
return;
|
|
4181
|
+
}
|
|
4182
|
+
try {
|
|
4183
|
+
const ids = await resolveMessageIds(id, options.query);
|
|
4184
|
+
if (ids.length === 0) {
|
|
4185
|
+
console.log("No matching Gmail messages.");
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
await confirmBatchIfNeeded(options.query, ids, "Apply label to");
|
|
4189
|
+
const result = await labelEmails(ids, labelName);
|
|
4190
|
+
const runId = await recordManualRun({
|
|
4191
|
+
query: options.query,
|
|
4192
|
+
requestedActions: [{ type: "label", label: labelName }],
|
|
4193
|
+
result
|
|
4194
|
+
});
|
|
4195
|
+
printMutationSummary(result, runId);
|
|
4196
|
+
} catch (error) {
|
|
4197
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4198
|
+
process.exitCode = 1;
|
|
4199
|
+
}
|
|
4200
|
+
});
|
|
4201
|
+
program.command("read [id]").description("Mark email(s) as read").option("-q, --query <query>", "Mark all matching emails as read").action(async (id, options) => {
|
|
4202
|
+
const status = await loadRuntimeStatus();
|
|
4203
|
+
if (!status.gmailReady) {
|
|
4204
|
+
await requireLiveGmailReadiness("read");
|
|
4205
|
+
return;
|
|
4206
|
+
}
|
|
4207
|
+
try {
|
|
4208
|
+
const ids = await resolveMessageIds(id, options.query);
|
|
4209
|
+
if (ids.length === 0) {
|
|
4210
|
+
console.log("No matching Gmail messages.");
|
|
4211
|
+
return;
|
|
4212
|
+
}
|
|
4213
|
+
await confirmBatchIfNeeded(options.query, ids, "Mark as read");
|
|
4214
|
+
const result = await markRead(ids);
|
|
4215
|
+
const runId = await recordManualRun({
|
|
4216
|
+
query: options.query,
|
|
4217
|
+
requestedActions: [{ type: "mark_read" }],
|
|
4218
|
+
result
|
|
4219
|
+
});
|
|
4220
|
+
printMutationSummary(result, runId);
|
|
4221
|
+
} catch (error) {
|
|
4222
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4223
|
+
process.exitCode = 1;
|
|
4224
|
+
}
|
|
4225
|
+
});
|
|
4226
|
+
program.command("forward <id> <email>").description("Forward email to another address").action(async (id, email) => {
|
|
4227
|
+
const status = await loadRuntimeStatus();
|
|
4228
|
+
if (!status.gmailReady) {
|
|
4229
|
+
await requireLiveGmailReadiness("forward");
|
|
4230
|
+
return;
|
|
4231
|
+
}
|
|
4232
|
+
try {
|
|
4233
|
+
const result = await forwardEmail(id, email);
|
|
4234
|
+
const runId = await recordManualRun({
|
|
4235
|
+
requestedActions: [{ type: "forward", to: email }],
|
|
4236
|
+
result
|
|
4237
|
+
});
|
|
4238
|
+
printMutationSummary(result, runId);
|
|
4239
|
+
} catch (error) {
|
|
4240
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4241
|
+
process.exitCode = 1;
|
|
4242
|
+
}
|
|
4243
|
+
});
|
|
4244
|
+
program.command("undo <run-id>").description("Undo a previous action run").action(async (runId) => {
|
|
4245
|
+
const status = await loadRuntimeStatus();
|
|
4246
|
+
if (!status.gmailReady) {
|
|
4247
|
+
await requireLiveGmailReadiness("undo");
|
|
4248
|
+
return;
|
|
4249
|
+
}
|
|
4250
|
+
try {
|
|
4251
|
+
const result = await undoRun(runId);
|
|
4252
|
+
printSection(`Undo ${ui.dim(result.runId)}`);
|
|
4253
|
+
printKeyValue("status", formatStatus(result.status));
|
|
4254
|
+
printKeyValue("undone", ui.green(String(result.undoneCount)));
|
|
4255
|
+
printKeyValue("warnings", result.warningCount > 0 ? ui.yellow(String(result.warningCount)) : ui.dim("0"));
|
|
4256
|
+
printKeyValue("errors", result.errorCount > 0 ? ui.red(String(result.errorCount)) : ui.dim("0"));
|
|
4257
|
+
for (const warning of result.warnings) {
|
|
4258
|
+
console.log(`${ui.yellow("warning")} ${warning}`);
|
|
4259
|
+
}
|
|
4260
|
+
} catch (error) {
|
|
4261
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4262
|
+
process.exitCode = 1;
|
|
4263
|
+
}
|
|
4264
|
+
});
|
|
4265
|
+
program.command("history").description("Show recent run history").option("-n, --count <number>", "Number of entries", "20").option("--email <id>", "Filter by email ID").action(async (options) => {
|
|
4266
|
+
const runs = options.email ? await getRunsByEmail(options.email) : await getRecentRuns(Number(options.count));
|
|
4267
|
+
if (runs.length === 0) {
|
|
4268
|
+
console.log("No action history yet.");
|
|
4269
|
+
return;
|
|
4270
|
+
}
|
|
4271
|
+
printHistoryTable(runs);
|
|
4272
|
+
});
|
|
4273
|
+
var labels = program.command("labels").description("Manage Gmail labels");
|
|
4274
|
+
labels.command("list").description("List all Gmail labels").action(async () => {
|
|
4275
|
+
const status = await loadRuntimeStatus();
|
|
4276
|
+
if (!status.gmailReady) {
|
|
4277
|
+
await requireLiveGmailReadiness("labels list");
|
|
4278
|
+
return;
|
|
4279
|
+
}
|
|
4280
|
+
const labels2 = await listLabels();
|
|
4281
|
+
printSection("Labels");
|
|
4282
|
+
console.log(
|
|
4283
|
+
[
|
|
4284
|
+
pad2(ui.dim("TYPE"), 8),
|
|
4285
|
+
pad2(ui.dim("NAME"), 24),
|
|
4286
|
+
pad2(ui.dim("MESSAGES"), 10),
|
|
4287
|
+
pad2(ui.dim("UNREAD"), 8),
|
|
4288
|
+
ui.dim("ID")
|
|
4289
|
+
].join(" ")
|
|
4290
|
+
);
|
|
4291
|
+
for (const label of labels2) {
|
|
4292
|
+
console.log(
|
|
4293
|
+
[
|
|
4294
|
+
pad2(label.type === "system" ? ui.cyan(label.type) : ui.magenta(label.type), 8),
|
|
4295
|
+
pad2(truncate2(label.name, 24), 24),
|
|
4296
|
+
pad2(String(label.messagesTotal), 10),
|
|
4297
|
+
pad2(String(label.messagesUnread), 8),
|
|
4298
|
+
ui.dim(label.id)
|
|
4299
|
+
].join(" ")
|
|
4300
|
+
);
|
|
4301
|
+
}
|
|
4302
|
+
});
|
|
4303
|
+
labels.command("create <name>").description("Create a new Gmail label").action(async (name) => {
|
|
4304
|
+
const status = await loadRuntimeStatus();
|
|
4305
|
+
if (!status.gmailReady) {
|
|
4306
|
+
await requireLiveGmailReadiness("labels create");
|
|
4307
|
+
return;
|
|
4308
|
+
}
|
|
4309
|
+
try {
|
|
4310
|
+
const label = await createLabel(name);
|
|
4311
|
+
printSection("Label Created");
|
|
4312
|
+
printKeyValue("name", label.name);
|
|
4313
|
+
printKeyValue("id", ui.dim(label.id));
|
|
4314
|
+
} catch (error) {
|
|
4315
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4316
|
+
process.exitCode = 1;
|
|
4317
|
+
}
|
|
4318
|
+
});
|
|
4319
|
+
var stats = program.command("stats").description("Email analytics");
|
|
4320
|
+
stats.command("overview", { isDefault: true }).description("Inbox overview stats").action(async () => {
|
|
4321
|
+
const overview = await getInboxOverview();
|
|
4322
|
+
if (overview.total === 0) {
|
|
4323
|
+
console.log("No cached emails yet. Run `inboxctl sync` first.");
|
|
4324
|
+
return;
|
|
4325
|
+
}
|
|
4326
|
+
printInboxOverview(overview);
|
|
4327
|
+
});
|
|
4328
|
+
stats.command("senders").description("Top senders by volume").option("--top <number>", "Number of senders", "20").option("--period <period>", "Time period (day|week|month|year|all)", "all").option("--min-unread <percent>", "Minimum unread rate filter").action(async (options) => {
|
|
4329
|
+
try {
|
|
4330
|
+
const top = parseIntegerOption(options.top, "top");
|
|
4331
|
+
const minUnread = parsePercentOption(options.minUnread, "min-unread");
|
|
4332
|
+
const period = options.period;
|
|
4333
|
+
if (!["day", "week", "month", "year", "all"].includes(period)) {
|
|
4334
|
+
throw new Error("period must be one of: day, week, month, year, all.");
|
|
4335
|
+
}
|
|
4336
|
+
const senders = await getTopSenders({
|
|
4337
|
+
limit: top,
|
|
4338
|
+
period,
|
|
4339
|
+
minUnreadRate: minUnread
|
|
4340
|
+
});
|
|
4341
|
+
if (senders.length === 0) {
|
|
4342
|
+
console.log("No cached sender stats matched that filter.");
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
printTopSendersTable(period, senders);
|
|
4346
|
+
} catch (error) {
|
|
4347
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4348
|
+
process.exitCode = 1;
|
|
4349
|
+
}
|
|
4350
|
+
});
|
|
4351
|
+
stats.command("newsletters").description("Detected newsletters and mailing lists").option("--min-unread <percent>", "Minimum unread rate filter").action(async (options) => {
|
|
4352
|
+
try {
|
|
4353
|
+
const minUnread = parsePercentOption(options.minUnread, "min-unread");
|
|
4354
|
+
const newsletters = await getNewsletters({
|
|
4355
|
+
minUnreadRate: minUnread
|
|
4356
|
+
});
|
|
4357
|
+
if (newsletters.length === 0) {
|
|
4358
|
+
console.log("No newsletter senders matched that filter.");
|
|
4359
|
+
return;
|
|
4360
|
+
}
|
|
4361
|
+
printNewslettersTable(newsletters);
|
|
4362
|
+
} catch (error) {
|
|
4363
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4364
|
+
process.exitCode = 1;
|
|
4365
|
+
}
|
|
4366
|
+
});
|
|
4367
|
+
stats.command("labels").description("Email count per label").action(async () => {
|
|
4368
|
+
const labels2 = await getLabelDistribution();
|
|
4369
|
+
if (labels2.length === 0) {
|
|
4370
|
+
console.log("No cached emails yet. Run `inboxctl sync` first.");
|
|
4371
|
+
return;
|
|
4372
|
+
}
|
|
4373
|
+
printLabelDistributionTable(labels2);
|
|
4374
|
+
});
|
|
4375
|
+
stats.command("volume").description("Email volume over time").option("--period <period>", "Granularity (day|week|month)", "day").action(async (options) => {
|
|
4376
|
+
try {
|
|
4377
|
+
const period = options.period;
|
|
4378
|
+
if (!["day", "week", "month"].includes(period)) {
|
|
4379
|
+
throw new Error("period must be one of: day, week, month.");
|
|
4380
|
+
}
|
|
4381
|
+
const points = await getVolumeByPeriod(period, getVolumeRange(period));
|
|
4382
|
+
if (points.length === 0) {
|
|
4383
|
+
console.log("No cached emails yet. Run `inboxctl sync` first.");
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
printVolumeTable(period, points);
|
|
4387
|
+
} catch (error) {
|
|
4388
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4389
|
+
process.exitCode = 1;
|
|
4390
|
+
}
|
|
4391
|
+
});
|
|
4392
|
+
stats.command("sender <email>").description("Detailed stats for a sender or @domain").action(async (email) => {
|
|
4393
|
+
const detail = await getSenderStats(email);
|
|
4394
|
+
if (!detail) {
|
|
4395
|
+
console.log(`No cached emails found for ${email}.`);
|
|
4396
|
+
return;
|
|
4397
|
+
}
|
|
4398
|
+
printSenderDetail(detail);
|
|
4399
|
+
});
|
|
4400
|
+
var rules = program.command("rules").description("Rule management (IaC)");
|
|
4401
|
+
rules.command("deploy [file]").description("Deploy rules from YAML files").action(async (file) => {
|
|
4402
|
+
const config = loadConfig();
|
|
4403
|
+
try {
|
|
4404
|
+
const result = file ? await deployLoadedRule(await loadRuleFile(file)) : await deployAllRules(config.rulesDir);
|
|
4405
|
+
printRuleDeploySummary(result);
|
|
4406
|
+
} catch (error) {
|
|
4407
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4408
|
+
process.exitCode = 1;
|
|
4409
|
+
}
|
|
4410
|
+
});
|
|
4411
|
+
rules.command("status [name]").description("Show deployed rules and their status").action(async (name) => {
|
|
4412
|
+
try {
|
|
4413
|
+
if (name) {
|
|
4414
|
+
const rule = await getRuleStatus(name);
|
|
4415
|
+
if (!rule) {
|
|
4416
|
+
console.log(`Rule not found: ${name}`);
|
|
4417
|
+
process.exitCode = 1;
|
|
4418
|
+
return;
|
|
4419
|
+
}
|
|
4420
|
+
printRuleStatusDetail(rule);
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
4423
|
+
const rules2 = await getAllRulesStatus();
|
|
4424
|
+
if (rules2.length === 0) {
|
|
4425
|
+
console.log("No rules have been deployed yet.");
|
|
4426
|
+
return;
|
|
4427
|
+
}
|
|
4428
|
+
printRuleStatusTable(rules2);
|
|
4429
|
+
} catch (error) {
|
|
4430
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4431
|
+
process.exitCode = 1;
|
|
4432
|
+
}
|
|
4433
|
+
});
|
|
4434
|
+
rules.command("run [name]").description("Execute a rule against matching emails").option("--apply", "Actually execute (default is dry-run)").option("--max <number>", "Maximum emails to process", "100").option("--all", "Run all enabled rules").action(async (name, options) => {
|
|
4435
|
+
const maxEmails = Number(options.max);
|
|
4436
|
+
if (!Number.isInteger(maxEmails) || maxEmails <= 0) {
|
|
4437
|
+
console.log(`Invalid --max value: ${options.max}`);
|
|
4438
|
+
process.exitCode = 1;
|
|
4439
|
+
return;
|
|
4440
|
+
}
|
|
4441
|
+
if (!options.all && !name) {
|
|
4442
|
+
console.log("Provide a rule name or use --all.");
|
|
4443
|
+
process.exitCode = 1;
|
|
4444
|
+
return;
|
|
4445
|
+
}
|
|
4446
|
+
if (options.apply) {
|
|
4447
|
+
const status = await loadRuntimeStatus();
|
|
4448
|
+
if (!status.gmailReady) {
|
|
4449
|
+
await requireLiveGmailReadiness("rules run --apply");
|
|
4450
|
+
return;
|
|
4451
|
+
}
|
|
4452
|
+
}
|
|
4453
|
+
try {
|
|
4454
|
+
if (options.all) {
|
|
4455
|
+
const result2 = await runAllRules({
|
|
4456
|
+
dryRun: !options.apply,
|
|
4457
|
+
maxEmails
|
|
4458
|
+
});
|
|
4459
|
+
printRunAllRulesResult(result2);
|
|
4460
|
+
return;
|
|
4461
|
+
}
|
|
4462
|
+
const result = await runRule(name, {
|
|
4463
|
+
dryRun: !options.apply,
|
|
4464
|
+
maxEmails
|
|
4465
|
+
});
|
|
4466
|
+
printRuleRunResult(result);
|
|
4467
|
+
} catch (error) {
|
|
4468
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4469
|
+
process.exitCode = 1;
|
|
4470
|
+
}
|
|
4471
|
+
});
|
|
4472
|
+
rules.command("undo <run-id>").description("Undo a specific rule run").action(async (runId) => {
|
|
4473
|
+
const status = await loadRuntimeStatus();
|
|
4474
|
+
if (!status.gmailReady) {
|
|
4475
|
+
await requireLiveGmailReadiness("rules undo");
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
try {
|
|
4479
|
+
const result = await undoRun(runId);
|
|
4480
|
+
printSection(`Undo ${ui.dim(result.runId)}`);
|
|
4481
|
+
printKeyValue("status", formatStatus(result.status));
|
|
4482
|
+
printKeyValue("undone", ui.green(String(result.undoneCount)));
|
|
4483
|
+
printKeyValue("warnings", result.warningCount > 0 ? ui.yellow(String(result.warningCount)) : ui.dim("0"));
|
|
4484
|
+
printKeyValue("errors", result.errorCount > 0 ? ui.red(String(result.errorCount)) : ui.dim("0"));
|
|
4485
|
+
} catch (error) {
|
|
4486
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4487
|
+
process.exitCode = 1;
|
|
4488
|
+
}
|
|
4489
|
+
});
|
|
4490
|
+
rules.command("enable <name>").description("Enable a deployed rule").action(async (name) => {
|
|
4491
|
+
try {
|
|
4492
|
+
const rule = await enableRule(name);
|
|
4493
|
+
printSection("Rule Enabled");
|
|
4494
|
+
printKeyValue("name", rule.name);
|
|
4495
|
+
printKeyValue("enabled", formatEnabled(rule.enabled));
|
|
4496
|
+
} catch (error) {
|
|
4497
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4498
|
+
process.exitCode = 1;
|
|
4499
|
+
}
|
|
4500
|
+
});
|
|
4501
|
+
rules.command("disable <name>").description("Disable a deployed rule").action(async (name) => {
|
|
4502
|
+
try {
|
|
4503
|
+
const rule = await disableRule(name);
|
|
4504
|
+
printSection("Rule Disabled");
|
|
4505
|
+
printKeyValue("name", rule.name);
|
|
4506
|
+
printKeyValue("enabled", formatEnabled(rule.enabled));
|
|
4507
|
+
} catch (error) {
|
|
4508
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4509
|
+
process.exitCode = 1;
|
|
4510
|
+
}
|
|
4511
|
+
});
|
|
4512
|
+
rules.command("diff").description("Show drift between YAML files and deployed rules").action(async () => {
|
|
4513
|
+
const config = loadConfig();
|
|
4514
|
+
try {
|
|
4515
|
+
const result = await detectDrift(config.rulesDir);
|
|
4516
|
+
if (result.entries.length === 0) {
|
|
4517
|
+
console.log("No rule files found.");
|
|
4518
|
+
return;
|
|
4519
|
+
}
|
|
4520
|
+
printDriftReport(result);
|
|
4521
|
+
} catch (error) {
|
|
4522
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4523
|
+
process.exitCode = 1;
|
|
4524
|
+
}
|
|
4525
|
+
});
|
|
4526
|
+
var filters = program.command("filters").description("Gmail server-side filters (always-on, applied at delivery time)");
|
|
4527
|
+
filters.command("list").description("List all Gmail server-side filters").action(async () => {
|
|
4528
|
+
try {
|
|
4529
|
+
const all = await listFilters();
|
|
4530
|
+
if (all.length === 0) {
|
|
4531
|
+
console.log("No Gmail filters found.");
|
|
4532
|
+
return;
|
|
4533
|
+
}
|
|
4534
|
+
printSection("Gmail Filters");
|
|
4535
|
+
console.log(
|
|
4536
|
+
[
|
|
4537
|
+
pad2(ui.dim("ID"), 32),
|
|
4538
|
+
pad2(ui.dim("CRITERIA"), 36),
|
|
4539
|
+
ui.dim("ACTIONS")
|
|
4540
|
+
].join(" ")
|
|
4541
|
+
);
|
|
4542
|
+
for (const f of all) {
|
|
4543
|
+
const criteria = [];
|
|
4544
|
+
if (f.criteria.from) criteria.push(`from:${f.criteria.from}`);
|
|
4545
|
+
if (f.criteria.to) criteria.push(`to:${f.criteria.to}`);
|
|
4546
|
+
if (f.criteria.subject) criteria.push(`subject:${f.criteria.subject}`);
|
|
4547
|
+
if (f.criteria.query) criteria.push(`query:${f.criteria.query}`);
|
|
4548
|
+
if (f.criteria.hasAttachment) criteria.push("has-attachment");
|
|
4549
|
+
if (f.criteria.size != null) criteria.push(`size:${f.criteria.sizeComparison ?? ""}${f.criteria.size}`);
|
|
4550
|
+
const actions = [];
|
|
4551
|
+
if (f.actions.archive) actions.push("archive");
|
|
4552
|
+
if (f.actions.markRead) actions.push("mark-read");
|
|
4553
|
+
if (f.actions.star) actions.push("star");
|
|
4554
|
+
if (f.actions.addLabelNames.length > 0) actions.push(`label:${f.actions.addLabelNames.join(",")}`);
|
|
4555
|
+
if (f.actions.forward) actions.push(`forward:${f.actions.forward}`);
|
|
4556
|
+
console.log(
|
|
4557
|
+
[
|
|
4558
|
+
pad2(ui.dim(f.id), 32),
|
|
4559
|
+
pad2(truncate2(criteria.join(" "), 36), 36),
|
|
4560
|
+
truncate2(actions.join(", ") || "-", 60)
|
|
4561
|
+
].join(" ")
|
|
4562
|
+
);
|
|
4563
|
+
}
|
|
4564
|
+
} catch (error) {
|
|
4565
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4566
|
+
process.exitCode = 1;
|
|
4567
|
+
}
|
|
4568
|
+
});
|
|
4569
|
+
filters.command("get <id>").description("Get details of a Gmail server-side filter").action(async (id) => {
|
|
4570
|
+
try {
|
|
4571
|
+
const f = await getFilter(id);
|
|
4572
|
+
printSection("Filter");
|
|
4573
|
+
printKeyValue("id", f.id);
|
|
4574
|
+
console.log();
|
|
4575
|
+
console.log(ui.bold("Criteria"));
|
|
4576
|
+
if (f.criteria.from) printKeyValue(" from", f.criteria.from);
|
|
4577
|
+
if (f.criteria.to) printKeyValue(" to", f.criteria.to);
|
|
4578
|
+
if (f.criteria.subject) printKeyValue(" subject", f.criteria.subject);
|
|
4579
|
+
if (f.criteria.query) printKeyValue(" query", f.criteria.query);
|
|
4580
|
+
if (f.criteria.negatedQuery) printKeyValue(" not", f.criteria.negatedQuery);
|
|
4581
|
+
if (f.criteria.hasAttachment) printKeyValue(" has-attachment", "yes");
|
|
4582
|
+
if (f.criteria.excludeChats) printKeyValue(" exclude-chats", "yes");
|
|
4583
|
+
if (f.criteria.size != null) {
|
|
4584
|
+
printKeyValue(
|
|
4585
|
+
" size",
|
|
4586
|
+
`${f.criteria.sizeComparison ?? ""} ${f.criteria.size} bytes`
|
|
4587
|
+
);
|
|
4588
|
+
}
|
|
4589
|
+
console.log();
|
|
4590
|
+
console.log(ui.bold("Actions"));
|
|
4591
|
+
if (f.actions.archive) printKeyValue(" archive", "yes");
|
|
4592
|
+
if (f.actions.markRead) printKeyValue(" mark-read", "yes");
|
|
4593
|
+
if (f.actions.star) printKeyValue(" star", "yes");
|
|
4594
|
+
if (f.actions.addLabelNames.length > 0) printKeyValue(" add-label", f.actions.addLabelNames.join(", "));
|
|
4595
|
+
if (f.actions.removeLabelNames.length > 0) printKeyValue(" remove-label", f.actions.removeLabelNames.join(", "));
|
|
4596
|
+
if (f.actions.forward) printKeyValue(" forward", f.actions.forward);
|
|
4597
|
+
} catch (error) {
|
|
4598
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4599
|
+
process.exitCode = 1;
|
|
4600
|
+
}
|
|
4601
|
+
});
|
|
4602
|
+
filters.command("create").description("Create a Gmail server-side filter").option("--from <address>", "Match emails from this address").option("--to <address>", "Match emails sent to this address").option("--subject <text>", "Match emails with this text in the subject").option("--query <q>", "Match using Gmail search syntax").option("--negated-query <q>", "Exclude emails matching this Gmail query").option("--has-attachment", "Match emails with attachments").option("--exclude-chats", "Exclude chat messages from matches").option("--size <bytes>", "Match emails by size threshold", parseInt).option("--size-comparison <direction>", "larger or smaller (use with --size)").option("--label <name>", "Apply this label to matching emails (created if missing)").option("--archive", "Archive matching emails (skip inbox)").option("--mark-read", "Mark matching emails as read").option("--star", "Star matching emails").option("--forward <email>", "Forward matching emails to this address").action(async (opts) => {
|
|
4603
|
+
try {
|
|
4604
|
+
const f = await createFilter({
|
|
4605
|
+
from: opts.from,
|
|
4606
|
+
to: opts.to,
|
|
4607
|
+
subject: opts.subject,
|
|
4608
|
+
query: opts.query,
|
|
4609
|
+
negatedQuery: opts.negatedQuery,
|
|
4610
|
+
hasAttachment: opts.hasAttachment || void 0,
|
|
4611
|
+
excludeChats: opts.excludeChats || void 0,
|
|
4612
|
+
size: opts.size,
|
|
4613
|
+
sizeComparison: opts.sizeComparison,
|
|
4614
|
+
labelName: opts.label,
|
|
4615
|
+
archive: opts.archive || void 0,
|
|
4616
|
+
markRead: opts.markRead || void 0,
|
|
4617
|
+
star: opts.star || void 0,
|
|
4618
|
+
forward: opts.forward
|
|
4619
|
+
});
|
|
4620
|
+
printSection("Filter Created");
|
|
4621
|
+
printKeyValue("id", f.id);
|
|
4622
|
+
const criteriaStr = [];
|
|
4623
|
+
if (f.criteria.from) criteriaStr.push(`from:${f.criteria.from}`);
|
|
4624
|
+
if (f.criteria.to) criteriaStr.push(`to:${f.criteria.to}`);
|
|
4625
|
+
if (f.criteria.subject) criteriaStr.push(`subject:${f.criteria.subject}`);
|
|
4626
|
+
if (f.criteria.query) criteriaStr.push(f.criteria.query);
|
|
4627
|
+
if (criteriaStr.length > 0) printKeyValue("criteria", criteriaStr.join(", "));
|
|
4628
|
+
const actionsStr = [];
|
|
4629
|
+
if (f.actions.archive) actionsStr.push("archive");
|
|
4630
|
+
if (f.actions.markRead) actionsStr.push("mark-read");
|
|
4631
|
+
if (f.actions.star) actionsStr.push("star");
|
|
4632
|
+
if (f.actions.addLabelNames.length > 0) actionsStr.push(`label: ${f.actions.addLabelNames.join(", ")}`);
|
|
4633
|
+
if (f.actions.forward) actionsStr.push(`forward: ${f.actions.forward}`);
|
|
4634
|
+
if (actionsStr.length > 0) printKeyValue("actions", actionsStr.join(", "));
|
|
4635
|
+
console.log();
|
|
4636
|
+
console.log(ui.dim("Note: filters apply to incoming mail from now on. To process existing mail, use `inboxctl rules`."));
|
|
4637
|
+
} catch (error) {
|
|
4638
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4639
|
+
process.exitCode = 1;
|
|
4640
|
+
}
|
|
4641
|
+
});
|
|
4642
|
+
filters.command("delete <id>").description("Delete a Gmail server-side filter").action(async (id) => {
|
|
4643
|
+
try {
|
|
4644
|
+
await deleteFilter(id);
|
|
4645
|
+
printSection("Filter Deleted");
|
|
4646
|
+
printKeyValue("id", id);
|
|
4647
|
+
} catch (error) {
|
|
4648
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
4649
|
+
process.exitCode = 1;
|
|
4650
|
+
}
|
|
4651
|
+
});
|
|
4652
|
+
program.command("demo").description("Launch the seeded demo mailbox for screenshots, recordings, and safe exploration").action(async () => {
|
|
4653
|
+
await runDemoSession();
|
|
4654
|
+
});
|
|
4655
|
+
program.command("mcp").description("Start MCP server on stdio").action(async () => {
|
|
4656
|
+
await startMcpServer();
|
|
4657
|
+
});
|
|
4658
|
+
program.action(async (options) => {
|
|
4659
|
+
if (options.demo) {
|
|
4660
|
+
await runDemoSession();
|
|
4661
|
+
return;
|
|
4662
|
+
}
|
|
4663
|
+
await startTuiApp({
|
|
4664
|
+
noSync: Boolean(options.noSync)
|
|
4665
|
+
});
|
|
4666
|
+
});
|
|
4667
|
+
program.parse();
|
|
4668
|
+
//# sourceMappingURL=cli.js.map
|