zele 0.3.13 → 0.3.15

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.
Files changed (44) hide show
  1. package/README.md +8 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/auth.js.map +1 -1
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +10 -8
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/attachment.js +44 -5
  8. package/dist/commands/attachment.js.map +1 -1
  9. package/dist/commands/auth-cmd.js +1 -2
  10. package/dist/commands/auth-cmd.js.map +1 -1
  11. package/dist/commands/calendar.js +4 -6
  12. package/dist/commands/calendar.js.map +1 -1
  13. package/dist/commands/draft.js +2 -3
  14. package/dist/commands/draft.js.map +1 -1
  15. package/dist/commands/label.js +4 -5
  16. package/dist/commands/label.js.map +1 -1
  17. package/dist/commands/mail.js +24 -21
  18. package/dist/commands/mail.js.map +1 -1
  19. package/dist/db.js +24 -1
  20. package/dist/db.js.map +1 -1
  21. package/dist/gmail-client.js +39 -4
  22. package/dist/gmail-client.js.map +1 -1
  23. package/dist/mail-tui.js +47 -12
  24. package/dist/mail-tui.js.map +1 -1
  25. package/dist/output.d.ts +3 -1
  26. package/dist/output.js +7 -2
  27. package/dist/output.js.map +1 -1
  28. package/package.json +11 -5
  29. package/src/app.log +9 -0
  30. package/src/auth.ts +1 -1
  31. package/src/cli.ts +31 -30
  32. package/src/commands/attachment.ts +49 -6
  33. package/src/commands/auth-cmd.ts +1 -2
  34. package/src/commands/calendar.ts +4 -7
  35. package/src/commands/draft.ts +2 -3
  36. package/src/commands/label.ts +4 -4
  37. package/src/commands/mail.ts +24 -19
  38. package/src/db.ts +26 -1
  39. package/src/gmail-client.ts +46 -4
  40. package/src/mail-tui.test.ts +170 -0
  41. package/src/mail-tui.tsx +87 -20
  42. package/src/output.ts +8 -3
  43. package/bin/zele +0 -27
  44. package/src/opentui-react.d.ts +0 -9
@@ -33,13 +33,11 @@ export function registerAttachmentCommands(cli: Goke) {
33
33
  )
34
34
 
35
35
  if (attachments.length === 0) {
36
- out.hint('No attachments')
36
+ out.printList([], { summary: 'No attachments' })
37
37
  return
38
38
  }
39
39
 
40
- out.printList(attachments)
41
-
42
- out.hint(`${attachments.length} attachment(s)`)
40
+ out.printList(attachments, { summary: `${attachments.length} attachment(s)` })
43
41
  out.hint('Use: zele attachment get <messageId> <attachmentId>')
44
42
  })
45
43
 
@@ -63,9 +61,18 @@ export function registerAttachmentCommands(cli: Goke) {
63
61
  }
64
62
 
65
63
  const meta = msg.attachments.find((a) => a.attachmentId === attachmentId)
66
- const filename = options.filename ?? meta?.filename ?? `${messageId}_${attachmentId.slice(0, 8)}`
64
+ const fallbackFilename = `${messageId}_${attachmentId.slice(0, 8)}`
65
+ const rawFilename = options.filename ?? meta?.filename
66
+ const filename = sanitizeFilename(rawFilename, fallbackFilename)
67
+
68
+ const outDir = path.resolve(options.outDir)
69
+ const outPath = path.join(outDir, filename)
67
70
 
68
- const outPath = path.resolve(options.outDir, filename)
71
+ // Security: verify the resolved path is within the output directory
72
+ if (!outPath.startsWith(outDir + path.sep) && outPath !== outDir) {
73
+ out.error(`Security error: filename "${rawFilename}" would write outside output directory`)
74
+ process.exit(1)
75
+ }
69
76
 
70
77
  // Check if file already exists with same size (skip re-download)
