zele 0.3.14 → 0.3.16

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 (56) hide show
  1. package/README.md +25 -0
  2. package/dist/api-utils.d.ts +3 -0
  3. package/dist/api-utils.js +6 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.js +1 -1
  6. package/dist/auth.js.map +1 -1
  7. package/dist/calendar-time.js +6 -0
  8. package/dist/calendar-time.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/attachment.js +42 -2
  12. package/dist/commands/attachment.js.map +1 -1
  13. package/dist/commands/auth-cmd.js +1 -1
  14. package/dist/commands/auth-cmd.js.map +1 -1
  15. package/dist/commands/calendar.js +1 -1
  16. package/dist/commands/calendar.js.map +1 -1
  17. package/dist/commands/draft.js +2 -2
  18. package/dist/commands/draft.js.map +1 -1
  19. package/dist/commands/filter.d.ts +2 -0
  20. package/dist/commands/filter.js +59 -0
  21. package/dist/commands/filter.js.map +1 -0
  22. package/dist/commands/mail-actions.js +12 -2
  23. package/dist/commands/mail-actions.js.map +1 -1
  24. package/dist/commands/mail.js +176 -93
  25. package/dist/commands/mail.js.map +1 -1
  26. package/dist/db.js +24 -1
  27. package/dist/db.js.map +1 -1
  28. package/dist/gmail-client.d.ts +28 -0
  29. package/dist/gmail-client.js +168 -13
  30. package/dist/gmail-client.js.map +1 -1
  31. package/dist/mail-tui.js +34 -9
  32. package/dist/mail-tui.js.map +1 -1
  33. package/dist/output.d.ts +2 -0
  34. package/dist/output.js +4 -0
  35. package/dist/output.js.map +1 -1
  36. package/package.json +8 -3
  37. package/skills/zele/SKILL.md +112 -0
  38. package/src/api-utils.ts +7 -0
  39. package/src/app.log +9 -0
  40. package/src/auth.ts +1 -1
  41. package/src/calendar-time.test.ts +35 -0
  42. package/src/calendar-time.ts +5 -0
  43. package/src/cli.ts +6 -1
  44. package/src/commands/attachment.ts +47 -2
  45. package/src/commands/auth-cmd.ts +1 -1
  46. package/src/commands/calendar.ts +1 -1
  47. package/src/commands/draft.ts +2 -2
  48. package/src/commands/filter.ts +68 -0
  49. package/src/commands/mail-actions.ts +14 -2
  50. package/src/commands/mail.ts +186 -98
  51. package/src/db.ts +26 -1
  52. package/src/gmail-client.ts +202 -20
  53. package/src/mail-tui.test.ts +170 -0
  54. package/src/mail-tui.tsx +56 -9
  55. package/src/output.ts +8 -1
  56. package/src/opentui-react.d.ts +0 -9
@@ -78,10 +78,16 @@ export interface ThreadListItem {
78
78
  snippet: string
79
79
  subject: string
80
80
  from: Sender
81
+ to: Sender[]
82
+ cc: Sender[]
81
83
  date: string
82
84
  labelIds: string[]
83
85
  unread: boolean
86
+ starred: boolean
84
87
  messageCount: number
88
+ inReplyTo: string | null
89
+ hasAttachments: boolean
90
+ listUnsubscribe: string | null
85
91
  }
86
92
 
87
93
  export interface ThreadListResult {
@@ -173,6 +179,15 @@ function sanitizeSnippet(snippet: string): string {
173
179
  .trim()
174
180
  }
175
181
 
182
+ /**
183
+ * Sanitize header values to prevent CRLF injection attacks.
184
+ * The mimetext library does not sanitize custom header values, so newlines
185
+ * in In-Reply-To or References could inject arbitrary headers.
186
+ */
187
+ function sanitizeHeaderValue(value: string): string {
188
+ return value.replace(/[\r\n]/g, ' ').trim()
189
+ }
190
+
176
191
  function encodeBase64Url(data: string | Buffer) {
177
192
  const buf = typeof data === 'string' ? Buffer.from(data) : data
178
193
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
@@ -205,6 +220,24 @@ function gmailBoundary<T>(email: string, fn: () => Promise<T>) {
205
220
  })
206
221
  }
207
222
 
