zele 0.2.0 → 0.3.5

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 (158) hide show
  1. package/README.md +38 -1
  2. package/bin/zele +27 -0
  3. package/dist/api-utils.d.ts +51 -2
  4. package/dist/api-utils.js +89 -3
  5. package/dist/api-utils.js.map +1 -1
  6. package/dist/auth.d.ts +27 -6
  7. package/dist/auth.js +185 -129
  8. package/dist/auth.js.map +1 -1
  9. package/dist/calendar-client.d.ts +16 -9
  10. package/dist/calendar-client.js +163 -59
  11. package/dist/calendar-client.js.map +1 -1
  12. package/dist/cli.js +28 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands/attachment.js +17 -15
  15. package/dist/commands/attachment.js.map +1 -1
  16. package/dist/commands/auth-cmd.js +20 -9
  17. package/dist/commands/auth-cmd.js.map +1 -1
  18. package/dist/commands/calendar.js +67 -78
  19. package/dist/commands/calendar.js.map +1 -1
  20. package/dist/commands/draft.js +25 -18
  21. package/dist/commands/draft.js.map +1 -1
  22. package/dist/commands/label.js +33 -45
  23. package/dist/commands/label.js.map +1 -1
  24. package/dist/commands/mail-actions.js +11 -13
  25. package/dist/commands/mail-actions.js.map +1 -1
  26. package/dist/commands/mail.js +114 -128
  27. package/dist/commands/mail.js.map +1 -1
  28. package/dist/commands/profile.js +18 -21
  29. package/dist/commands/profile.js.map +1 -1
  30. package/dist/commands/watch.d.ts +2 -0
  31. package/dist/commands/watch.js +73 -0
  32. package/dist/commands/watch.js.map +1 -0
  33. package/dist/db.js +12 -13
  34. package/dist/db.js.map +1 -1
  35. package/dist/generated/browser.d.ts +12 -27
  36. package/dist/generated/client.d.ts +13 -28
  37. package/dist/generated/client.js +1 -1
  38. package/dist/generated/commonInputTypes.d.ts +90 -26
  39. package/dist/generated/enums.d.ts +0 -4
  40. package/dist/generated/enums.js +0 -3
  41. package/dist/generated/enums.js.map +1 -1
  42. package/dist/generated/internal/class.d.ts +22 -55
  43. package/dist/generated/internal/class.js +12 -4
  44. package/dist/generated/internal/class.js.map +1 -1
  45. package/dist/generated/internal/prismaNamespace.d.ts +272 -511
  46. package/dist/generated/internal/prismaNamespace.js +54 -66
  47. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  48. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
  49. package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
  50. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  51. package/dist/generated/models/Account.d.ts +1637 -0
  52. package/dist/generated/models/Account.js +2 -0
  53. package/dist/generated/models/Account.js.map +1 -0
  54. package/dist/generated/models/CalendarList.d.ts +1161 -0
  55. package/dist/generated/models/CalendarList.js +2 -0
  56. package/dist/generated/models/CalendarList.js.map +1 -0
  57. package/dist/generated/models/Label.d.ts +1161 -0
  58. package/dist/generated/models/Label.js +2 -0
  59. package/dist/generated/models/Label.js.map +1 -0
  60. package/dist/generated/models/Profile.d.ts +1269 -0
  61. package/dist/generated/models/Profile.js +2 -0
  62. package/dist/generated/models/Profile.js.map +1 -0
  63. package/dist/generated/models/SyncState.d.ts +1130 -0
  64. package/dist/generated/models/SyncState.js +2 -0
  65. package/dist/generated/models/SyncState.js.map +1 -0
  66. package/dist/generated/models/Thread.d.ts +1608 -0
  67. package/dist/generated/models/Thread.js +2 -0
  68. package/dist/generated/models/Thread.js.map +1 -0
  69. package/dist/generated/models.d.ts +6 -9
  70. package/dist/gmail-client.d.ts +119 -94
  71. package/dist/gmail-client.js +862 -315
  72. package/dist/gmail-client.js.map +1 -1
  73. package/dist/mail-tui.d.ts +1 -0
  74. package/dist/mail-tui.js +517 -0
  75. package/dist/mail-tui.js.map +1 -0
  76. package/dist/output.d.ts +6 -4
  77. package/dist/output.js +124 -17
  78. package/dist/output.js.map +1 -1
  79. package/package.json +39 -11
  80. package/schema.prisma +81 -113
  81. package/src/api-utils.ts +103 -5
  82. package/src/auth.ts +224 -143
  83. package/src/calendar-client.ts +196 -89
  84. package/src/cli.ts +32 -1
  85. package/src/commands/attachment.ts +18 -19
  86. package/src/commands/auth-cmd.ts +19 -9
  87. package/src/commands/calendar.ts +42 -85
  88. package/src/commands/draft.ts +19 -22
  89. package/src/commands/label.ts +21 -57
  90. package/src/commands/mail-actions.ts +11 -19
  91. package/src/commands/mail.ts +104 -149
  92. package/src/commands/profile.ts +12 -28
  93. package/src/commands/watch.ts +88 -0
  94. package/src/db.ts +13 -16
  95. package/src/generated/browser.ts +49 -0
  96. package/src/generated/client.ts +71 -0
  97. package/src/generated/commonInputTypes.ts +332 -0
  98. package/src/generated/enums.ts +17 -0
  99. package/src/generated/internal/class.ts +250 -0
  100. package/src/generated/internal/prismaNamespace.ts +1198 -0
  101. package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
  102. package/src/generated/models/Account.ts +1848 -0
  103. package/src/generated/models/CalendarList.ts +1331 -0
  104. package/src/generated/models/Label.ts +1331 -0
  105. package/src/generated/models/Profile.ts +1439 -0
  106. package/src/generated/models/SyncState.ts +1300 -0
  107. package/src/generated/models/Thread.ts +1787 -0
  108. package/src/generated/models.ts +17 -0
  109. package/src/gmail-client.test.ts +59 -0
  110. package/src/gmail-client.ts +1034 -422
  111. package/src/mail-tui.tsx +1061 -0
  112. package/src/output.test.ts +1093 -0
  113. package/src/output.ts +128 -20
  114. package/src/schema.sql +58 -68
  115. package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
  116. package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
  117. package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
  118. package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
  119. package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
  120. package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
  121. package/AGENTS.md +0 -26
  122. package/CHANGELOG.md +0 -36
  123. package/dist/generated/models/accounts.d.ts +0 -2000
  124. package/dist/generated/models/accounts.js +0 -2
  125. package/dist/generated/models/accounts.js.map +0 -1
  126. package/dist/generated/models/calendar_events.d.ts +0 -1433
  127. package/dist/generated/models/calendar_events.js +0 -2
  128. package/dist/generated/models/calendar_events.js.map +0 -1
  129. package/dist/generated/models/calendar_lists.d.ts +0 -1131
  130. package/dist/generated/models/calendar_lists.js +0 -2
  131. package/dist/generated/models/calendar_lists.js.map +0 -1
  132. package/dist/generated/models/label_counts.d.ts +0 -1131
  133. package/dist/generated/models/label_counts.js +0 -2
  134. package/dist/generated/models/label_counts.js.map +0 -1
  135. package/dist/generated/models/labels.d.ts +0 -1131
  136. package/dist/generated/models/labels.js +0 -2
  137. package/dist/generated/models/labels.js.map +0 -1
  138. package/dist/generated/models/profiles.d.ts +0 -1131
  139. package/dist/generated/models/profiles.js +0 -2
  140. package/dist/generated/models/profiles.js.map +0 -1
  141. package/dist/generated/models/sync_states.d.ts +0 -1107
  142. package/dist/generated/models/sync_states.js +0 -2
  143. package/dist/generated/models/sync_states.js.map +0 -1
  144. package/dist/generated/models/thread_lists.d.ts +0 -1404
  145. package/dist/generated/models/thread_lists.js +0 -2
  146. package/dist/generated/models/thread_lists.js.map +0 -1
  147. package/dist/generated/models/threads.d.ts +0 -1247
  148. package/dist/generated/models/threads.js +0 -2
  149. package/dist/generated/models/threads.js.map +0 -1
  150. package/dist/gmail-cache.d.ts +0 -60
  151. package/dist/gmail-cache.js +0 -264
  152. package/dist/gmail-cache.js.map +0 -1
  153. package/docs/gogcli-gmail-implementation.md +0 -599
  154. package/scripts/test-device-code-clients.ts +0 -186
  155. package/scripts/test-micropython-scopes.ts +0 -72
  156. package/scripts/test-oauth-clients.ts +0 -257
  157. package/src/gmail-cache.ts +0 -339
  158. package/tsconfig.json +0 -16