71
78
  if (fs.existsSync(outPath) && meta) {
@@ -103,3 +110,39 @@ function formatSize(bytes: number): string {
103
110
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
104
111
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
105
112
  }
113
+
114
+ /**
115
+ * Sanitize a filename to prevent path traversal and filesystem issues.
116
+ * - Strips directory components (path traversal prevention)
117
+ * - Removes null bytes and control characters
118
+ * - Replaces characters problematic on Windows/macOS/Linux
119
+ * - Handles Windows reserved names
120
+ * - Limits length to 255 characters
121
+ */
122
+ function sanitizeFilename(name: string | undefined, fallback: string): string {
123
+ if (!name || name.trim().length === 0) return fallback
124
+
125
+ let sanitized = name
126
+ // Strip directory components (critical for path traversal prevention)
127
+ .split(/[/\\]/).pop() || ''
128
+ // Remove null bytes and control characters
129
+ .replace(/[\x00-\x1f]/g, '')
130
+ // Replace characters problematic across filesystems: < > : " / \ | ? *
131
+ .replace(/[<>:"/\\|?*]/g, '_')
132
+ // Trim leading/trailing spaces and dots (problematic on Windows)
133
+ .replace(/^[\s.]+|[\s.]+$/g, '')
134
+
135
+ // Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
136
+ if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(sanitized)) {
137
+ sanitized = `_${sanitized}`
138
+ }
139
+
140
+ // Limit length (255 is common filesystem limit)
141
+ if (sanitized.length > 255) {
142
+ const ext = path.extname(sanitized)
143
+ const base = sanitized.slice(0, 255 - ext.length)
144
+ sanitized = base + ext
145
+ }
146
+
147
+ return sanitized.length > 0 ? sanitized : fallback
148
+ }
@@ -92,8 +92,7 @@ export function registerAuthCommands(cli: Goke) {
92
92
  status: 'Authenticated',
93
93
  expires: s.expiresAt?.toISOString() ?? 'unknown',
94
94
  })),
95
+ { summary: `${statuses.length} account(s)` },
95
96
  )
96
-
97
- out.hint(`${statuses.length} account(s)`)
98
97
  })
99
98
  }
@@ -55,12 +55,11 @@ export function registerCalendarCommands(cli: Goke) {
55
55
  )
56
56
 
57
57
  if (merged.length === 0) {
58
- out.hint('No calendars found')
58
+ out.printList([], { summary: 'No calendars found' })
59
59
  return
60
60
  }
61
61
 
62
- out.printList(merged)
63
- out.hint(`${merged.length} calendars`)
62
+ out.printList(merged, { summary: `${merged.length} calendars` })
64
63
  })
65
64
 
66
65
  // =========================================================================
@@ -175,7 +174,7 @@ export function registerCalendarCommands(cli: Goke) {
175
174
  .slice(0, max)
176
175
 
177
176
  if (merged.length === 0) {
178
- out.hint('No events found')
177
+ out.printList([], { summary: 'No events found' })
179
178
  return
180
179
  }
181
180
 
@@ -192,10 +191,8 @@ export function registerCalendarCommands(cli: Goke) {
192
191
  ...(e.calendarId && e.calendarId !== calendarId ? { calendar: e.calendarId } : {}),
193
192
  }
194
193
  }),
195
- { nextPage: allResults[0]?.result.nextPageToken },
194
+ { nextPage: allResults[0]?.result.nextPageToken, summary: `${merged.length} events` },
196
195
  )
197
-
198
- out.hint(`${merged.length} events`)
199
196
  })
200
197
 
201
198
  // =========================================================================
@@ -59,7 +59,7 @@ export function registerDraftCommands(cli: Goke) {
59
59
  .slice(0, options.max)
60
60
 
61
61
  if (merged.length === 0) {
62
- out.hint('No drafts found')
62
+ out.printList([], { summary: 'No drafts found' })
63
63
  return
64
64
  }
65
65
 
@@ -72,9 +72,8 @@ export function registerDraftCommands(cli: Goke) {
72
72
  subject: d.subject,
73
73
  date: out.formatDate(d.date),
74
74
  })),
