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.
Files changed (157) hide show
  1. package/README.md +1 -1
  2. package/bin/zele +27 -0
  3. package/dist/api-utils.d.ts +51 -2
  4. package/dist/api-utils.js +89 -3
  5. package/dist/api-utils.js.map +1 -1
  6. package/dist/auth.d.ts +27 -6
  7. package/dist/auth.js +185 -129
  8. package/dist/auth.js.map +1 -1
  9. package/dist/calendar-client.d.ts +16 -9
  10. package/dist/calendar-client.js +163 -59
  11. package/dist/calendar-client.js.map +1 -1
  12. package/dist/cli.js +26 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands/attachment.js +17 -15
  15. package/dist/commands/attachment.js.map +1 -1
  16. package/dist/commands/auth-cmd.js +20 -9
  17. package/dist/commands/auth-cmd.js.map +1 -1
  18. package/dist/commands/calendar.js +67 -78
  19. package/dist/commands/calendar.js.map +1 -1
  20. package/dist/commands/draft.js +25 -18
  21. package/dist/commands/draft.js.map +1 -1
  22. package/dist/commands/label.js +33 -45
  23. package/dist/commands/label.js.map +1 -1
  24. package/dist/commands/mail-actions.js +11 -13
  25. package/dist/commands/mail-actions.js.map +1 -1
  26. package/dist/commands/mail.js +112 -126
  27. package/dist/commands/mail.js.map +1 -1
  28. package/dist/commands/profile.js +18 -21
  29. package/dist/commands/profile.js.map +1 -1
  30. package/dist/commands/watch.js +33 -261
  31. package/dist/commands/watch.js.map +1 -1
  32. package/dist/db.js +12 -13
  33. package/dist/db.js.map +1 -1
  34. package/dist/generated/browser.d.ts +12 -27
  35. package/dist/generated/client.d.ts +13 -28
  36. package/dist/generated/client.js +1 -1
  37. package/dist/generated/commonInputTypes.d.ts +90 -26
  38. package/dist/generated/enums.d.ts +0 -4
  39. package/dist/generated/enums.js +0 -3
  40. package/dist/generated/enums.js.map +1 -1
  41. package/dist/generated/internal/class.d.ts +22 -55
  42. package/dist/generated/internal/class.js +12 -4
  43. package/dist/generated/internal/class.js.map +1 -1
  44. package/dist/generated/internal/prismaNamespace.d.ts +272 -511
  45. package/dist/generated/internal/prismaNamespace.js +54 -66
  46. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  47. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
  48. package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
  49. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  50. package/dist/generated/models/Account.d.ts +1637 -0
  51. package/dist/generated/models/Account.js +2 -0
  52. package/dist/generated/models/Account.js.map +1 -0
  53. package/dist/generated/models/CalendarList.d.ts +1161 -0
  54. package/dist/generated/models/CalendarList.js +2 -0
  55. package/dist/generated/models/CalendarList.js.map +1 -0
  56. package/dist/generated/models/Label.d.ts +1161 -0
  57. package/dist/generated/models/Label.js +2 -0
  58. package/dist/generated/models/Label.js.map +1 -0
  59. package/dist/generated/models/Profile.d.ts +1269 -0
  60. package/dist/generated/models/Profile.js +2 -0
  61. package/dist/generated/models/Profile.js.map +1 -0
  62. package/dist/generated/models/SyncState.d.ts +1130 -0
  63. package/dist/generated/models/SyncState.js +2 -0
  64. package/dist/generated/models/SyncState.js.map +1 -0
  65. package/dist/generated/models/Thread.d.ts +1608 -0
  66. package/dist/generated/models/Thread.js +2 -0
  67. package/dist/generated/models/Thread.js.map +1 -0
  68. package/dist/generated/models.d.ts +6 -9
  69. package/dist/gmail-client.d.ts +119 -94
  70. package/dist/gmail-client.js +862 -322
  71. package/dist/gmail-client.js.map +1 -1
  72. package/dist/mail-tui.d.ts +1 -0
  73. package/dist/mail-tui.js +517 -0
  74. package/dist/mail-tui.js.map +1 -0
  75. package/dist/output.d.ts +6 -0
  76. package/dist/output.js +124 -11
  77. package/dist/output.js.map +1 -1
  78. package/package.json +39 -11
  79. package/schema.prisma +81 -113
  80. package/src/api-utils.ts +103 -5
  81. package/src/auth.ts +224 -143
  82. package/src/calendar-client.ts +196 -89
  83. package/src/cli.ts +30 -1
  84. package/src/commands/attachment.ts +18 -19
  85. package/src/commands/auth-cmd.ts +19 -9
  86. package/src/commands/calendar.ts +42 -85
  87. package/src/commands/draft.ts +19 -22
  88. package/src/commands/label.ts +21 -57
  89. package/src/commands/mail-actions.ts +11 -19
  90. package/src/commands/mail.ts +102 -147
  91. package/src/commands/profile.ts +12 -28
  92. package/src/commands/watch.ts +37 -304
  93. package/src/db.ts +13 -16
  94. package/src/generated/browser.ts +49 -0
  95. package/src/generated/client.ts +71 -0
  96. package/src/generated/commonInputTypes.ts +332 -0
  97. package/src/generated/enums.ts +17 -0
  98. package/src/generated/internal/class.ts +250 -0
  99. package/src/generated/internal/prismaNamespace.ts +1198 -0
  100. package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
  101. package/src/generated/models/Account.ts +1848 -0
  102. package/src/generated/models/CalendarList.ts +1331 -0
  103. package/src/generated/models/Label.ts +1331 -0
  104. package/src/generated/models/Profile.ts +1439 -0
  105. package/src/generated/models/SyncState.ts +1300 -0
  106. package/src/generated/models/Thread.ts +1787 -0
  107. package/src/generated/models.ts +17 -0
  108. package/src/gmail-client.test.ts +59 -0
  109. package/src/gmail-client.ts +1034 -429
  110. package/src/mail-tui.tsx +1061 -0
  111. package/src/output.test.ts +1093 -0
  112. package/src/output.ts +128 -13
  113. package/src/schema.sql +58 -68
  114. package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
  115. package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
  116. package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
  117. package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
  118. package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
  119. package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
  120. package/AGENTS.md +0 -26
  121. package/CHANGELOG.md +0 -43
  122. package/dist/generated/models/accounts.d.ts +0 -2000
  123. package/dist/generated/models/accounts.js +0 -2
  124. package/dist/generated/models/accounts.js.map +0 -1
  125. package/dist/generated/models/calendar_events.d.ts +0 -1433
  126. package/dist/generated/models/calendar_events.js +0 -2
  127. package/dist/generated/models/calendar_events.js.map +0 -1
  128. package/dist/generated/models/calendar_lists.d.ts +0 -1131
  129. package/dist/generated/models/calendar_lists.js +0 -2
  130. package/dist/generated/models/calendar_lists.js.map +0 -1
  131. package/dist/generated/models/label_counts.d.ts +0 -1131
  132. package/dist/generated/models/label_counts.js +0 -2
  133. package/dist/generated/models/label_counts.js.map +0 -1
  134. package/dist/generated/models/labels.d.ts +0 -1131
  135. package/dist/generated/models/labels.js +0 -2
  136. package/dist/generated/models/labels.js.map +0 -1
  137. package/dist/generated/models/profiles.d.ts +0 -1131
  138. package/dist/generated/models/profiles.js +0 -2
  139. package/dist/generated/models/profiles.js.map +0 -1
  140. package/dist/generated/models/sync_states.d.ts +0 -1107
  141. package/dist/generated/models/sync_states.js +0 -2
  142. package/dist/generated/models/sync_states.js.map +0 -1
  143. package/dist/generated/models/thread_lists.d.ts +0 -1404
  144. package/dist/generated/models/thread_lists.js +0 -2
  145. package/dist/generated/models/thread_lists.js.map +0 -1
  146. package/dist/generated/models/threads.d.ts +0 -1247
  147. package/dist/generated/models/threads.js +0 -2
  148. package/dist/generated/models/threads.js.map +0 -1
  149. package/dist/gmail-cache.d.ts +0 -60
  150. package/dist/gmail-cache.js +0 -264
  151. package/dist/gmail-cache.js.map +0 -1
  152. package/docs/gogcli-gmail-implementation.md +0 -599
  153. package/scripts/test-device-code-clients.ts +0 -186
  154. package/scripts/test-micropython-scopes.ts +0 -72
  155. package/scripts/test-oauth-clients.ts +0 -257
  156. package/src/gmail-cache.ts +0 -339
  157. package/tsconfig.json +0 -16
@@ -1,16 +1,19 @@
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
- // Ported from Zero's GoogleMailManager (apps/server/src/lib/driver/google.ts) with CLI adaptations:
5
- // - No HTML sanitization (CLI renders text)
6
- // - No Effect library (simple retry loop)
7
- // - Uses batchModify for label mutations (more efficient than per-thread modify)
8
- // - Body decoding inline with Buffer (no base64-js dependency)
9
- // - Concurrent hydration with configurable concurrency limit
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
  import { gmail as gmailApi } from '@googleapis/gmail';
11
11
  import { createMimeMessage } from 'mimetext';
12
12
  import { parseFrom, parseAddressList } from './email-utils.js';
