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/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