zele 0.3.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 +1 -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 +26 -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 +112 -126
- 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.js +33 -261
- package/dist/commands/watch.js.map +1 -1
- 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 -322
- 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 -0
- package/dist/output.js +124 -11
- 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 +30 -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 +102 -147
- package/src/commands/profile.ts +12 -28
- package/src/commands/watch.ts +37 -304
- 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 -429
- package/src/mail-tui.tsx +1061 -0
- package/src/output.test.ts +1093 -0
- package/src/output.ts +128 -13
- 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 -43
- 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/gmail-client.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
// Gmail API client for CLI/TUI use.
|
|
2
2
|
// Wraps the @googleapis/gmail SDK with structured methods, object params, and inferred return types.
|
|
3
3
|
// No abstract interfaces, no RPC layer — just a concrete class for Gmail.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// Cache is built into the client for expensive read paths.
|
|
5
|
+
// List/search entry-point calls always fetch fresh IDs and then reuse per-thread cache
|
|
6
|
+
// to avoid repeated N+1 hydration calls.
|
|
7
|
+
// Raw Google API responses are stored in the cache (gmail_v1.Schema$*) so the cache
|
|
8
|
+
// is resilient to changes in our own parsed types. Parsing happens at read time.
|
|
9
|
+
// When account is not provided (bootstrap/login flow), cache is skipped entirely.
|
|
10
10
|
|
|
11
11
|
import { gmail as gmailApi, type gmail_v1 } from '@googleapis/gmail'
|
|
12
12
|
import type { OAuth2Client } from 'google-auth-library'
|
|
13
13
|
import { createMimeMessage } from 'mimetext'
|
|
14
14
|
import { parseFrom, parseAddressList } from './email-utils.js'
|
|
15
|
-
import
|
|
15
|
+
import * as errore from 'errore'
|
|
16
|
+
import { withRetry, mapConcurrent, AuthError, isAuthLikeError, ApiError, NotFoundError, EmptyThreadError, MissingDataError } from './api-utils.js'
|
|
17
|
+
import { renderEmailBody } from './output.js'
|
|
18
|
+
import { getPrisma } from './db.js'
|
|
19
|
+
import type { AccountId } from './auth.js'
|
|
16
20
|
|
|
17
21
|
// ---------------------------------------------------------------------------
|
|
18
22
|
// Types
|
|
@@ -42,8 +46,9 @@ export interface ParsedMessage {
|
|
|
42
46
|
inReplyTo?: string
|
|
43
47
|
references?: string
|
|
44
48
|
listUnsubscribe?: string
|
|
45
|
-
body: string // decoded
|
|
49
|
+
body: string // decoded body (html preferred for rich rendering)
|
|
46
50
|
mimeType: string // 'text/plain' or 'text/html'
|
|
51
|
+
textBody: string | null // decoded text/plain body when available (for reply parsing)
|
|
47
52
|
attachments: AttachmentMeta[]
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -81,10 +86,29 @@ export interface ThreadListItem {
|
|
|
81
86
|
|
|
82
87
|
export interface ThreadListResult {
|
|
83
88
|
threads: ThreadListItem[]
|
|
89
|
+
/** Raw Google gmail_v1.Schema$Thread metadata responses, parallel to threads[]. */
|
|
90
|
+
rawThreads: gmail_v1.Schema$Thread[]
|
|
84
91
|
nextPageToken: string | null
|
|
85
92
|
resultSizeEstimate: number
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
/** Result from getThread() — includes both parsed data and the raw Google response. */
|
|
96
|
+
export interface ThreadResult {
|
|
97
|
+
parsed: ThreadData
|
|
98
|
+
raw: gmail_v1.Schema$Thread
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Watch event type
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export interface WatchEvent {
|
|
106
|
+
account: AccountId
|
|
107
|
+
type: 'new_message'
|
|
108
|
+
message: ParsedMessage
|
|
109
|
+
threadId: string
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
// ---------------------------------------------------------------------------
|
|
89
113
|
// Constants
|
|
90
114
|
// ---------------------------------------------------------------------------
|
|
@@ -115,6 +139,40 @@ function decodeBase64Url(encoded: string) {
|
|
|
115
139
|
return Buffer.from(base64, 'base64').toString('utf-8')
|
|
116
140
|
}
|
|
117
141
|
|
|
142
|
+
function decodeHtmlEntities(value: string): string {
|
|
143
|
+
const namedEntities: Record<string, string> = {
|
|
144
|
+
amp: '&',
|
|
145
|
+
lt: '<',
|
|
146
|
+
gt: '>',
|
|
147
|
+
quot: '"',
|
|
148
|
+
apos: "'",
|
|
149
|
+
nbsp: ' ',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return value.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, entity: string) => {
|
|
153
|
+
if (entity.startsWith('#x') || entity.startsWith('#X')) {
|
|
154
|
+
const codePoint = Number.parseInt(entity.slice(2), 16)
|
|
155
|
+
if (!Number.isFinite(codePoint)) return match
|
|
156
|
+
return String.fromCodePoint(codePoint)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (entity.startsWith('#')) {
|
|
160
|
+
const codePoint = Number.parseInt(entity.slice(1), 10)
|
|
161
|
+
if (!Number.isFinite(codePoint)) return match
|
|
162
|
+
return String.fromCodePoint(codePoint)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return namedEntities[entity.toLowerCase()] ?? match
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sanitizeSnippet(snippet: string): string {
|
|
170
|
+
return decodeHtmlEntities(snippet)
|
|
171
|
+
.replace(/[\u00A0\u00AD\u034F\u061C\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, ' ')
|
|
172
|
+
.replace(/\s+/g, ' ')
|
|
173
|
+
.trim()
|
|
174
|
+
}
|
|
175
|
+
|
|
118
176
|
function encodeBase64Url(data: string | Buffer) {
|
|
119
177
|
const buf = typeof data === 'string' ? Buffer.from(data) : data
|
|
120
178
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
@@ -124,12 +182,134 @@ function encodeBase64Url(data: string | Buffer) {
|
|
|
124
182
|
// GmailClient
|
|
125
183
|
// ---------------------------------------------------------------------------
|
|
126
184
|
|
|
185
|
+
// TTL constants in milliseconds
|
|
186
|
+
const TTL = {
|
|
187
|
+
THREAD: 30 * 60 * 1000, // 30 minutes
|
|
188
|
+
LABELS: 30 * 60 * 1000, // 30 minutes
|
|
189
|
+
PROFILE: 24 * 60 * 60 * 1000, // 24 hours
|
|
190
|
+
} as const
|
|
191
|
+
|
|
192
|
+
function isExpired(createdAt: Date, ttlMs: number): boolean {
|
|
193
|
+
return createdAt.getTime() + ttlMs < Date.now()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Boundary helper: wrap a googleapis SDK call, converting auth-like errors to AuthError values.
|
|
197
|
+
* Non-auth errors are wrapped in ApiError so they remain error values (no throwing).
|
|
198
|
+
* Original error is preserved as `cause` for debugging. */
|
|
199
|
+
function gmailBoundary<T>(email: string, fn: () => Promise<T>) {
|
|
200
|
+
return errore.tryAsync({
|
|
201
|
+
try: fn,
|
|
202
|
+
catch: (err) => isAuthLikeError(err)
|
|
203
|
+
? new AuthError({ email, reason: String(err) })
|
|
204
|
+
: new ApiError({ reason: String(err), cause: err }),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
127
208
|
export class GmailClient {
|
|
128
209
|
private gmail: gmail_v1.Gmail
|
|
129
210
|
private labelIdCache: Record<string, string> = {}
|
|
211
|
+
private account: AccountId | null
|
|
130
212
|
|
|
131
|
-
constructor({ auth }: { auth: OAuth2Client }) {
|
|
213
|
+
constructor({ auth, account }: { auth: OAuth2Client; account?: AccountId }) {
|
|
132
214
|
this.gmail = gmailApi({ version: 'v1', auth })
|
|
215
|
+
this.account = account ?? null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// =========================================================================
|
|
219
|
+
// Cache helpers (private) — skip all cache ops when account is null
|
|
220
|
+
// =========================================================================
|
|
221
|
+
|
|
222
|
+
private get cacheEnabled(): boolean {
|
|
223
|
+
return this.account !== null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async getCachedThread(threadId: string): Promise<gmail_v1.Schema$Thread | undefined> {
|
|
227
|
+
if (!this.cacheEnabled) return undefined
|
|
228
|
+
const prisma = await getPrisma()
|
|
229
|
+
const row = await prisma.thread.findUnique({
|
|
230
|
+
where: { email_appId_threadId: { email: this.account!.email, appId: this.account!.appId, threadId } },
|
|
231
|
+
})
|
|
232
|
+
if (!row || isExpired(row.createdAt, row.ttlMs)) return undefined
|
|
233
|
+
return JSON.parse(row.rawData) as gmail_v1.Schema$Thread
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async cacheThreadData(threadId: string, raw: gmail_v1.Schema$Thread, parsed: ThreadData): Promise<void> {
|
|
237
|
+
if (!this.cacheEnabled) return
|
|
238
|
+
const prisma = await getPrisma()
|
|
239
|
+
await prisma.thread.upsert({
|
|
240
|
+
where: { email_appId_threadId: { email: this.account!.email, appId: this.account!.appId, threadId } },
|
|
241
|
+
create: {
|
|
242
|
+
email: this.account!.email, appId: this.account!.appId, threadId,
|
|
243
|
+
subject: parsed.subject, snippet: parsed.snippet,
|
|
244
|
+
fromEmail: parsed.from.email, fromName: parsed.from.name ?? '',
|
|
245
|
+
date: parsed.date, labelIds: parsed.labelIds.join(','),
|
|
246
|
+
hasUnread: parsed.hasUnread, msgCount: parsed.messageCount,
|
|
247
|
+
historyId: parsed.historyId,
|
|
248
|
+
rawData: JSON.stringify(raw), ttlMs: TTL.THREAD, createdAt: new Date(),
|
|
249
|
+
},
|
|
250
|
+
update: {
|
|
251
|
+
subject: parsed.subject, snippet: parsed.snippet,
|
|
252
|
+
fromEmail: parsed.from.email, fromName: parsed.from.name ?? '',
|
|
253
|
+
date: parsed.date, labelIds: parsed.labelIds.join(','),
|
|
254
|
+
hasUnread: parsed.hasUnread, msgCount: parsed.messageCount,
|
|
255
|
+
historyId: parsed.historyId,
|
|
256
|
+
rawData: JSON.stringify(raw), ttlMs: TTL.THREAD, createdAt: new Date(),
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async invalidateThreads(threadIds: string[]): Promise<void> {
|
|
262
|
+
if (!this.cacheEnabled) return
|
|
263
|
+
const prisma = await getPrisma()
|
|
264
|
+
await prisma.thread.deleteMany({ where: { email: this.account!.email, appId: this.account!.appId, threadId: { in: threadIds } } })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async invalidateThread(threadId: string): Promise<void> {
|
|
268
|
+
if (!this.cacheEnabled) return
|
|
269
|
+
const prisma = await getPrisma()
|
|
270
|
+
await prisma.thread.deleteMany({ where: { email: this.account!.email, appId: this.account!.appId, threadId } })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async getCachedLabels(): Promise<gmail_v1.Schema$Label[] | undefined> {
|
|
274
|
+
if (!this.cacheEnabled) return undefined
|
|
275
|
+
const prisma = await getPrisma()
|
|
276
|
+
const row = await prisma.label.findUnique({ where: { email_appId: { email: this.account!.email, appId: this.account!.appId } } })
|
|
277
|
+
if (!row || isExpired(row.createdAt, row.ttlMs)) return undefined
|
|
278
|
+
return JSON.parse(row.rawData) as gmail_v1.Schema$Label[]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async cacheLabelsData(raw: gmail_v1.Schema$Label[]): Promise<void> {
|
|
282
|
+
if (!this.cacheEnabled) return
|
|
283
|
+
const prisma = await getPrisma()
|
|
284
|
+
await prisma.label.upsert({
|
|
285
|
+
where: { email_appId: { email: this.account!.email, appId: this.account!.appId } },
|
|
286
|
+
create: { email: this.account!.email, appId: this.account!.appId, rawData: JSON.stringify(raw), ttlMs: TTL.LABELS, createdAt: new Date() },
|
|
287
|
+
update: { rawData: JSON.stringify(raw), ttlMs: TTL.LABELS, createdAt: new Date() },
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async invalidateLabels(): Promise<void> {
|
|
292
|
+
if (!this.cacheEnabled) return
|
|
293
|
+
const prisma = await getPrisma()
|
|
294
|
+
await prisma.label.deleteMany({ where: { email: this.account!.email, appId: this.account!.appId } })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async getCachedProfile(): Promise<{ emailAddress: string; messagesTotal: number; threadsTotal: number; historyId: string } | undefined> {
|
|
298
|
+
if (!this.cacheEnabled) return undefined
|
|
299
|
+
const prisma = await getPrisma()
|
|
300
|
+
const row = await prisma.profile.findUnique({ where: { email_appId: { email: this.account!.email, appId: this.account!.appId } } })
|
|
301
|
+
if (!row || isExpired(row.createdAt, row.ttlMs)) return undefined
|
|
302
|
+
return { emailAddress: row.emailAddress, messagesTotal: row.messagesTotal, threadsTotal: row.threadsTotal, historyId: row.historyId }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private async cacheProfileData(profile: { emailAddress: string; messagesTotal: number; threadsTotal: number; historyId: string }): Promise<void> {
|
|
306
|
+
if (!this.cacheEnabled) return
|
|
307
|
+
const prisma = await getPrisma()
|
|
308
|
+
await prisma.profile.upsert({
|
|
309
|
+
where: { email_appId: { email: this.account!.email, appId: this.account!.appId } },
|
|
310
|
+
create: { email: this.account!.email, appId: this.account!.appId, ...profile, ttlMs: TTL.PROFILE, createdAt: new Date() },
|
|
311
|
+
update: { ...profile, ttlMs: TTL.PROFILE, createdAt: new Date() },
|
|
312
|
+
})
|
|
133
313
|
}
|
|
134
314
|
|
|
135
315
|
// =========================================================================
|
|
@@ -148,47 +328,78 @@ export class GmailClient {
|
|
|
148
328
|
maxResults?: number
|
|
149
329
|
labelIds?: string[]
|
|
150
330
|
pageToken?: string
|
|
151
|
-
} = {}): Promise<ThreadListResult> {
|
|
331
|
+
} = {}): Promise<ThreadListResult | AuthError | ApiError> {
|
|
152
332
|
const { q, resolvedLabelIds } = this.buildSearchParams(folder, query, labelIds)
|
|
153
333
|
|
|
154
|
-
const res = await
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
334
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
335
|
+
withRetry(() =>
|
|
336
|
+
this.gmail.users.threads.list({
|
|
337
|
+
userId: 'me',
|
|
338
|
+
q: q || undefined,
|
|
339
|
+
labelIds: resolvedLabelIds.length > 0 ? resolvedLabelIds : undefined,
|
|
340
|
+
maxResults,
|
|
341
|
+
pageToken: pageToken || undefined,
|
|
342
|
+
}),
|
|
343
|
+
),
|
|
162
344
|
)
|
|
345
|
+
if (res instanceof Error) return res
|
|
163
346
|
|
|
164
347
|
const rawThreads = res.data.threads ?? []
|
|
165
348
|
|
|
166
|
-
// Hydrate with metadata
|
|
167
|
-
const
|
|
349
|
+
// Hydrate with metadata — collect both raw and parsed
|
|
350
|
+
const hydrated = await mapConcurrent(rawThreads, async (t) => {
|
|
168
351
|
if (!t.id) return null
|
|
169
|
-
|
|
170
|
-
|
|
352
|
+
|
|
353
|
+
const cached = await this.getCachedThread(t.id)
|
|
354
|
+
if (cached && (!t.historyId || !cached.historyId || t.historyId === cached.historyId)) {
|
|
355
|
+
return {
|
|
356
|
+
parsed: GmailClient.parseRawThreadListItem(cached),
|
|
357
|
+
raw: cached,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Boundary: threads.get — auth errors abort via mapConcurrent, others skip.
|
|
362
|
+
const detail = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
363
|
+
withRetry(() =>
|
|
171
364
|
this.gmail.users.threads.get({
|
|
172
365
|
userId: 'me',
|
|
173
366
|
id: t.id!,
|
|
174
|
-
format: '
|
|
175
|
-
metadataHeaders: ['Subject', 'From', 'Date', 'To'],
|
|
367
|
+
format: 'full',
|
|
176
368
|
}),
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
if (detail instanceof AuthError) return detail
|
|
372
|
+
if (detail instanceof Error) return null
|
|
373
|
+
|
|
374
|
+
const parsed = GmailClient.parseRawThread(detail.data)
|
|
375
|
+
await this.cacheThreadData(t.id, detail.data, parsed)
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
parsed: GmailClient.parseRawThreadListItem(detail.data),
|
|
379
|
+
raw: detail.data,
|
|
181
380
|
}
|
|
182
381
|
})
|
|
183
382
|
|
|
184
|
-
return
|
|
185
|
-
|
|
383
|
+
if (hydrated instanceof Error) return hydrated
|
|
384
|
+
|
|
385
|
+
const valid = hydrated.filter((t): t is NonNullable<typeof t> => t !== null)
|
|
386
|
+
const result: ThreadListResult = {
|
|
387
|
+
threads: valid.map((t) => t.parsed),
|
|
388
|
+
rawThreads: valid.map((t) => t.raw),
|
|
186
389
|
nextPageToken: res.data.nextPageToken ?? null,
|
|
187
390
|
resultSizeEstimate: res.data.resultSizeEstimate ?? 0,
|
|
188
391
|
}
|
|
392
|
+
|
|
393
|
+
return result
|
|
189
394
|
}
|
|
190
395
|
|
|
191
|
-
async getThread({ threadId }: { threadId: string }) {
|
|
396
|
+
async getThread({ threadId }: { threadId: string }): Promise<ThreadResult> {
|
|
397
|
+
// Check cache
|
|
398
|
+
const cached = await this.getCachedThread(threadId)
|
|
399
|
+
if (cached) {
|
|
400
|
+
return { parsed: GmailClient.parseRawThread(cached), raw: cached }
|
|
401
|
+
}
|
|
402
|
+
|
|
192
403
|
const res = await withRetry(() =>
|
|
193
404
|
this.gmail.users.threads.get({
|
|
194
405
|
userId: 'me',
|
|
@@ -197,37 +408,13 @@ export class GmailClient {
|
|
|
197
408
|
}),
|
|
198
409
|
)
|
|
199
410
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
id: threadId,
|
|
203
|
-
historyId: res.data.historyId ?? null,
|
|
204
|
-
messages: [],
|
|
205
|
-
subject: '',
|
|
206
|
-
snippet: '',
|
|
207
|
-
from: { email: '' },
|
|
208
|
-
date: '',
|
|
209
|
-
labelIds: [],
|
|
210
|
-
hasUnread: false,
|
|
211
|
-
messageCount: 0,
|
|
212
|
-
} satisfies ThreadData
|
|
213
|
-
}
|
|
411
|
+
const parsed = GmailClient.parseRawThread(res.data)
|
|
412
|
+
const result: ThreadResult = { parsed, raw: res.data }
|
|
214
413
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const allLabels = [...new Set(messages.flatMap((m) => m.labelIds))]
|
|
414
|
+
// Write cache
|
|
415
|
+
await this.cacheThreadData(threadId, res.data, parsed)
|
|
218
416
|
|
|
219
|
-
return
|
|
220
|
-
id: threadId,
|
|
221
|
-
historyId: res.data.historyId ?? null,
|
|
222
|
-
messages,
|
|
223
|
-
subject: latest.subject,
|
|
224
|
-
snippet: latest.snippet,
|
|
225
|
-
from: latest.from,
|
|
226
|
-
date: latest.date,
|
|
227
|
-
labelIds: allLabels,
|
|
228
|
-
hasUnread: messages.some((m) => m.unread),
|
|
229
|
-
messageCount: messages.filter((m) => !m.isDraft).length,
|
|
230
|
-
} satisfies ThreadData
|
|
417
|
+
return result
|
|
231
418
|
}
|
|
232
419
|
|
|
233
420
|
async getMessage({
|
|
@@ -236,14 +423,17 @@ export class GmailClient {
|
|
|
236
423
|
}: {
|
|
237
424
|
messageId: string
|
|
238
425
|
format?: 'full' | 'metadata' | 'minimal' | 'raw'
|
|
239
|
-
}) {
|
|
240
|
-
const res = await
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
426
|
+
}): Promise<ParsedMessage | { id: string; raw: string } | AuthError | ApiError> {
|
|
427
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
428
|
+
withRetry(() =>
|
|
429
|
+
this.gmail.users.messages.get({
|
|
430
|
+
userId: 'me',
|
|
431
|
+
id: messageId,
|
|
432
|
+
format,
|
|
433
|
+
}),
|
|
434
|
+
),
|
|
246
435
|
)
|
|
436
|
+
if (res instanceof Error) return res
|
|
247
437
|
|
|
248
438
|
if (format === 'raw') {
|
|
249
439
|
return {
|
|
@@ -255,16 +445,19 @@ export class GmailClient {
|
|
|
255
445
|
return this.parseMessage(res.data)
|
|
256
446
|
}
|
|
257
447
|
|
|
258
|
-
async getRawMessage({ messageId }: { messageId: string }) {
|
|
259
|
-
const res = await
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
448
|
+
async getRawMessage({ messageId }: { messageId: string }): Promise<string | MissingDataError | AuthError | ApiError> {
|
|
449
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
450
|
+
withRetry(() =>
|
|
451
|
+
this.gmail.users.messages.get({
|
|
452
|
+
userId: 'me',
|
|
453
|
+
id: messageId,
|
|
454
|
+
format: 'raw',
|
|
455
|
+
}),
|
|
456
|
+
),
|
|
265
457
|
)
|
|
458
|
+
if (res instanceof Error) return res
|
|
266
459
|
|
|
267
|
-
if (!res.data.raw)
|
|
460
|
+
if (!res.data.raw) return new MissingDataError({ what: 'raw email data', resource: `message ${messageId}` })
|
|
268
461
|
return decodeBase64Url(res.data.raw)
|
|
269
462
|
}
|
|
270
463
|
|
|
@@ -321,44 +514,128 @@ export class GmailClient {
|
|
|
321
514
|
}
|
|
322
515
|
|
|
323
516
|
// =========================================================================
|
|
324
|
-
//
|
|
517
|
+
// Reply / Forward (high-level composition)
|
|
325
518
|
// =========================================================================
|
|
326
519
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
520
|
+
/**
|
|
521
|
+
* Reply to a thread. Handles reply-to resolution, reply-all CC computation,
|
|
522
|
+
* References/In-Reply-To headers, and subject prefixing.
|
|
523
|
+
*/
|
|
524
|
+
async replyToThread({
|
|
525
|
+
threadId,
|
|
330
526
|
body,
|
|
527
|
+
replyAll = false,
|
|
331
528
|
cc,
|
|
332
|
-
bcc,
|
|
333
|
-
threadId,
|
|
334
529
|
fromEmail,
|
|
335
|
-
attachments,
|
|
336
530
|
}: {
|
|
337
|
-
|
|
338
|
-
subject: string
|
|
531
|
+
threadId: string
|
|
339
532
|
body: string
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
threadId?: string
|
|
533
|
+
replyAll?: boolean
|
|
534
|
+
cc?: Array<{ email: string }>
|
|
343
535
|
fromEmail?: string
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
536
|
+
}): Promise<EmptyThreadError | AuthError | ApiError | gmail_v1.Schema$Message> {
|
|
537
|
+
const { parsed: thread } = await this.getThread({ threadId })
|
|
538
|
+
if (thread.messages.length === 0) {
|
|
539
|
+
return new EmptyThreadError({ threadId })
|
|
540
|
+
}
|
|
347
541
|
|
|
348
|
-
const
|
|
349
|
-
this.gmail.users.drafts.create({
|
|
350
|
-
userId: 'me',
|
|
351
|
-
requestBody: {
|
|
352
|
-
message: { raw, threadId },
|
|
353
|
-
},
|
|
354
|
-
}),
|
|
355
|
-
)
|
|
542
|
+
const lastMsg = thread.messages[thread.messages.length - 1]!
|
|
356
543
|
|
|
357
|
-
|
|
544
|
+
const replyTo = lastMsg.replyTo ?? lastMsg.from.email
|
|
545
|
+
const to = [{ email: replyTo }]
|
|
546
|
+
|
|
547
|
+
let resolvedCc: Array<{ email: string }> | undefined
|
|
548
|
+
if (replyAll) {
|
|
549
|
+
const profile = await this.getProfile()
|
|
550
|
+
if (profile instanceof Error) return profile
|
|
551
|
+
const myEmail = profile.emailAddress.toLowerCase()
|
|
552
|
+
|
|
553
|
+
const allRecipients = [
|
|
554
|
+
...lastMsg.to.map((r) => r.email),
|
|
555
|
+
...(lastMsg.cc?.map((r) => r.email) ?? []),
|
|
556
|
+
]
|
|
557
|
+
.filter((e) => e.toLowerCase() !== myEmail)
|
|
558
|
+
.filter((e) => e.toLowerCase() !== replyTo.toLowerCase())
|
|
559
|
+
|
|
560
|
+
if (allRecipients.length > 0) {
|
|
561
|
+
resolvedCc = allRecipients.map((e) => ({ email: e }))
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (cc) {
|
|
566
|
+
resolvedCc = [...(resolvedCc ?? []), ...cc]
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ')
|
|
570
|
+
|
|
571
|
+
const result = await this.sendMessage({
|
|
572
|
+
to,
|
|
573
|
+
subject: lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`,
|
|
574
|
+
body,
|
|
575
|
+
cc: resolvedCc,
|
|
576
|
+
threadId,
|
|
577
|
+
inReplyTo: lastMsg.messageId,
|
|
578
|
+
references: refs || undefined,
|
|
579
|
+
fromEmail,
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
await this.invalidateThread(threadId)
|
|
583
|
+
|
|
584
|
+
return result
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Forward a thread. Fetches the last message, renders its body,
|
|
589
|
+
* builds the "Forwarded message" block, and sends.
|
|
590
|
+
*/
|
|
591
|
+
async forwardThread({
|
|
592
|
+
threadId,
|
|
593
|
+
to,
|
|
594
|
+
body,
|
|
595
|
+
fromEmail,
|
|
596
|
+
}: {
|
|
597
|
+
threadId: string
|
|
598
|
+
to: Array<{ email: string }>
|
|
599
|
+
body?: string
|
|
600
|
+
fromEmail?: string
|
|
601
|
+
}): Promise<EmptyThreadError | gmail_v1.Schema$Message> {
|
|
602
|
+
const { parsed: thread } = await this.getThread({ threadId })
|
|
603
|
+
if (thread.messages.length === 0) {
|
|
604
|
+
return new EmptyThreadError({ threadId })
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const lastMsg = thread.messages[thread.messages.length - 1]!
|
|
608
|
+
const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType)
|
|
609
|
+
|
|
610
|
+
const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
|
|
611
|
+
? `${lastMsg.from.name} <${lastMsg.from.email}>`
|
|
612
|
+
: lastMsg.from.email
|
|
613
|
+
|
|
614
|
+
const fullBody = [
|
|
615
|
+
body ?? '',
|
|
616
|
+
'',
|
|
617
|
+
'---------- Forwarded message ----------',
|
|
618
|
+
`From: ${fromStr}`,
|
|
619
|
+
`Date: ${lastMsg.date}`,
|
|
620
|
+
`Subject: ${lastMsg.subject}`,
|
|
621
|
+
`To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
|
|
622
|
+
'',
|
|
623
|
+
renderedBody,
|
|
624
|
+
].join('\n')
|
|
625
|
+
|
|
626
|
+
return this.sendMessage({
|
|
627
|
+
to,
|
|
628
|
+
subject: `Fwd: ${lastMsg.subject}`,
|
|
629
|
+
body: fullBody,
|
|
630
|
+
fromEmail,
|
|
631
|
+
})
|
|
358
632
|
}
|
|
359
633
|
|
|
360
|
-
|
|
361
|
-
|
|
634
|
+
// =========================================================================
|
|
635
|
+
// Draft operations
|
|
636
|
+
// =========================================================================
|
|
637
|
+
|
|
638
|
+
async createDraft({
|
|
362
639
|
to,
|
|
363
640
|
subject,
|
|
364
641
|
body,
|
|
@@ -368,7 +645,6 @@ export class GmailClient {
|
|
|
368
645
|
fromEmail,
|
|
369
646
|
attachments,
|
|
370
647
|
}: {
|
|
371
|
-
draftId: string
|
|
372
648
|
to: Array<{ name?: string; email: string }>
|
|
373
649
|
subject: string
|
|
374
650
|
body: string
|
|
@@ -381,9 +657,8 @@ export class GmailClient {
|
|
|
381
657
|
const raw = this.buildMimeMessage({ to, subject, body, cc, bcc, attachments, fromEmail })
|
|
382
658
|
|
|
383
659
|
const res = await withRetry(() =>
|
|
384
|
-
this.gmail.users.drafts.
|
|
660
|
+
this.gmail.users.drafts.create({
|
|
385
661
|
userId: 'me',
|
|
386
|
-
id: draftId,
|
|
387
662
|
requestBody: {
|
|
388
663
|
message: { raw, threadId },
|
|
389
664
|
},
|
|
@@ -393,16 +668,19 @@ export class GmailClient {
|
|
|
393
668
|
return res.data
|
|
394
669
|
}
|
|
395
670
|
|
|
396
|
-
async getDraft({ draftId }: { draftId: string }) {
|
|
397
|
-
const res = await
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
671
|
+
async getDraft({ draftId }: { draftId: string }): Promise<NotFoundError | AuthError | ApiError | { id: string; message: ParsedMessage; to: string[]; cc: string[]; bcc: string[] }> {
|
|
672
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
673
|
+
withRetry(() =>
|
|
674
|
+
this.gmail.users.drafts.get({
|
|
675
|
+
userId: 'me',
|
|
676
|
+
id: draftId,
|
|
677
|
+
format: 'full',
|
|
678
|
+
}),
|
|
679
|
+
),
|
|
403
680
|
)
|
|
681
|
+
if (res instanceof Error) return res
|
|
404
682
|
|
|
405
|
-
if (!res.data || !res.data.message)
|
|
683
|
+
if (!res.data || !res.data.message) return new NotFoundError({ resource: `draft ${draftId}` })
|
|
406
684
|
|
|
407
685
|
const message = this.parseMessage(res.data.message)
|
|
408
686
|
const headers = res.data.message.payload?.headers ?? []
|
|
@@ -424,41 +702,47 @@ export class GmailClient {
|
|
|
424
702
|
query?: string
|
|
425
703
|
maxResults?: number
|
|
426
704
|
pageToken?: string
|
|
427
|
-
} = {}) {
|
|
428
|
-
const res = await
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
705
|
+
} = {}): Promise<{ drafts: Array<{ id: string; threadId: string | null; subject: string; to: string[]; date: string; snippet: string }>; nextPageToken: string | null } | AuthError | ApiError> {
|
|
706
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
707
|
+
withRetry(() =>
|
|
708
|
+
this.gmail.users.drafts.list({
|
|
709
|
+
userId: 'me',
|
|
710
|
+
q: query || undefined,
|
|
711
|
+
maxResults,
|
|
712
|
+
pageToken: pageToken || undefined,
|
|
713
|
+
}),
|
|
714
|
+
),
|
|
435
715
|
)
|
|
716
|
+
if (res instanceof Error) return res
|
|
436
717
|
|
|
437
718
|
const drafts = await mapConcurrent(res.data.drafts ?? [], async (draft) => {
|
|
438
719
|
if (!draft.id) return null
|
|
439
|
-
|
|
440
|
-
|
|
720
|
+
// Boundary: drafts.get — auth errors abort via mapConcurrent, others skip.
|
|
721
|
+
const detail = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
722
|
+
withRetry(() =>
|
|
441
723
|
this.gmail.users.drafts.get({
|
|
442
724
|
userId: 'me',
|
|
443
725
|
id: draft.id!,
|
|
444
726
|
format: 'metadata',
|
|
445
727
|
}),
|
|
446
|
-
)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
728
|
+
),
|
|
729
|
+
)
|
|
730
|
+
if (detail instanceof AuthError) return detail
|
|
731
|
+
if (detail instanceof Error) return null
|
|
732
|
+
if (!detail.data.message) return null
|
|
733
|
+
const headers = detail.data.message.payload?.headers ?? []
|
|
734
|
+
return {
|
|
735
|
+
id: draft.id,
|
|
736
|
+
threadId: detail.data.message.threadId ?? null,
|
|
737
|
+
subject: this.getHeader(headers, 'subject') ?? '(no subject)',
|
|
738
|
+
to: this.getHeaderValues(headers, 'to'),
|
|
739
|
+
date: this.getHeader(headers, 'date') ?? '',
|
|
740
|
+
snippet: detail.data.message.snippet ?? '',
|
|
459
741
|
}
|
|
460
742
|
})
|
|
461
743
|
|
|
744
|
+
if (drafts instanceof Error) return drafts
|
|
745
|
+
|
|
462
746
|
return {
|
|
463
747
|
drafts: drafts.filter((d): d is NonNullable<typeof d> => d !== null),
|
|
464
748
|
nextPageToken: res.data.nextPageToken ?? null,
|
|
@@ -489,34 +773,42 @@ export class GmailClient {
|
|
|
489
773
|
// Label mutations (read/unread, star, archive, trash, labels)
|
|
490
774
|
// =========================================================================
|
|
491
775
|
|
|
492
|
-
async markAsRead({ threadIds }: { threadIds: string[] }) {
|
|
776
|
+
async markAsRead({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
|
|
493
777
|
const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
|
|
494
778
|
labelIds.includes('UNREAD'),
|
|
495
779
|
)
|
|
780
|
+
if (messageIds instanceof Error) return messageIds
|
|
496
781
|
if (messageIds.length === 0) return
|
|
497
782
|
await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
|
|
783
|
+
await this.invalidateAfterThreadMutation(threadIds)
|
|
498
784
|
}
|
|
499
785
|
|
|
500
|
-
async markAsUnread({ threadIds }: { threadIds: string[] }) {
|
|
786
|
+
async markAsUnread({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
|
|
501
787
|
const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
|
|
502
788
|
!labelIds.includes('UNREAD'),
|
|
503
789
|
)
|
|
790
|
+
if (messageIds instanceof Error) return messageIds
|
|
504
791
|
if (messageIds.length === 0) return
|
|
505
792
|
await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
|
|
793
|
+
await this.invalidateAfterThreadMutation(threadIds)
|
|
506
794
|
}
|
|
507
795
|
|
|
508
|
-
async star({ threadIds }: { threadIds: string[] }) {
|
|
796
|
+
async star({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
|
|
509
797
|
const messageIds = await this.getMessageIdsForThreads(threadIds)
|
|
798
|
+
if (messageIds instanceof Error) return messageIds
|
|
510
799
|
if (messageIds.length === 0) return
|
|
511
800
|
await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
|
|
801
|
+
await this.invalidateAfterThreadMutation(threadIds)
|
|
512
802
|
}
|
|
513
803
|
|
|
514
|
-
async unstar({ threadIds }: { threadIds: string[] }) {
|
|
804
|
+
async unstar({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
|
|
515
805
|
const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
|
|
516
806
|
labelIds.includes('STARRED'),
|
|
517
807
|
)
|
|
808
|
+
if (messageIds instanceof Error) return messageIds
|
|
518
809
|
if (messageIds.length === 0) return
|
|
519
810
|
await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
|
|
811
|
+
await this.invalidateAfterThreadMutation(threadIds)
|
|
520
812
|
}
|
|
521
813
|
|
|
522
814
|
async modifyLabels({
|
|
@@ -527,20 +819,26 @@ export class GmailClient {
|
|
|
527
819
|
threadIds: string[]
|
|
528
820
|
addLabelIds?: string[]
|
|
529
821
|
removeLabelIds?: string[]
|
|
530
|
-
}) {
|
|
822
|
+
}): Promise<void | AuthError | ApiError> {
|
|
531
823
|
// Resolve add labels (auto-create if missing), but only look up remove labels (never create)
|
|
532
824
|
const resolvedAdd = await Promise.all(addLabelIds.map((l) => this.resolveLabelId(l)))
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
825
|
+
const addErr = resolvedAdd.find((r): r is AuthError | ApiError => r instanceof Error)
|
|
826
|
+
if (addErr) return addErr
|
|
827
|
+
|
|
828
|
+
const resolvedRemoveRaw = await Promise.all(removeLabelIds.map((l) => this.lookupLabelId(l)))
|
|
829
|
+
const removeErr = resolvedRemoveRaw.find((r): r is AuthError | ApiError => r instanceof Error)
|
|
830
|
+
if (removeErr) return removeErr
|
|
831
|
+
const resolvedRemove = resolvedRemoveRaw.filter((id): id is string => typeof id === 'string')
|
|
536
832
|
|
|
537
833
|
const messageIds = await this.getMessageIdsForThreads(threadIds)
|
|
834
|
+
if (messageIds instanceof Error) return messageIds
|
|
538
835
|
if (messageIds.length === 0) return
|
|
539
836
|
|
|
540
837
|
await this.batchModifyMessages(messageIds, {
|
|
541
|
-
addLabelIds: resolvedAdd,
|
|
838
|
+
addLabelIds: resolvedAdd.filter((r): r is string => typeof r === 'string'),
|
|
542
839
|
removeLabelIds: resolvedRemove,
|
|
543
840
|
})
|
|
841
|
+
await this.invalidateAfterThreadMutation(threadIds)
|
|
544
842
|
}
|
|
545
843
|
|
|
546
844
|
async trash({ threadId }: { threadId: string }) {
|
|
@@ -550,6 +848,7 @@ export class GmailClient {
|
|
|
550
848
|
id: threadId,
|
|
551
849
|
}),
|
|
552
850
|
)
|
|
851
|
+
await this.invalidateAfterThreadMutation([threadId])
|
|
553
852
|
}
|
|
554
853
|
|
|
555
854
|
async untrash({ threadId }: { threadId: string }) {
|
|
@@ -559,25 +858,24 @@ export class GmailClient {
|
|
|
559
858
|
id: threadId,
|
|
560
859
|
}),
|
|
561
860
|
)
|
|
861
|
+
await this.invalidateAfterThreadMutation([threadId])
|
|
562
862
|
}
|
|
563
863
|
|
|
564
|
-
async archive({ threadIds }: { threadIds: string[] }) {
|
|
864
|
+
async archive({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
|
|
565
865
|
const messageIds = await this.getMessageIdsForThreads(threadIds)
|
|
866
|
+
if (messageIds instanceof Error) return messageIds
|
|
566
867
|
if (messageIds.length === 0) return
|
|
567
868
|
await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
|
|
869
|
+
await this.invalidateAfterThreadMutation(threadIds)
|
|
568
870
|
}
|
|
569
871
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
userId: 'me',
|
|
574
|
-
id: messageId,
|
|
575
|
-
}),
|
|
576
|
-
)
|
|
872
|
+
/** Invalidate thread cache after a thread mutation. */
|
|
873
|
+
private async invalidateAfterThreadMutation(threadIds: string[]): Promise<void> {
|
|
874
|
+
await this.invalidateThreads(threadIds)
|
|
577
875
|
}
|
|
578
876
|
|
|
579
877
|
/** Moves all spam threads to trash. Does not permanently delete. */
|
|
580
|
-
async trashAllSpam() {
|
|
878
|
+
async trashAllSpam(): Promise<{ count: number } | AuthError | ApiError> {
|
|
581
879
|
let totalDeleted = 0
|
|
582
880
|
let pageToken: string | undefined
|
|
583
881
|
|
|
@@ -587,11 +885,13 @@ export class GmailClient {
|
|
|
587
885
|
maxResults: 100,
|
|
588
886
|
pageToken,
|
|
589
887
|
})
|
|
888
|
+
if (res instanceof Error) return res
|
|
590
889
|
|
|
591
890
|
if (res.threads.length === 0) break
|
|
592
891
|
|
|
593
892
|
const threadIds = res.threads.map((t) => t.id)
|
|
594
893
|
const messageIds = await this.getMessageIdsForThreads(threadIds)
|
|
894
|
+
if (messageIds instanceof Error) return messageIds
|
|
595
895
|
await this.batchModifyMessages(messageIds, {
|
|
596
896
|
addLabelIds: ['TRASH'],
|
|
597
897
|
removeLabelIds: ['SPAM', 'INBOX'],
|
|
@@ -609,26 +909,26 @@ export class GmailClient {
|
|
|
609
909
|
// Labels CRUD
|
|
610
910
|
// =========================================================================
|
|
611
911
|
|
|
612
|
-
async listLabels() {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
)
|
|
912
|
+
async listLabels(): Promise<{ parsed: ReturnType<typeof GmailClient.parseRawLabels>; raw: gmail_v1.Schema$Label[] } | AuthError | ApiError> {
|
|
913
|
+
// Check cache
|
|
914
|
+
const cached = await this.getCachedLabels()
|
|
915
|
+
if (cached) {
|
|
916
|
+
return { parsed: GmailClient.parseRawLabels(cached), raw: cached }
|
|
917
|
+
}
|
|
616
918
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
type: (label.type ?? 'user') as 'system' | 'user',
|
|
622
|
-
messageListVisibility: label.messageListVisibility ?? null,
|
|
623
|
-
labelListVisibility: label.labelListVisibility ?? null,
|
|
624
|
-
color: label.color
|
|
625
|
-
? {
|
|
626
|
-
backgroundColor: label.color.backgroundColor ?? '',
|
|
627
|
-
textColor: label.color.textColor ?? '',
|
|
628
|
-
}
|
|
629
|
-
: null,
|
|
630
|
-
})) ?? []
|
|
919
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
920
|
+
withRetry(() =>
|
|
921
|
+
this.gmail.users.labels.list({ userId: 'me' }),
|
|
922
|
+
),
|
|
631
923
|
)
|
|
924
|
+
if (res instanceof Error) return res
|
|
925
|
+
|
|
926
|
+
const rawLabels = res.data.labels ?? []
|
|
927
|
+
|
|
928
|
+
// Write cache
|
|
929
|
+
await this.cacheLabelsData(rawLabels)
|
|
930
|
+
|
|
931
|
+
return { parsed: GmailClient.parseRawLabels(rawLabels), raw: rawLabels }
|
|
632
932
|
}
|
|
633
933
|
|
|
634
934
|
async getLabel({ labelId }: { labelId: string }) {
|
|
@@ -675,35 +975,14 @@ export class GmailClient {
|
|
|
675
975
|
}),
|
|
676
976
|
)
|
|
677
977
|
|
|
978
|
+
await this.invalidateLabels()
|
|
979
|
+
|
|
678
980
|
return {
|
|
679
981
|
id: res.data.id ?? '',
|
|
680
982
|
name: res.data.name ?? name,
|
|
681
983
|
}
|
|
682
984
|
}
|
|
683
985
|
|
|
684
|
-
async updateLabel({
|
|
685
|
-
labelId,
|
|
686
|
-
name,
|
|
687
|
-
color,
|
|
688
|
-
}: {
|
|
689
|
-
labelId: string
|
|
690
|
-
name?: string
|
|
691
|
-
color?: { backgroundColor: string; textColor: string }
|
|
692
|
-
}) {
|
|
693
|
-
await withRetry(() =>
|
|
694
|
-
this.gmail.users.labels.update({
|
|
695
|
-
userId: 'me',
|
|
696
|
-
id: labelId,
|
|
697
|
-
requestBody: {
|
|
698
|
-
name,
|
|
699
|
-
color,
|
|
700
|
-
},
|
|
701
|
-
}),
|
|
702
|
-
)
|
|
703
|
-
// Invalidate label ID cache since name may have changed
|
|
704
|
-
this.labelIdCache = {}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
986
|
async deleteLabel({ labelId }: { labelId: string }) {
|
|
708
987
|
await withRetry(() =>
|
|
709
988
|
this.gmail.users.labels.delete({
|
|
@@ -711,58 +990,71 @@ export class GmailClient {
|
|
|
711
990
|
id: labelId,
|
|
712
991
|
}),
|
|
713
992
|
)
|
|
714
|
-
// Invalidate label ID cache
|
|
715
993
|
this.labelIdCache = {}
|
|
994
|
+
await this.invalidateLabels()
|
|
716
995
|
}
|
|
717
996
|
|
|
718
997
|
// =========================================================================
|
|
719
998
|
// Label counts (unread counts per folder/label)
|
|
720
999
|
// =========================================================================
|
|
721
1000
|
|
|
722
|
-
async getLabelCounts() {
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
1001
|
+
async getLabelCounts(): Promise<{ parsed: Array<{ label: string; count: number }>; raw: gmail_v1.Schema$Label[]; archiveEstimate: number | null } | AuthError | ApiError> {
|
|
1002
|
+
// Always fresh: label counts are user-facing live data.
|
|
1003
|
+
|
|
1004
|
+
// Archive count is best-effort — auth errors propagate, others yield null.
|
|
1005
|
+
const archiveRes = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
726
1006
|
withRetry(() =>
|
|
727
1007
|
this.gmail.users.threads.list({
|
|
728
1008
|
userId: 'me',
|
|
729
1009
|
q: 'in:archive',
|
|
730
1010
|
maxResults: 1,
|
|
731
1011
|
}),
|
|
732
|
-
)
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1012
|
+
),
|
|
1013
|
+
)
|
|
1014
|
+
if (archiveRes instanceof AuthError) return archiveRes
|
|
1015
|
+
// archiveRes may be ApiError (non-auth failure) — archive count unavailable
|
|
1016
|
+
const labelsResult = await this.listLabels()
|
|
1017
|
+
if (labelsResult instanceof Error) return labelsResult
|
|
1018
|
+
|
|
1019
|
+
// Fetch detailed counts for each label — collect both raw and parsed
|
|
1020
|
+
const rawDetails: gmail_v1.Schema$Label[] = []
|
|
1021
|
+
const counts = await mapConcurrent(labelsResult.parsed, async (label) => {
|
|
736
1022
|
if (!label.id) return null
|
|
737
|
-
|
|
738
|
-
|
|
1023
|
+
// Boundary: labels.get — auth errors abort via mapConcurrent, others skip.
|
|
1024
|
+
const detail = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1025
|
+
withRetry(() =>
|
|
739
1026
|
this.gmail.users.labels.get({
|
|
740
1027
|
userId: 'me',
|
|
741
1028
|
id: label.id,
|
|
742
1029
|
}),
|
|
743
|
-
)
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1030
|
+
),
|
|
1031
|
+
)
|
|
1032
|
+
if (detail instanceof AuthError) return detail
|
|
1033
|
+
if (detail instanceof Error) return null
|
|
1034
|
+
rawDetails.push(detail.data)
|
|
1035
|
+
const labelName = (detail.data.name ?? detail.data.id ?? '').toLowerCase()
|
|
1036
|
+
const isTotalLabel = labelName === 'draft' || labelName === 'sent'
|
|
1037
|
+
return {
|
|
1038
|
+
label: labelName === 'draft' ? 'drafts' : labelName,
|
|
1039
|
+
count: Number(isTotalLabel ? detail.data.threadsTotal : detail.data.threadsUnread) || 0,
|
|
752
1040
|
}
|
|
753
1041
|
})
|
|
754
1042
|
|
|
1043
|
+
if (counts instanceof Error) return counts
|
|
1044
|
+
|
|
755
1045
|
const result = counts.filter((c): c is NonNullable<typeof c> => c !== null)
|
|
756
1046
|
|
|
757
1047
|
// Add archive count (same as Zero's count() method)
|
|
758
|
-
if (archiveRes) {
|
|
1048
|
+
if (!(archiveRes instanceof Error)) {
|
|
759
1049
|
result.push({
|
|
760
1050
|
label: 'archive',
|
|
761
1051
|
count: Number(archiveRes.data.resultSizeEstimate ?? 0),
|
|
762
1052
|
})
|
|
763
1053
|
}
|
|
764
1054
|
|
|
765
|
-
|
|
1055
|
+
const archiveEstimate = !(archiveRes instanceof Error) ? Number(archiveRes.data.resultSizeEstimate ?? 0) : null
|
|
1056
|
+
|
|
1057
|
+
return { parsed: result, raw: rawDetails, archiveEstimate }
|
|
766
1058
|
}
|
|
767
1059
|
|
|
768
1060
|
// =========================================================================
|
|
@@ -789,182 +1081,97 @@ export class GmailClient {
|
|
|
789
1081
|
return data.replace(/-/g, '+').replace(/_/g, '/')
|
|
790
1082
|
}
|
|
791
1083
|
|
|
792
|
-
async getMessageAttachments({ messageId }: { messageId: string }) {
|
|
793
|
-
const res = await withRetry(() =>
|
|
794
|
-
this.gmail.users.messages.get({
|
|
795
|
-
userId: 'me',
|
|
796
|
-
id: messageId,
|
|
797
|
-
}),
|
|
798
|
-
)
|
|
799
|
-
|
|
800
|
-
const parts = res.data.payload?.parts
|
|
801
|
-
if (!parts) return []
|
|
802
|
-
|
|
803
|
-
const attachmentParts = this.findAttachmentParts(parts)
|
|
804
|
-
|
|
805
|
-
const attachments = await mapConcurrent(attachmentParts, async (part) => {
|
|
806
|
-
const attId = part.body?.attachmentId
|
|
807
|
-
if (!attId) return null
|
|
808
|
-
try {
|
|
809
|
-
const data = await this.getAttachment({ messageId, attachmentId: attId })
|
|
810
|
-
return {
|
|
811
|
-
filename: part.filename ?? '',
|
|
812
|
-
mimeType: part.mimeType ?? '',
|
|
813
|
-
size: Number(part.body?.size ?? 0),
|
|
814
|
-
attachmentId: attId,
|
|
815
|
-
data,
|
|
816
|
-
}
|
|
817
|
-
} catch {
|
|
818
|
-
return null
|
|
819
|
-
}
|
|
820
|
-
})
|
|
821
|
-
|
|
822
|
-
return attachments.filter((a): a is NonNullable<typeof a> => a !== null)
|
|
823
|
-
}
|
|
824
|
-
|
|
825
1084
|
// =========================================================================
|
|
826
1085
|
// Account / profile
|
|
827
1086
|
// =========================================================================
|
|
828
1087
|
|
|
829
|
-
async getProfile() {
|
|
830
|
-
|
|
831
|
-
|
|
1088
|
+
async getProfile(): Promise<{ emailAddress: string; messagesTotal: number; threadsTotal: number; historyId: string } | AuthError | ApiError> {
|
|
1089
|
+
// Check cache
|
|
1090
|
+
const cached = await this.getCachedProfile()
|
|
1091
|
+
if (cached) return cached
|
|
1092
|
+
|
|
1093
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1094
|
+
withRetry(() =>
|
|
1095
|
+
this.gmail.users.getProfile({ userId: 'me' }),
|
|
1096
|
+
),
|
|
832
1097
|
)
|
|
1098
|
+
if (res instanceof Error) return res
|
|
833
1099
|
|
|
834
|
-
|
|
1100
|
+
const profile = {
|
|
835
1101
|
emailAddress: res.data.emailAddress ?? '',
|
|
836
1102
|
messagesTotal: res.data.messagesTotal ?? 0,
|
|
837
1103
|
threadsTotal: res.data.threadsTotal ?? 0,
|
|
838
1104
|
historyId: res.data.historyId ?? '',
|
|
839
1105
|
}
|
|
840
|
-
}
|
|
841
1106
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const primaryEmail = profile.emailAddress
|
|
1107
|
+
// Write cache
|
|
1108
|
+
await this.cacheProfileData(profile)
|
|
845
1109
|
|
|
846
|
-
|
|
847
|
-
{ email: primaryEmail, primary: true },
|
|
848
|
-
]
|
|
849
|
-
|
|
850
|
-
try {
|
|
851
|
-
const settings = await withRetry(() =>
|
|
852
|
-
this.gmail.users.settings.sendAs.list({ userId: 'me' }),
|
|
853
|
-
)
|
|
854
|
-
|
|
855
|
-
for (const alias of settings.data.sendAs ?? []) {
|
|
856
|
-
if (alias.isPrimary && alias.sendAsEmail === primaryEmail) continue
|
|
857
|
-
aliases.push({
|
|
858
|
-
email: alias.sendAsEmail ?? '',
|
|
859
|
-
name: alias.displayName ?? undefined,
|
|
860
|
-
primary: alias.isPrimary ?? false,
|
|
861
|
-
})
|
|
862
|
-
}
|
|
863
|
-
} catch {
|
|
864
|
-
// sendAs.list may fail if the user doesn't have permission
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
return aliases
|
|
1110
|
+
return profile
|
|
868
1111
|
}
|
|
869
1112
|
|
|
870
1113
|
// =========================================================================
|
|
871
|
-
//
|
|
1114
|
+
// Static: parse raw Google API responses (used by cache readers)
|
|
872
1115
|
// =========================================================================
|
|
873
1116
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
historyTypes,
|
|
878
|
-
}: {
|
|
879
|
-
startHistoryId: string
|
|
880
|
-
labelId?: string
|
|
881
|
-
historyTypes?: Array<'messageAdded' | 'messageDeleted' | 'labelAdded' | 'labelRemoved'>
|
|
882
|
-
}) {
|
|
883
|
-
const allHistory: gmail_v1.Schema$History[] = []
|
|
884
|
-
let pageToken: string | undefined
|
|
885
|
-
let latestHistoryId = startHistoryId
|
|
886
|
-
|
|
887
|
-
while (true) {
|
|
888
|
-
const res = await withRetry(() =>
|
|
889
|
-
this.gmail.users.history.list({
|
|
890
|
-
userId: 'me',
|
|
891
|
-
startHistoryId,
|
|
892
|
-
labelId,
|
|
893
|
-
historyTypes,
|
|
894
|
-
pageToken,
|
|
895
|
-
}),
|
|
896
|
-
)
|
|
1117
|
+
/** Parse a raw gmail_v1.Schema$Thread (format: full) into ThreadData. */
|
|
1118
|
+
static parseRawThread(raw: gmail_v1.Schema$Thread): ThreadData {
|
|
1119
|
+
const messages = (raw.messages ?? []).map((m) => GmailClient.parseRawMessage(m))
|
|
897
1120
|
|
|
898
|
-
|
|
899
|
-
|
|
1121
|
+
if (messages.length === 0) {
|
|
1122
|
+
return {
|
|
1123
|
+
id: raw.id ?? '',
|
|
1124
|
+
historyId: raw.historyId ?? null,
|
|
1125
|
+
messages: [],
|
|
1126
|
+
subject: '',
|
|
1127
|
+
snippet: '',
|
|
1128
|
+
from: { email: '' },
|
|
1129
|
+
date: '',
|
|
1130
|
+
labelIds: [],
|
|
1131
|
+
hasUnread: false,
|
|
1132
|
+
messageCount: 0,
|
|
900
1133
|
}
|
|
901
|
-
latestHistoryId = res.data.historyId ?? latestHistoryId
|
|
902
|
-
|
|
903
|
-
pageToken = res.data.nextPageToken ?? undefined
|
|
904
|
-
if (!pageToken) break
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
return {
|
|
908
|
-
history: allHistory,
|
|
909
|
-
historyId: latestHistoryId,
|
|
910
1134
|
}
|
|
911
|
-
}
|
|
912
1135
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
// OAuth credentials (Thunderbird's client ID), we cannot create Pub/Sub
|
|
916
|
-
// resources on their GCP project. Users would need their own GCP project,
|
|
917
|
-
// which defeats the zero-config design. The CLI uses History API polling
|
|
918
|
-
// instead (see src/commands/watch.ts). This method is kept for potential
|
|
919
|
-
// future use if users bring their own GCP credentials.
|
|
920
|
-
async watch({
|
|
921
|
-
topicName,
|
|
922
|
-
labelIds = ['INBOX'],
|
|
923
|
-
}: {
|
|
924
|
-
topicName: string
|
|
925
|
-
labelIds?: string[]
|
|
926
|
-
}) {
|
|
927
|
-
const res = await withRetry(() =>
|
|
928
|
-
this.gmail.users.watch({
|
|
929
|
-
userId: 'me',
|
|
930
|
-
requestBody: {
|
|
931
|
-
topicName,
|
|
932
|
-
labelIds,
|
|
933
|
-
},
|
|
934
|
-
}),
|
|
935
|
-
)
|
|
1136
|
+
const latest = messages.findLast((m) => !m.isDraft) ?? messages[messages.length - 1]!
|
|
1137
|
+
const allLabels = [...new Set(messages.flatMap((m) => m.labelIds))]
|
|
936
1138
|
|
|
937
1139
|
return {
|
|
938
|
-
|
|
939
|
-
|
|
1140
|
+
id: raw.id ?? '',
|
|
1141
|
+
historyId: raw.historyId ?? null,
|
|
1142
|
+
messages,
|
|
1143
|
+
subject: latest.subject,
|
|
1144
|
+
snippet: latest.snippet,
|
|
1145
|
+
from: latest.from,
|
|
1146
|
+
date: latest.date,
|
|
1147
|
+
labelIds: allLabels,
|
|
1148
|
+
hasUnread: messages.some((m) => m.unread),
|
|
1149
|
+
messageCount: messages.filter((m) => !m.isDraft).length,
|
|
940
1150
|
}
|
|
941
1151
|
}
|
|
942
1152
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
this.gmail.users.stop({ userId: 'me' }),
|
|
946
|
-
)
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
// =========================================================================
|
|
950
|
-
// Private: message parsing
|
|
951
|
-
// =========================================================================
|
|
952
|
-
|
|
953
|
-
private parseMessage(message: gmail_v1.Schema$Message): ParsedMessage {
|
|
1153
|
+
/** Parse a raw gmail_v1.Schema$Message into ParsedMessage. */
|
|
1154
|
+
static parseRawMessage(message: gmail_v1.Schema$Message): ParsedMessage {
|
|
954
1155
|
const headers = message.payload?.headers ?? []
|
|
955
1156
|
const labelIds = message.labelIds ?? []
|
|
956
1157
|
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
1158
|
+
const getHeader = (name: string) =>
|
|
1159
|
+
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? null
|
|
1160
|
+
|
|
1161
|
+
const fromHeader = getHeader('from') ?? ''
|
|
1162
|
+
const toHeader = getHeader('to') ?? ''
|
|
1163
|
+
const ccHeaders = headers
|
|
1164
|
+
.filter((h) => h.name?.toLowerCase() === 'cc')
|
|
1165
|
+
.map((h) => h.value ?? '')
|
|
1166
|
+
.filter((v) => v.length > 0)
|
|
960
1167
|
|
|
961
|
-
const { body, mimeType } =
|
|
1168
|
+
const { body, mimeType, textBody } = GmailClient.extractBodyStatic(message.payload ?? {})
|
|
962
1169
|
|
|
963
1170
|
return {
|
|
964
1171
|
id: message.id ?? '',
|
|
965
1172
|
threadId: message.threadId ?? '',
|
|
966
|
-
subject: (
|
|
967
|
-
snippet: message.snippet ?? '',
|
|
1173
|
+
subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
|
|
1174
|
+
snippet: sanitizeSnippet(message.snippet ?? ''),
|
|
968
1175
|
from: parseFrom(fromHeader),
|
|
969
1176
|
to: toHeader ? parseAddressList(toHeader) : [],
|
|
970
1177
|
cc:
|
|
@@ -972,112 +1179,149 @@ export class GmailClient {
|
|
|
972
1179
|
? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
|
|
973
1180
|
: null,
|
|
974
1181
|
bcc: [],
|
|
975
|
-
replyTo:
|
|
976
|
-
date:
|
|
1182
|
+
replyTo: getHeader('reply-to') ?? undefined,
|
|
1183
|
+
date: getHeader('date') ?? '',
|
|
977
1184
|
labelIds,
|
|
978
1185
|
unread: labelIds.includes('UNREAD'),
|
|
979
1186
|
starred: labelIds.includes('STARRED'),
|
|
980
1187
|
isDraft: labelIds.includes('DRAFT'),
|
|
981
|
-
messageId:
|
|
982
|
-
inReplyTo:
|
|
983
|
-
references:
|
|
984
|
-
listUnsubscribe:
|
|
1188
|
+
messageId: getHeader('message-id') ?? '',
|
|
1189
|
+
inReplyTo: getHeader('in-reply-to') ?? undefined,
|
|
1190
|
+
references: getHeader('references') ?? undefined,
|
|
1191
|
+
listUnsubscribe: getHeader('list-unsubscribe') ?? undefined,
|
|
985
1192
|
body,
|
|
986
1193
|
mimeType,
|
|
987
|
-
|
|
1194
|
+
textBody,
|
|
1195
|
+
attachments: GmailClient.extractAttachmentMetaStatic(message.payload?.parts ?? []),
|
|
988
1196
|
}
|
|
989
1197
|
}
|
|
990
1198
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
): ThreadListItem {
|
|
995
|
-
const messages = thread.messages ?? []
|
|
996
|
-
// Use the last non-draft message, or the last message
|
|
1199
|
+
/** Parse raw gmail_v1.Schema$Thread (format: metadata) into ThreadListItem. */
|
|
1200
|
+
static parseRawThreadListItem(raw: gmail_v1.Schema$Thread): ThreadListItem {
|
|
1201
|
+
const messages = raw.messages ?? []
|
|
997
1202
|
const latest =
|
|
998
1203
|
messages.findLast((m) => !m.labelIds?.includes('DRAFT')) ?? messages[messages.length - 1]
|
|
999
1204
|
|
|
1000
1205
|
const headers = latest?.payload?.headers ?? []
|
|
1001
1206
|
const allLabels = [...new Set(messages.flatMap((m) => m.labelIds ?? []))]
|
|
1002
1207
|
|
|
1208
|
+
const getHeader = (name: string) =>
|
|
1209
|
+
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? null
|
|
1210
|
+
|
|
1003
1211
|
return {
|
|
1004
|
-
id:
|
|
1005
|
-
historyId:
|
|
1006
|
-
snippet: latest?.snippet ?? '',
|
|
1007
|
-
subject: (
|
|
1008
|
-
from: parseFrom(
|
|
1009
|
-
date:
|
|
1212
|
+
id: raw.id ?? '',
|
|
1213
|
+
historyId: raw.historyId ?? null,
|
|
1214
|
+
snippet: sanitizeSnippet(latest?.snippet ?? ''),
|
|
1215
|
+
subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
|
|
1216
|
+
from: parseFrom(getHeader('from') ?? ''),
|
|
1217
|
+
date: getHeader('date') ?? '',
|
|
1010
1218
|
labelIds: allLabels,
|
|
1011
1219
|
unread: allLabels.includes('UNREAD'),
|
|
1012
1220
|
messageCount: messages.filter((m) => !m.labelIds?.includes('DRAFT')).length,
|
|
1013
1221
|
}
|
|
1014
1222
|
}
|
|
1015
1223
|
|
|
1224
|
+
/** Parse raw gmail_v1.Schema$Label[] from labels.list into our label objects. */
|
|
1225
|
+
static parseRawLabels(rawLabels: gmail_v1.Schema$Label[]) {
|
|
1226
|
+
return rawLabels.map((label) => ({
|
|
1227
|
+
id: label.id ?? '',
|
|
1228
|
+
name: label.name ?? '',
|
|
1229
|
+
type: (label.type ?? 'user') as 'system' | 'user',
|
|
1230
|
+
messageListVisibility: label.messageListVisibility ?? null,
|
|
1231
|
+
labelListVisibility: label.labelListVisibility ?? null,
|
|
1232
|
+
color: label.color
|
|
1233
|
+
? {
|
|
1234
|
+
backgroundColor: label.color.backgroundColor ?? '',
|
|
1235
|
+
textColor: label.color.textColor ?? '',
|
|
1236
|
+
}
|
|
1237
|
+
: null,
|
|
1238
|
+
}))
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/** Parse raw gmail_v1.Schema$Label[] (with counts) into label count objects. */
|
|
1242
|
+
static parseRawLabelCounts(
|
|
1243
|
+
rawLabels: gmail_v1.Schema$Label[],
|
|
1244
|
+
archiveEstimate: number | null,
|
|
1245
|
+
) {
|
|
1246
|
+
const result = rawLabels
|
|
1247
|
+
.map((detail) => {
|
|
1248
|
+
const labelName = (detail.name ?? detail.id ?? '').toLowerCase()
|
|
1249
|
+
const isTotalLabel = labelName === 'draft' || labelName === 'sent'
|
|
1250
|
+
return {
|
|
1251
|
+
label: labelName === 'draft' ? 'drafts' : labelName,
|
|
1252
|
+
count: Number(isTotalLabel ? detail.threadsTotal : detail.threadsUnread) || 0,
|
|
1253
|
+
}
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
if (archiveEstimate !== null) {
|
|
1257
|
+
result.push({ label: 'archive', count: archiveEstimate })
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return result
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1016
1263
|
// =========================================================================
|
|
1017
|
-
// Private: body extraction
|
|
1264
|
+
// Private static: body/attachment extraction (for static parse methods)
|
|
1018
1265
|
// =========================================================================
|
|
1019
1266
|
|
|
1020
|
-
private
|
|
1267
|
+
private static extractBodyStatic(payload: gmail_v1.Schema$MessagePart): {
|
|
1021
1268
|
body: string
|
|
1022
1269
|
mimeType: string
|
|
1270
|
+
textBody: string | null
|
|
1023
1271
|
} {
|
|
1024
|
-
// Direct body on payload
|
|
1025
1272
|
if (payload.body?.data) {
|
|
1273
|
+
const mime = payload.mimeType ?? 'text/plain'
|
|
1026
1274
|
return {
|
|
1027
1275
|
body: decodeBase64Url(payload.body.data),
|
|
1028
|
-
mimeType:
|
|
1276
|
+
mimeType: mime,
|
|
1277
|
+
textBody: mime === 'text/plain' ? decodeBase64Url(payload.body.data) : null,
|
|
1029
1278
|
}
|
|
1030
1279
|
}
|
|
1031
1280
|
|
|
1032
1281
|
if (!payload.parts) {
|
|
1033
|
-
return { body: '', mimeType: 'text/plain' }
|
|
1282
|
+
return { body: '', mimeType: 'text/plain', textBody: null }
|
|
1034
1283
|
}
|
|
1035
1284
|
|
|
1036
|
-
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1285
|
+
const htmlData = GmailClient.findBodyPartStatic(payload.parts, 'text/html')
|
|
1286
|
+
const textData = GmailClient.findBodyPartStatic(payload.parts, 'text/plain')
|
|
1287
|
+
const textBody = textData ? decodeBase64Url(textData) : null
|
|
1288
|
+
|
|
1289
|
+
if (htmlData) {
|
|
1290
|
+
return { body: decodeBase64Url(htmlData), mimeType: 'text/html', textBody }
|
|
1040
1291
|
}
|
|
1041
1292
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
return { body: decodeBase64Url(textBody), mimeType: 'text/plain' }
|
|
1293
|
+
if (textData) {
|
|
1294
|
+
return { body: textBody!, mimeType: 'text/plain', textBody }
|
|
1045
1295
|
}
|
|
1046
1296
|
|
|
1047
|
-
// Nested multipart (e.g. multipart/alternative inside multipart/mixed)
|
|
1048
1297
|
for (const part of payload.parts) {
|
|
1049
1298
|
if (part.parts) {
|
|
1050
|
-
const nested =
|
|
1299
|
+
const nested = GmailClient.extractBodyStatic(part)
|
|
1051
1300
|
if (nested.body) return nested
|
|
1052
1301
|
}
|
|
1053
1302
|
}
|
|
1054
1303
|
|
|
1055
|
-
return { body: '', mimeType: 'text/plain' }
|
|
1304
|
+
return { body: '', mimeType: 'text/plain', textBody: null }
|
|
1056
1305
|
}
|
|
1057
1306
|
|
|
1058
|
-
private
|
|
1307
|
+
private static findBodyPartStatic(parts: gmail_v1.Schema$MessagePart[], mimeType: string): string | null {
|
|
1059
1308
|
for (const part of parts) {
|
|
1060
1309
|
if (part.mimeType === mimeType && part.body?.data) {
|
|
1061
1310
|
return part.body.data
|
|
1062
1311
|
}
|
|
1063
1312
|
if (part.parts) {
|
|
1064
|
-
const found =
|
|
1313
|
+
const found = GmailClient.findBodyPartStatic(part.parts, mimeType)
|
|
1065
1314
|
if (found) return found
|
|
1066
1315
|
}
|
|
1067
1316
|
}
|
|
1068
1317
|
return null
|
|
1069
1318
|
}
|
|
1070
1319
|
|
|
1071
|
-
|
|
1072
|
-
// Private: attachment handling
|
|
1073
|
-
// =========================================================================
|
|
1074
|
-
|
|
1075
|
-
private extractAttachmentMeta(parts: gmail_v1.Schema$MessagePart[]): AttachmentMeta[] {
|
|
1320
|
+
private static extractAttachmentMetaStatic(parts: gmail_v1.Schema$MessagePart[]): AttachmentMeta[] {
|
|
1076
1321
|
const results: AttachmentMeta[] = []
|
|
1077
1322
|
|
|
1078
1323
|
for (const part of parts) {
|
|
1079
1324
|
if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
|
|
1080
|
-
// Skip inline images (content-disposition: inline with content-id)
|
|
1081
1325
|
const disposition =
|
|
1082
1326
|
part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? ''
|
|
1083
1327
|
const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id')
|
|
@@ -1093,38 +1337,225 @@ export class GmailClient {
|
|
|
1093
1337
|
}
|
|
1094
1338
|
}
|
|
1095
1339
|
|
|
1096
|
-
// Recurse into nested parts
|
|
1097
1340
|
if (part.parts) {
|
|
1098
|
-
results.push(...
|
|
1341
|
+
results.push(...GmailClient.extractAttachmentMetaStatic(part.parts))
|
|
1099
1342
|
}
|
|
1100
1343
|
}
|
|
1101
1344
|
|
|
1102
1345
|
return results
|
|
1103
1346
|
}
|
|
1104
1347
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
const
|
|
1348
|
+
async getEmailAliases(): Promise<Array<{ email: string; name?: string; primary: boolean }> | AuthError | ApiError> {
|
|
1349
|
+
const profile = await this.getProfile()
|
|
1350
|
+
if (profile instanceof Error) return profile
|
|
1351
|
+
const primaryEmail = profile.emailAddress
|
|
1109
1352
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const disposition =
|
|
1114
|
-
part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? ''
|
|
1115
|
-
const isInline = disposition.toLowerCase().includes('inline')
|
|
1116
|
-
const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id')
|
|
1353
|
+
const aliases: Array<{ email: string; name?: string; primary: boolean }> = [
|
|
1354
|
+
{ email: primaryEmail, primary: true },
|
|
1355
|
+
]
|
|
1117
1356
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1357
|
+
// Boundary: sendAs.list — auth errors propagate; permission-denied is expected
|
|
1358
|
+
// (some accounts lack Gmail settings access) and yields primary email only.
|
|
1359
|
+
const settings = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1360
|
+
withRetry(() =>
|
|
1361
|
+
this.gmail.users.settings.sendAs.list({ userId: 'me' }),
|
|
1362
|
+
),
|
|
1363
|
+
)
|
|
1364
|
+
if (settings instanceof AuthError) return settings
|
|
1365
|
+
if (settings instanceof Error) return aliases // permission denied — return primary only
|
|
1366
|
+
|
|
1367
|
+
for (const alias of settings.data.sendAs ?? []) {
|
|
1368
|
+
if (alias.isPrimary && alias.sendAsEmail === primaryEmail) continue
|
|
1369
|
+
aliases.push({
|
|
1370
|
+
email: alias.sendAsEmail ?? '',
|
|
1371
|
+
name: alias.displayName ?? undefined,
|
|
1372
|
+
primary: alias.isPrimary ?? false,
|
|
1373
|
+
})
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return aliases
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// =========================================================================
|
|
1380
|
+
// History / sync
|
|
1381
|
+
// =========================================================================
|
|
1382
|
+
|
|
1383
|
+
async listHistory({
|
|
1384
|
+
startHistoryId,
|
|
1385
|
+
labelId,
|
|
1386
|
+
historyTypes,
|
|
1387
|
+
}: {
|
|
1388
|
+
startHistoryId: string
|
|
1389
|
+
labelId?: string
|
|
1390
|
+
historyTypes?: Array<'messageAdded' | 'messageDeleted' | 'labelAdded' | 'labelRemoved'>
|
|
1391
|
+
}): Promise<{ history: gmail_v1.Schema$History[]; historyId: string } | AuthError | ApiError> {
|
|
1392
|
+
const allHistory: gmail_v1.Schema$History[] = []
|
|
1393
|
+
let pageToken: string | undefined
|
|
1394
|
+
let latestHistoryId = startHistoryId
|
|
1395
|
+
|
|
1396
|
+
while (true) {
|
|
1397
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1398
|
+
withRetry(() =>
|
|
1399
|
+
this.gmail.users.history.list({
|
|
1400
|
+
userId: 'me',
|
|
1401
|
+
startHistoryId,
|
|
1402
|
+
labelId,
|
|
1403
|
+
historyTypes,
|
|
1404
|
+
pageToken,
|
|
1405
|
+
}),
|
|
1406
|
+
),
|
|
1407
|
+
)
|
|
1408
|
+
if (res instanceof Error) return res
|
|
1409
|
+
|
|
1410
|
+
if (res.data.history) {
|
|
1411
|
+
allHistory.push(...res.data.history)
|
|
1412
|
+
}
|
|
1413
|
+
latestHistoryId = res.data.historyId ?? latestHistoryId
|
|
1414
|
+
|
|
1415
|
+
pageToken = res.data.nextPageToken ?? undefined
|
|
1416
|
+
if (!pageToken) break
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
return {
|
|
1420
|
+
history: allHistory,
|
|
1421
|
+
historyId: latestHistoryId,
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// =========================================================================
|
|
1426
|
+
// Watch: async generator for inbox polling via History API
|
|
1427
|
+
// =========================================================================
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Poll for new messages using the Gmail History API.
|
|
1431
|
+
* Yields WatchEvent objects as new messages arrive.
|
|
1432
|
+
* Handles history seeding, expiry re-seeding, and client-side query filtering.
|
|
1433
|
+
* Persists historyId in the DB so it survives across CLI invocations.
|
|
1434
|
+
*/
|
|
1435
|
+
async *watchInbox({
|
|
1436
|
+
folder = 'inbox',
|
|
1437
|
+
intervalMs = 15_000,
|
|
1438
|
+
query,
|
|
1439
|
+
once = false,
|
|
1440
|
+
}: {
|
|
1441
|
+
folder?: string
|
|
1442
|
+
intervalMs?: number
|
|
1443
|
+
query?: string
|
|
1444
|
+
once?: boolean
|
|
1445
|
+
} = {}): AsyncGenerator<WatchEvent> {
|
|
1446
|
+
if (!this.account) throw new MissingDataError({ what: 'authenticated account', resource: 'watchInbox' })
|
|
1447
|
+
|
|
1448
|
+
const filterLabelId = WATCH_FOLDER_LABELS[folder]
|
|
1449
|
+
if (!filterLabelId) {
|
|
1450
|
+
throw new NotFoundError({ resource: `watch folder "${folder}". Supported: ${Object.keys(WATCH_FOLDER_LABELS).join(', ')}` })
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Seed historyId — guaranteed non-undefined after this block
|
|
1454
|
+
let historyId: string = await getLastHistoryId(this.account) ?? ''
|
|
1455
|
+
if (!historyId) {
|
|
1456
|
+
const profile = await this.getProfile()
|
|
1457
|
+
if (profile instanceof Error) throw profile
|
|
1458
|
+
historyId = profile.historyId
|
|
1459
|
+
await setLastHistoryId(this.account, historyId)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
while (true) {
|
|
1463
|
+
// listHistory returns errors as values — check and handle history expiry.
|
|
1464
|
+
const historyResult = await this.listHistory({
|
|
1465
|
+
startHistoryId: historyId,
|
|
1466
|
+
labelId: filterLabelId,
|
|
1467
|
+
historyTypes: ['messageAdded'],
|
|
1468
|
+
})
|
|
1469
|
+
|
|
1470
|
+
if (historyResult instanceof Error) {
|
|
1471
|
+
if (!isHistoryExpired(historyResult)) throw historyResult
|
|
1472
|
+
// historyId expired — Google only keeps ~7 days. Re-seed.
|
|
1473
|
+
const profile = await this.getProfile()
|
|
1474
|
+
if (profile instanceof Error) throw profile
|
|
1475
|
+
historyId = profile.historyId
|
|
1476
|
+
await setLastHistoryId(this.account, historyId)
|
|
1477
|
+
// Retry once after reseed
|
|
1478
|
+
const retryResult = await this.listHistory({ startHistoryId: historyId, labelId: filterLabelId, historyTypes: ['messageAdded'] })
|
|
1479
|
+
if (retryResult instanceof Error) throw retryResult
|
|
1480
|
+
yield* this.pollOnceFromHistory(retryResult, historyId, query, (newId) => { historyId = newId })
|
|
1481
|
+
} else {
|
|
1482
|
+
yield* this.pollOnceFromHistory(historyResult, historyId, query, (newId) => { historyId = newId })
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (once) return
|
|
1486
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** Single poll tick from pre-fetched history — yields WatchEvents for new messages. */
|
|
1491
|
+
private async *pollOnceFromHistory(
|
|
1492
|
+
historyData: { history: gmail_v1.Schema$History[]; historyId: string },
|
|
1493
|
+
prevHistoryId: string,
|
|
1494
|
+
query: string | undefined,
|
|
1495
|
+
updateHistoryId: (newId: string) => void,
|
|
1496
|
+
): AsyncGenerator<WatchEvent> {
|
|
1497
|
+
const { history, historyId: newHistoryId } = historyData
|
|
1498
|
+
|
|
1499
|
+
if (newHistoryId !== prevHistoryId) {
|
|
1500
|
+
updateHistoryId(newHistoryId)
|
|
1501
|
+
await setLastHistoryId(this.account!, newHistoryId)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if (history.length === 0) return
|
|
1505
|
+
|
|
1506
|
+
// Collect unique message IDs from messageAdded events
|
|
1507
|
+
const seenIds = new Set<string>()
|
|
1508
|
+
const messageIds: string[] = []
|
|
1509
|
+
|
|
1510
|
+
for (const entry of history) {
|
|
1511
|
+
for (const added of entry.messagesAdded ?? []) {
|
|
1512
|
+
const id = added.message?.id
|
|
1513
|
+
if (id && !seenIds.has(id)) {
|
|
1514
|
+
seenIds.add(id)
|
|
1515
|
+
messageIds.push(id)
|
|
1120
1516
|
}
|
|
1121
1517
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (messageIds.length === 0) return
|
|
1521
|
+
|
|
1522
|
+
// Hydrate messages with metadata (bounded concurrency).
|
|
1523
|
+
// getMessage returns errors as values — auth errors abort via mapConcurrent,
|
|
1524
|
+
// 404s (message deleted between history and hydration) are skipped.
|
|
1525
|
+
const hydrated = await mapConcurrent(messageIds, async (msgId) => {
|
|
1526
|
+
const msg = await this.getMessage({ messageId: msgId, format: 'metadata' })
|
|
1527
|
+
if (msg instanceof AuthError) return msg // abort batch
|
|
1528
|
+
if (msg instanceof Error) return null // skip this message
|
|
1529
|
+
if ('raw' in msg) return null
|
|
1530
|
+
if (query && !matchesQuery(msg, query)) return null
|
|
1531
|
+
return msg
|
|
1532
|
+
})
|
|
1533
|
+
if (hydrated instanceof Error) throw hydrated // propagate to generator consumer
|
|
1534
|
+
|
|
1535
|
+
for (const msg of hydrated) {
|
|
1536
|
+
if (!msg) continue
|
|
1537
|
+
yield {
|
|
1538
|
+
account: this.account!,
|
|
1539
|
+
type: 'new_message',
|
|
1540
|
+
message: msg,
|
|
1541
|
+
threadId: msg.threadId,
|
|
1124
1542
|
}
|
|
1125
1543
|
}
|
|
1544
|
+
}
|
|
1126
1545
|
|
|
1127
|
-
|
|
1546
|
+
// =========================================================================
|
|
1547
|
+
// Private: message parsing (delegates to static methods)
|
|
1548
|
+
// =========================================================================
|
|
1549
|
+
|
|
1550
|
+
private parseMessage(message: gmail_v1.Schema$Message): ParsedMessage {
|
|
1551
|
+
return GmailClient.parseRawMessage(message)
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
private parseThreadListItem(
|
|
1555
|
+
threadId: string,
|
|
1556
|
+
thread: gmail_v1.Schema$Thread,
|
|
1557
|
+
): ThreadListItem {
|
|
1558
|
+
return GmailClient.parseRawThreadListItem({ ...thread, id: threadId })
|
|
1128
1559
|
}
|
|
1129
1560
|
|
|
1130
1561
|
// =========================================================================
|
|
@@ -1211,11 +1642,13 @@ export class GmailClient {
|
|
|
1211
1642
|
// =========================================================================
|
|
1212
1643
|
|
|
1213
1644
|
/** Look up a label ID by name. Returns null if not found (never creates). */
|
|
1214
|
-
private async lookupLabelId(labelNameOrId: string): Promise<string | null> {
|
|
1645
|
+
private async lookupLabelId(labelNameOrId: string): Promise<string | null | AuthError | ApiError> {
|
|
1215
1646
|
if (SYSTEM_LABEL_IDS.has(labelNameOrId)) return labelNameOrId
|
|
1216
1647
|
if (this.labelIdCache[labelNameOrId]) return this.labelIdCache[labelNameOrId]!
|
|
1217
1648
|
|
|
1218
|
-
const
|
|
1649
|
+
const labelsResult = await this.listLabels()
|
|
1650
|
+
if (labelsResult instanceof Error) return labelsResult
|
|
1651
|
+
const { parsed: labels } = labelsResult
|
|
1219
1652
|
const match = labels.find((l) => l.name.toLowerCase() === labelNameOrId.toLowerCase())
|
|
1220
1653
|
if (match) {
|
|
1221
1654
|
this.labelIdCache[labelNameOrId] = match.id
|
|
@@ -1226,11 +1659,13 @@ export class GmailClient {
|
|
|
1226
1659
|
}
|
|
1227
1660
|
|
|
1228
1661
|
/** Resolve a label ID by name, auto-creating if it doesn't exist. */
|
|
1229
|
-
private async resolveLabelId(labelNameOrId: string): Promise<string> {
|
|
1662
|
+
private async resolveLabelId(labelNameOrId: string): Promise<string | AuthError | ApiError> {
|
|
1230
1663
|
if (SYSTEM_LABEL_IDS.has(labelNameOrId)) return labelNameOrId
|
|
1231
1664
|
if (this.labelIdCache[labelNameOrId]) return this.labelIdCache[labelNameOrId]!
|
|
1232
1665
|
|
|
1233
|
-
const
|
|
1666
|
+
const labelsResult = await this.listLabels()
|
|
1667
|
+
if (labelsResult instanceof Error) return labelsResult
|
|
1668
|
+
const { parsed: labels } = labelsResult
|
|
1234
1669
|
const match = labels.find((l) => l.name.toLowerCase() === labelNameOrId.toLowerCase())
|
|
1235
1670
|
if (match) {
|
|
1236
1671
|
this.labelIdCache[labelNameOrId] = match.id
|
|
@@ -1309,17 +1744,20 @@ export class GmailClient {
|
|
|
1309
1744
|
private async getMessageIdsForThreads(
|
|
1310
1745
|
threadIds: string[],
|
|
1311
1746
|
filter?: (labelIds: string[]) => boolean,
|
|
1312
|
-
) {
|
|
1747
|
+
): Promise<string[] | AuthError | ApiError> {
|
|
1313
1748
|
const allIds: string[] = []
|
|
1314
1749
|
|
|
1315
|
-
await mapConcurrent(threadIds, async (threadId) => {
|
|
1316
|
-
const res = await
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1750
|
+
const result = await mapConcurrent(threadIds, async (threadId) => {
|
|
1751
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1752
|
+
withRetry(() =>
|
|
1753
|
+
this.gmail.users.threads.get({
|
|
1754
|
+
userId: 'me',
|
|
1755
|
+
id: threadId,
|
|
1756
|
+
format: 'metadata',
|
|
1757
|
+
}),
|
|
1758
|
+
),
|
|
1322
1759
|
)
|
|
1760
|
+
if (res instanceof Error) return res
|
|
1323
1761
|
|
|
1324
1762
|
for (const msg of res.data.messages ?? []) {
|
|
1325
1763
|
if (!msg.id) continue
|
|
@@ -1327,6 +1765,7 @@ export class GmailClient {
|
|
|
1327
1765
|
allIds.push(msg.id)
|
|
1328
1766
|
}
|
|
1329
1767
|
})
|
|
1768
|
+
if (result instanceof Error) return result
|
|
1330
1769
|
|
|
1331
1770
|
return [...new Set(allIds)]
|
|
1332
1771
|
}
|
|
@@ -1387,3 +1826,169 @@ export class GmailClient {
|
|
|
1387
1826
|
)
|
|
1388
1827
|
}
|
|
1389
1828
|
}
|
|
1829
|
+
|
|
1830
|
+
// ---------------------------------------------------------------------------
|
|
1831
|
+
// Watch: folder label mapping
|
|
1832
|
+
// ---------------------------------------------------------------------------
|
|
1833
|
+
|
|
1834
|
+
const WATCH_FOLDER_LABELS: Record<string, string> = {
|
|
1835
|
+
inbox: 'INBOX',
|
|
1836
|
+
sent: 'SENT',
|
|
1837
|
+
trash: 'TRASH',
|
|
1838
|
+
spam: 'SPAM',
|
|
1839
|
+
starred: 'STARRED',
|
|
1840
|
+
drafts: 'DRAFT',
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// ---------------------------------------------------------------------------
|
|
1844
|
+
// Watch: sync state persistence (historyId in DB)
|
|
1845
|
+
// ---------------------------------------------------------------------------
|
|
1846
|
+
|
|
1847
|
+
async function getLastHistoryId(account: AccountId): Promise<string | undefined> {
|
|
1848
|
+
const prisma = await getPrisma()
|
|
1849
|
+
const row = await prisma.syncState.findUnique({
|
|
1850
|
+
where: { email_appId_key: { email: account.email, appId: account.appId, key: 'history_id' } },
|
|
1851
|
+
})
|
|
1852
|
+
return row?.value
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
async function setLastHistoryId(account: AccountId, historyId: string): Promise<void> {
|
|
1856
|
+
const prisma = await getPrisma()
|
|
1857
|
+
await prisma.syncState.upsert({
|
|
1858
|
+
where: { email_appId_key: { email: account.email, appId: account.appId, key: 'history_id' } },
|
|
1859
|
+
create: { email: account.email, appId: account.appId, key: 'history_id', value: historyId },
|
|
1860
|
+
update: { value: historyId },
|
|
1861
|
+
})
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// ---------------------------------------------------------------------------
|
|
1865
|
+
// Watch: history expiry detection
|
|
1866
|
+
// ---------------------------------------------------------------------------
|
|
1867
|
+
|
|
1868
|
+
function isHistoryExpired(err: any): boolean {
|
|
1869
|
+
const status = err?.code ?? err?.status ?? err?.response?.status
|
|
1870
|
+
if (status === 404) return true
|
|
1871
|
+
if (status === 400) {
|
|
1872
|
+
const message = err?.message ?? err?.response?.data?.error?.message ?? ''
|
|
1873
|
+
if (message.includes('historyId')) return true
|
|
1874
|
+
}
|
|
1875
|
+
return false
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// ---------------------------------------------------------------------------
|
|
1879
|
+
// Watch: client-side Gmail query matching
|
|
1880
|
+
// ---------------------------------------------------------------------------
|
|
1881
|
+
// The History API doesn't support server-side query filtering, so we parse
|
|
1882
|
+
// common Gmail search operators and match against message metadata.
|
|
1883
|
+
//
|
|
1884
|
+
// Supported operators: from:, to:, cc:, subject:, is:unread/read/starred,
|
|
1885
|
+
// has:attachment, and plain text (matches subject + from).
|
|
1886
|
+
// Multiple terms are AND-ed together. Quoted phrases and negation supported.
|
|
1887
|
+
//
|
|
1888
|
+
// Limitations vs full Gmail search:
|
|
1889
|
+
// - label: not supported (labelIds are API IDs like Label_123, not names)
|
|
1890
|
+
// - has:attachment uses Content-Type heuristic (metadata format lacks parts)
|
|
1891
|
+
// - OR, {}, newer_than:, older_than:, etc. are server-only — warned & skipped
|
|
1892
|
+
//
|
|
1893
|
+
// See https://support.google.com/mail/answer/7190 for the full Gmail spec.
|
|
1894
|
+
// ---------------------------------------------------------------------------
|
|
1895
|
+
|
|
1896
|
+
const SERVER_ONLY_OPERATORS = new Set([
|
|
1897
|
+
'in', 'label', 'after', 'before', 'newer_than', 'older_than',
|
|
1898
|
+
'filename', 'size', 'larger', 'smaller', 'deliveredto', 'rfc822msgid',
|
|
1899
|
+
'list', 'category',
|
|
1900
|
+
])
|
|
1901
|
+
|
|
1902
|
+
const SUPPORTED_OPERATORS = new Set([
|
|
1903
|
+
'from', 'to', 'cc', 'subject', 'is', 'has',
|
|
1904
|
+
])
|
|
1905
|
+
|
|
1906
|
+
interface QueryTerm {
|
|
1907
|
+
operator: string | null
|
|
1908
|
+
value: string
|
|
1909
|
+
negated: boolean
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
const warnedOperators = new Set<string>()
|
|
1913
|
+
|
|
1914
|
+
function matchesQuery(msg: ParsedMessage, query: string): boolean {
|
|
1915
|
+
const terms = parseQueryTerms(query)
|
|
1916
|
+
return terms.every((term) => matchesTerm(msg, term))
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function parseQueryTerms(query: string): QueryTerm[] {
|
|
1920
|
+
const terms: QueryTerm[] = []
|
|
1921
|
+
const regex = /(-?)(?:(\w+):)?(?:"([^"]*)"|([\S]+))/gi
|
|
1922
|
+
let match: RegExpExecArray | null
|
|
1923
|
+
|
|
1924
|
+
while ((match = regex.exec(query)) !== null) {
|
|
1925
|
+
const negated = match[1] === '-'
|
|
1926
|
+
const rawOperator = match[2]?.toLowerCase() ?? null
|
|
1927
|
+
const value = (match[3] ?? match[4] ?? '').toLowerCase()
|
|
1928
|
+
if (!value) continue
|
|
1929
|
+
|
|
1930
|
+
if (!rawOperator && value === 'or') continue
|
|
1931
|
+
|
|
1932
|
+
if (rawOperator && SERVER_ONLY_OPERATORS.has(rawOperator)) {
|
|
1933
|
+
if (!warnedOperators.has(rawOperator)) {
|
|
1934
|
+
warnedOperators.add(rawOperator)
|
|
1935
|
+
console.error(`# --query: "${rawOperator}:" is a server-only operator (use "mail search" instead), skipping`)
|
|
1936
|
+
}
|
|
1937
|
+
continue
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if (rawOperator && !SUPPORTED_OPERATORS.has(rawOperator)) {
|
|
1941
|
+
if (!warnedOperators.has(rawOperator)) {
|
|
1942
|
+
warnedOperators.add(rawOperator)
|
|
1943
|
+
console.error(`# --query: unknown operator "${rawOperator}:", skipping`)
|
|
1944
|
+
}
|
|
1945
|
+
continue
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
terms.push({ operator: rawOperator, value, negated })
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
return terms
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function senderMatches(sender: { name?: string; email: string }, value: string): boolean {
|
|
1955
|
+
const full = `${sender.name ?? ''} ${sender.email}`.toLowerCase()
|
|
1956
|
+
return full.includes(value)
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function matchesTerm(msg: ParsedMessage, term: QueryTerm): boolean {
|
|
1960
|
+
let result: boolean
|
|
1961
|
+
|
|
1962
|
+
switch (term.operator) {
|
|
1963
|
+
case 'from':
|
|
1964
|
+
result = senderMatches(msg.from, term.value)
|
|
1965
|
+
break
|
|
1966
|
+
case 'to':
|
|
1967
|
+
result = msg.to.some((r) => senderMatches(r, term.value))
|
|
1968
|
+
break
|
|
1969
|
+
case 'cc':
|
|
1970
|
+
result = (msg.cc ?? []).some((r) => senderMatches(r, term.value))
|
|
1971
|
+
break
|
|
1972
|
+
case 'subject':
|
|
1973
|
+
result = msg.subject.toLowerCase().includes(term.value)
|
|
1974
|
+
break
|
|
1975
|
+
case 'is':
|
|
1976
|
+
if (term.value === 'unread') result = msg.unread
|
|
1977
|
+
else if (term.value === 'read') result = !msg.unread
|
|
1978
|
+
else if (term.value === 'starred') result = msg.starred
|
|
1979
|
+
else result = false
|
|
1980
|
+
break
|
|
1981
|
+
case 'has':
|
|
1982
|
+
if (term.value === 'attachment') result = msg.mimeType.includes('multipart/mixed')
|
|
1983
|
+
else result = false
|
|
1984
|
+
break
|
|
1985
|
+
default: {
|
|
1986
|
+
const subject = msg.subject.toLowerCase()
|
|
1987
|
+
const from = `${msg.from.name ?? ''} ${msg.from.email}`.toLowerCase()
|
|
1988
|
+
result = subject.includes(term.value) || from.includes(term.value)
|
|
1989
|
+
break
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
return term.negated ? !result : result
|
|
1994
|
+
}
|