13
- import { withRetry, mapConcurrent } from './api-utils.js';
13
+ import * as errore from 'errore';
14
+ import { withRetry, mapConcurrent, AuthError, isAuthLikeError, ApiError, NotFoundError, EmptyThreadError, MissingDataError } from './api-utils.js';
15
+ import { renderEmailBody } from './output.js';
16
+ import { getPrisma } from './db.js';
14
17
  // ---------------------------------------------------------------------------
15
18
  // Constants
16
19
  // ---------------------------------------------------------------------------
@@ -37,6 +40,37 @@ function decodeBase64Url(encoded) {
37
40
  const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
38
41
  return Buffer.from(base64, 'base64').toString('utf-8');
39
42
  }
43
+ function decodeHtmlEntities(value) {
44
+ const namedEntities = {
45
+ amp: '&',
46
+ lt: '<',
47
+ gt: '>',
48
+ quot: '"',
49
+ apos: "'",
50
+ nbsp: ' ',
51
+ };
52
+ return value.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, entity) => {
53
+ if (entity.startsWith('#x') || entity.startsWith('#X')) {
54
+ const codePoint = Number.parseInt(entity.slice(2), 16);
55
+ if (!Number.isFinite(codePoint))
56
+ return match;
57
+ return String.fromCodePoint(codePoint);
58
+ }
59
+ if (entity.startsWith('#')) {
60
+ const codePoint = Number.parseInt(entity.slice(1), 10);
61
+ if (!Number.isFinite(codePoint))
62
+ return match;
63
+ return String.fromCodePoint(codePoint);
64
+ }
65
+ return namedEntities[entity.toLowerCase()] ?? match;
66
+ });
67
+ }
68
+ function sanitizeSnippet(snippet) {
69
+ return decodeHtmlEntities(snippet)
70
+ .replace(/[\u00A0\u00AD\u034F\u061C\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, ' ')
71
+ .replace(/\s+/g, ' ')
72
+ .trim();
73
+ }
40
74
  function encodeBase64Url(data) {
41
75
  const buf = typeof data === 'string' ? Buffer.from(data) : data;
42
76
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -44,90 +78,211 @@ function encodeBase64Url(data) {
44
78
  // ---------------------------------------------------------------------------
45
79
  // GmailClient
46
80
  // ---------------------------------------------------------------------------
81
+ // TTL constants in milliseconds
82
+ const TTL = {
83
+ THREAD: 30 * 60 * 1000, // 30 minutes
84
+ LABELS: 30 * 60 * 1000, // 30 minutes
85
+ PROFILE: 24 * 60 * 60 * 1000, // 24 hours
86
+ };
87
+ function isExpired(createdAt, ttlMs) {
88
+ return createdAt.getTime() + ttlMs < Date.now();
89
+ }
90
+ /** Boundary helper: wrap a googleapis SDK call, converting auth-like errors to AuthError values.
91
+ * Non-auth errors are wrapped in ApiError so they remain error values (no throwing).
92
+ * Original error is preserved as `cause` for debugging. */
93
+ function gmailBoundary(email, fn) {
94
+ return errore.tryAsync({
95
+ try: fn,
96
+ catch: (err) => isAuthLikeError(err)
97
+ ? new AuthError({ email, reason: String(err) })
98
+ : new ApiError({ reason: String(err), cause: err }),
99
+ });
100
+ }
47
101
  export class GmailClient {
48
102
  gmail;
49
103
  labelIdCache = {};
50
- constructor({ auth }) {
104
+ account;
105
+ constructor({ auth, account }) {
51
106
  this.gmail = gmailApi({ version: 'v1', auth });
107
+ this.account = account ?? null;
108
+ }
109
+ // =========================================================================
110
+ // Cache helpers (private) — skip all cache ops when account is null
111
+ // =========================================================================
112
+ get cacheEnabled() {
113
+ return this.account !== null;
114
+ }
115
+ async getCachedThread(threadId) {
116
+ if (!this.cacheEnabled)
117
+ return undefined;
118
+ const prisma = await getPrisma();
119
+ const row = await prisma.thread.findUnique({
120
+ where: { email_appId_threadId: { email: this.account.email, appId: this.account.appId, threadId } },
121
+ });
122
+ if (!row || isExpired(row.createdAt, row.ttlMs))
123
+ return undefined;
124
+ return JSON.parse(row.rawData);
125
+ }
126
+ async cacheThreadData(threadId, raw, parsed) {
127
+ if (!this.cacheEnabled)
128
+ return;
129
+ const prisma = await getPrisma();
130
+ await prisma.thread.upsert({
131
+ where: { email_appId_threadId: { email: this.account.email, appId: this.account.appId, threadId } },
132
+ create: {
133
+ email: this.account.email, appId: this.account.appId, threadId,
134
+ subject: parsed.subject, snippet: parsed.snippet,
135
+ fromEmail: parsed.from.email, fromName: parsed.from.name ?? '',
136
+ date: parsed.date, labelIds: parsed.labelIds.join(','),
137
+ hasUnread: parsed.hasUnread, msgCount: parsed.messageCount,
138
+ historyId: parsed.historyId,
139
+ rawData: JSON.stringify(raw), ttlMs: TTL.THREAD, createdAt: new Date(),
140
+ },
141
+ update: {
142
+ subject: parsed.subject, snippet: parsed.snippet,
143
+ fromEmail: parsed.from.email, fromName: parsed.from.name ?? '',
144
+ date: parsed.date, labelIds: parsed.labelIds.join(','),
145
+ hasUnread: parsed.hasUnread, msgCount: parsed.messageCount,
146
+ historyId: parsed.historyId,
147
+ rawData: JSON.stringify(raw), ttlMs: TTL.THREAD, createdAt: new Date(),
148
+ },
149
+ });
150
+ }
151
+ async invalidateThreads(threadIds) {
152
+ if (!this.cacheEnabled)
153
+ return;
154
+ const prisma = await getPrisma();
155
+ await prisma.thread.deleteMany({ where: { email: this.account.email, appId: this.account.appId, threadId: { in: threadIds } } });
156
+ }
157
+ async invalidateThread(threadId) {
158
+ if (!this.cacheEnabled)
159
+ return;
160
+ const prisma = await getPrisma();
161
+ await prisma.thread.deleteMany({ where: { email: this.account.email, appId: this.account.appId, threadId } });
162
+ }
163
+ async getCachedLabels() {
164
+ if (!this.cacheEnabled)
165
+ return undefined;
166
+ const prisma = await getPrisma();
167
+ const row = await prisma.label.findUnique({ where: { email_appId: { email: this.account.email, appId: this.account.appId } } });
168
+ if (!row || isExpired(row.createdAt, row.ttlMs))
169
+ return undefined;
170
+ return JSON.parse(row.rawData);
171
+ }
172
+ async cacheLabelsData(raw) {
173
+ if (!this.cacheEnabled)
174
+ return;
175
+ const prisma = await getPrisma();
176
+ await prisma.label.upsert({
177
+ where: { email_appId: { email: this.account.email, appId: this.account.appId } },
178
+ create: { email: this.account.email, appId: this.account.appId, rawData: JSON.stringify(raw), ttlMs: TTL.LABELS, createdAt: new Date() },
179
+ update: { rawData: JSON.stringify(raw), ttlMs: TTL.LABELS, createdAt: new Date() },
180
+ });
181
+ }
182
+ async invalidateLabels() {
183
+ if (!this.cacheEnabled)
184
+ return;
185
+ const prisma = await getPrisma();
186
+ await prisma.label.deleteMany({ where: { email: this.account.email, appId: this.account.appId } });
187
+ }
188
+ async getCachedProfile() {
189
+ if (!this.cacheEnabled)
190
+ return undefined;
191
+ const prisma = await getPrisma();
192
+ const row = await prisma.profile.findUnique({ where: { email_appId: { email: this.account.email, appId: this.account.appId } } });
193
+ if (!row || isExpired(row.createdAt, row.ttlMs))
194
+ return undefined;
195
+ return { emailAddress: row.emailAddress, messagesTotal: row.messagesTotal, threadsTotal: row.threadsTotal, historyId: row.historyId };
196
+ }
197
+ async cacheProfileData(profile) {
198
+ if (!this.cacheEnabled)
199
+ return;
200
+ const prisma = await getPrisma();
201
+ await prisma.profile.upsert({
202
+ where: { email_appId: { email: this.account.email, appId: this.account.appId } },
203
+ create: { email: this.account.email, appId: this.account.appId, ...profile, ttlMs: TTL.PROFILE, createdAt: new Date() },
204
+ update: { ...profile, ttlMs: TTL.PROFILE, createdAt: new Date() },
205
+ });
52
206
  }
53
207
  // =========================================================================
54
208
  // Thread operations
55
209
  // =========================================================================
56
210
  async listThreads({ query, folder, maxResults = 25, labelIds, pageToken, } = {}) {
57
211
  const { q, resolvedLabelIds } = this.buildSearchParams(folder, query, labelIds);
58
- const res = await withRetry(() => this.gmail.users.threads.list({
212
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.threads.list({
59
213
  userId: 'me',
60
214
  q: q || undefined,
61
215
  labelIds: resolvedLabelIds.length > 0 ? resolvedLabelIds : undefined,
62
216
  maxResults,
63
217
  pageToken: pageToken || undefined,
64
- }));
218
+ })));
219
+ if (res instanceof Error)
220
+ return res;
65
221
  const rawThreads = res.data.threads ?? [];
66
- // Hydrate with metadata
67
- const threads = await mapConcurrent(rawThreads, async (t) => {
222
+ // Hydrate with metadata — collect both raw and parsed
223
+ const hydrated = await mapConcurrent(rawThreads, async (t) => {
68
224
  if (!t.id)
69
225
  return null;
70
- try {
71
- const detail = await withRetry(() => this.gmail.users.threads.get({
72
- userId: 'me',
73
- id: t.id,
74
- format: 'metadata',
75
- metadataHeaders: ['Subject', 'From', 'Date', 'To'],
76
- }));
77
- return this.parseThreadListItem(t.id, detail.data);
226
+ const cached = await this.getCachedThread(t.id);
227
+ if (cached && (!t.historyId || !cached.historyId || t.historyId === cached.historyId)) {
228
+ return {
229
+ parsed: GmailClient.parseRawThreadListItem(cached),
230
+ raw: cached,
231
+ };
78
232
  }
79
- catch {
233
+ // Boundary: threads.get — auth errors abort via mapConcurrent, others skip.
234
+ const detail = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.threads.get({
235
+ userId: 'me',
236
+ id: t.id,
237
+ format: 'full',
238
+ })));
239
+ if (detail instanceof AuthError)
240
+ return detail;
241
+ if (detail instanceof Error)
80
242
  return null;
81
- }
243
+ const parsed = GmailClient.parseRawThread(detail.data);
244
+ await this.cacheThreadData(t.id, detail.data, parsed);
245
+ return {
246
+ parsed: GmailClient.parseRawThreadListItem(detail.data),
247
+ raw: detail.data,
248
+ };
82
249
  });
83
- return {
84
- threads: threads.filter((t) => t !== null),
250
+ if (hydrated instanceof Error)
251
+ return hydrated;
252
+ const valid = hydrated.filter((t) => t !== null);
253
+ const result = {
254
+ threads: valid.map((t) => t.parsed),
255
+ rawThreads: valid.map((t) => t.raw),
85
256
  nextPageToken: res.data.nextPageToken ?? null,
86
257
  resultSizeEstimate: res.data.resultSizeEstimate ?? 0,
87
258
  };
259
+ return result;
88
260
  }
89
261
  async getThread({ threadId }) {
262
+ // Check cache
263
+ const cached = await this.getCachedThread(threadId);
264
+ if (cached) {
265
+ return { parsed: GmailClient.parseRawThread(cached), raw: cached };
266
+ }
90
267
  const res = await withRetry(() => this.gmail.users.threads.get({
91
268
  userId: 'me',
92
269
  id: threadId,
93
270
  format: 'full',
94
271
  }));
95
- if (!res.data.messages || res.data.messages.length === 0) {
96
- return {
97
- id: threadId,
98
- historyId: res.data.historyId ?? null,
99
- messages: [],
100
- subject: '',
101
- snippet: '',
102
- from: { email: '' },
103
- date: '',
104
- labelIds: [],
105
- hasUnread: false,
106
- messageCount: 0,
107
- };
108
- }
109
- const messages = res.data.messages.map((m) => this.parseMessage(m));
110
- const latest = messages.findLast((m) => !m.isDraft) ?? messages[messages.length - 1];
111
- const allLabels = [...new Set(messages.flatMap((m) => m.labelIds))];
112
- return {
113
- id: threadId,
114
- historyId: res.data.historyId ?? null,
115
- messages,
116
- subject: latest.subject,
117
- snippet: latest.snippet,
118
- from: latest.from,
119
- date: latest.date,
120
- labelIds: allLabels,
121
- hasUnread: messages.some((m) => m.unread),
122
- messageCount: messages.filter((m) => !m.isDraft).length,
123
- };
272
+ const parsed = GmailClient.parseRawThread(res.data);
273
+ const result = { parsed, raw: res.data };
274
+ // Write cache
275
+ await this.cacheThreadData(threadId, res.data, parsed);
276
+ return result;
124
277
  }
125
278
  async getMessage({ messageId, format = 'full', }) {
126
- const res = await withRetry(() => this.gmail.users.messages.get({
279
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.messages.get({
127
280
  userId: 'me',
128
281
  id: messageId,
129
282
  format,
130
- }));
283
+ })));
284
+ if (res instanceof Error)
285
+ return res;
131
286
  if (format === 'raw') {
132
287
  return {
133
288
  id: messageId,
@@ -137,13 +292,15 @@ export class GmailClient {
137
292
  return this.parseMessage(res.data);
138
293
  }
139
294
  async getRawMessage({ messageId }) {
140
- const res = await withRetry(() => this.gmail.users.messages.get({
295
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.messages.get({
141
296
  userId: 'me',
142
297
  id: messageId,
143
298
  format: 'raw',
144
- }));
299
+ })));
300
+ if (res instanceof Error)
301
+ return res;
145
302
  if (!res.data.raw)
146
- throw new Error('No raw email data found');
303
+ return new MissingDataError({ what: 'raw email data', resource: `message ${messageId}` });
147
304
  return decodeBase64Url(res.data.raw);
148
305
  }
149
306
  // =========================================================================
@@ -171,6 +328,86 @@ export class GmailClient {
171
328
  return res.data;
172
329
  }
173
330
  // =========================================================================
331
+ // Reply / Forward (high-level composition)
332
+ // =========================================================================
333
+ /**
334
+ * Reply to a thread. Handles reply-to resolution, reply-all CC computation,
335
+ * References/In-Reply-To headers, and subject prefixing.
336
+ */
337
+ async replyToThread({ threadId, body, replyAll = false, cc, fromEmail, }) {
338
+ const { parsed: thread } = await this.getThread({ threadId });
339
+ if (thread.messages.length === 0) {
340
+ return new EmptyThreadError({ threadId });
341
+ }
342
+ const lastMsg = thread.messages[thread.messages.length - 1];
343
+ const replyTo = lastMsg.replyTo ?? lastMsg.from.email;
344
+ const to = [{ email: replyTo }];
345
+ let resolvedCc;
346
+ if (replyAll) {
347
+ const profile = await this.getProfile();
348
+ if (profile instanceof Error)
349
+ return profile;
350
+ const myEmail = profile.emailAddress.toLowerCase();
351
+ const allRecipients = [
352
+ ...lastMsg.to.map((r) => r.email),
353
+ ...(lastMsg.cc?.map((r) => r.email) ?? []),
354
+ ]
355
+ .filter((e) => e.toLowerCase() !== myEmail)
356
+ .filter((e) => e.toLowerCase() !== replyTo.toLowerCase());
357
+ if (allRecipients.length > 0) {
358
+ resolvedCc = allRecipients.map((e) => ({ email: e }));
359
+ }
360
+ }
361
+ if (cc) {
362
+ resolvedCc = [...(resolvedCc ?? []), ...cc];
363
+ }
364
+ const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ');
365
+ const result = await this.sendMessage({
366
+ to,
367
+ subject: lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`,
368
+ body,
369
+ cc: resolvedCc,
370
+ threadId,
371
+ inReplyTo: lastMsg.messageId,
372
+ references: refs || undefined,
373
+ fromEmail,
374
+ });
375
+ await this.invalidateThread(threadId);
376
+ return result;
377
+ }
378
+ /**
379
+ * Forward a thread. Fetches the last message, renders its body,
380
+ * builds the "Forwarded message" block, and sends.
381
+ */
382
+ async forwardThread({ threadId, to, body, fromEmail, }) {
383
+ const { parsed: thread } = await this.getThread({ threadId });
384
+ if (thread.messages.length === 0) {
385
+ return new EmptyThreadError({ threadId });
386
+ }
387
+ const lastMsg = thread.messages[thread.messages.length - 1];
388
+ const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType);
389
+ const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
390
+ ? `${lastMsg.from.name} <${lastMsg.from.email}>`
391
+ : lastMsg.from.email;
392
+ const fullBody = [
393
+ body ?? '',
394
+ '',
395
+ '---------- Forwarded message ----------',
396
+ `From: ${fromStr}`,
397
+ `Date: ${lastMsg.date}`,
398
+ `Subject: ${lastMsg.subject}`,
399
+ `To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
400
+ '',
401
+ renderedBody,
402
+ ].join('\n');
403
+ return this.sendMessage({
404
+ to,
405
+ subject: `Fwd: ${lastMsg.subject}`,
406
+ body: fullBody,
407
+ fromEmail,
408
+ });
409
+ }
410
+ // =========================================================================
174
411
  // Draft operations
175
412
  // =========================================================================
176
413
  async createDraft({ to, subject, body, cc, bcc, threadId, fromEmail, attachments, }) {
@@ -183,25 +420,16 @@ export class GmailClient {
183
420
  }));
184
421
  return res.data;
185
422
  }
186
- async updateDraft({ draftId, to, subject, body, cc, bcc, threadId, fromEmail, attachments, }) {
187
- const raw = this.buildMimeMessage({ to, subject, body, cc, bcc, attachments, fromEmail });
188
- const res = await withRetry(() => this.gmail.users.drafts.update({
189
- userId: 'me',
190
- id: draftId,
191
- requestBody: {
192
- message: { raw, threadId },
193
- },
194
- }));
195
- return res.data;
196
- }
197
423
  async getDraft({ draftId }) {
198
- const res = await withRetry(() => this.gmail.users.drafts.get({
424
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.drafts.get({
199
425
  userId: 'me',
200
426
  id: draftId,
201
427
  format: 'full',
202
- }));
428
+ })));
429
+ if (res instanceof Error)
430
+ return res;
203
431
  if (!res.data || !res.data.message)
204
- throw new Error('Draft not found');
432
+ return new NotFoundError({ resource: `draft ${draftId}` });
205
433
  const message = this.parseMessage(res.data.message);
206
434
  const headers = res.data.message.payload?.headers ?? [];
207
435
  return {
@@ -213,37 +441,41 @@ export class GmailClient {
213
441
  };
214
442
  }
215
443
  async listDrafts({ query, maxResults = 20, pageToken, } = {}) {
216
- const res = await withRetry(() => this.gmail.users.drafts.list({
444
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.drafts.list({
217
445
  userId: 'me',
218
446
  q: query || undefined,
219
447
  maxResults,
220
448
  pageToken: pageToken || undefined,
221
- }));
449
+ })));
450
+ if (res instanceof Error)
451
+ return res;
222
452
  const drafts = await mapConcurrent(res.data.drafts ?? [], async (draft) => {
223
453
  if (!draft.id)
224
454
  return null;
225
- try {
226
- const detail = await withRetry(() => this.gmail.users.drafts.get({
227
- userId: 'me',
228
- id: draft.id,
229
- format: 'metadata',
230
- }));
231
- if (!detail.data.message)
232
- return null;
233
- const headers = detail.data.message.payload?.headers ?? [];
234
- return {
235
- id: draft.id,
236
- threadId: detail.data.message.threadId ?? null,
237
- subject: this.getHeader(headers, 'subject') ?? '(no subject)',
238
- to: this.getHeaderValues(headers, 'to'),
239
- date: this.getHeader(headers, 'date') ?? '',
240
- snippet: detail.data.message.snippet ?? '',
241
- };
242
- }
243
- catch {
455
+ // Boundary: drafts.get — auth errors abort via mapConcurrent, others skip.
456
+ const detail = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.drafts.get({
457
+ userId: 'me',
458
+ id: draft.id,
459
+ format: 'metadata',
460
+ })));
461
+ if (detail instanceof AuthError)
462
+ return detail;
463
+ if (detail instanceof Error)
244
464
  return null;
245
- }
465
+ if (!detail.data.message)
466
+ return null;
467
+ const headers = detail.data.message.payload?.headers ?? [];
468
+ return {
469
+ id: draft.id,
470
+ threadId: detail.data.message.threadId ?? null,
471
+ subject: this.getHeader(headers, 'subject') ?? '(no subject)',
472
+ to: this.getHeaderValues(headers, 'to'),
473
+ date: this.getHeader(headers, 'date') ?? '',
474
+ snippet: detail.data.message.snippet ?? '',
475
+ };
246
476
  });