75
+ { summary: `${merged.length} draft(s)` },
75
76
  )
76
-
77
- out.hint(`${merged.length} draft(s)`)
78
77
  })
79
78
 
80
79
  // =========================================================================
@@ -40,7 +40,7 @@ export function registerLabelCommands(cli: Goke) {
40
40
  )
41
41
 
42
42
  if (merged.length === 0) {
43
- out.hint('No labels found')
43
+ out.printList([], { summary: 'No labels found' })
44
44
  return
45
45
  }
46
46
 
@@ -58,9 +58,8 @@ export function registerLabelCommands(cli: Goke) {
58
58
  name: l.name,
59
59
  type: l.type,
60
60
  })),
61
+ { summary: `${merged.length} label(s)` },
61
62
  )
62
-
63
- out.hint(`${merged.length} label(s)`)
64
63
  })
65
64
 
66
65
  // =========================================================================
@@ -168,7 +167,7 @@ export function registerLabelCommands(cli: Goke) {
168
167
  const withCounts = merged.filter((c) => c.count > 0).sort((a, b) => b.count - a.count)
169
168
 
170
169
  if (withCounts.length === 0) {
171
- out.hint('All clear — no unread messages')
170
+ out.printList([], { summary: 'All clear — no unread messages' })
172
171
  return
173
172
  }
174
173
 
@@ -179,6 +178,7 @@ export function registerLabelCommands(cli: Goke) {
179
178
  label: c.label,
180
179
  count: c.count,
181
180
  })),
181
+ { summary: `${withCounts.length} label(s) with unread` },
182
182
  )
183
183
  })
184
184
  }
@@ -6,7 +6,9 @@
6
6
  import type { Goke } from 'goke'
7
7
  import { z } from 'zod'
8
8
  import fs from 'node:fs'
9
+ import path from 'node:path'
9
10
  import React from 'react'
11
+ import { lookup as mimeLookup } from 'mrmime'
10
12
  import { getClients, getClient, listAccounts, login } from '../auth.js'
11
13
  import type { ThreadListResult } from '../gmail-client.js'
12
14
  import { AuthError } from '../api-utils.js'
@@ -23,19 +25,6 @@ export function registerMailCommands(cli: Goke) {
23
25
  // mail (TUI)
24
26
  // =========================================================================
25
27
 
26
- cli
27
- .command('mail', 'Browse emails in TUI')
28
- .action(async () => {
29
- const accounts = await listAccounts()
30
- if (accounts.length === 0) {
31
- const result = await login()
32
- if (result instanceof Error) handleCommandError(result)
33
- }
34
-
35
- const { renderWithProviders } = await import('termcast')
36
- const { default: Command } = await import('../mail-tui.js')
37
- await renderWithProviders(React.createElement(Command))
38
- })
39
28
 
40
29
  // =========================================================================
41
30
  // mail list
@@ -86,7 +75,7 @@ export function registerMailCommands(cli: Goke) {
86
75
  .slice(0, max)
87
76
 
88
77
  if (merged.length === 0) {
89
- out.hint('No threads found')
78
+ out.printList([], { summary: 'No threads found' })
90
79
  return
91
80
  }
92
81
 
@@ -101,9 +90,8 @@ export function registerMailCommands(cli: Goke) {
101
90
  date: out.formatDate(t.date),
102
91
  messages: t.messageCount,
103
92
  })),
93
+ { summary: `${merged.length} threads (${folder})` },
104
94
  )
105
-
106
- out.hint(`${merged.length} threads (${folder})`)
107
95
  })
108
96
 
109
97
  // =========================================================================