@@ -0,0 +1,1061 @@
1
+ // Termcast email extension — browse and read emails in a Raycast-like TUI.
2
+ // Uses List with detail view, sections for date grouping, infinite scroll via
3
+ // useCachedPromise pagination, and a dropdown for account selection.
4
+ // Reuses existing GmailClient, auth, and email-to-markdown conversion from the CLI.
5
+ //
6
+ // gaxios (used by googleapis) checks `typeof window !== 'undefined'` to decide whether
7
+ // to use window.fetch or import('node-fetch'). Termcast provides a window global (for
8
+ // the Raycast UI runtime) but without fetch. gaxios sees window exists → tries
9
+ // window.fetch → gets undefined → "fetchImpl is not a function". Fix: ensure
10
+ // window.fetch is set to the native Bun fetch.
11
+ const globalWithWindow = globalThis as unknown as { window?: { fetch?: typeof globalThis.fetch } }
12
+ if (typeof globalThis.fetch === 'function' && typeof globalWithWindow.window?.fetch !== 'function') {
13
+ globalWithWindow.window = { ...globalWithWindow.window, fetch: globalThis.fetch }
14
+ }
15
+
16
+ import {
17
+ Action,
18
+ ActionPanel,
19
+ Color,
20
+ Detail,
21
+ Form,
22
+ Icon,
23
+ List,
24
+ showToast,
25
+ Toast,
26
+ useNavigation,
27
+ showFailureToast,
28
+ } from 'termcast'
29
+ import { useCachedPromise } from '@termcast/utils'
30
+ import { useState, useMemo, useCallback, useEffect } from 'react'
31
+
32
+ import { getClients, getClient, listAccounts, login, logout, type AuthStatus } from './auth.js'
33
+ import type { GmailClient, ThreadListItem, ThreadData } from './gmail-client.js'
34
+ import { AuthError, ApiError, isTruthy } from './api-utils.js'
35
+ import { renderEmailBody, replyParser, formatDate, formatSender } from './output.js'
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Constants
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const PAGE_SIZE = 25
42
+
43
+ const ACCOUNT_COLORS = [Color.Blue, Color.Green, Color.Purple, Color.Orange, Color.Magenta]
44
+
45
+ const ADD_ACCOUNT = '__add_account__'
46
+ const MANAGE_ACCOUNTS = '__manage_accounts__'
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Helpers
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function accountColor(email: string): string {
53
+ let hash = 0
54
+ for (const c of email) hash = ((hash << 5) - hash + c.charCodeAt(0)) | 0
55
+ return ACCOUNT_COLORS[Math.abs(hash) % ACCOUNT_COLORS.length]!
56
+ }
57
+
58
+ /** Classify a date string into a section bucket. */
59
+ function dateSection(dateStr: string): string {
60
+ const date = new Date(dateStr)
61
+ if (isNaN(date.getTime())) return 'Older'
62
+
63
+ const now = new Date()
64
+ const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000)
65
+ const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
66
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
67
+ const yesterday = new Date(today.getTime() - 86400000)
68
+ const weekAgo = new Date(today.getTime() - 7 * 86400000)
69
+ const monthAgo = new Date(today.getTime() - 30 * 86400000)
70
+
71
+ if (date >= tenMinutesAgo) return 'Last 10 Minutes'
72
+ if (date >= oneHourAgo) return 'Last Hour'
73
+ if (date >= today) return 'Today'
74
+ if (date >= yesterday) return 'Yesterday'
75
+ if (date >= weekAgo) return 'This Week'
76
+ if (date >= monthAgo) return 'This Month'
77
+ return 'Older'
78
+ }
79
+
80
+ const SECTION_ORDER = ['Last 10 Minutes', 'Last Hour', 'Today', 'Yesterday', 'This Week', 'This Month', 'Older']
81
+
82
+ function threadStatusIcon(thread: ThreadListItem & { starred?: boolean }): { source: typeof Icon[keyof typeof Icon]; tintColor: string } {
83
+ const unread = thread.unread
84
+ const starred = thread.labelIds?.includes('STARRED') ?? false
85
+
86
+ if (unread && starred) return { source: Icon.Star, tintColor: Color.Yellow }
87
+ if (unread) return { source: Icon.CircleFilled, tintColor: Color.Blue }
88
+ if (starred) return { source: Icon.Star, tintColor: Color.Yellow }
89
+ return { source: Icon.Circle, tintColor: Color.SecondaryText }
90
+ }
91
+
92
+ /** Extended thread item with account info. */
93
+ interface ThreadItem extends ThreadListItem {
94
+ account: string
95
+ }
96
+
97
+ type MailCursor =
98
+ | { mode: 'single'; nextPageToken?: string }
99
+ | { mode: 'multi'; nextByAccount: Record<string, string | null> }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Data fetching
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function useAccounts() {
106
+ return useCachedPromise(async () => {
107
+ const accounts = await listAccounts()
108
+ return accounts
109
+ }, [])
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Account Dropdown
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function AccountDropdown({
117
+ accounts,
118
+ value,
119
+ onChange,
120
+ onAdded,
121
+ onRemoved,
122
+ }: {
123
+ accounts: { email: string; appId: string }[]
124
+ value: string
125
+ onChange: (value: string) => void
126
+ onAdded?: (email: string) => void | Promise<void>
127
+ onRemoved?: (email: string) => void | Promise<void>
128
+ }) {
129
+ const { push } = useNavigation()
130
+
131
+ return (
132
+ <List.Dropdown
133
+ tooltip="Account"
134
+ value={value}
135
+ onChange={(newValue) => {
136
+ if (newValue === ADD_ACCOUNT) {
137
+ push(<AddAccount onAdded={onAdded} />)
138
+ return
139
+ }
140
+ if (newValue === MANAGE_ACCOUNTS) {
141
+ push(<ManageAccounts onAdded={onAdded} onRemoved={onRemoved} />)
142
+ return
143
+ }
144
+ onChange(newValue)
145
+ }}
146
+ >
147
+ <List.Dropdown.Item title="All Accounts" value="all" icon={Icon.Globe} />
148
+ <List.Dropdown.Section title="Accounts">
149
+ {accounts.map((a) => (
150
+ <List.Dropdown.Item
151
+ key={a.email}
152
+ title={a.email}
153
+ value={a.email}
154
+ icon={{ source: Icon.Person, tintColor: accountColor(a.email) }}
155
+ />
156
+ ))}
157
+ </List.Dropdown.Section>
158
+ <List.Dropdown.Section>
159
+ <List.Dropdown.Item title="Add Account" value={ADD_ACCOUNT} icon={Icon.Plus} />
160
+ <List.Dropdown.Item title="Manage Accounts" value={MANAGE_ACCOUNTS} icon={Icon.Gear} />
161
+ </List.Dropdown.Section>
162
+ </List.Dropdown>
163
+ )
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Add Account (interactive login)
168
+ // ---------------------------------------------------------------------------
169
+
170
+ function AddAccount({
171
+ onAdded,
172
+ }: {
173
+ onAdded?: (email: string) => void | Promise<void>
174
+ }) {
175
+ const { pop } = useNavigation()
176
+ const [isLoggingIn, setIsLoggingIn] = useState(false)
177
+ const [didAutoStart, setDidAutoStart] = useState(false)
178
+
179
+ const handleLogin = async () => {
180
+ if (isLoggingIn) return
181
+
182
+ setIsLoggingIn(true)
183
+ const result = await login(undefined, {
184
+ openBrowser: true,
185
+ allowManualCodeEntry: false,
186
+ showInstructions: false,
187
+ })
188
+ setIsLoggingIn(false)
189
+
190
+ if (result instanceof Error) {
191
+ await showFailureToast(result, { title: 'Failed to add account' })
192
+ return
193
+ }
194
+
195
+ await onAdded?.(result.email)
196
+ await showToast({ style: Toast.Style.Success, title: `Added ${result.email}` })
197
+ pop()
198
+ }
199
+
200
+ useEffect(() => {
201
+ if (didAutoStart) return
202
+ setDidAutoStart(true)
203
+ void handleLogin()
204
+ }, [didAutoStart])
205
+
206
+ return (
207
+ <Detail
208
+ navigationTitle="Add Account"
209
+ markdown={
210
+ `# Add Account\n\n` +
211
+ `The browser opens automatically for Google sign-in.\n\n` +
212
+ `Complete login in the browser, then come back here. ` +
213
+ `This screen waits for the localhost callback and will finish automatically.`
214
+ }
215
+ actions={
216
+ <ActionPanel>
217
+ <Action
218
+ title={isLoggingIn ? 'Waiting for Login...' : 'Open Browser Again'}
219
+ icon={Icon.Globe}
220
+ onAction={handleLogin}
221
+ />
222
+ </ActionPanel>
223
+ }
224
+ />
225
+ )
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Manage Accounts
230
+ // ---------------------------------------------------------------------------
231
+
232
+ function ManageAccounts({
233
+ onAdded,
234
+ onRemoved,
235
+ }: {
236
+ onAdded?: (email: string) => void | Promise<void>
237
+ onRemoved?: (email: string) => void | Promise<void>
238
+ }) {
239
+ const accounts = useAccounts()
240
+
241
+ const handleAdded = async (email: string) => {
242
+ await accounts.revalidate()
243
+ await onAdded?.(email)
244
+ }
245
+
246
+ const handleRemoved = async (email: string) => {
247
+ const result = await logout(email)
248
+ if (result instanceof Error) {
249
+ await showFailureToast(result, { title: `Failed to remove ${email}` })
250
+ return
251
+ }
252
+
253
+ await accounts.revalidate()
254
+ await onRemoved?.(email)
255
+ await showToast({ style: Toast.Style.Success, title: `Removed ${email}` })
256
+ }
257
+
258
+ return (
259
+ <List navigationTitle="Manage Accounts" isLoading={accounts.isLoading}>
260
+ {accounts.data?.map((a: AuthStatus) => (
261
+ <List.Item
262
+ key={`${a.email}-${a.appId}`}
263
+ title={a.email}
264
+ icon={{ source: Icon.Person, tintColor: accountColor(a.email) }}
265
+ accessories={[{ tag: { value: a.appId.slice(0, 12) + '...', color: Color.SecondaryText } }]}
266
+ actions={
267
+ <ActionPanel>
268
+ <Action.CopyToClipboard title="Copy Email" content={a.email} />
269
+ <Action
270
+ title="Logout Account"
271
+ icon={Icon.Trash}
272
+ style={Action.Style.Destructive}
273
+ onAction={() => handleRemoved(a.email)}
274
+ />
275
+ </ActionPanel>
276
+ }
277
+ />
278
+ ))}
279
+ <List.Item
280
+ key="add-account"
281
+ title="Add Account"
282
+ icon={Icon.Plus}
283
+ actions={
284
+ <ActionPanel>
285
+ <Action.Push title="Add Account" target={<AddAccount onAdded={handleAdded} />} />
286
+ </ActionPanel>
287
+ }
288
+ />
289
+ </List>
290
+ )
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Reply Form
295
+ // ---------------------------------------------------------------------------
296
+
297
+ function ReplyForm({
298
+ threadId,
299
+ account,
300
+ replyAll,
301
+ revalidate,
302
+ }: {
303
+ threadId: string
304
+ account: string
305
+ replyAll?: boolean
306
+ revalidate: () => void
307
+ }) {
308
+ const { pop } = useNavigation()
309
+ const [isLoading, setIsLoading] = useState(false)
310
+
311
+ const handleSubmit = async (values: { body: string }) => {
312
+ if (!values.body?.trim()) {
313
+ await showToast({ style: Toast.Style.Failure, title: 'Body is required' })
314
+ return
315
+ }
316
+ setIsLoading(true)
317
+ const { client } = await getClient([account])
318
+ const result = await client.replyToThread({
319
+ threadId,
320
+ body: values.body,
321
+ replyAll,
322
+ })
323
+ setIsLoading(false)
324
+ if (result instanceof Error) {
325
+ await showFailureToast(result, { title: 'Failed to send reply' })
326
+ return
327
+ }
328
+ await showToast({ style: Toast.Style.Success, title: 'Reply sent' })
329
+ revalidate()
330
+ pop()
331
+ }
332
+
333
+ return (
334
+ <Form
335
+ isLoading={isLoading}
336
+ navigationTitle={replyAll ? 'Reply All' : 'Reply'}
337
+ actions={
338
+ <ActionPanel>
339
+ <Action.SubmitForm title="Send Reply" onSubmit={handleSubmit} />
340
+ </ActionPanel>
341
+ }
342
+ >
343
+ <Form.TextArea id="body" title="Message" placeholder="Type your reply..." />
344
+ </Form>
345
+ )
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Forward Form
350
+ // ---------------------------------------------------------------------------
351
+
352
+ function ForwardForm({
353
+ threadId,
354
+ account,
355
+ revalidate,
356
+ }: {
357
+ threadId: string
358
+ account: string
359
+ revalidate: () => void
360
+ }) {
361
+ const { pop } = useNavigation()
362
+ const [isLoading, setIsLoading] = useState(false)
363
+
364
+ const handleSubmit = async (values: { to: string; body: string }) => {
365
+ if (!values.to?.trim()) {
366
+ await showToast({ style: Toast.Style.Failure, title: 'Recipient is required' })
367
+ return
368
+ }
369
+ setIsLoading(true)
370
+ const recipients = values.to.split(',').map((e) => ({ email: e.trim() })).filter((e) => e.email)
371
+ const { client } = await getClient([account])
372
+ const result = await client.forwardThread({
373
+ threadId,
374
+ to: recipients,
375
+ body: values.body || undefined,
376
+ })
377
+ setIsLoading(false)
378
+ if (result instanceof Error) {
379
+ await showFailureToast(result, { title: 'Failed to forward' })
380
+ return
381
+ }
382
+ await showToast({ style: Toast.Style.Success, title: `Forwarded to ${values.to}` })
383
+ revalidate()
384
+ pop()
385
+ }
386
+
387
+ return (
388
+ <Form
389
+ isLoading={isLoading}
390
+ navigationTitle="Forward"
391
+ actions={
392
+ <ActionPanel>
393
+ <Action.SubmitForm title="Forward" onSubmit={handleSubmit} />
394
+ </ActionPanel>
395
+ }
396
+ >
397
+ <Form.TextField id="to" title="To" placeholder="recipient@example.com" />
398
+ <Form.TextArea id="body" title="Message" placeholder="Optional message to prepend..." />
399
+ </Form>
400
+ )
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Thread Detail (full thread view, pushed via Enter)
405
+ // ---------------------------------------------------------------------------
406
+
407
+ function ThreadDetail({
408
+ threadId,
409
+ account,
410
+ revalidate,
411
+ }: {
412
+ threadId: string
413
+ account: string
414
+ revalidate: () => void
415
+ }) {
416
+ const thread = useCachedPromise(
417
+ async (tid: string, acct: string) => {
418
+ const { client } = await getClient([acct])
419
+ const result = await client.getThread({ threadId: tid })
420
+ if (result instanceof Error) throw result
421
+ return result.parsed
422
+ },
423
+ [threadId, account],
424
+ )
425
+
426
+ if (thread.isLoading || !thread.data) {
427
+ return <Detail markdown="" navigationTitle="Loading..." />
428
+ }
429
+
430
+ const t = thread.data
431
+ const messages = t.messages
432
+
433
+ // Build markdown: each message as a section with compact heading
434
+ // Prefer text/plain + email-reply-parser for clean quote stripping.
435
+ // Fall back to HTML → turndown when no text body is available.
436
+ const parts = messages.map((msg) => {
437
+ const senderName = msg.from.name || msg.from.email
438
+ const heading = `### ${senderName} — ${formatDate(msg.date)}`
439
+
440
+ const attachmentLine =
441
+ msg.attachments.length > 0
442
+ ? `📎 ${msg.attachments.map((a) => `${a.filename} (${formatSize(a.size)})`).join(', ')}`
443
+ : null
444
+
445
+ let body: string
446
+ if (msg.textBody) {
447
+ body = replyParser.parseReply(msg.textBody)
448
+ } else {
449
+ body = renderEmailBody(msg.body, msg.mimeType)
450
+ }
451
+
452
+ return [heading, attachmentLine, '', body].filter((l) => l !== null).join('\n')
453
+ })
454
+
455
+ const markdown = `# ${t.subject}\n\n---\n\n` + parts.join('\n\n---\n\n')
456
+
457
+ // Collect unique participants
458
+ const participants = new Map<string, string>()
459
+ for (const msg of messages) {
460
+ participants.set(msg.from.email, msg.from.name || msg.from.email)
461
+ for (const r of msg.to) participants.set(r.email, r.name || r.email)
462
+ }
463
+
464
+ const labels = [...new Set(messages.flatMap((m) => m.labelIds))]
465
+ .filter((l): l is string => typeof l === 'string' && !l.startsWith('Label_')) // skip internal IDs
466
+ .slice(0, 10)
467
+
468
+ const latestMsg = messages[messages.length - 1]!
469
+
470
+ return (
471
+ <Detail
472
+ navigationTitle={t.subject}
473
+ markdown={markdown}
474
+ metadata={
475
+ <Detail.Metadata>
476
+ <Detail.Metadata.Label title="From" text={formatSender(latestMsg.from)} />
477
+ <Detail.Metadata.Label title="To" text={latestMsg.to.map((r) => r.name || r.email).join(', ')} />
478
+ {latestMsg.cc && latestMsg.cc.length > 0 && (
479
+ <Detail.Metadata.Label title="Cc" text={latestMsg.cc.map((r) => r.name || r.email).join(', ')} />
480
+ )}
481
+ <Detail.Metadata.Label title="Date" text={latestMsg.date} />
482
+ <Detail.Metadata.Separator />
483
+ <Detail.Metadata.Label title="Messages" text={String(t.messageCount)} />
484
+ <Detail.Metadata.Label title="Participants" text={[...participants.values()].join(', ')} />
485
+ {labels.length > 0 && (
486
+ <Detail.Metadata.TagList title="Labels">
487
+ {labels.map((l) => (
488
+ <Detail.Metadata.TagList.Item key={l} text={l} color={labelColor(l)} />
489
+ ))}
490
+ </Detail.Metadata.TagList>
491
+ )}
492
+ <Detail.Metadata.Separator />
493
+ <Detail.Metadata.Label title="Thread ID" text={t.id} />
494
+ <Detail.Metadata.Label title="Account" text={account} />
495
+ </Detail.Metadata>
496
+ }
497
+ actions={
498
+ <ActionPanel>
499
+ <ActionPanel.Section title="Reply & Forward">
500
+ <Action.Push
501
+ title="Reply"
502
+ icon={Icon.Reply}
503
+ shortcut={{ modifiers: ['ctrl'], key: 'r' }}
504
+ target={<ReplyForm threadId={threadId} account={account} revalidate={revalidate} />}
505
+ />
506
+ <Action.Push
507
+ title="Reply All"
508
+ icon={Icon.Reply}
509
+ shortcut={{ modifiers: ['ctrl', 'shift'], key: 'r' }}
510
+ target={<ReplyForm threadId={threadId} account={account} replyAll revalidate={revalidate} />}
511
+ />
512
+ <Action.Push
513
+ title="Forward"
514
+ icon={Icon.Forward}
515
+ shortcut={{ modifiers: ['ctrl'], key: 'f' }}
516
+ target={<ForwardForm threadId={threadId} account={account} revalidate={revalidate} />}
517
+ />
518
+ </ActionPanel.Section>
519
+ <ActionPanel.Section title="Copy">
520
+ <Action.CopyToClipboard title="Copy Thread ID" content={t.id} />
521
+ <Action.CopyToClipboard title="Copy Subject" content={t.subject} />
522
+ {latestMsg && (
523
+ <Action.CopyToClipboard
524
+ title="Copy Email Body"
525
+ content={renderEmailBody(latestMsg.body, latestMsg.mimeType)}
526
+ />
527
+ )}
528
+ </ActionPanel.Section>
529
+ </ActionPanel>
530
+ }
531
+ />
532
+ )
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Main Command
537
+ // ---------------------------------------------------------------------------
538
+
539
+ export default function Command() {
540
+ const [selectedAccount, setSelectedAccount] = useState('all')
541
+ const [searchText, setSearchText] = useState('')
542
+ const [isShowingDetail, setIsShowingDetail] = useState(true)
543
+ const [selectedThreads, setSelectedThreads] = useState<string[]>([])
544
+
545
+ const accounts = useAccounts()
546
+ const accountList = accounts.data ?? []
547
+
548
+ // Fetch threads with pagination
549
+ const {
550
+ data: threads,
551
+ isLoading,
552
+ pagination,
553
+ revalidate,
554
+ } = useCachedPromise(
555
+ (query: string, account: string) => {
556
+ return async ({ cursor }: { page: number; cursor?: MailCursor }) => {
557
+ const accountFilter = account === 'all' ? undefined : [account]
558
+ const clients = await getClients(accountFilter)
559
+
560
+ // Single selected account: standard cursor pagination.
561
+ if (account !== 'all') {
562
+ const pageToken = cursor?.mode === 'single' ? cursor.nextPageToken : undefined
563
+ const { email, client } = clients[0]!
564
+ const result = await client.listThreads({
565
+ query: query || undefined,
566
+ maxResults: PAGE_SIZE,
567
+ pageToken: pageToken || undefined,
568
+ })
569
+ if (result instanceof Error) {
570
+ await showFailureToast(result, { title: 'Failed to fetch emails' })
571
+ return { data: [] as ThreadItem[], hasMore: false }
572
+ }
573
+ const data: ThreadItem[] = result.threads.map((t) => ({ ...t, account: email }))
574
+ return {
575
+ data,
576
+ hasMore: !!result.nextPageToken,
577
+ cursor: {
578
+ mode: 'single',
579
+ nextPageToken: result.nextPageToken ?? undefined,
580
+ } satisfies MailCursor,
581
+ }
582
+ }
583
+
584
+ // Multi-account: keep one token per account and merge sorted pages.
585
+ const previousByAccount = cursor?.mode === 'multi' ? cursor.nextByAccount : {}
586
+
587
+ const results = await Promise.all(
588
+ clients.map(async ({ email, client }) => {
589
+ // null means this account is exhausted and should not be fetched anymore.
590
+ if (previousByAccount[email] === null) {
591
+ return {
592
+ email,
593
+ result: null as null,
594
+ nextPageToken: null as string | null,
595
+ }
596
+ }
597
+
598
+ const result = await client.listThreads({
599
+ query: query || undefined,
600
+ maxResults: PAGE_SIZE,
601
+ pageToken: previousByAccount[email] ?? undefined,
602
+ })
603
+ if (result instanceof Error) {
604
+ return {
605
+ email,
606
+ result: null as null,
607
+ nextPageToken: null as string | null,
608
+ }
609
+ }
610
+ return {
611
+ email,
612
+ result,
613
+ nextPageToken: result.nextPageToken ?? null,
614
+ }
615
+ }),
616
+ )
617
+
618
+ const successfulResults = results
619
+ .map((r) => (r.result ? { email: r.email, result: r.result } : null))
620
+ .filter(isTruthy)
621
+
622
+ const merged: ThreadItem[] = successfulResults
623
+ .flatMap(({ email, result }) => result.threads.map((t) => ({ ...t, account: email })))
624
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
625
+
626
+ const nextByAccount: Record<string, string | null> = {}
627
+ for (const { email, nextPageToken } of results) {
628
+ nextByAccount[email] = nextPageToken
629
+ }
630
+
631
+ const hasMore = Object.values(nextByAccount).some((token) => token !== null)
632
+
633
+ return {
634
+ data: merged,
635
+ hasMore,
636
+ cursor: { mode: 'multi', nextByAccount } satisfies MailCursor,
637
+ }
638
+ }
639
+ },
640
+ [searchText, selectedAccount],
641
+ { keepPreviousData: true },
642
+ )
643
+
644
+ const handleAccountAdded = useCallback(
645
+ async (email: string) => {
646
+ await accounts.revalidate()
647
+ setSelectedAccount(email)
648
+ await revalidate()
649
+ },
650
+ [accounts, revalidate],
651
+ )
652
+
653
+ const handleAccountRemoved = useCallback(
654
+ async (email: string) => {
655
+ await accounts.revalidate()
656
+ if (selectedAccount === email) {
657
+ setSelectedAccount('all')
658
+ }
659
+ await revalidate()
660
+ },
661
+ [accounts, revalidate, selectedAccount],
662
+ )
663
+
664
+ const allThreads = threads ?? []
665
+
666
+ // Group threads into sections
667
+ const sections = useMemo(() => {
668
+ const groups = new Map<string, ThreadItem[]>()
669
+ for (const section of SECTION_ORDER) groups.set(section, [])
670
+
671
+ for (const thread of allThreads) {
672
+ const section = dateSection(thread.date)
673
+ const list = groups.get(section)
674
+ if (list) list.push(thread)
675
+ }
676
+
677
+ return SECTION_ORDER.map((name) => ({
678
+ name,
679
+ threads: groups.get(name) ?? [],
680
+ })).filter((s) => s.threads.length > 0)
681
+ }, [allThreads])
682
+
683
+ const multiAccount = accountList.length > 1
684
+
685
+ // Selection helpers
686
+ const toggleSelection = useCallback((threadId: string) => {
687
+ setSelectedThreads((prev) =>
688
+ prev.includes(threadId) ? prev.filter((id) => id !== threadId) : [...prev, threadId],
689
+ )
690
+ }, [])
691
+
692
+ // Bulk actions
693
+ const handleBulkAction = useCallback(
694
+ async (
695
+ actionName: string,
696
+ fn: (client: GmailClient, ids: string[]) => Promise<void | Error>,
697
+ ) => {
698
+ if (selectedThreads.length === 0) return
699
+
700
+ // Group selected threads by account
701
+ const byAccount = new Map<string, string[]>()
702
+ for (const tid of selectedThreads) {
703
+ const thread = allThreads.find((t: ThreadItem) => t.id === tid)
704
+ if (!thread) continue
705
+ const list = byAccount.get(thread.account) ?? []
706
+ list.push(tid)
707
+ byAccount.set(thread.account, list)
708
+ }
709
+
710
+ for (const [acct, ids] of byAccount) {
711
+ const { client } = await getClient([acct])
712
+ const result = await fn(client, ids)
713
+ if (result instanceof Error) {
714
+ await showFailureToast(result, { title: `Failed to ${actionName}` })
715
+ return
716
+ }
717
+ }
718
+
719
+ await showToast({ style: Toast.Style.Success, title: `${actionName}: ${selectedThreads.length} thread(s)` })
720
+ setSelectedThreads([])
721
+ revalidate()
722
+ },
723
+ [selectedThreads, allThreads, revalidate],
724
+ )
725
+
726
+ return (
727
+ <List
728
+ isLoading={isLoading || accounts.isLoading}
729
+ isShowingDetail={isShowingDetail}
730
+ searchBarPlaceholder="Search emails..."
731
+ onSearchTextChange={setSearchText}
732
+ throttle
733
+ pagination={pagination ? { ...pagination, pageSize: PAGE_SIZE } : undefined}
734
+ searchBarAccessory={
735
+ accountList.length > 0 ? (
736
+ <AccountDropdown
737
+ accounts={accountList}
738
+ value={selectedAccount}
739
+ onChange={setSelectedAccount}
740
+ onAdded={handleAccountAdded}
741
+ onRemoved={handleAccountRemoved}
742
+ />
743
+ ) : undefined
744
+ }
745
+ >
746
+ {sections.map((section) => (
747
+ <List.Section key={section.name} title={section.name}>
748
+ {section.threads.map((thread) => {
749
+ const isSelected = selectedThreads.includes(thread.id)
750
+ const hasSelection = selectedThreads.length > 0
751
+
752
+ // Icon: selection mode or status
753
+ const icon = hasSelection
754
+ ? { source: isSelected ? Icon.CheckCircle : Icon.Circle, tintColor: isSelected ? Color.Blue : Color.SecondaryText }
755
+ : threadStatusIcon(thread)
756
+
757
+ // Accessories
758
+ const accessories: Array<{ text?: string; tag?: string | { value: string; color?: string }; icon?: string | null }> = []
759
+ if (thread.messageCount > 1) {
760
+ accessories.push({ tag: { value: String(thread.messageCount), color: Color.SecondaryText } })
761
+ }
762
+ if (multiAccount || selectedAccount === 'all') {
763
+ accessories.push({ tag: { value: thread.account.split('@')[0] ?? thread.account, color: accountColor(thread.account) } })
764
+ }
765
+ accessories.push({ text: formatDate(thread.date) })
766
+
767
+ // Detail panel: latest message body as markdown
768
+ const detail = isShowingDetail ? (
769
+ <List.Item.Detail
770
+ markdown={`# ${thread.subject}\n\n${thread.snippet}`}
771
+ metadata={
772
+ <List.Item.Detail.Metadata>
773
+ <List.Item.Detail.Metadata.Label title="From" text={formatSender(thread.from)} />
774
+ <List.Item.Detail.Metadata.Label title="Date" text={thread.date} />
775
+ <List.Item.Detail.Metadata.Separator />
776
+ {thread.labelIds.length > 0 && (
777
+ <List.Item.Detail.Metadata.TagList title="Labels">
778
+ {thread.labelIds
779
+ .filter((l) => !l.startsWith('Label_'))
780
+ .slice(0, 8)
781
+ .map((l) => (
782
+ <List.Item.Detail.Metadata.TagList.Item key={l} text={l} color={labelColor(l)} />
783
+ ))}
784
+ </List.Item.Detail.Metadata.TagList>
785
+ )}
786
+ <List.Item.Detail.Metadata.Separator />
787
+ <List.Item.Detail.Metadata.Label title="Messages" text={String(thread.messageCount)} />
788
+ <List.Item.Detail.Metadata.Label title="Thread ID" text={thread.id} />
789
+ {multiAccount && (
790
+ <List.Item.Detail.Metadata.Label title="Account" text={thread.account} />
791
+ )}
792
+ </List.Item.Detail.Metadata>
793
+ }
794
+ />
795
+ ) : undefined
796
+
797
+ return (
798
+ <List.Item
799
+ key={`${thread.account}-${thread.id}`}
800
+ title={thread.subject || '(no subject)'}
801
+ subtitle={formatSender(thread.from)}
802
+ icon={icon}
803
+ accessories={accessories}
804
+ keywords={[thread.from.email, thread.from.name ?? '', thread.account]}
805
+ detail={detail}
806
+ actions={
807
+ <ActionPanel>
808
+ {/* Selection actions (when items are selected) */}
809
+ {hasSelection && (
810
+ <ActionPanel.Section title="Selection">
811
+ <Action
812
+ title={isSelected ? 'Deselect Thread' : 'Select Thread'}
813
+ icon={isSelected ? Icon.CheckCircle : Icon.Circle}
814
+ onAction={() => toggleSelection(thread.id)}
815
+ />
816
+ <Action
817
+ title={`Archive ${selectedThreads.length} Selected`}
818
+ icon={Icon.Tray}
819
+ onAction={() =>
820
+ handleBulkAction('Archived', (c, ids) => c.archive({ threadIds: ids }))
821
+ }
822
+ />
823
+ <Action
824
+ title={`Mark ${selectedThreads.length} as Read`}
825
+ icon={Icon.Eye}
826
+ onAction={() =>
827
+ handleBulkAction('Marked as read', (c, ids) => c.markAsRead({ threadIds: ids }))
828
+ }
829
+ />
830
+ <Action
831
+ title={`Star ${selectedThreads.length} Selected`}
832
+ icon={Icon.Star}
833
+ onAction={() =>
834
+ handleBulkAction('Starred', (c, ids) => c.star({ threadIds: ids }))
835
+ }
836
+ />
837
+ <Action
838
+ title={`Trash ${selectedThreads.length} Selected`}
839
+ icon={Icon.Trash}
840
+ style={Action.Style.Destructive}
841
+ onAction={() =>
842
+ handleBulkAction('Trashed', async (c, ids) => {
843
+ for (const id of ids) {
844
+ await c.trash({ threadId: id })
845
+ }
846
+ })
847
+ }
848
+ />
849
+ <Action
850
+ title="Deselect All"
851
+ icon={Icon.XMarkCircle}
852
+ onAction={() => setSelectedThreads([])}
853
+ />
854
+ </ActionPanel.Section>
855
+ )}
856
+
857
+ {/* Primary actions */}
858
+ <ActionPanel.Section>
859
+ <Action.Push
860
+ title="Open Thread"
861
+ icon={Icon.Eye}
862
+ target={
863
+ <ThreadDetail
864
+ threadId={thread.id}
865
+ account={thread.account}
866
+ revalidate={revalidate}
867
+ />
868
+ }
869
+ />
870
+ {!hasSelection && (
871
+ <Action
872
+ title="Select Thread"
873
+ icon={Icon.CheckCircle}
874
+ shortcut={{ modifiers: ['ctrl'], key: 'x' }}
875
+ onAction={() => toggleSelection(thread.id)}
876
+ />
877
+ )}
878
+ <Action
879
+ title={thread.unread ? 'Mark as Read' : 'Mark as Unread'}
880
+ icon={thread.unread ? Icon.Eye : Icon.EyeDisabled}
881
+ shortcut={{ modifiers: ['ctrl'], key: 'u' }}
882
+ onAction={async () => {
883
+ const { client } = await getClient([thread.account])
884
+ const result = thread.unread
885
+ ? await client.markAsRead({ threadIds: [thread.id] })
886
+ : await client.markAsUnread({ threadIds: [thread.id] })
887
+ if (result instanceof Error) {
888
+ await showFailureToast(result)
889
+ return
890
+ }
891
+ await showToast({
892
+ style: Toast.Style.Success,
893
+ title: thread.unread ? 'Marked as read' : 'Marked as unread',
894
+ })
895
+ revalidate()
896
+ }}
897
+ />
898
+ <Action
899
+ title="Archive"
900
+ icon={Icon.Tray}
901
+ shortcut={{ modifiers: ['ctrl'], key: 'e' }}
902
+ onAction={async () => {
903
+ const { client } = await getClient([thread.account])
904
+ const result = await client.archive({ threadIds: [thread.id] })
905
+ if (result instanceof Error) {
906
+ await showFailureToast(result)
907
+ return
908
+ }
909
+ await showToast({ style: Toast.Style.Success, title: 'Archived' })
910
+ revalidate()
911
+ }}
912
+ />
913
+ <Action
914
+ title={thread.labelIds.includes('STARRED') ? 'Unstar' : 'Star'}
915
+ icon={Icon.Star}
916
+ shortcut={{ modifiers: ['ctrl'], key: 's' }}
917
+ onAction={async () => {
918
+ const { client } = await getClient([thread.account])
919
+ const isStarred = thread.labelIds.includes('STARRED')
920
+ const result = isStarred
921
+ ? await client.unstar({ threadIds: [thread.id] })
922
+ : await client.star({ threadIds: [thread.id] })
923
+ if (result instanceof Error) {
924
+ await showFailureToast(result)
925
+ return
926
+ }
927
+ await showToast({
928
+ style: Toast.Style.Success,
929
+ title: isStarred ? 'Unstarred' : 'Starred',
930
+ })
931
+ revalidate()
932
+ }}
933
+ />
934
+ </ActionPanel.Section>
935
+
936
+ {/* Reply & Forward */}
937
+ <ActionPanel.Section title="Reply & Forward">
938
+ <Action.Push
939
+ title="Reply"
940
+ icon={Icon.Reply}
941
+ shortcut={{ modifiers: ['ctrl'], key: 'r' }}
942
+ target={
943
+ <ReplyForm
944
+ threadId={thread.id}
945
+ account={thread.account}
946
+ revalidate={revalidate}
947
+ />
948
+ }
949
+ />
950
+ <Action.Push
951
+ title="Reply All"
952
+ icon={Icon.Reply}
953
+ shortcut={{ modifiers: ['ctrl', 'shift'], key: 'r' }}
954
+ target={
955
+ <ReplyForm
956
+ threadId={thread.id}
957
+ account={thread.account}
958
+ replyAll
959
+ revalidate={revalidate}
960
+ />
961
+ }
962
+ />
963
+ <Action.Push
964
+ title="Forward"
965
+ icon={Icon.Forward}
966
+ shortcut={{ modifiers: ['ctrl'], key: 'f' }}
967
+ target={
968
+ <ForwardForm
969
+ threadId={thread.id}
970
+ account={thread.account}
971
+ revalidate={revalidate}
972
+ />
973
+ }
974
+ />
975
+ </ActionPanel.Section>
976
+
977
+ {/* Copy */}
978
+ <ActionPanel.Section title="Copy">
979
+ <Action.CopyToClipboard title="Copy Thread ID" content={thread.id} />
980
+ <Action.CopyToClipboard title="Copy Subject" content={thread.subject} />
981
+ <Action.CopyToClipboard title="Copy Sender Email" content={thread.from.email} />
982
+ </ActionPanel.Section>
983
+
984
+ {/* Danger */}
985
+ <ActionPanel.Section>
986
+ <Action
987
+ title="Trash"
988
+ icon={Icon.Trash}
989
+ style={Action.Style.Destructive}
990
+ shortcut={{ modifiers: ['ctrl'], key: 'backspace' }}
991
+ onAction={async () => {
992
+ const { client } = await getClient([thread.account])
993
+ await client.trash({ threadId: thread.id })
994
+ await showToast({ style: Toast.Style.Success, title: 'Trashed' })
995
+ revalidate()
996
+ }}
997
+ />
998
+ </ActionPanel.Section>
999
+
1000
+ {/* Utility */}
1001
+ <ActionPanel.Section>
1002
+ <Action
1003
+ title="Refresh"
1004
+ icon={Icon.ArrowClockwise}
1005
+ shortcut={{ modifiers: ['ctrl', 'shift'], key: 'r' }}
1006
+ onAction={() => revalidate()}
1007
+ />
1008
+ <Action
1009
+ title="Toggle Detail"
1010
+ icon={Icon.Sidebar}
1011
+ shortcut={{ modifiers: ['ctrl'], key: 'd' }}
1012
+ onAction={() => setIsShowingDetail((v) => !v)}
1013
+ />
1014
+ </ActionPanel.Section>
1015
+ </ActionPanel>
1016
+ }
1017
+ />
1018
+ )
1019
+ })}
1020
+ </List.Section>
1021
+ ))}
1022
+ </List>
1023
+ )
1024
+ }
1025
+
1026
+ // ---------------------------------------------------------------------------
1027
+ // Label color mapping
1028
+ // ---------------------------------------------------------------------------
1029
+
1030
+ function labelColor(label: string): string {
1031
+ switch (label) {
1032
+ case 'INBOX':
1033
+ return Color.Blue
1034
+ case 'STARRED':
1035
+ return Color.Yellow
1036
+ case 'IMPORTANT':
1037
+ return Color.Orange
1038
+ case 'SENT':
1039
+ return Color.Green
1040
+ case 'DRAFT':
1041
+ return Color.Purple
1042
+ case 'SPAM':
1043
+ return Color.Red
1044
+ case 'TRASH':
1045
+ return Color.Red
1046
+ case 'UNREAD':
1047
+ return Color.Blue
1048
+ default:
1049
+ return Color.SecondaryText
1050
+ }
1051
+ }
1052
+
1053
+ // ---------------------------------------------------------------------------
1054
+ // Size formatting
1055
+ // ---------------------------------------------------------------------------
1056
+
1057
+ function formatSize(bytes: number): string {
1058
+ if (bytes < 1024) return `${bytes} B`
1059
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
1060
+ return `${(bytes / 1048576).toFixed(1)} MB`
1061
+ }