477
+ if (drafts instanceof Error)
478
+ return drafts;
247
479
  return {
248
480
  drafts: drafts.filter((d) => d !== null),
249
481
  nextPageToken: res.data.nextPageToken ?? null,
@@ -267,63 +499,88 @@ export class GmailClient {
267
499
  // =========================================================================
268
500
  async markAsRead({ threadIds }) {
269
501
  const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) => labelIds.includes('UNREAD'));
502
+ if (messageIds instanceof Error)
503
+ return messageIds;
270
504
  if (messageIds.length === 0)
271
505
  return;
272
506
  await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] });
507
+ await this.invalidateAfterThreadMutation(threadIds);
273
508
  }
274
509
  async markAsUnread({ threadIds }) {
275
510
  const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) => !labelIds.includes('UNREAD'));
511
+ if (messageIds instanceof Error)
512
+ return messageIds;
276
513
  if (messageIds.length === 0)
277
514
  return;
278
515
  await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] });
516
+ await this.invalidateAfterThreadMutation(threadIds);
279
517
  }
280
518
  async star({ threadIds }) {
281
519
  const messageIds = await this.getMessageIdsForThreads(threadIds);
520
+ if (messageIds instanceof Error)
521
+ return messageIds;
282
522
  if (messageIds.length === 0)
283
523
  return;
284
524
  await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] });
