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.
- package/README.md +38 -1
- package/bin/zele +27 -0
- package/dist/api-utils.d.ts +51 -2
- package/dist/api-utils.js +89 -3
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.d.ts +27 -6
- package/dist/auth.js +185 -129
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +16 -9
- package/dist/calendar-client.js +163 -59
- package/dist/calendar-client.js.map +1 -1
- package/dist/cli.js +28 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +17 -15
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +20 -9
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +67 -78
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +25 -18
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/label.js +33 -45
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.js +11 -13
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.js +114 -128
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.js +18 -21
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +73 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/db.js +12 -13
- package/dist/db.js.map +1 -1
- package/dist/generated/browser.d.ts +12 -27
- package/dist/generated/client.d.ts +13 -28
- package/dist/generated/client.js +1 -1
- package/dist/generated/commonInputTypes.d.ts +90 -26
- package/dist/generated/enums.d.ts +0 -4
- package/dist/generated/enums.js +0 -3
- package/dist/generated/enums.js.map +1 -1
- package/dist/generated/internal/class.d.ts +22 -55
- package/dist/generated/internal/class.js +12 -4
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +272 -511
- package/dist/generated/internal/prismaNamespace.js +54 -66
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
- package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/Account.d.ts +1637 -0
- package/dist/generated/models/Account.js +2 -0
- package/dist/generated/models/Account.js.map +1 -0
- package/dist/generated/models/CalendarList.d.ts +1161 -0
- package/dist/generated/models/CalendarList.js +2 -0
- package/dist/generated/models/CalendarList.js.map +1 -0
- package/dist/generated/models/Label.d.ts +1161 -0
- package/dist/generated/models/Label.js +2 -0
- package/dist/generated/models/Label.js.map +1 -0
- package/dist/generated/models/Profile.d.ts +1269 -0
- package/dist/generated/models/Profile.js +2 -0
- package/dist/generated/models/Profile.js.map +1 -0
- package/dist/generated/models/SyncState.d.ts +1130 -0
- package/dist/generated/models/SyncState.js +2 -0
- package/dist/generated/models/SyncState.js.map +1 -0
- package/dist/generated/models/Thread.d.ts +1608 -0
- package/dist/generated/models/Thread.js +2 -0
- package/dist/generated/models/Thread.js.map +1 -0
- package/dist/generated/models.d.ts +6 -9
- package/dist/gmail-client.d.ts +119 -94
- package/dist/gmail-client.js +862 -315
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.d.ts +1 -0
- package/dist/mail-tui.js +517 -0
- package/dist/mail-tui.js.map +1 -0
- package/dist/output.d.ts +6 -4
- package/dist/output.js +124 -17
- package/dist/output.js.map +1 -1
- package/package.json +39 -11
- package/schema.prisma +81 -113
- package/src/api-utils.ts +103 -5
- package/src/auth.ts +224 -143
- package/src/calendar-client.ts +196 -89
- package/src/cli.ts +32 -1
- package/src/commands/attachment.ts +18 -19
- package/src/commands/auth-cmd.ts +19 -9
- package/src/commands/calendar.ts +42 -85
- package/src/commands/draft.ts +19 -22
- package/src/commands/label.ts +21 -57
- package/src/commands/mail-actions.ts +11 -19
- package/src/commands/mail.ts +104 -149
- package/src/commands/profile.ts +12 -28
- package/src/commands/watch.ts +88 -0
- package/src/db.ts +13 -16
- package/src/generated/browser.ts +49 -0
- package/src/generated/client.ts +71 -0
- package/src/generated/commonInputTypes.ts +332 -0
- package/src/generated/enums.ts +17 -0
- package/src/generated/internal/class.ts +250 -0
- package/src/generated/internal/prismaNamespace.ts +1198 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
- package/src/generated/models/Account.ts +1848 -0
- package/src/generated/models/CalendarList.ts +1331 -0
- package/src/generated/models/Label.ts +1331 -0
- package/src/generated/models/Profile.ts +1439 -0
- package/src/generated/models/SyncState.ts +1300 -0
- package/src/generated/models/Thread.ts +1787 -0
- package/src/generated/models.ts +17 -0
- package/src/gmail-client.test.ts +59 -0
- package/src/gmail-client.ts +1034 -422
- package/src/mail-tui.tsx +1061 -0
- package/src/output.test.ts +1093 -0
- package/src/output.ts +128 -20
- package/src/schema.sql +58 -68
- package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
- package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
- package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
- package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
- package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
- package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
- package/AGENTS.md +0 -26
- package/CHANGELOG.md +0 -36
- package/dist/generated/models/accounts.d.ts +0 -2000
- package/dist/generated/models/accounts.js +0 -2
- package/dist/generated/models/accounts.js.map +0 -1
- package/dist/generated/models/calendar_events.d.ts +0 -1433
- package/dist/generated/models/calendar_events.js +0 -2
- package/dist/generated/models/calendar_events.js.map +0 -1
- package/dist/generated/models/calendar_lists.d.ts +0 -1131
- package/dist/generated/models/calendar_lists.js +0 -2
- package/dist/generated/models/calendar_lists.js.map +0 -1
- package/dist/generated/models/label_counts.d.ts +0 -1131
- package/dist/generated/models/label_counts.js +0 -2
- package/dist/generated/models/label_counts.js.map +0 -1
- package/dist/generated/models/labels.d.ts +0 -1131
- package/dist/generated/models/labels.js +0 -2
- package/dist/generated/models/labels.js.map +0 -1
- package/dist/generated/models/profiles.d.ts +0 -1131
- package/dist/generated/models/profiles.js +0 -2
- package/dist/generated/models/profiles.js.map +0 -1
- package/dist/generated/models/sync_states.d.ts +0 -1107
- package/dist/generated/models/sync_states.js +0 -2
- package/dist/generated/models/sync_states.js.map +0 -1
- package/dist/generated/models/thread_lists.d.ts +0 -1404
- package/dist/generated/models/thread_lists.js +0 -2
- package/dist/generated/models/thread_lists.js.map +0 -1
- package/dist/generated/models/threads.d.ts +0 -1247
- package/dist/generated/models/threads.js +0 -2
- package/dist/generated/models/threads.js.map +0 -1
- package/dist/gmail-cache.d.ts +0 -60
- package/dist/gmail-cache.js +0 -264
- package/dist/gmail-cache.js.map +0 -1
- package/docs/gogcli-gmail-implementation.md +0 -599
- package/scripts/test-device-code-clients.ts +0 -186
- package/scripts/test-micropython-scopes.ts +0 -72
- package/scripts/test-oauth-clients.ts +0 -257
- package/src/gmail-cache.ts +0 -339
- package/tsconfig.json +0 -16
package/src/mail-tui.tsx
ADDED
|
@@ -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
|
+
}
|