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.
- package/README.md +8 -1
- package/dist/auth.js +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -8
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +44 -5
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +1 -2
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +4 -6
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +2 -3
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/label.js +4 -5
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail.js +24 -21
- package/dist/commands/mail.js.map +1 -1
- package/dist/db.js +24 -1
- package/dist/db.js.map +1 -1
- package/dist/gmail-client.js +39 -4
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.js +47 -12
- package/dist/mail-tui.js.map +1 -1
- package/dist/output.d.ts +3 -1
- package/dist/output.js +7 -2
- package/dist/output.js.map +1 -1
- package/package.json +11 -5
- package/src/app.log +9 -0
- package/src/auth.ts +1 -1
- package/src/cli.ts +31 -30
- package/src/commands/attachment.ts +49 -6
- package/src/commands/auth-cmd.ts +1 -2
- package/src/commands/calendar.ts +4 -7
- package/src/commands/draft.ts +2 -3
- package/src/commands/label.ts +4 -4
- package/src/commands/mail.ts +24 -19
- package/src/db.ts +26 -1
- package/src/gmail-client.ts +46 -4
- package/src/mail-tui.test.ts +170 -0
- package/src/mail-tui.tsx +87 -20
- package/src/output.ts +8 -3
- package/bin/zele +0 -27
- 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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/auth-cmd.ts
CHANGED
package/src/commands/calendar.ts
CHANGED
|
@@ -55,12 +55,11 @@ export function registerCalendarCommands(cli: Goke) {
|
|
|
55
55
|
)
|
|
56
56
|
|
|
57
57
|
if (merged.length === 0) {
|
|
58
|
-
out.
|
|
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.
|
|
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
|
// =========================================================================
|
package/src/commands/draft.ts
CHANGED
|
@@ -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.
|
|
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
|
// =========================================================================
|
package/src/commands/label.ts
CHANGED
|
@@ -40,7 +40,7 @@ export function registerLabelCommands(cli: Goke) {
|
|
|
40
40
|
)
|
|
41
41
|
|
|
42
42
|
if (merged.length === 0) {
|
|
43
|
-
out.
|
|
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.
|
|
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
|
}
|
package/src/commands/mail.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
*/
|
package/src/gmail-client.ts
CHANGED
|
@@ -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
|
-
|
|
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:${
|
|
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)
|