525
+ await this.invalidateAfterThreadMutation(threadIds);
285
526
  }
286
527
  async unstar({ threadIds }) {
287
528
  const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) => labelIds.includes('STARRED'));
529
+ if (messageIds instanceof Error)
530
+ return messageIds;
288
531
  if (messageIds.length === 0)
289
532
  return;
290
533
  await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] });
534
+ await this.invalidateAfterThreadMutation(threadIds);
291
535
  }
292
536
  async modifyLabels({ threadIds, addLabelIds = [], removeLabelIds = [], }) {
293
537
  // Resolve add labels (auto-create if missing), but only look up remove labels (never create)
294
538
  const resolvedAdd = await Promise.all(addLabelIds.map((l) => this.resolveLabelId(l)));
295
- const resolvedRemove = (await Promise.all(removeLabelIds.map((l) => this.lookupLabelId(l)))).filter((id) => id !== null);
539
+ const addErr = resolvedAdd.find((r) => r instanceof Error);
540
+ if (addErr)
541
+ return addErr;
542
+ const resolvedRemoveRaw = await Promise.all(removeLabelIds.map((l) => this.lookupLabelId(l)));
543
+ const removeErr = resolvedRemoveRaw.find((r) => r instanceof Error);
544
+ if (removeErr)
545
+ return removeErr;
546
+ const resolvedRemove = resolvedRemoveRaw.filter((id) => typeof id === 'string');
296
547
  const messageIds = await this.getMessageIdsForThreads(threadIds);
548
+ if (messageIds instanceof Error)
549
+ return messageIds;
297
550
  if (messageIds.length === 0)
298
551
  return;
299
552
  await this.batchModifyMessages(messageIds, {
300
- addLabelIds: resolvedAdd,
553
+ addLabelIds: resolvedAdd.filter((r) => typeof r === 'string'),
301
554
  removeLabelIds: resolvedRemove,
302
555
  });
556
+ await this.invalidateAfterThreadMutation(threadIds);
303
557
  }
304
558
  async trash({ threadId }) {
305
559
  await withRetry(() => this.gmail.users.threads.trash({
306
560
  userId: 'me',
307
561
  id: threadId,
308
562
  }));
563
+ await this.invalidateAfterThreadMutation([threadId]);
309
564
  }
310
565
  async untrash({ threadId }) {
311
566
  await withRetry(() => this.gmail.users.threads.untrash({
312
567
  userId: 'me',
313
568
  id: threadId,
314
569
  }));
570
+ await this.invalidateAfterThreadMutation([threadId]);
315
571
  }
316
572
  async archive({ threadIds }) {
317
573
  const messageIds = await this.getMessageIdsForThreads(threadIds);
574
+ if (messageIds instanceof Error)
575
+ return messageIds;
318
576
  if (messageIds.length === 0)
319
577
  return;
320
578
  await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] });
579
+ await this.invalidateAfterThreadMutation(threadIds);
321
580
  }
