zele 0.2.0 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/bin/zele +27 -0
- package/dist/api-utils.d.ts +51 -2
- package/dist/api-utils.js +89 -3
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.d.ts +27 -6
- package/dist/auth.js +185 -129
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +16 -9
- package/dist/calendar-client.js +163 -59
- package/dist/calendar-client.js.map +1 -1
- package/dist/cli.js +28 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +17 -15
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +20 -9
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +67 -78
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +25 -18
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/label.js +33 -45
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.js +11 -13
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.js +114 -128
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.js +18 -21
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +73 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/db.js +12 -13
- package/dist/db.js.map +1 -1
- package/dist/generated/browser.d.ts +12 -27
- package/dist/generated/client.d.ts +13 -28
- package/dist/generated/client.js +1 -1
- package/dist/generated/commonInputTypes.d.ts +90 -26
- package/dist/generated/enums.d.ts +0 -4
- package/dist/generated/enums.js +0 -3
- package/dist/generated/enums.js.map +1 -1
- package/dist/generated/internal/class.d.ts +22 -55
- package/dist/generated/internal/class.js +12 -4
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +272 -511
- package/dist/generated/internal/prismaNamespace.js +54 -66
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
- package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/Account.d.ts +1637 -0
- package/dist/generated/models/Account.js +2 -0
- package/dist/generated/models/Account.js.map +1 -0
- package/dist/generated/models/CalendarList.d.ts +1161 -0
- package/dist/generated/models/CalendarList.js +2 -0
- package/dist/generated/models/CalendarList.js.map +1 -0
- package/dist/generated/models/Label.d.ts +1161 -0
- package/dist/generated/models/Label.js +2 -0
- package/dist/generated/models/Label.js.map +1 -0
- package/dist/generated/models/Profile.d.ts +1269 -0
- package/dist/generated/models/Profile.js +2 -0
- package/dist/generated/models/Profile.js.map +1 -0
- package/dist/generated/models/SyncState.d.ts +1130 -0
- package/dist/generated/models/SyncState.js +2 -0
- package/dist/generated/models/SyncState.js.map +1 -0
- package/dist/generated/models/Thread.d.ts +1608 -0
- package/dist/generated/models/Thread.js +2 -0
- package/dist/generated/models/Thread.js.map +1 -0
- package/dist/generated/models.d.ts +6 -9
- package/dist/gmail-client.d.ts +119 -94
- package/dist/gmail-client.js +862 -315
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.d.ts +1 -0
- package/dist/mail-tui.js +517 -0
- package/dist/mail-tui.js.map +1 -0
- package/dist/output.d.ts +6 -4
- package/dist/output.js +124 -17
- package/dist/output.js.map +1 -1
- package/package.json +39 -11
- package/schema.prisma +81 -113
- package/src/api-utils.ts +103 -5
- package/src/auth.ts +224 -143
- package/src/calendar-client.ts +196 -89
- package/src/cli.ts +32 -1
- package/src/commands/attachment.ts +18 -19
- package/src/commands/auth-cmd.ts +19 -9
- package/src/commands/calendar.ts +42 -85
- package/src/commands/draft.ts +19 -22
- package/src/commands/label.ts +21 -57
- package/src/commands/mail-actions.ts +11 -19
- package/src/commands/mail.ts +104 -149
- package/src/commands/profile.ts +12 -28
- package/src/commands/watch.ts +88 -0
- package/src/db.ts +13 -16
- package/src/generated/browser.ts +49 -0
- package/src/generated/client.ts +71 -0
- package/src/generated/commonInputTypes.ts +332 -0
- package/src/generated/enums.ts +17 -0
- package/src/generated/internal/class.ts +250 -0
- package/src/generated/internal/prismaNamespace.ts +1198 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
- package/src/generated/models/Account.ts +1848 -0
- package/src/generated/models/CalendarList.ts +1331 -0
- package/src/generated/models/Label.ts +1331 -0
- package/src/generated/models/Profile.ts +1439 -0
- package/src/generated/models/SyncState.ts +1300 -0
- package/src/generated/models/Thread.ts +1787 -0
- package/src/generated/models.ts +17 -0
- package/src/gmail-client.test.ts +59 -0
- package/src/gmail-client.ts +1034 -422
- package/src/mail-tui.tsx +1061 -0
- package/src/output.test.ts +1093 -0
- package/src/output.ts +128 -20
- package/src/schema.sql +58 -68
- package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
- package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
- package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
- package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
- package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
- package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
- package/AGENTS.md +0 -26
- package/CHANGELOG.md +0 -36
- package/dist/generated/models/accounts.d.ts +0 -2000
- package/dist/generated/models/accounts.js +0 -2
- package/dist/generated/models/accounts.js.map +0 -1
- package/dist/generated/models/calendar_events.d.ts +0 -1433
- package/dist/generated/models/calendar_events.js +0 -2
- package/dist/generated/models/calendar_events.js.map +0 -1
- package/dist/generated/models/calendar_lists.d.ts +0 -1131
- package/dist/generated/models/calendar_lists.js +0 -2
- package/dist/generated/models/calendar_lists.js.map +0 -1
- package/dist/generated/models/label_counts.d.ts +0 -1131
- package/dist/generated/models/label_counts.js +0 -2
- package/dist/generated/models/label_counts.js.map +0 -1
- package/dist/generated/models/labels.d.ts +0 -1131
- package/dist/generated/models/labels.js +0 -2
- package/dist/generated/models/labels.js.map +0 -1
- package/dist/generated/models/profiles.d.ts +0 -1131
- package/dist/generated/models/profiles.js +0 -2
- package/dist/generated/models/profiles.js.map +0 -1
- package/dist/generated/models/sync_states.d.ts +0 -1107
- package/dist/generated/models/sync_states.js +0 -2
- package/dist/generated/models/sync_states.js.map +0 -1
- package/dist/generated/models/thread_lists.d.ts +0 -1404
- package/dist/generated/models/thread_lists.js +0 -2
- package/dist/generated/models/thread_lists.js.map +0 -1
- package/dist/generated/models/threads.d.ts +0 -1247
- package/dist/generated/models/threads.js +0 -2
- package/dist/generated/models/threads.js.map +0 -1
- package/dist/gmail-cache.d.ts +0 -60
- package/dist/gmail-cache.js +0 -264
- package/dist/gmail-cache.js.map +0 -1
- package/docs/gogcli-gmail-implementation.md +0 -599
- package/scripts/test-device-code-clients.ts +0 -186
- package/scripts/test-micropython-scopes.ts +0 -72
- package/scripts/test-oauth-clients.ts +0 -257
- package/src/gmail-cache.ts +0 -339
- package/tsconfig.json +0 -16
package/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,222 +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
|
-
},
|
|
582
|
-
}));
|
|
583
|
-
return {
|
|
584
|
-
historyId: res.data.historyId ?? '',
|
|
585
|
-
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,
|
|
586
795
|
};
|
|
587
796
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}
|
|
591
|
-
// =========================================================================
|
|
592
|
-
// Private: message parsing
|
|
593
|
-
// =========================================================================
|
|
594
|
-
parseMessage(message) {
|
|
797
|
+
/** Parse a raw gmail_v1.Schema$Message into ParsedMessage. */
|
|
798
|
+
static parseRawMessage(message) {
|
|
595
799
|
const headers = message.payload?.headers ?? [];
|
|
596
800
|
const labelIds = message.labelIds ?? [];
|
|
597
|
-
const
|
|
598
|
-
const
|
|
599
|
-
const
|
|
600
|
-
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 ?? {});
|
|
601
809
|
return {
|
|
602
810
|
id: message.id ?? '',
|
|
603
811
|
threadId: message.threadId ?? '',
|
|
604
|
-
subject: (
|
|
605
|
-
snippet: message.snippet ?? '',
|
|
812
|
+
subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
|
|
813
|
+
snippet: sanitizeSnippet(message.snippet ?? ''),
|
|
606
814
|
from: parseFrom(fromHeader),
|
|
607
815
|
to: toHeader ? parseAddressList(toHeader) : [],
|
|
608
816
|
cc: ccHeaders.length > 0
|
|
609
817
|
? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
|
|
610
818
|
: null,
|
|
611
819
|
bcc: [],
|
|
612
|
-
replyTo:
|
|
613
|
-
date:
|
|
820
|
+
replyTo: getHeader('reply-to') ?? undefined,
|
|
821
|
+
date: getHeader('date') ?? '',
|
|
614
822
|
labelIds,
|
|
615
823
|
unread: labelIds.includes('UNREAD'),
|
|
616
824
|
starred: labelIds.includes('STARRED'),
|
|
617
825
|
isDraft: labelIds.includes('DRAFT'),
|
|
618
|
-
messageId:
|
|
619
|
-
inReplyTo:
|
|
620
|
-
references:
|
|
621
|
-
listUnsubscribe:
|
|
826
|
+
messageId: getHeader('message-id') ?? '',
|
|
827
|
+
inReplyTo: getHeader('in-reply-to') ?? undefined,
|
|
828
|
+
references: getHeader('references') ?? undefined,
|
|
829
|
+
listUnsubscribe: getHeader('list-unsubscribe') ?? undefined,
|
|
622
830
|
body,
|
|
623
831
|
mimeType,
|
|
624
|
-
|
|
832
|
+
textBody,
|
|
833
|
+
attachments: GmailClient.extractAttachmentMetaStatic(message.payload?.parts ?? []),
|
|
625
834
|
};
|
|
626
835
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
836
|
+
/** Parse raw gmail_v1.Schema$Thread (format: metadata) into ThreadListItem. */
|
|
837
|
+
static parseRawThreadListItem(raw) {
|
|
838
|
+
const messages = raw.messages ?? [];
|
|
630
839
|
const latest = messages.findLast((m) => !m.labelIds?.includes('DRAFT')) ?? messages[messages.length - 1];
|
|
631
840
|
const headers = latest?.payload?.headers ?? [];
|
|
632
841
|
const allLabels = [...new Set(messages.flatMap((m) => m.labelIds ?? []))];
|
|
842
|
+
const getHeader = (name) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? null;
|
|
633
843
|
return {
|
|
634
|
-
id:
|
|
635
|
-
historyId:
|
|
636
|
-
snippet: latest?.snippet ?? '',
|
|
637
|
-
subject: (
|
|
638
|
-
from: parseFrom(
|
|
639
|
-
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') ?? '',
|
|
640
850
|
labelIds: allLabels,
|
|
641
851
|
unread: allLabels.includes('UNREAD'),
|
|
642
852
|
messageCount: messages.filter((m) => !m.labelIds?.includes('DRAFT')).length,
|
|
643
853
|
};
|
|
644
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
|
+
}
|
|
645
887
|
// =========================================================================
|
|
646
|
-
// Private: body extraction
|
|
888
|
+
// Private static: body/attachment extraction (for static parse methods)
|
|
647
889
|
// =========================================================================
|
|
648
|
-
|
|
649
|
-
// Direct body on payload
|
|
890
|
+
static extractBodyStatic(payload) {
|
|
650
891
|
if (payload.body?.data) {
|
|
892
|
+
const mime = payload.mimeType ?? 'text/plain';
|
|
651
893
|
return {
|
|
652
894
|
body: decodeBase64Url(payload.body.data),
|
|
653
|
-
mimeType:
|
|
895
|
+
mimeType: mime,
|
|
896
|
+
textBody: mime === 'text/plain' ? decodeBase64Url(payload.body.data) : null,
|
|
654
897
|
};
|
|
655
898
|
}
|
|
656
899
|
if (!payload.parts) {
|
|
657
|
-
return { body: '', mimeType: 'text/plain' };
|
|
900
|
+
return { body: '', mimeType: 'text/plain', textBody: null };
|
|
658
901
|
}
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
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 };
|
|
663
907
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
return { body: decodeBase64Url(textBody), mimeType: 'text/plain' };
|
|
908
|
+
if (textData) {
|
|
909
|
+
return { body: textBody, mimeType: 'text/plain', textBody };
|
|
667
910
|
}
|
|
668
|
-
// Nested multipart (e.g. multipart/alternative inside multipart/mixed)
|
|
669
911
|
for (const part of payload.parts) {
|
|
670
912
|
if (part.parts) {
|
|
671
|
-
const nested =
|
|
913
|
+
const nested = GmailClient.extractBodyStatic(part);
|
|
672
914
|
if (nested.body)
|
|
673
915
|
return nested;
|
|
674
916
|
}
|
|
675
917
|
}
|
|
676
|
-
return { body: '', mimeType: 'text/plain' };
|
|
918
|
+
return { body: '', mimeType: 'text/plain', textBody: null };
|
|
677
919
|
}
|
|
678
|
-
|
|
920
|
+
static findBodyPartStatic(parts, mimeType) {
|
|
679
921
|
for (const part of parts) {
|
|
680
922
|
if (part.mimeType === mimeType && part.body?.data) {
|
|
681
923
|
return part.body.data;
|
|
682
924
|
}
|
|
683
925
|
if (part.parts) {
|
|
684
|
-
const found =
|
|
926
|
+
const found = GmailClient.findBodyPartStatic(part.parts, mimeType);
|
|
685
927
|
if (found)
|
|
686
928
|
return found;
|
|
687
929
|
}
|
|
688
930
|
}
|
|
689
931
|
return null;
|
|
690
932
|
}
|
|
691
|
-
|
|
692
|
-
// Private: attachment handling
|
|
693
|
-
// =========================================================================
|
|
694
|
-
extractAttachmentMeta(parts) {
|
|
933
|
+
static extractAttachmentMetaStatic(parts) {
|
|
695
934
|
const results = [];
|
|
696
935
|
for (const part of parts) {
|
|
697
936
|
if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
|
|
698
|
-
// Skip inline images (content-disposition: inline with content-id)
|
|
699
937
|
const disposition = part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? '';
|
|
700
938
|
const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id');
|
|
701
939
|
const isInline = disposition.toLowerCase().includes('inline');
|
|
@@ -708,30 +946,182 @@ export class GmailClient {
|
|
|
708
946
|
});
|
|
709
947
|
}
|
|
710
948
|
}
|
|
711
|
-
// Recurse into nested parts
|
|
712
949
|
if (part.parts) {
|
|
713
|
-
results.push(...
|
|
950
|
+
results.push(...GmailClient.extractAttachmentMetaStatic(part.parts));
|
|
714
951
|
}
|
|
715
952
|
}
|
|
716
953
|
return results;
|
|
717
954
|
}
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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);
|
|
729
1000
|
}
|
|
730
|
-
|
|
731
|
-
|
|
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; });
|
|
732
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
|
+
};
|
|
733
1115
|
}
|
|
734
|
-
|
|
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 });
|
|
735
1125
|
}
|
|
736
1126
|
// =========================================================================
|
|
737
1127
|
// Private: MIME message construction
|
|
@@ -792,7 +1182,10 @@ export class GmailClient {
|
|
|
792
1182
|
return labelNameOrId;
|
|
793
1183
|
if (this.labelIdCache[labelNameOrId])
|
|
794
1184
|
return this.labelIdCache[labelNameOrId];
|
|
795
|
-
const
|
|
1185
|
+
const labelsResult = await this.listLabels();
|
|
1186
|
+
if (labelsResult instanceof Error)
|
|
1187
|
+
return labelsResult;
|
|
1188
|
+
const { parsed: labels } = labelsResult;
|
|
796
1189
|
const match = labels.find((l) => l.name.toLowerCase() === labelNameOrId.toLowerCase());
|
|
797
1190
|
if (match) {
|
|
798
1191
|
this.labelIdCache[labelNameOrId] = match.id;
|
|
@@ -806,7 +1199,10 @@ export class GmailClient {
|
|
|
806
1199
|
return labelNameOrId;
|
|
807
1200
|
if (this.labelIdCache[labelNameOrId])
|
|
808
1201
|
return this.labelIdCache[labelNameOrId];
|
|
809
|
-
const
|
|
1202
|
+
const labelsResult = await this.listLabels();
|
|
1203
|
+
if (labelsResult instanceof Error)
|
|
1204
|
+
return labelsResult;
|
|
1205
|
+
const { parsed: labels } = labelsResult;
|
|
810
1206
|
const match = labels.find((l) => l.name.toLowerCase() === labelNameOrId.toLowerCase());
|
|
811
1207
|
if (match) {
|
|
812
1208
|
this.labelIdCache[labelNameOrId] = match.id;
|
|
@@ -872,12 +1268,14 @@ export class GmailClient {
|
|
|
872
1268
|
// =========================================================================
|
|
873
1269
|
async getMessageIdsForThreads(threadIds, filter) {
|
|
874
1270
|
const allIds = [];
|
|
875
|
-
await mapConcurrent(threadIds, async (threadId) => {
|
|
876
|
-
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({
|
|
877
1273
|
userId: 'me',
|
|
878
1274
|
id: threadId,
|
|
879
1275
|
format: 'metadata',
|
|
880
|
-
}));
|
|
1276
|
+
})));
|
|
1277
|
+
if (res instanceof Error)
|
|
1278
|
+
return res;
|
|
881
1279
|
for (const msg of res.data.messages ?? []) {
|
|
882
1280
|
if (!msg.id)
|
|
883
1281
|
continue;
|
|
@@ -886,6 +1284,8 @@ export class GmailClient {
|
|
|
886
1284
|
allIds.push(msg.id);
|
|
887
1285
|
}
|
|
888
1286
|
});
|
|
1287
|
+
if (result instanceof Error)
|
|
1288
|
+
return result;
|
|
889
1289
|
return [...new Set(allIds)];
|
|
890
1290
|
}
|
|
891
1291
|
async batchModifyMessages(messageIds, body) {
|
|
@@ -924,4 +1324,151 @@ export class GmailClient {
|
|
|
924
1324
|
.filter(Boolean));
|
|
925
1325
|
}
|
|
926
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
|
+
}
|
|
927
1474
|
//# sourceMappingURL=gmail-client.js.map
|