@@ -150,7 +138,7 @@ export function registerMailCommands(cli: Goke) {
150
138
  .slice(0, max)
151
139
 
152
140
  if (merged.length === 0) {
153
- out.hint(`No results for "${query}"`)
141
+ out.printList([], { summary: `No results for "${query}"` })
154
142
  return
155
143
  }
156
144
 
@@ -165,9 +153,8 @@ export function registerMailCommands(cli: Goke) {
165
153
  date: out.formatDate(t.date),
166
154
  messages: t.messageCount,
167
155
  })),
156
+ { summary: `${merged.length} results for "${query}"` },
168
157
  )
169
-
170
- out.hint(`${merged.length} results for "${query}"`)
171
158
  })
172
159
 
173
160
  // =========================================================================
@@ -280,6 +267,7 @@ export function registerMailCommands(cli: Goke) {
280
267
  .option('--cc <cc>', z.string().describe('CC recipients (comma-separated)'))
281
268
  .option('--bcc <bcc>', z.string().describe('BCC recipients (comma-separated)'))
282
269
  .option('--from <from>', z.string().describe('Send-as alias email'))
270
+ .option('--attach <attach>', z.array(z.string()).describe('File to attach (repeatable: --attach a.pdf --attach b.png)'))
283
271
  .action(async (options) => {
284
272
  if (!options.to) {
285
273
  out.error('--to is required')
@@ -308,6 +296,22 @@ export function registerMailCommands(cli: Goke) {
308
296
  process.exit(1)
309
297
  }
310
298
 
299
+ // Resolve attachment file paths (one file per --attach flag)
300
+ const attachments = options.attach
301
+ ? options.attach.map((filePath) => {
302
+ const resolved = path.resolve(filePath)
303
+ if (!fs.existsSync(resolved)) {
304
+ out.error(`Attachment not found: ${resolved}`)
305
+ process.exit(1)
306
+ }
307
+ return {
308
+ filename: path.basename(resolved),
309
+ mimeType: mimeLookup(resolved) ?? 'application/octet-stream',
310
+ content: fs.readFileSync(resolved),
311
+ }
312
+ })
313
+ : undefined
314
+
311
315
  const parseEmails = (str: string) =>
312
316
  str.split(',').map((e) => e.trim()).filter(Boolean).map((email) => ({ email }))
313
317
 
@@ -320,6 +324,7 @@ export function registerMailCommands(cli: Goke) {
320
324
  cc: options.cc ? parseEmails(options.cc) : undefined,
321
325
  bcc: options.bcc ? parseEmails(options.bcc) : undefined,
322
326
  fromEmail: options.from,
327
+ attachments,
323
328
  })
324
329
 
325
330
  out.printYaml(result)
package/src/db.ts CHANGED
@@ -37,8 +37,12 @@ export function getPrisma(): Promise<PrismaClient> {
37
37
  }
38
38
 
39
39
  async function initializePrisma(): Promise<PrismaClient> {
40
+ // Create directory with restrictive permissions (owner only)
40
41
  if (!fs.existsSync(ZELE_DIR)) {
41
- fs.mkdirSync(ZELE_DIR, { recursive: true })
42
+ fs.mkdirSync(ZELE_DIR, { recursive: true, mode: 0o700 })
43
+ } else {
44
+ // Ensure existing directory has correct permissions
45
+ fs.chmodSync(ZELE_DIR, 0o700)
42
46
  }
43
47
 
44
48
  const adapter = new PrismaLibSql({ url: `file:${DB_PATH}` })
@@ -54,6 +58,9 @@ async function initializePrisma(): Promise<PrismaClient> {
54
58
  // Run schema.sql — uses CREATE TABLE IF NOT EXISTS so it's idempotent
55
59
  await applySchema(prisma)
56
60
 
61
+ // Secure database files (owner read/write only)
62
+ secureDatabase()
63
+
57
64
  prismaInstance = prisma
58
65
  return prisma
59
66
  }
@@ -86,6 +93,24 @@ async function applySchema(prisma: PrismaClient): Promise<void> {
86
93
  }
87
94
  }
88
95
 
96
+ /**
97
+ * Set restrictive permissions on database files.
98
+ * SQLite WAL mode creates additional -wal and -shm files that also need protection.
99
+ */
100
+ function secureDatabase(): void {
101
+ const filesToSecure = [
102
+ DB_PATH,
103
+ `${DB_PATH}-wal`,
104
+ `${DB_PATH}-shm`,
105
+ ]
106
+
107
+ for (const filePath of filesToSecure) {
108
+ if (fs.existsSync(filePath)) {
109
+ fs.chmodSync(filePath, 0o600)
110
+ }
111
+ }
112
+ }
113
+
89
114
  /**
90
115
  * Close the Prisma connection.
91
116
  */
@@ -173,6 +173,15 @@ function sanitizeSnippet(snippet: string): string {
173
173
  .trim()
174
174
  }
175
175
 
176
+ /**
177
+ * Sanitize header values to prevent CRLF injection attacks.
178
+ * The mimetext library does not sanitize custom header values, so newlines
179
+ * in In-Reply-To or References could inject arbitrary headers.
180
+ */
181
+ function sanitizeHeaderValue(value: string): string {
182
+ return value.replace(/[\r\n]/g, ' ').trim()
183
+ }
184
+
176
185
  function encodeBase64Url(data: string | Buffer) {
177
186
  const buf = typeof data === 'string' ? Buffer.from(data) : data
178
187
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
@@ -205,6 +214,24 @@ function gmailBoundary<T>(email: string, fn: () => Promise<T>) {
205
214
  })
206
215
  }
207
216
 
217
+ /**
218
+ * Known folder names that map to Gmail system folders/labels.
219
+ * Used to validate custom label names and prevent query injection.
220
+ */
221
+ const KNOWN_FOLDERS = new Set([
222
+ 'inbox',
223
+ 'sent',
224
+ 'trash',
225
+ 'bin',
226
+ 'spam',
227
+ 'drafts',
228
+ 'draft',
229
+ 'starred',
230
+ 'archive',
231
+ 'snoozed',
232
+ 'all',
233
+ ])
234
+
208
235
  export class GmailClient {
209
236
  private gmail: gmail_v1.Gmail
210
237
  private labelIdCache: Record<string, string> = {}
@@ -1615,7 +1642,7 @@ export class GmailClient {
1615
1642
  })
1616
1643
 
1617
1644
  if (inReplyTo) {
1618
- msg.setHeader('In-Reply-To', inReplyTo)
1645
+ msg.setHeader('In-Reply-To', sanitizeHeaderValue(inReplyTo))
1619
1646
  }
1620
1647
 
1621
1648
  if (references) {
@@ -1623,6 +1650,7 @@ export class GmailClient {
1623
1650
  .split(' ')
1624
1651
  .filter(Boolean)
1625
1652
  .map((ref) => {
1653
+ ref = sanitizeHeaderValue(ref)
1626
1654
  if (!ref.startsWith('<')) ref = `<${ref}`
1627
1655
  if (!ref.endsWith('>')) ref = `${ref}>`
1628
1656
  return ref
@@ -1707,7 +1735,21 @@ export class GmailClient {
1707
1735
 
1708
1736
  // For non-inbox folders, use Gmail search syntax.
1709
1737
  // Caller-provided labelIds are preserved as additional filters.
1710
- switch (folder) {
1738
+
1739
+ // Normalize folder name to lowercase for consistent matching
1740
+ const normalizedFolder = folder.toLowerCase()
1741
+
1742
+ // Validate custom label names to prevent query injection.
1743
+ // Gmail query operators like "OR", "from:", parentheses, etc. could manipulate search results.
1744
+ // Known folders are handled by the switch cases below; custom labels must be safe characters only.
1745
+ // Slashes are allowed for nested labels (e.g., "work/projects").
1746
+ if (!KNOWN_FOLDERS.has(normalizedFolder) && !/^[\w\/-]+$/.test(normalizedFolder)) {
1747
+ throw new Error(
1748
+ `Invalid folder/label name: "${folder}". Use alphanumeric characters, underscores, hyphens, and slashes only.`,
1749
+ )
1750
+ }
1751
+
1752
+ switch (normalizedFolder) {
1711
1753
  case 'sent':
1712
1754
  q = `in:sent ${q}`.trim()
1713
1755
  break
@@ -1735,8 +1777,8 @@ export class GmailClient {
1735
1777
  q = `in:anywhere ${q}`.trim()
1736
1778
  break
1737
1779
  default:
1738
- // Treat as a label name
1739
- q = `label:${folder} ${q}`.trim()
1780
+ // Treat as a label name (use normalized for consistency)
1781
+ q = `label:${normalizedFolder} ${q}`.trim()
1740
1782
  break
1741
1783
  }
1742
1784
 
@@ -0,0 +1,170 @@
1
+ // E2E test for the mail TUI mailbox folder switching.
2
+ // Uses tuistory to launch the TUI via termcast dev and verify folder filter actions.
3
+
4
+ import path from 'path'
5
+ import { test, expect, afterEach } from 'vitest'
6
+ import { launchTerminal, type TerminalSession } from 'tuistory'
7
+
8
+ const PROJECT_ROOT = path.resolve(import.meta.dirname, '..')
9
+
10
+ let session: TerminalSession
11
+
12
+ afterEach(() => {
13
+ session?.close()
14
+ })
15
+
16
+ /**
17
+ * Navigate down in the actions panel until the cursor line contains the target text.
18
+ * Returns true if found, false if we hit maxSteps without finding it.
19
+ */
20
+ async function navigateToAction(
21
+ session: TerminalSession,
22
+ target: string,
23
+ maxSteps = 25,
24
+ ): Promise<boolean> {
25
+ for (let i = 0; i < maxSteps; i++) {
26
+ const text = await session.text({ trimEnd: true })
27
+ // tuistory renders the cursor line with › prefix
28
+ const lines = text.split('\n')
29
+ const cursorLine = lines.find((l) => l.includes('›') && l.includes(target))
30
+ if (cursorLine) return true
31
+ await session.press('down')
32
+ }
33
+ return false
34
+ }
35
+
36
+ /**
37
+ * Extract just the visible portion of the actions panel overlay from a terminal snapshot.
38
+ * Strips the background list content and returns only action items.
39
+ */
40
+ function extractActionsPanel(text: string): string {
41
+ const lines = text.split('\n')
42
+ const panelLines: string[] = []
43
+ let inPanel = false
44
+ for (const line of lines) {
45
+ if (line.includes('Actions') && line.includes('esc')) inPanel = true
46
+ if (inPanel) {
47
+ // Extract the content between the box-drawing borders
48
+ const match = line.match(/│\s*(.*?)\s*│/)
49
+ if (match) {
50
+ panelLines.push(match[1]!.trimEnd())
51
+ }
52
+ }
53
+ if (inPanel && line.includes('╰')) break
54
+ }
55
+ return panelLines.filter((l) => l.length > 0).join('\n')
56
+ }
57
+
58
+ test('mailbox folder filter switches between folders', async () => {
59
+ session = await launchTerminal({
60
+ command: 'termcast',
61
+ args: ['dev'],
62
+ cols: 120,
63
+ rows: 36,
64
+ cwd: PROJECT_ROOT,
65
+ })
66
+
67
+ // Wait for the TUI to render with inbox
68
+ await session.waitForText('Search', { timeout: 20000 })
69
+ const initialScreen = await session.text({ trimEnd: true })
70
+ expect(initialScreen).toContain('Search Inbox...')
71
+
72
+ // Open actions panel and navigate to "Sent" action
73
+ await session.press(['ctrl', 'k'])
74
+ await session.waitForText('Actions', { timeout: 5000 })
75
+
76
+ const foundSent = await navigateToAction(session, 'Sent')
77
+ expect(foundSent).toBe(true)
78
+
79
+ // Snapshot the actions panel with cursor on Sent
80
+ const actionsWithSent = extractActionsPanel(await session.text({ trimEnd: true }))
81
+ expect(actionsWithSent).toMatchInlineSnapshot(`
82
+ "Actions esc
83
+ > Search actions...
84
+ Copy Thread ID
85
+ Copy Subject
86
+ Copy Sender Email
87
+ ⌧ Trash ⌃BACKSPACE
88
+ Mailbox ▀
89
+ ✓ Inbox
90
+ ›○ Sent
91
+ ○ Starred
92
+ ○ Drafts
93
+ ↵ select ↑↓ navigate"
94
+ `)
95
+
96
+ await session.press('enter')
97
+
98
+ // Verify the screen updated to Sent folder
99
+ await session.waitForText('Search Sent', { timeout: 15000 })
100
+ const sentScreen = await session.text({ trimEnd: true })
101
+ expect(sentScreen).toContain('Search Sent...')
102
+
103
+ // Now switch back to Inbox via actions panel
104
+ await session.press(['ctrl', 'k'])
105
+ await session.waitForText('Actions', { timeout: 5000 })
106
+
107
+ const foundInbox = await navigateToAction(session, 'Inbox')
108
+ expect(foundInbox).toBe(true)
109
+
110
+ // Snapshot the actions panel with cursor on Inbox (should show checkmark on Sent now)
111
+ const actionsWithInbox = extractActionsPanel(await session.text({ trimEnd: true }))
112
+ expect(actionsWithInbox).toMatchInlineSnapshot(`
113
+ "Actions esc
114
+ > Search actions...
115
+ Copy Thread ID
116
+ Copy Subject
117
+ Copy Sender Email
118
+ ⌧ Trash ⌃BACKSPACE
119
+ Mailbox ▀
120
+ ›○ Inbox
121
+ ✓ Sent
122
+ ○ Starred
123
+ ○ Drafts
124
+ ↵ select ↑↓ navigate"
125
+ `)
126
+
127
+ await session.press('enter')
128
+
129
+ // Verify switched back to Inbox
130
+ await session.waitForText('Search Inbox', { timeout: 15000 })
131
+ const inboxScreen = await session.text({ trimEnd: true })
132
+ expect(inboxScreen).toContain('Search Inbox...')
133
+ }, 60000)
134
+
135
+ test('actions panel lists mailbox folder options', async () => {
136
+ session = await launchTerminal({
137
+ command: 'termcast',
138
+ args: ['dev'],
139
+ cols: 120,
140
+ rows: 36,
141
+ cwd: PROJECT_ROOT,
142
+ })
143
+
144
+ await session.waitForText('Search', { timeout: 20000 })
145
+
146
+ // Open actions panel
147
+ await session.press(['ctrl', 'k'])
148
+ await session.waitForText('Actions', { timeout: 5000 })
149
+
150
+ // Navigate down to Mailbox section
151
+ const foundStarred = await navigateToAction(session, 'Starred')
152
+ expect(foundStarred).toBe(true)
153
+
154
+ // Snapshot the Mailbox section visible in the actions panel
155
+ const actionsText = extractActionsPanel(await session.text({ trimEnd: true }))
156
+ expect(actionsText).toMatchInlineSnapshot(`
157
+ "Actions esc
158
+ > Search actions...
159
+ Copy Thread ID
160
+ Copy Subject
161
+ Copy Sender Email
162
+ ⌧ Trash ⌃BACKSPACE
163
+ Mailbox ▀
164
+ ✓ Inbox
165
+ ○ Sent
166
+ ›○ Starred
167
+ ○ Drafts
168
+ ↵ select ↑↓ navigate"
169
+ `)
170
+ }, 30000)