322
- async deleteMessage({ messageId }) {
323
- await withRetry(() => this.gmail.users.messages.delete({
324
- userId: 'me',
325
- id: messageId,
326
- }));
581
+ /** Invalidate thread cache after a thread mutation. */
582
+ async invalidateAfterThreadMutation(threadIds) {
583
+ await this.invalidateThreads(threadIds);
327
584
  }
328
585
  /** Moves all spam threads to trash. Does not permanently delete. */
329
586
  async trashAllSpam() {
@@ -335,10 +592,14 @@ export class GmailClient {
335
592
  maxResults: 100,
336
593
  pageToken,
337
594
  });
595
+ if (res instanceof Error)
596
+ return res;
338
597
  if (res.threads.length === 0)
339
598
  break;
340
599
  const threadIds = res.threads.map((t) => t.id);
341
600
  const messageIds = await this.getMessageIdsForThreads(threadIds);
601
+ if (messageIds instanceof Error)
602
+ return messageIds;
342
603
  await this.batchModifyMessages(messageIds, {
343
604
  addLabelIds: ['TRASH'],
344
605
  removeLabelIds: ['SPAM', 'INBOX'],
@@ -354,20 +615,18 @@ export class GmailClient {
354
615
  // Labels CRUD
355
616
  // =========================================================================
356
617
  async listLabels() {
357
- const res = await withRetry(() => this.gmail.users.labels.list({ userId: 'me' }));
358
- return (res.data.labels?.map((label) => ({
359
- id: label.id ?? '',
360
- name: label.name ?? '',
361
- type: (label.type ?? 'user'),
362
- messageListVisibility: label.messageListVisibility ?? null,
363
- labelListVisibility: label.labelListVisibility ?? null,
364
- color: label.color
365
- ? {
366
- backgroundColor: label.color.backgroundColor ?? '',
367
- textColor: label.color.textColor ?? '',
368
- }
369
- : null,
370
- })) ?? []);
618
+ // Check cache
619
+ const cached = await this.getCachedLabels();
620
+ if (cached) {
621
+ return { parsed: GmailClient.parseRawLabels(cached), raw: cached };
622
+ }
623
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.labels.list({ userId: 'me' })));
624
+ if (res instanceof Error)
625
+ return res;
626
+ const rawLabels = res.data.labels ?? [];
627
+ // Write cache
628
+ await this.cacheLabelsData(rawLabels);
629
+ return { parsed: GmailClient.parseRawLabels(rawLabels), raw: rawLabels };
371
630
  }
372
631
  async getLabel({ labelId }) {
373
632
  const res = await withRetry(() => this.gmail.users.labels.get({
@@ -400,72 +659,71 @@ export class GmailClient {
400
659
  color,
401
660
  },
402
661
  }));
662
+ await this.invalidateLabels();
403
663
  return {
404
664
  id: res.data.id ?? '',
405
665
  name: res.data.name ?? name,
406
666
  };
407
667
  }
408
- async updateLabel({ labelId, name, color, }) {
409
- await withRetry(() => this.gmail.users.labels.update({
410
- userId: 'me',
411
- id: labelId,
412
- requestBody: {
413
- name,
414
- color,
415
- },
416
- }));
417
- // Invalidate label ID cache since name may have changed
418
- this.labelIdCache = {};
419
- }
420
668
  async deleteLabel({ labelId }) {
421
669
  await withRetry(() => this.gmail.users.labels.delete({
422
670
  userId: 'me',
423
671
  id: labelId,
424
672
  }));
425
- // Invalidate label ID cache
426
673
  this.labelIdCache = {};
674
+ await this.invalidateLabels();
427
675
  }
428
676
  // =========================================================================
429
677
  // Label counts (unread counts per folder/label)
430
678
  // =========================================================================
431
679
  async getLabelCounts() {
432
- // Fetch label counts and archive count concurrently
433
- const [labels, archiveRes] = await Promise.all([
434
- this.listLabels(),
435
- withRetry(() => this.gmail.users.threads.list({
436
- userId: 'me',
437
- q: 'in:archive',
438
- maxResults: 1,
439
- })).catch(() => null),
440
- ]);
441
- const counts = await mapConcurrent(labels, async (label) => {
680
+ // Always fresh: label counts are user-facing live data.
681
+ // Archive count is best-effort — auth errors propagate, others yield null.
682
+ const archiveRes = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.threads.list({
683
+ userId: 'me',
684
+ q: 'in:archive',
685
+ maxResults: 1,
686
+ })));
687
+ if (archiveRes instanceof AuthError)
688
+ return archiveRes;
689
+ // archiveRes may be ApiError (non-auth failure) archive count unavailable
690
+ const labelsResult = await this.listLabels();
691
+ if (labelsResult instanceof Error)
692
+ return labelsResult;
693
+ // Fetch detailed counts for each label — collect both raw and parsed
694
+ const rawDetails = [];
695
+ const counts = await mapConcurrent(labelsResult.parsed, async (label) => {
442
696
  if (!label.id)
443
697
  return null;
444
- try {
445
- const detail = await withRetry(() => this.gmail.users.labels.get({
446
- userId: 'me',
447
- id: label.id,
448
- }));
449
- const labelName = (detail.data.name ?? detail.data.id ?? '').toLowerCase();
450
- const isTotalLabel = labelName === 'draft' || labelName === 'sent';
451
- return {
452
- label: labelName === 'draft' ? 'drafts' : labelName,
453
- count: Number(isTotalLabel ? detail.data.threadsTotal : detail.data.threadsUnread) || 0,
454
- };
455
- }
456
- catch {
698
+ // Boundary: labels.get — auth errors abort via mapConcurrent, others skip.
699
+ const detail = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.labels.get({
700
+ userId: 'me',
701
+ id: label.id,
702
+ })));
703
+ if (detail instanceof AuthError)
704
+ return detail;
705
+ if (detail instanceof Error)
457
706
  return null;
458
- }
707
+ rawDetails.push(detail.data);
708
+ const labelName = (detail.data.name ?? detail.data.id ?? '').toLowerCase();
709
+ const isTotalLabel = labelName === 'draft' || labelName === 'sent';
710
+ return {
711
+ label: labelName === 'draft' ? 'drafts' : labelName,
712
+ count: Number(isTotalLabel ? detail.data.threadsTotal : detail.data.threadsUnread) || 0,
713
+ };
459
714
  });
715
+ if (counts instanceof Error)
716
+ return counts;
460
717
  const result = counts.filter((c) => c !== null);
461
718
  // Add archive count (same as Zero's count() method)
462
- if (archiveRes) {
719
+ if (!(archiveRes instanceof Error)) {
463
720
  result.push({
464
721
  label: 'archive',
465
722
  count: Number(archiveRes.data.resultSizeEstimate ?? 0),
466
723
  });
467
724
  }
468
- return result;
725
+ const archiveEstimate = !(archiveRes instanceof Error) ? Number(archiveRes.data.resultSizeEstimate ?? 0) : null;
726
+ return { parsed: result, raw: rawDetails, archiveEstimate };
469
727
  }
470
728
  // =========================================================================
471
729
  // Attachments
@@ -480,229 +738,202 @@ export class GmailClient {
480
738
  // Convert base64url to standard base64
481
739
  return data.replace(/-/g, '+').replace(/_/g, '/');
482
740
  }
483
- async getMessageAttachments({ messageId }) {
484
- const res = await withRetry(() => this.gmail.users.messages.get({
485
- userId: 'me',
486
- id: messageId,
487
- }));
488
- const parts = res.data.payload?.parts;
489
- if (!parts)
490
- return [];
491
- const attachmentParts = this.findAttachmentParts(parts);
492
- const attachments = await mapConcurrent(attachmentParts, async (part) => {
493
- const attId = part.body?.attachmentId;
494
- if (!attId)
495
- return null;
496
- try {
497
- const data = await this.getAttachment({ messageId, attachmentId: attId });
498
- return {
499
- filename: part.filename ?? '',
500
- mimeType: part.mimeType ?? '',
501
- size: Number(part.body?.size ?? 0),
502
- attachmentId: attId,
503
- data,
504
- };
505
- }
506
- catch {
507
- return null;
508
- }
509
- });
510
- return attachments.filter((a) => a !== null);
511
- }
512
741
  // =========================================================================
513
742
  // Account / profile
514
743
  // =========================================================================
515
744
  async getProfile() {
516
- const res = await withRetry(() => this.gmail.users.getProfile({ userId: 'me' }));
517
- return {
745
+ // Check cache
746
+ const cached = await this.getCachedProfile();
747
+ if (cached)
748
+ return cached;
749
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.getProfile({ userId: 'me' })));
750
+ if (res instanceof Error)
751
+ return res;
752
+ const profile = {
518
753
  emailAddress: res.data.emailAddress ?? '',
519
754
  messagesTotal: res.data.messagesTotal ?? 0,
520
755
  threadsTotal: res.data.threadsTotal ?? 0,
521
756
  historyId: res.data.historyId ?? '',
522
757
  };
523
- }
524
- async getEmailAliases() {
525
- const profile = await this.getProfile();
526
- const primaryEmail = profile.emailAddress;
527
- const aliases = [
528
- { email: primaryEmail, primary: true },
529
- ];
530
- try {
531
- const settings = await withRetry(() => this.gmail.users.settings.sendAs.list({ userId: 'me' }));
532
- for (const alias of settings.data.sendAs ?? []) {
533
- if (alias.isPrimary && alias.sendAsEmail === primaryEmail)
534
- continue;
535
- aliases.push({
536
- email: alias.sendAsEmail ?? '',
537
- name: alias.displayName ?? undefined,
538
- primary: alias.isPrimary ?? false,
539
- });
540
- }
541
- }
542
- catch {
543
- // sendAs.list may fail if the user doesn't have permission
544
- }
545
- return aliases;
758
+ // Write cache
759
+ await this.cacheProfileData(profile);
760
+ return profile;
546
761
  }
547
762
  // =========================================================================
548
- // History / sync
763
+ // Static: parse raw Google API responses (used by cache readers)
549
764
  // =========================================================================