223
+ /**
224
+ * Known folder names that map to Gmail system folders/labels.
225
+ * Used to validate custom label names and prevent query injection.
226
+ */
227
+ const KNOWN_FOLDERS = new Set([
228
+ 'inbox',
229
+ 'sent',
230
+ 'trash',
231
+ 'bin',
232
+ 'spam',
233
+ 'drafts',
234
+ 'draft',
235
+ 'starred',
236
+ 'archive',
237
+ 'snoozed',
238
+ 'all',
239
+ ])
240
+
208
241
  export class GmailClient {
209
242
  private gmail: gmail_v1.Gmail
210
243
  private labelIdCache: Record<string, string> = {}
@@ -779,7 +812,8 @@ export class GmailClient {
779
812
  )
780
813
  if (messageIds instanceof Error) return messageIds
781
814
  if (messageIds.length === 0) return
782
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
815
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
816
+ if (mod instanceof Error) return mod
783
817
  await this.invalidateAfterThreadMutation(threadIds)
784
818
  }
785
819
 
@@ -789,7 +823,8 @@ export class GmailClient {
789
823
  )
790
824
  if (messageIds instanceof Error) return messageIds
791
825
  if (messageIds.length === 0) return
792
- await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
826
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
827
+ if (mod instanceof Error) return mod
793
828
  await this.invalidateAfterThreadMutation(threadIds)
794
829
  }
795
830
 
@@ -797,7 +832,8 @@ export class GmailClient {
797
832
  const messageIds = await this.getMessageIdsForThreads(threadIds)
798
833
  if (messageIds instanceof Error) return messageIds
799
834
  if (messageIds.length === 0) return
800
- await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
835
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
836
+ if (mod instanceof Error) return mod
801
837
  await this.invalidateAfterThreadMutation(threadIds)
802
838
  }
803
839
 
@@ -807,7 +843,8 @@ export class GmailClient {
807
843
  )
808
844
  if (messageIds instanceof Error) return messageIds
809
845
  if (messageIds.length === 0) return
810
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
846
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
847
+ if (mod instanceof Error) return mod
811
848
  await this.invalidateAfterThreadMutation(threadIds)
812
849
  }
813
850
 
@@ -834,10 +871,11 @@ export class GmailClient {
834
871
  if (messageIds instanceof Error) return messageIds
835
872
  if (messageIds.length === 0) return
836
873
 
837
- await this.batchModifyMessages(messageIds, {
874
+ const mod = await this.batchModifyMessages(messageIds, {
838
875
  addLabelIds: resolvedAdd.filter((r): r is string => typeof r === 'string'),
839
876
  removeLabelIds: resolvedRemove,
840
877
  })
878
+ if (mod instanceof Error) return mod
841
879
  await this.invalidateAfterThreadMutation(threadIds)
842
880
  }
843
881
 
@@ -865,7 +903,28 @@ export class GmailClient {
865
903
  const messageIds = await this.getMessageIdsForThreads(threadIds)
866
904
  if (messageIds instanceof Error) return messageIds
867
905
  if (messageIds.length === 0) return
868
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
906
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
907
+ if (mod instanceof Error) return mod
908
+ await this.invalidateAfterThreadMutation(threadIds)
909
+ }
910
+
911
+ async markAsSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
912
+ const messageIds = await this.getMessageIdsForThreads(threadIds)
913
+ if (messageIds instanceof Error) return messageIds
914
+ if (messageIds.length === 0) return
915
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['SPAM'], removeLabelIds: ['INBOX'] })
916
+ if (mod instanceof Error) return mod
917
+ await this.invalidateAfterThreadMutation(threadIds)
918
+ }
919
+
920
+ async unmarkSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
921
+ const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
922
+ labelIds.includes('SPAM'),
923
+ )
924
+ if (messageIds instanceof Error) return messageIds
925
+ if (messageIds.length === 0) return
926
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['SPAM'], addLabelIds: ['INBOX'] })
927
+ if (mod instanceof Error) return mod
869
928
  await this.invalidateAfterThreadMutation(threadIds)
870
929
  }
871
930
 
