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/dist/gmail-client.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
import { gmail as gmailApi } from '@googleapis/gmail';
|
|
11
11
|
import { createMimeMessage } from 'mimetext';
|
|
12
12
|
import { parseFrom, parseAddressList } from './email-utils.js';
|
|
13
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
//
|
|
763
|
+
// Static: parse raw Google API responses (used by cache readers)
|
|
549
764
|
// =========================================================================
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
572
|
-
historyId:
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
|
605
|
-
const
|
|
606
|
-
const
|
|
607
|
-
const
|
|
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: (
|
|
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:
|
|
620
|
-
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:
|
|
626
|
-
inReplyTo:
|
|
627
|
-
references:
|
|
628
|
-
listUnsubscribe:
|
|
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
|
-
|
|
832
|
+
textBody,
|
|
833
|
+
attachments: GmailClient.extractAttachmentMetaStatic(message.payload?.parts ?? []),
|
|
632
834
|
};
|
|
633
835
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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:
|
|
642
|
-
historyId:
|
|
643
|
-
snippet: latest?.snippet ?? '',
|
|
644
|
-
subject: (
|
|
645
|
-
from: parseFrom(
|
|
646
|
-
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
|
-
|
|
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:
|
|
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
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(...
|
|
950
|
+
results.push(...GmailClient.extractAttachmentMetaStatic(part.parts));
|
|
721
951
|
}
|
|
722
952
|
}
|
|
723
953
|
return results;
|
|
724
954
|
}
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|