550
- async listHistory({ startHistoryId, labelId, historyTypes, }) {
551
- const allHistory = [];
552
- let pageToken;
553
- let latestHistoryId = startHistoryId;
554
- while (true) {
555
- const res = await withRetry(() => this.gmail.users.history.list({
556
- userId: 'me',
557
- startHistoryId,
558
- labelId,
559
- historyTypes,
560
- pageToken,
561
- }));
562
- if (res.data.history) {
563
- allHistory.push(...res.data.history);
564
- }
565
- latestHistoryId = res.data.historyId ?? latestHistoryId;
566
- pageToken = res.data.nextPageToken ?? undefined;
567
- if (!pageToken)
568
- break;
765
+ /** Parse a raw gmail_v1.Schema$Thread (format: full) into ThreadData. */
766
+ static parseRawThread(raw) {
767
+ const messages = (raw.messages ?? []).map((m) => GmailClient.parseRawMessage(m));
768
+ if (messages.length === 0) {
769
+ return {
770
+ id: raw.id ?? '',
771
+ historyId: raw.historyId ?? null,
772
+ messages: [],
773
+ subject: '',
774
+ snippet: '',
775
+ from: { email: '' },
776
+ date: '',
777
+ labelIds: [],
778
+ hasUnread: false,
779
+ messageCount: 0,
780
+ };
569
781
  }
782
+ const latest = messages.findLast((m) => !m.isDraft) ?? messages[messages.length - 1];
783
+ const allLabels = [...new Set(messages.flatMap((m) => m.labelIds))];
570
784
  return {
571
- history: allHistory,
572
- historyId: latestHistoryId,
573
- };
574
- }
575
- // NOTE: This method wraps Gmail's push notification API (users.watch),
576
- // which requires a Google Cloud Pub/Sub topic. Since zele uses borrowed
577
- // OAuth credentials (Thunderbird's client ID), we cannot create Pub/Sub
578
- // resources on their GCP project. Users would need their own GCP project,
579
- // which defeats the zero-config design. The CLI uses History API polling
580
- // instead (see src/commands/watch.ts). This method is kept for potential
581
- // future use if users bring their own GCP credentials.
582
- async watch({ topicName, labelIds = ['INBOX'], }) {
583
- const res = await withRetry(() => this.gmail.users.watch({
584
- userId: 'me',
585
- requestBody: {
586
- topicName,
587
- labelIds,
588
- },
589
- }));
590
- return {
591
- historyId: res.data.historyId ?? '',
592
- expiration: res.data.expiration ?? '',
785
+ id: raw.id ?? '',
786
+ historyId: raw.historyId ?? null,
787
+ messages,
788
+ subject: latest.subject,
789
+ snippet: latest.snippet,
790
+ from: latest.from,
791
+ date: latest.date,
792
+ labelIds: allLabels,
793
+ hasUnread: messages.some((m) => m.unread),
794
+ messageCount: messages.filter((m) => !m.isDraft).length,
593
795
  };
594
796
  }
595
- async stopWatch() {
596
- await withRetry(() => this.gmail.users.stop({ userId: 'me' }));
597
- }
598
- // =========================================================================
599
- // Private: message parsing
600
- // =========================================================================
601
- parseMessage(message) {
797
+ /** Parse a raw gmail_v1.Schema$Message into ParsedMessage. */
798
+ static parseRawMessage(message) {
602
799
  const headers = message.payload?.headers ?? [];
603
800
  const labelIds = message.labelIds ?? [];
604
- const fromHeader = this.getHeader(headers, 'from') ?? '';
605
- const toHeader = this.getHeader(headers, 'to') ?? '';
606
- const ccHeaders = this.getHeaderAll(headers, 'cc');
607
- const { body, mimeType } = this.extractBody(message.payload ?? {});
801
+ const getHeader = (name) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? null;
802
+ const fromHeader = getHeader('from') ?? '';
803
+ const toHeader = getHeader('to') ?? '';
804
+ const ccHeaders = headers
805
+ .filter((h) => h.name?.toLowerCase() === 'cc')
806
+ .map((h) => h.value ?? '')
807
+ .filter((v) => v.length > 0);
808
+ const { body, mimeType, textBody } = GmailClient.extractBodyStatic(message.payload ?? {});
608
809
  return {
609
810
  id: message.id ?? '',
610
811
  threadId: message.threadId ?? '',
611
- subject: (this.getHeader(headers, 'subject') ?? '(no subject)').replace(/"/g, '').trim(),
612
- snippet: message.snippet ?? '',
812
+ subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
813
+ snippet: sanitizeSnippet(message.snippet ?? ''),
613
814
  from: parseFrom(fromHeader),
614
815
  to: toHeader ? parseAddressList(toHeader) : [],
615
816
  cc: ccHeaders.length > 0
616
817
  ? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
617
818
  : null,
618
819
  bcc: [],
619
- replyTo: this.getHeader(headers, 'reply-to') ?? undefined,
620
- date: this.getHeader(headers, 'date') ?? '',
820
+ replyTo: getHeader('reply-to') ?? undefined,
821
+ date: getHeader('date') ?? '',
621
822
  labelIds,
622
823
  unread: labelIds.includes('UNREAD'),
623
824
  starred: labelIds.includes('STARRED'),
624
825
  isDraft: labelIds.includes('DRAFT'),
625
- messageId: this.getHeader(headers, 'message-id') ?? '',
626
- inReplyTo: this.getHeader(headers, 'in-reply-to') ?? undefined,
627
- references: this.getHeader(headers, 'references') ?? undefined,
628
- listUnsubscribe: this.getHeader(headers, 'list-unsubscribe') ?? undefined,
826
+ messageId: getHeader('message-id') ?? '',
827
+ inReplyTo: getHeader('in-reply-to') ?? undefined,
828
+ references: getHeader('references') ?? undefined,
829
+ listUnsubscribe: getHeader('list-unsubscribe') ?? undefined,
629
830
  body,
630
831
  mimeType,
631
- attachments: this.extractAttachmentMeta(message.payload?.parts ?? []),
832
+ textBody,
833
+ attachments: GmailClient.extractAttachmentMetaStatic(message.payload?.parts ?? []),
632
834
  };
633
835
  }
634
- parseThreadListItem(threadId, thread) {
635
- const messages = thread.messages ?? [];
636
- // Use the last non-draft message, or the last message
836
+ /** Parse raw gmail_v1.Schema$Thread (format: metadata) into ThreadListItem. */
837
+ static parseRawThreadListItem(raw) {
838
+ const messages = raw.messages ?? [];
637
839
  const latest = messages.findLast((m) => !m.labelIds?.includes('DRAFT')) ?? messages[messages.length - 1];
638
840
  const headers = latest?.payload?.headers ?? [];
639
841
  const allLabels = [...new Set(messages.flatMap((m) => m.labelIds ?? []))];
842
+ const getHeader = (name) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? null;
640
843
  return {
641
- id: threadId,
642
- historyId: thread.historyId ?? null,
643
- snippet: latest?.snippet ?? '',
644
- subject: (this.getHeader(headers, 'subject') ?? '(no subject)').replace(/"/g, '').trim(),
645
- from: parseFrom(this.getHeader(headers, 'from') ?? ''),
646
- date: this.getHeader(headers, 'date') ?? '',
844
+ id: raw.id ?? '',
845
+ historyId: raw.historyId ?? null,
846
+ snippet: sanitizeSnippet(latest?.snippet ?? ''),
847
+ subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
848
+ from: parseFrom(getHeader('from') ?? ''),
849
+ date: getHeader('date') ?? '',
647
850
  labelIds: allLabels,
648
851
  unread: allLabels.includes('UNREAD'),
649
852
  messageCount: messages.filter((m) => !m.labelIds?.includes('DRAFT')).length,
650
853
  };
651
854
  }
855
+ /** Parse raw gmail_v1.Schema$Label[] from labels.list into our label objects. */
856
+ static parseRawLabels(rawLabels) {
857
+ return rawLabels.map((label) => ({
858
+ id: label.id ?? '',
859
+ name: label.name ?? '',
860
+ type: (label.type ?? 'user'),
861
+ messageListVisibility: label.messageListVisibility ?? null,
862
+ labelListVisibility: label.labelListVisibility ?? null,
863
+ color: label.color
864
+ ? {
865
+ backgroundColor: label.color.backgroundColor ?? '',
866
+ textColor: label.color.textColor ?? '',
867
+ }
868
+ : null,
869
+ }));
870
+ }
871
+ /** Parse raw gmail_v1.Schema$Label[] (with counts) into label count objects. */
872
+ static parseRawLabelCounts(rawLabels, archiveEstimate) {
873
+ const result = rawLabels
874
+ .map((detail) => {
875
+ const labelName = (detail.name ?? detail.id ?? '').toLowerCase();
876
+ const isTotalLabel = labelName === 'draft' || labelName === 'sent';
877
+ return {
878
+ label: labelName === 'draft' ? 'drafts' : labelName,
879
+ count: Number(isTotalLabel ? detail.threadsTotal : detail.threadsUnread) || 0,
880
+ };
881
+ });
882
+ if (archiveEstimate !== null) {
883
+ result.push({ label: 'archive', count: archiveEstimate });
884
+ }
885
+ return result;
886
+ }
652
887
  // =========================================================================
653
- // Private: body extraction
888
+ // Private static: body/attachment extraction (for static parse methods)
654
889
  // =========================================================================
655
- extractBody(payload) {
656
- // Direct body on payload
890
+ static extractBodyStatic(payload) {
657
891
  if (payload.body?.data) {
892
+ const mime = payload.mimeType ?? 'text/plain';
658
893
  return {
659
894
  body: decodeBase64Url(payload.body.data),
660
- mimeType: payload.mimeType ?? 'text/plain',
895
+ mimeType: mime,
896
+ textBody: mime === 'text/plain' ? decodeBase64Url(payload.body.data) : null,
661
897
  };
662
898
  }
663
899
  if (!payload.parts) {
664
- return { body: '', mimeType: 'text/plain' };
900
+ return { body: '', mimeType: 'text/plain', textBody: null };
665
901
  }
666
- // Prefer text/html, fallback to text/plain
667
- const htmlBody = this.findBodyPart(payload.parts, 'text/html');
668
- if (htmlBody) {
669
- return { body: decodeBase64Url(htmlBody), mimeType: 'text/html' };
902
+ const htmlData = GmailClient.findBodyPartStatic(payload.parts, 'text/html');
903
+ const textData = GmailClient.findBodyPartStatic(payload.parts, 'text/plain');
904
+ const textBody = textData ? decodeBase64Url(textData) : null;
905
+ if (htmlData) {
906
+ return { body: decodeBase64Url(htmlData), mimeType: 'text/html', textBody };
670
907
  }
671
- const textBody = this.findBodyPart(payload.parts, 'text/plain');
672
- if (textBody) {
673
- return { body: decodeBase64Url(textBody), mimeType: 'text/plain' };
908
+ if (textData) {
909
+ return { body: textBody, mimeType: 'text/plain', textBody };
674
910
  }
675
- // Nested multipart (e.g. multipart/alternative inside multipart/mixed)
676
911
  for (const part of payload.parts) {
677
912
  if (part.parts) {
678
- const nested = this.extractBody(part);
913
+ const nested = GmailClient.extractBodyStatic(part);
679
914
  if (nested.body)
680
915
  return nested;
681
916
  }
682
917
  }
683
- return { body: '', mimeType: 'text/plain' };
918
+ return { body: '', mimeType: 'text/plain', textBody: null };
684
919
  }
685
- findBodyPart(parts, mimeType) {
920
+ static findBodyPartStatic(parts, mimeType) {
686
921
  for (const part of parts) {
687
922
  if (part.mimeType === mimeType && part.body?.data) {
688
923
  return part.body.data;
689
924
  }
690
925
  if (part.parts) {
691
- const found = this.findBodyPart(part.parts, mimeType);
926
+ const found = GmailClient.findBodyPartStatic(part.parts, mimeType);
692
927
  if (found)
693
928
  return found;
694
929
  }
695
930
  }
696
931
  return null;
697
932
  }
698
- // =========================================================================
699
- // Private: attachment handling
700
- // =========================================================================
701
- extractAttachmentMeta(parts) {
933
+ static extractAttachmentMetaStatic(parts) {
702
934
  const results = [];
703
935
  for (const part of parts) {
704
936
  if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
705
- // Skip inline images (content-disposition: inline with content-id)
706
937
  const disposition = part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? '';
707
938
  const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id');
708
939
  const isInline = disposition.toLowerCase().includes('inline');
@@ -715,30 +946,182 @@ export class GmailClient {
715
946
  });
716
947
  }
717
948
  }
718
- // Recurse into nested parts
719
949
  if (part.parts) {
720
- results.push(...this.extractAttachmentMeta(part.parts));
950
+ results.push(...GmailClient.extractAttachmentMetaStatic(part.parts));
721
951
  }
722
952
  }
723
953
  return results;
724
954
  }
725
- findAttachmentParts(parts) {
726
- const results = [];
727
- for (const part of parts) {
728
- if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
729
- // Filter out inline CID images (same logic as Zero's findAttachments)
730
- const disposition = part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? '';
731
- const isInline = disposition.toLowerCase().includes('inline');
732
- const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id');
733
- if (!isInline || !hasContentId) {
734
- results.push(part);
735
- }
955
+ async getEmailAliases() {
956
+ const profile = await this.getProfile();
957
+ if (profile instanceof Error)
958
+ return profile;
959
+ const primaryEmail = profile.emailAddress;
960
+ const aliases = [
961
+ { email: primaryEmail, primary: true },
962
+ ];
963
+ // Boundary: sendAs.list auth errors propagate; permission-denied is expected
964
+ // (some accounts lack Gmail settings access) and yields primary email only.
965
+ const settings = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.settings.sendAs.list({ userId: 'me' })));
966
+ if (settings instanceof AuthError)
967
+ return settings;
968
+ if (settings instanceof Error)
969
+ return aliases; // permission denied — return primary only
970
+ for (const alias of settings.data.sendAs ?? []) {
971
+ if (alias.isPrimary && alias.sendAsEmail === primaryEmail)
972
+ continue;
973
+ aliases.push({
974
+ email: alias.sendAsEmail ?? '',
975
+ name: alias.displayName ?? undefined,
976
+ primary: alias.isPrimary ?? false,
977
+ });
978
+ }
979
+ return aliases;
980
+ }
981
+ // =========================================================================
982
+ // History / sync
983
+ // =========================================================================
984
+ async listHistory({ startHistoryId, labelId, historyTypes, }) {
985
+ const allHistory = [];
986
+ let pageToken;
987
+ let latestHistoryId = startHistoryId;
988
+ while (true) {
989
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.history.list({
990
+ userId: 'me',
991
+ startHistoryId,
992
+ labelId,
993
+ historyTypes,
994
+ pageToken,
995
+ })));
996
+ if (res instanceof Error)
997
+ return res;
998
+ if (res.data.history) {
999
+ allHistory.push(...res.data.history);
736
1000
  }
737
- if (part.parts) {
738
- results.push(...this.findAttachmentParts(part.parts));
1001
+ latestHistoryId = res.data.historyId ?? latestHistoryId;
1002
+ pageToken = res.data.nextPageToken ?? undefined;
1003
+ if (!pageToken)
1004
+ break;
1005
+ }
1006
+ return {
1007
+ history: allHistory,
1008
+ historyId: latestHistoryId,
1009
+ };
1010
+ }
1011
+ // =========================================================================
1012
+ // Watch: async generator for inbox polling via History API
1013
+ // =========================================================================
1014
+ /**
1015
+ * Poll for new messages using the Gmail History API.
1016
+ * Yields WatchEvent objects as new messages arrive.
1017
+ * Handles history seeding, expiry re-seeding, and client-side query filtering.
1018
+ * Persists historyId in the DB so it survives across CLI invocations.
1019
+ */
1020
+ async *watchInbox({ folder = 'inbox', intervalMs = 15_000, query, once = false, } = {}) {
1021
+ if (!this.account)
1022
+ throw new MissingDataError({ what: 'authenticated account', resource: 'watchInbox' });
1023
+ const filterLabelId = WATCH_FOLDER_LABELS[folder];
1024
+ if (!filterLabelId) {
1025
+ throw new NotFoundError({ resource: `watch folder "${folder}". Supported: ${Object.keys(WATCH_FOLDER_LABELS).join(', ')}` });
1026
+ }
1027
+ // Seed historyId — guaranteed non-undefined after this block
1028
+ let historyId = await getLastHistoryId(this.account) ?? '';
1029
+ if (!historyId) {
1030
+ const profile = await this.getProfile();
1031
+ if (profile instanceof Error)
1032
+ throw profile;
1033
+ historyId = profile.historyId;
1034
+ await setLastHistoryId(this.account, historyId);
1035
+ }
1036
+ while (true) {
1037
+ // listHistory returns errors as values — check and handle history expiry.
1038
+ const historyResult = await this.listHistory({
1039
+ startHistoryId: historyId,
1040
+ labelId: filterLabelId,
1041
+ historyTypes: ['messageAdded'],
1042
+ });
1043
+ if (historyResult instanceof Error) {
1044
+ if (!isHistoryExpired(historyResult))
1045
+ throw historyResult;
1046
+ // historyId expired — Google only keeps ~7 days. Re-seed.
1047
+ const profile = await this.getProfile();
1048
+ if (profile instanceof Error)
1049
+ throw profile;
1050
+ historyId = profile.historyId;
1051
+ await setLastHistoryId(this.account, historyId);
1052
+ // Retry once after reseed
1053
+ const retryResult = await this.listHistory({ startHistoryId: historyId, labelId: filterLabelId, historyTypes: ['messageAdded'] });
1054
+ if (retryResult instanceof Error)
1055
+ throw retryResult;
1056
+ yield* this.pollOnceFromHistory(retryResult, historyId, query, (newId) => { historyId = newId; });
1057
+ }
1058
+ else {
1059
+ yield* this.pollOnceFromHistory(historyResult, historyId, query, (newId) => { historyId = newId; });
739
1060
  }
1061
+ if (once)
1062
+ return;
1063
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
1064
+ }
1065
+ }
1066
+ /** Single poll tick from pre-fetched history — yields WatchEvents for new messages. */
1067
+ async *pollOnceFromHistory(historyData, prevHistoryId, query, updateHistoryId) {
1068
+ const { history, historyId: newHistoryId } = historyData;
1069
+ if (newHistoryId !== prevHistoryId) {
1070
+ updateHistoryId(newHistoryId);
1071
+ await setLastHistoryId(this.account, newHistoryId);
1072
+ }
1073
+ if (history.length === 0)
1074
+ return;
1075
+ // Collect unique message IDs from messageAdded events
1076
+ const seenIds = new Set();
1077
+ const messageIds = [];
1078
+ for (const entry of history) {
1079
+ for (const added of entry.messagesAdded ?? []) {
1080
+ const id = added.message?.id;
1081
+ if (id && !seenIds.has(id)) {
1082
+ seenIds.add(id);
1083
+ messageIds.push(id);
1084
+ }
1085
+ }
1086
+ }
1087
+ if (messageIds.length === 0)
1088
+ return;
1089
+ // Hydrate messages with metadata (bounded concurrency).
1090
+ // getMessage returns errors as values — auth errors abort via mapConcurrent,
1091
+ // 404s (message deleted between history and hydration) are skipped.
1092
+ const hydrated = await mapConcurrent(messageIds, async (msgId) => {
1093
+ const msg = await this.getMessage({ messageId: msgId, format: 'metadata' });
1094
+ if (msg instanceof AuthError)
1095
+ return msg; // abort batch
1096
+ if (msg instanceof Error)
1097
+ return null; // skip this message
1098
+ if ('raw' in msg)
1099
+ return null;
1100
+ if (query && !matchesQuery(msg, query))
1101
+ return null;
1102
+ return msg;
1103
+ });
1104
+ if (hydrated instanceof Error)
1105
+ throw hydrated; // propagate to generator consumer
1106
+ for (const msg of hydrated) {
1107
+ if (!msg)
1108
+ continue;
1109
+ yield {
1110
+ account: this.account,
1111
+ type: 'new_message',
1112
+ message: msg,
1113
+ threadId: msg.threadId,
1114
+ };
740
1115
  }
741
- return results;
1116
+ }
1117
+ // =========================================================================
1118
+ // Private: message parsing (delegates to static methods)
1119
+ // =========================================================================
1120
+ parseMessage(message) {
1121
+ return GmailClient.parseRawMessage(message);
1122
+ }
1123
+ parseThreadListItem(threadId, thread) {
1124
+ return GmailClient.parseRawThreadListItem({ ...thread, id: threadId });
742
1125
  }
743
1126
  // =========================================================================
744
1127
  // Private: MIME message construction
@@ -799,7 +1182,10 @@ export class GmailClient {
799
1182
  return labelNameOrId;
800
1183
  if (this.labelIdCache[labelNameOrId])
801
1184
  return this.labelIdCache[labelNameOrId];
802
- const labels = await this.listLabels();
1185
+ const labelsResult = await this.listLabels();
1186
+ if (labelsResult instanceof Error)
1187
+ return labelsResult;
1188
+ const { parsed: labels } = labelsResult;
803
1189
  const match = labels.find((l) => l.name.toLowerCase() === labelNameOrId.toLowerCase());
804
1190
  if (match) {
805
1191
  this.labelIdCache[labelNameOrId] = match.id;
@@ -813,7 +1199,10 @@ export class GmailClient {
813
1199
  return labelNameOrId;
814
1200
  if (this.labelIdCache[labelNameOrId])
815
1201
  return this.labelIdCache[labelNameOrId];
816
- const labels = await this.listLabels();
1202
+ const labelsResult = await this.listLabels();
1203
+ if (labelsResult instanceof Error)
1204
+ return labelsResult;
1205
+ const { parsed: labels } = labelsResult;
817
1206
  const match = labels.find((l) => l.name.toLowerCase() === labelNameOrId.toLowerCase());
818
1207
  if (match) {
819
1208
  this.labelIdCache[labelNameOrId] = match.id;
@@ -879,12 +1268,14 @@ export class GmailClient {
879
1268
  // =========================================================================
880
1269
  async getMessageIdsForThreads(threadIds, filter) {
881
1270
  const allIds = [];
882
- await mapConcurrent(threadIds, async (threadId) => {
883
- const res = await withRetry(() => this.gmail.users.threads.get({
1271
+ const result = await mapConcurrent(threadIds, async (threadId) => {
1272
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.threads.get({
884
1273
  userId: 'me',
885
1274
  id: threadId,
886
1275
  format: 'metadata',
887
- }));
1276
+ })));
1277
+ if (res instanceof Error)
1278
+ return res;
888
1279
  for (const msg of res.data.messages ?? []) {
889
1280
  if (!msg.id)
890
1281
  continue;
@@ -893,6 +1284,8 @@ export class GmailClient {
893
1284
  allIds.push(msg.id);
894
1285
  }
895
1286
  });
1287
+ if (result instanceof Error)
1288
+ return result;
896
1289
  return [...new Set(allIds)];
897
1290
  }
898
1291
  async batchModifyMessages(messageIds, body) {
@@ -931,4 +1324,151 @@ export class GmailClient {
931
1324
  .filter(Boolean));
932
1325
  }
933
1326
  }
1327
+ // ---------------------------------------------------------------------------
1328
+ // Watch: folder label mapping
1329
+ // ---------------------------------------------------------------------------
1330
+ const WATCH_FOLDER_LABELS = {
1331
+ inbox: 'INBOX',
1332
+ sent: 'SENT',
1333
+ trash: 'TRASH',
1334
+ spam: 'SPAM',
1335
+ starred: 'STARRED',
1336
+ drafts: 'DRAFT',
1337
+ };
1338
+ // ---------------------------------------------------------------------------
1339
+ // Watch: sync state persistence (historyId in DB)
1340
+ // ---------------------------------------------------------------------------
1341
+ async function getLastHistoryId(account) {
1342
+ const prisma = await getPrisma();
1343
+ const row = await prisma.syncState.findUnique({
1344
+ where: { email_appId_key: { email: account.email, appId: account.appId, key: 'history_id' } },
1345
+ });
1346
+ return row?.value;
1347
+ }
1348
+ async function setLastHistoryId(account, historyId) {
1349
+ const prisma = await getPrisma();
1350
+ await prisma.syncState.upsert({
1351
+ where: { email_appId_key: { email: account.email, appId: account.appId, key: 'history_id' } },
1352
+ create: { email: account.email, appId: account.appId, key: 'history_id', value: historyId },
1353
+ update: { value: historyId },
1354
+ });
1355
+ }
1356
+ // ---------------------------------------------------------------------------
1357
+ // Watch: history expiry detection
1358
+ // ---------------------------------------------------------------------------
1359
+ function isHistoryExpired(err) {
1360
+ const status = err?.code ?? err?.status ?? err?.response?.status;
1361
+ if (status === 404)
1362
+ return true;
1363
+ if (status === 400) {
1364
+ const message = err?.message ?? err?.response?.data?.error?.message ?? '';
1365
+ if (message.includes('historyId'))
1366
+ return true;
1367
+ }
1368
+ return false;
1369
+ }
1370
+ // ---------------------------------------------------------------------------
1371
+ // Watch: client-side Gmail query matching
1372
+ // ---------------------------------------------------------------------------
1373
+ // The History API doesn't support server-side query filtering, so we parse
1374
+ // common Gmail search operators and match against message metadata.
1375
+ //
1376
+ // Supported operators: from:, to:, cc:, subject:, is:unread/read/starred,
1377
+ // has:attachment, and plain text (matches subject + from).
1378
+ // Multiple terms are AND-ed together. Quoted phrases and negation supported.
1379
+ //
1380
+ // Limitations vs full Gmail search:
1381
+ // - label: not supported (labelIds are API IDs like Label_123, not names)
1382
+ // - has:attachment uses Content-Type heuristic (metadata format lacks parts)
1383
+ // - OR, {}, newer_than:, older_than:, etc. are server-only — warned & skipped
1384
+ //
1385
+ // See https://support.google.com/mail/answer/7190 for the full Gmail spec.
1386
+ // ---------------------------------------------------------------------------
1387
+ const SERVER_ONLY_OPERATORS = new Set([
1388
+ 'in', 'label', 'after', 'before', 'newer_than', 'older_than',
1389
+ 'filename', 'size', 'larger', 'smaller', 'deliveredto', 'rfc822msgid',
1390
+ 'list', 'category',
1391
+ ]);
1392
+ const SUPPORTED_OPERATORS = new Set([
1393
+ 'from', 'to', 'cc', 'subject', 'is', 'has',
1394
+ ]);
1395
+ const warnedOperators = new Set();
1396
+ function matchesQuery(msg, query) {
1397
+ const terms = parseQueryTerms(query);
1398
+ return terms.every((term) => matchesTerm(msg, term));
1399
+ }
1400
+ function parseQueryTerms(query) {
1401
+ const terms = [];
1402
+ const regex = /(-?)(?:(\w+):)?(?:"([^"]*)"|([\S]+))/gi;
1403
+ let match;
1404
+ while ((match = regex.exec(query)) !== null) {
1405
+ const negated = match[1] === '-';
1406
+ const rawOperator = match[2]?.toLowerCase() ?? null;
1407
+ const value = (match[3] ?? match[4] ?? '').toLowerCase();
1408
+ if (!value)
1409
+ continue;
1410
+ if (!rawOperator && value === 'or')
1411
+ continue;
1412
+ if (rawOperator && SERVER_ONLY_OPERATORS.has(rawOperator)) {
1413
+ if (!warnedOperators.has(rawOperator)) {
1414
+ warnedOperators.add(rawOperator);
1415
+ console.error(`# --query: "${rawOperator}:" is a server-only operator (use "mail search" instead), skipping`);
1416
+ }
1417
+ continue;
1418
+ }
1419
+ if (rawOperator && !SUPPORTED_OPERATORS.has(rawOperator)) {
1420
+ if (!warnedOperators.has(rawOperator)) {
1421
+ warnedOperators.add(rawOperator);
1422
+ console.error(`# --query: unknown operator "${rawOperator}:", skipping`);
1423
+ }
1424
+ continue;
1425
+ }
1426
+ terms.push({ operator: rawOperator, value, negated });
1427
+ }
1428
+ return terms;
1429
+ }
1430
+ function senderMatches(sender, value) {
1431
+ const full = `${sender.name ?? ''} ${sender.email}`.toLowerCase();
1432
+ return full.includes(value);
1433
+ }
1434
+ function matchesTerm(msg, term) {
1435
+ let result;
1436
+ switch (term.operator) {
1437
+ case 'from':
1438
+ result = senderMatches(msg.from, term.value);
1439
+ break;
1440
+ case 'to':
1441
+ result = msg.to.some((r) => senderMatches(r, term.value));
1442
+ break;
1443
+ case 'cc':
1444
+ result = (msg.cc ?? []).some((r) => senderMatches(r, term.value));
1445
+ break;
1446
+ case 'subject':
1447
+ result = msg.subject.toLowerCase().includes(term.value);
1448
+ break;
1449
+ case 'is':
1450
+ if (term.value === 'unread')
1451
+ result = msg.unread;
1452
+ else if (term.value === 'read')
1453
+ result = !msg.unread;
1454
+ else if (term.value === 'starred')
1455
+ result = msg.starred;
1456
+ else
1457
+ result = false;
1458
+ break;
1459
+ case 'has':
1460
+ if (term.value === 'attachment')
1461
+ result = msg.mimeType.includes('multipart/mixed');
1462
+ else
1463
+ result = false;
1464
+ break;
1465
+ default: {
1466
+ const subject = msg.subject.toLowerCase();
1467
+ const from = `${msg.from.name ?? ''} ${msg.from.email}`.toLowerCase();
1468
+ result = subject.includes(term.value) || from.includes(term.value);
1469
+ break;
1470
+ }
1471
+ }
1472
+ return term.negated ? !result : result;
1473
+ }
934
1474
  //# sourceMappingURL=gmail-client.js.map