@@ -892,10 +951,11 @@ export class GmailClient {
892
951
  const threadIds = res.threads.map((t) => t.id)
893
952
  const messageIds = await this.getMessageIdsForThreads(threadIds)
894
953
  if (messageIds instanceof Error) return messageIds
895
- await this.batchModifyMessages(messageIds, {
954
+ const mod = await this.batchModifyMessages(messageIds, {
896
955
  addLabelIds: ['TRASH'],
897
956
  removeLabelIds: ['SPAM', 'INBOX'],
898
957
  })
958
+ if (mod instanceof Error) return mod
899
959
 
900
960
  totalDeleted += threadIds.length
901
961
  pageToken = res.nextPageToken ?? undefined
@@ -994,6 +1054,61 @@ export class GmailClient {
994
1054
  await this.invalidateLabels()
995
1055
  }
996
1056
 
1057
+ // =========================================================================
1058
+ // Filters
1059
+ // =========================================================================
1060
+
1061
+ async listFilters(): Promise<{ parsed: gmail_v1.Schema$Filter[] } | AuthError | ApiError> {
1062
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1063
+ withRetry(() => this.gmail.users.settings.filters.list({ userId: 'me' })),
1064
+ )
1065
+ if (res instanceof Error) return res
1066
+ return { parsed: res.data.filter ?? [] }
1067
+ }
1068
+
1069
+ async createFilter(opts: {
1070
+ from?: string
1071
+ query?: string
1072
+ addLabelIds?: string[]
1073
+ removeLabelIds?: string[]
1074
+ }): Promise<gmail_v1.Schema$Filter | AuthError | ApiError> {
1075
+ const criteria: gmail_v1.Schema$FilterCriteria = {}
1076
+ if (opts.from) criteria.from = opts.from
1077
+ if (opts.query) criteria.query = opts.query
1078
+
1079
+ const action: gmail_v1.Schema$FilterAction = {}
1080
+ if (opts.addLabelIds?.length) action.addLabelIds = opts.addLabelIds
1081
+ if (opts.removeLabelIds?.length) action.removeLabelIds = opts.removeLabelIds
1082
+
1083
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1084
+ withRetry(() =>
1085
+ this.gmail.users.settings.filters.create({
1086
+ userId: 'me',
1087
+ requestBody: { criteria, action },
1088
+ }),
1089
+ ),
1090
+ )
1091
+ if (res instanceof Error) return res
1092
+ return res.data
1093
+ }
1094
+
1095
+ async deleteFilter(filterId: string): Promise<void | AuthError | ApiError> {
1096
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1097
+ withRetry(() =>
1098
+ this.gmail.users.settings.filters.delete({
1099
+ userId: 'me',
1100
+ id: filterId,
1101
+ }),
1102
+ ),
1103
+ )
1104
+ if (res instanceof Error) return res
1105
+ }
1106
+
1107
+ /** Resolve a label name to its ID, auto-creating if missing. Public wrapper for filter commands. */
1108
+ async resolveLabel(nameOrId: string): Promise<string | AuthError | ApiError> {
1109
+ return this.resolveLabelId(nameOrId)
1110
+ }
1111
+
997
1112
  // =========================================================================
998
1113
  // Label counts (unread counts per folder/label)
999
1114
  // =========================================================================
@@ -1233,16 +1348,47 @@ export class GmailClient {
1233
1348
  }
1234
1349
  }
1235
1350
 
1351
+ // Parse recipients from latest message
1352
+ const toHeader = getHeader('to') ?? ''
1353
+ const ccHeaders = headers
1354
+ .filter((h) => h.name?.toLowerCase() === 'cc')
1355
+ .map((h) => h.value ?? '')
1356
+ .filter((v) => v.length > 0)
1357
+
1358
+ // Check if any non-draft message in the thread is a reply (has In-Reply-To header)
1359
+ const inReplyTo =
1360
+ nonDraftMessages
1361
+ .map(
1362
+ (m) =>
1363
+ m.payload?.headers?.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value ??
1364
+ null,
1365
+ )
1366
+ .find((v) => v !== null) ?? null
1367
+
1368
+ // Check if any message has attachments (non-inline)
1369
+ const hasAttachments = messages.some((m) => this.hasNonInlineAttachments(m.payload))
1370
+
1371
+ // List-Unsubscribe from latest message
1372
+ const listUnsubscribe = getHeader('list-unsubscribe') ?? null
1373
+
1236
1374
  return {
1237
1375
  id: raw.id ?? '',
1238
1376
  historyId: raw.historyId ?? null,
1239
1377
  snippet: sanitizeSnippet(latest?.snippet ?? ''),
1240
1378
  subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
1241
1379
  from: displayFrom,
1380
+ to: toHeader ? parseAddressList(toHeader) : [],
1381
+ cc: ccHeaders.length > 0
1382
+ ? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
1383
+ : [],
1242
1384
  date: getHeader('date') ?? '',
1243
1385
  labelIds: allLabels,
1244
1386
  unread: allLabels.includes('UNREAD'),
1387
+ starred: allLabels.includes('STARRED'),
1245
1388
  messageCount: nonDraftMessages.length,
1389
+ inReplyTo,
1390
+ hasAttachments,
1391
+ listUnsubscribe,
1246
1392
  }
1247
1393
  }
1248
1394
 
@@ -1366,6 +1512,24 @@ export class GmailClient {
1366
1512
  return results
1367
1513
  }
1368
1514
 
1515
+ /** Quick check: does the message payload tree contain any non-inline attachments?
1516
+ * Checks the root part itself (some messages have attachment metadata there)
1517
+ * then recurses into child parts. */
1518
+ private hasNonInlineAttachments(part: gmail_v1.Schema$MessagePart | undefined): boolean {
1519
+ if (!part) return false
1520
+ if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
1521
+ const disposition =
1522
+ part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? ''
1523
+ const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id')
1524
+ const isInline = disposition.toLowerCase().includes('inline')
1525
+ if (!isInline || !hasContentId) return true
1526
+ }
1527
+ for (const child of part.parts ?? []) {
1528
+ if (this.hasNonInlineAttachments(child)) return true
1529
+ }
1530
+ return false
1531
+ }
1532
+
1369
1533
  async getEmailAliases(): Promise<Array<{ email: string; name?: string; primary: boolean }> | AuthError | ApiError> {
1370
1534
  const profile = await this.getProfile()
1371
1535
  if (profile instanceof Error) return profile
@@ -1615,7 +1779,7 @@ export class GmailClient {
1615
1779
  })
1616
1780
 
1617
1781
  if (inReplyTo) {
1618
- msg.setHeader('In-Reply-To', inReplyTo)
1782
+ msg.setHeader('In-Reply-To', sanitizeHeaderValue(inReplyTo))
1619
1783
  }
1620
1784
 
1621
1785
  if (references) {
@@ -1623,6 +1787,7 @@ export class GmailClient {
1623
1787
  .split(' ')
1624
1788
  .filter(Boolean)
1625
1789
  .map((ref) => {
1790
+ ref = sanitizeHeaderValue(ref)
1626
1791
  if (!ref.startsWith('<')) ref = `<${ref}`
1627
1792
  if (!ref.endsWith('>')) ref = `${ref}>`
1628
1793
  return ref
@@ -1707,7 +1872,21 @@ export class GmailClient {
1707
1872
 
1708
1873
  // For non-inbox folders, use Gmail search syntax.
1709
1874
  // Caller-provided labelIds are preserved as additional filters.
1710
- switch (folder) {
1875
+
1876
+ // Normalize folder name to lowercase for consistent matching
1877
+ const normalizedFolder = folder.toLowerCase()
1878
+
1879
+ // Validate custom label names to prevent query injection.
1880
+ // Gmail query operators like "OR", "from:", parentheses, etc. could manipulate search results.
1881
+ // Known folders are handled by the switch cases below; custom labels must be safe characters only.
1882
+ // Slashes are allowed for nested labels (e.g., "work/projects").
1883
+ if (!KNOWN_FOLDERS.has(normalizedFolder) && !/^[\w\/-]+$/.test(normalizedFolder)) {
1884
+ throw new Error(
1885
+ `Invalid folder/label name: "${folder}". Use alphanumeric characters, underscores, hyphens, and slashes only.`,
1886
+ )
1887
+ }
1888
+
1889
+ switch (normalizedFolder) {
1711
1890
  case 'sent':
1712
1891
  q = `in:sent ${q}`.trim()
1713
1892
  break
@@ -1735,8 +1914,8 @@ export class GmailClient {
1735
1914
  q = `in:anywhere ${q}`.trim()
1736
1915
  break
1737
1916
  default:
1738
- // Treat as a label name
1739
- q = `label:${folder} ${q}`.trim()
1917
+ // Treat as a label name (use normalized for consistency)
1918
+ q = `label:${normalizedFolder} ${q}`.trim()
1740
1919
  break
1741
1920
  }
1742
1921
 
@@ -1779,22 +1958,25 @@ export class GmailClient {
1779
1958
  private async batchModifyMessages(
1780
1959
  messageIds: string[],
1781
1960
  body: { addLabelIds?: string[]; removeLabelIds?: string[] },
1782
- ) {
1961
+ ): Promise<void | AuthError | ApiError> {
1783
1962
  if (messageIds.length === 0) return
1784
1963
 
1785
1964
  // Gmail batchModify accepts up to 1000 IDs
1786
1965
  const chunkSize = 1000
1787
1966
  for (let i = 0; i < messageIds.length; i += chunkSize) {
1788
1967
  const chunk = messageIds.slice(i, i + chunkSize)
1789
- await withRetry(() =>
1790
- this.gmail.users.messages.batchModify({
1791
- userId: 'me',
1792
- requestBody: {
1793
- ids: chunk,
1794
- ...body,
1795
- },
1796
- }),
1968
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1969
+ withRetry(() =>
1970
+ this.gmail.users.messages.batchModify({
1971
+ userId: 'me',
1972
+ requestBody: {
1973
+ ids: chunk,
1974
+ ...body,
1975
+ },
1976
+ }),
1977
+ ),
1797
1978
  )
1979
+ if (res instanceof Error) return res
1798
1980
  }
1799
1981
  }
1800
1982
 
@@ -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)
package/src/mail-tui.tsx CHANGED
@@ -78,6 +78,17 @@ const ACCOUNT_COLORS = [
78
78
  const ADD_ACCOUNT = '__add_account__'
79
79
  const MANAGE_ACCOUNTS = '__manage_accounts__'
80
80
 
81
+ const FOLDER_OPTIONS = [
82
+ { id: 'inbox', label: 'Inbox', icon: Icon.Envelope },
83
+ { id: 'sent', label: 'Sent', icon: Icon.PaperAirplane },
84
+ { id: 'starred', label: 'Starred', icon: Icon.Star },
85
+ { id: 'drafts', label: 'Drafts', icon: Icon.Pencil },
86
+ { id: 'archive', label: 'Archive', icon: Icon.Tray },
87
+ { id: 'spam', label: 'Spam', icon: Icon.ExclamationMark },
88
+ { id: 'trash', label: 'Trash', icon: Icon.Trash },
89
+ { id: 'all', label: 'All Mail', icon: Icon.List },
90
+ ] as const
91
+
81
92
  // ---------------------------------------------------------------------------
82
93
  // Helpers
83
94
  // ---------------------------------------------------------------------------
@@ -399,10 +410,19 @@ function ComposeForm({ mode, initialAccount, accounts, onSent }: ComposeFormProp
399
410
  ? 'Reply All'
400
411
  : 'Reply'
401
412
 
402
- const bodyPlaceholder =
403
- mode.type === 'forward'
404
- ? 'Add a message (optional)...'
405
- : 'Type your reply...'
413
+ const bodyPlaceholder = mode.type === 'forward'
414
+ ? `Add a message (optional)...
415
+
416
+ ---
417
+
418
+ Best,
419
+ Name`
420
+ : `Type your reply...
421
+
422
+ ---
423
+
424
+ Best,
425
+ Name`
406
426
 
407
427
  const handleSubmit = async (values: { to?: string; body?: string }) => {
408
428
  // Validate based on mode
@@ -700,6 +720,11 @@ export default function Command() {
700
720
  'all',
701
721
  { cacheNamespace: CACHE_NAMESPACE },
702
722
  )
723
+ const [activeFolder, setActiveFolder] = useCachedState(
724
+ 'activeFolder',
725
+ 'inbox' as string,
726
+ { cacheNamespace: CACHE_NAMESPACE },
727
+ )
703
728
  const [searchText, setSearchText] = useState('')
704
729
  const [isShowingDetail, setIsShowingDetail] = useCachedState(
705
730
  'isShowingDetail',
@@ -732,7 +757,7 @@ export default function Command() {
732
757
  pagination,
733
758
  revalidate,
734
759
  } = useCachedPromise(
735
- (query: string, account: string) => {
760
+ (query: string, account: string, folder: string) => {
736
761
  return async ({ cursor }: { page: number; cursor?: MailCursor }) => {
737
762
  const accountFilter = account === 'all' ? undefined : [account]
738
763
  const clients = await getClients(accountFilter)
@@ -744,6 +769,7 @@ export default function Command() {
744
769
  const { email, client } = clients[0]!
745
770
  const result = await client.listThreads({
746
771
  query: query || undefined,
772
+ folder,
747
773
  maxResults: pageSize,
748
774
  pageToken: pageToken || undefined,
749
775
  })
@@ -784,6 +810,7 @@ export default function Command() {
784
810
 
785
811
  const result = await client.listThreads({
786
812
  query: query || undefined,
813
+ folder,
787
814
  maxResults: pageSize,
788
815
  pageToken: previousByAccount[email] ?? undefined,
789
816
  })
@@ -833,7 +860,7 @@ export default function Command() {
833
860
  }
834
861
  }
835
862
  },
836
- [searchText, selectedAccount, pageSize],
863
+ [searchText, selectedAccount, activeFolder, pageSize],
837
864
  { keepPreviousData: true },
838
865
  )
839
866
 
@@ -933,7 +960,7 @@ export default function Command() {
933
960
  isLoading={isLoading || accounts.isLoading || isMutating}
934
961
  isShowingDetail={isShowingDetail}
935
962
  spacingMode={LIST_SPACING_MODE}
936
- searchBarPlaceholder='Search emails...'
963
+ searchBarPlaceholder={`Search ${FOLDER_OPTIONS.find((f) => f.id === activeFolder)?.label ?? 'emails'}...`}
937
964
  onSearchTextChange={setSearchText}
938
965
  throttle
939
966
  pagination={pagination ? { ...pagination, pageSize } : undefined}
@@ -990,7 +1017,7 @@ export default function Command() {
990
1017
  // Detail panel: latest message body as markdown
991
1018
  const detail = isShowingDetail ? (
992
1019
  <List.Item.Detail
993
- markdown={`# ${thread.subject}\n\n${thread.snippet}`}
1020
+ markdown={`\n\n${thread.snippet}`}
994
1021
  metadata={
995
1022
  <List.Item.Detail.Metadata>
996
1023
  <List.Item.Detail.Metadata.Label
@@ -1107,6 +1134,16 @@ export default function Command() {
1107
1134
  onAction={() => setSelectedThreads([])}
1108
1135
  />
1109
1136
  </ActionPanel.Section>
1137
+ <ActionPanel.Section title='Mailbox'>
1138
+ {FOLDER_OPTIONS.map((f) => (
1139
+ <Action
1140
+ key={f.id}
1141
+ title={f.label}
1142
+ icon={activeFolder === f.id ? Icon.CheckCircle : Icon.Circle}
1143
+ onAction={() => setActiveFolder(f.id)}
1144
+ />
1145
+ ))}
1146
+ </ActionPanel.Section>
1110
1147
  <ActionPanel.Section>
1111
1148
  <Action
1112
1149
  title='Refresh'
@@ -1157,7 +1194,7 @@ export default function Command() {
1157
1194
  <Action
1158
1195
  title={thread.unread ? 'Mark as Read' : 'Mark as Unread'}
1159
1196
  icon={thread.unread ? Icon.Eye : Icon.EyeDisabled}
1160
- shortcut={{ modifiers: ['ctrl'], key: 'u' }}
1197
+ // shortcut={{ modifiers: ['ctrl'], key: 'u' }}
1161
1198
  onAction={() => withMutation(async () => {
1162
1199
  const { client } = await getClient([thread.account])
1163
1200
  const result = thread.unread
@@ -1285,6 +1322,16 @@ export default function Command() {
1285
1322
  })}
1286
1323
  />
1287
1324
  </ActionPanel.Section>
1325
+ <ActionPanel.Section title='Mailbox'>
1326
+ {FOLDER_OPTIONS.map((f) => (
1327
+ <Action
1328
+ key={f.id}
1329
+ title={f.label}
1330
+ icon={activeFolder === f.id ? Icon.CheckCircle : Icon.Circle}
1331
+ onAction={() => setActiveFolder(f.id)}
1332
+ />
1333
+ ))}
1334
+ </ActionPanel.Section>
1288
1335
  <ActionPanel.Section>
1289
1336
  <Action
1290
1337
  title='Refresh'