@agent-native/core 0.100.0 → 0.100.1
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/templates/calendar/AGENTS.md +9 -0
- package/corpus/templates/calendar/actions/list-events.ts +659 -44
- package/corpus/templates/calendar/changelog/2026-07-13-calendar-inventory-reads-report-source-coverage.md +6 -0
- package/corpus/templates/calendar/server/lib/calendar-connector-catalog.ts +8 -0
- package/corpus/templates/calendar/server/lib/google-calendar.ts +173 -51
- package/corpus/templates/calendar/server/lib/ical-fetcher.ts +13 -2
- package/corpus/templates/calendar/server/plugins/agent-chat.ts +5 -3
- package/corpus/templates/calendar/shared/api.ts +2 -0
- package/corpus/templates/mail/AGENTS.md +12 -0
- package/corpus/templates/mail/actions/list-emails.ts +486 -4
- package/corpus/templates/mail/changelog/2026-07-13-coverage-aware-connected-inbox-inventory.md +6 -0
- package/corpus/templates/mail/server/db/schema.ts +17 -0
- package/corpus/templates/mail/server/lib/google-auth.ts +33 -5
- package/corpus/templates/mail/server/lib/inventory-cursor.ts +406 -0
- package/corpus/templates/mail/server/lib/list-inbox-emails.ts +21 -2
- package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -0
- package/corpus/templates/mail/server/plugins/agent-chat.ts +4 -9
- package/corpus/templates/mail/server/plugins/db.ts +20 -0
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
|
@@ -6,9 +6,24 @@ import { z } from "zod";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
getClients,
|
|
9
|
+
getConnectedAccounts,
|
|
9
10
|
fetchGmailLabelMap,
|
|
10
11
|
isConnected,
|
|
11
12
|
} from "../server/lib/google-auth.js";
|
|
13
|
+
import {
|
|
14
|
+
buildMailInventoryPage,
|
|
15
|
+
claimInventoryCursor,
|
|
16
|
+
compareInventoryItems,
|
|
17
|
+
createInventoryCursor,
|
|
18
|
+
inventoryQueryFingerprint,
|
|
19
|
+
releaseInventoryCursorClaim,
|
|
20
|
+
settleInventoryCursorClaim,
|
|
21
|
+
type MailInventoryError,
|
|
22
|
+
type MailInventoryFetchResult,
|
|
23
|
+
type MailInventoryItem,
|
|
24
|
+
type MailInventoryCursorState,
|
|
25
|
+
type MailInventoryCursorClaim,
|
|
26
|
+
} from "../server/lib/inventory-cursor.js";
|
|
12
27
|
import {
|
|
13
28
|
getSnoozedThreadIds,
|
|
14
29
|
getSyntheticEmailsForView,
|
|
@@ -38,6 +53,55 @@ function toCompact(emails: any[]): any[] {
|
|
|
38
53
|
}));
|
|
39
54
|
}
|
|
40
55
|
|
|
56
|
+
function toInventoryItem(
|
|
57
|
+
email: any,
|
|
58
|
+
includeSnippet: boolean,
|
|
59
|
+
): MailInventoryItem {
|
|
60
|
+
return {
|
|
61
|
+
id: String(email.id ?? "").slice(0, 256),
|
|
62
|
+
threadId: String(email.threadId ?? email.id ?? "").slice(0, 256),
|
|
63
|
+
accountEmail: String(email.accountEmail ?? "").slice(0, 320),
|
|
64
|
+
date: String(email.date ?? "").slice(0, 64),
|
|
65
|
+
from: email.from?.email
|
|
66
|
+
? {
|
|
67
|
+
...(email.from.name
|
|
68
|
+
? { name: String(email.from.name).slice(0, 160) }
|
|
69
|
+
: {}),
|
|
70
|
+
email: String(email.from.email).slice(0, 320),
|
|
71
|
+
}
|
|
72
|
+
: { email: String(email.from ?? "").slice(0, 320) },
|
|
73
|
+
subject: String(email.subject ?? "").slice(0, 500),
|
|
74
|
+
isUnread: email.hasUnread ?? !email.isRead,
|
|
75
|
+
...(email.isStarred !== undefined ? { isStarred: email.isStarred } : {}),
|
|
76
|
+
messageCount: email.messageCount ?? 1,
|
|
77
|
+
unreadCount: email.unreadCount ?? (email.isRead ? 0 : 1),
|
|
78
|
+
...(includeSnippet && email.snippet
|
|
79
|
+
? { snippet: String(email.snippet).slice(0, 320) }
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inventoryError(message: unknown): MailInventoryError {
|
|
85
|
+
const bounded = String(message ?? "Provider request failed")
|
|
86
|
+
.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
87
|
+
.replace(
|
|
88
|
+
/\b(access_token|refresh_token|id_token|token)=([^\s&]+)/gi,
|
|
89
|
+
"$1=[redacted]",
|
|
90
|
+
)
|
|
91
|
+
.slice(0, 240);
|
|
92
|
+
const rateLimited = /\b(?:429|quota|rate.?limit)\b/i.test(bounded);
|
|
93
|
+
const auth = /\b(?:401|403|auth|token|credential|permission)\b/i.test(
|
|
94
|
+
bounded,
|
|
95
|
+
);
|
|
96
|
+
return {
|
|
97
|
+
code: rateLimited ? "rate_limited" : auth ? "authentication" : "provider",
|
|
98
|
+
message: bounded,
|
|
99
|
+
retryable:
|
|
100
|
+
rateLimited ||
|
|
101
|
+
/\b(?:timeout|temporar|unavailable|5\d\d)\b/i.test(bounded),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
41
105
|
function latestPerThread(emails: any[]): any[] {
|
|
42
106
|
const byThread = new Map<
|
|
43
107
|
string,
|
|
@@ -85,6 +149,107 @@ function latestPerThread(emails: any[]): any[] {
|
|
|
85
149
|
);
|
|
86
150
|
}
|
|
87
151
|
|
|
152
|
+
async function localInventoryEnvelope(
|
|
153
|
+
emails: any[],
|
|
154
|
+
requestedAccounts: string[] | undefined,
|
|
155
|
+
resolvedAccounts: string[],
|
|
156
|
+
query: { view: string; q?: string },
|
|
157
|
+
limit: number,
|
|
158
|
+
ownerEmail: string,
|
|
159
|
+
cursor?: string,
|
|
160
|
+
) {
|
|
161
|
+
const normalizedRequested = requestedAccounts
|
|
162
|
+
? [...new Set(requestedAccounts.map((email) => email.toLowerCase()))]
|
|
163
|
+
: null;
|
|
164
|
+
const queryFingerprint = inventoryQueryFingerprint({
|
|
165
|
+
...query,
|
|
166
|
+
requestedAccounts: normalizedRequested,
|
|
167
|
+
limit,
|
|
168
|
+
source: "local",
|
|
169
|
+
});
|
|
170
|
+
let claim: MailInventoryCursorClaim | null = null;
|
|
171
|
+
let state: MailInventoryCursorState;
|
|
172
|
+
if (cursor) {
|
|
173
|
+
claim = await claimInventoryCursor(ownerEmail, cursor, queryFingerprint);
|
|
174
|
+
if (!claim) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
"Inventory cursor is invalid, expired, or has already been used.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
state = claim.state;
|
|
180
|
+
} else {
|
|
181
|
+
const inventoryItems = latestPerThread(emails)
|
|
182
|
+
.map((email) =>
|
|
183
|
+
toInventoryItem(
|
|
184
|
+
{
|
|
185
|
+
...email,
|
|
186
|
+
accountEmail: email.accountEmail ?? resolvedAccounts[0] ?? "local",
|
|
187
|
+
},
|
|
188
|
+
false,
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
.sort(compareInventoryItems);
|
|
192
|
+
state = {
|
|
193
|
+
queryFingerprint,
|
|
194
|
+
requestedAccounts: normalizedRequested,
|
|
195
|
+
firstPage: true,
|
|
196
|
+
accounts: resolvedAccounts.map((accountEmail) => {
|
|
197
|
+
const pending = inventoryItems.filter(
|
|
198
|
+
(item) =>
|
|
199
|
+
item.accountEmail.toLowerCase() === accountEmail.toLowerCase(),
|
|
200
|
+
);
|
|
201
|
+
return {
|
|
202
|
+
accountEmail,
|
|
203
|
+
status: "ok" as const,
|
|
204
|
+
exhausted: true,
|
|
205
|
+
pending,
|
|
206
|
+
emittedCount: 0,
|
|
207
|
+
knownCount: pending.length,
|
|
208
|
+
emittedThreadIds: [],
|
|
209
|
+
};
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const { items, hasMore } = await buildMailInventoryPage(
|
|
216
|
+
state,
|
|
217
|
+
limit,
|
|
218
|
+
async () => ({ items: [], errors: {}, nextPageTokens: {} }),
|
|
219
|
+
);
|
|
220
|
+
const nextCursor = claim
|
|
221
|
+
? await settleInventoryCursorClaim(claim, state, hasMore)
|
|
222
|
+
: hasMore
|
|
223
|
+
? await createInventoryCursor(ownerEmail, state)
|
|
224
|
+
: undefined;
|
|
225
|
+
return {
|
|
226
|
+
version: 1,
|
|
227
|
+
query,
|
|
228
|
+
requestedAccounts: state.requestedAccounts,
|
|
229
|
+
resolvedAccounts: state.accounts.map((account) => account.accountEmail),
|
|
230
|
+
queriedAccounts: state.accounts.map((account) => account.accountEmail),
|
|
231
|
+
accounts: state.accounts.map((account) => ({
|
|
232
|
+
accountEmail: account.accountEmail,
|
|
233
|
+
status: account.status,
|
|
234
|
+
count: account.knownCount ?? account.emittedCount,
|
|
235
|
+
emittedCount: account.emittedCount,
|
|
236
|
+
exhausted: account.exhausted && account.pending.length === 0,
|
|
237
|
+
})),
|
|
238
|
+
coverageComplete: true,
|
|
239
|
+
complete: !hasMore,
|
|
240
|
+
items,
|
|
241
|
+
page: {
|
|
242
|
+
returned: items.length,
|
|
243
|
+
hasMore,
|
|
244
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (claim) await releaseInventoryCursorClaim(claim);
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
88
253
|
async function readLocalEmails(ownerEmail: string): Promise<any[]> {
|
|
89
254
|
const data = await getUserSetting(ownerEmail, "local-emails");
|
|
90
255
|
if (data && Array.isArray((data as any).emails)) {
|
|
@@ -112,13 +277,27 @@ export default defineAction({
|
|
|
112
277
|
])
|
|
113
278
|
.optional()
|
|
114
279
|
.describe("View to list (default: inbox)"),
|
|
115
|
-
q: z.string().optional().describe("Full-text search query"),
|
|
280
|
+
q: z.string().max(500).optional().describe("Full-text search query"),
|
|
116
281
|
account: z
|
|
117
282
|
.string()
|
|
118
283
|
.optional()
|
|
119
284
|
.describe(
|
|
120
285
|
"Filter to a specific account email address. By default searches all connected accounts.",
|
|
121
286
|
),
|
|
287
|
+
accountEmails: z
|
|
288
|
+
.array(z.string().email())
|
|
289
|
+
.min(1)
|
|
290
|
+
.optional()
|
|
291
|
+
.describe(
|
|
292
|
+
"Inventory only: connected account email addresses to include.",
|
|
293
|
+
),
|
|
294
|
+
format: z
|
|
295
|
+
.enum(["legacy", "inventory"])
|
|
296
|
+
.optional()
|
|
297
|
+
.describe(
|
|
298
|
+
"Use inventory for a coverage-aware compact multi-account read.",
|
|
299
|
+
),
|
|
300
|
+
cursor: z.string().max(256).optional().describe("Inventory page cursor."),
|
|
122
301
|
limit: z.coerce
|
|
123
302
|
.number()
|
|
124
303
|
.optional()
|
|
@@ -146,7 +325,7 @@ export default defineAction({
|
|
|
146
325
|
view,
|
|
147
326
|
};
|
|
148
327
|
},
|
|
149
|
-
run: async (args) => {
|
|
328
|
+
run: async (args, ctx) => {
|
|
150
329
|
const view = args.view ?? "inbox";
|
|
151
330
|
const query = args.q;
|
|
152
331
|
const limit = args.limit ?? 50;
|
|
@@ -155,9 +334,47 @@ export default defineAction({
|
|
|
155
334
|
const accountFilter = args.account?.toLowerCase();
|
|
156
335
|
const ownerEmail = getRequestUserEmail();
|
|
157
336
|
if (!ownerEmail) throw new Error("no authenticated user");
|
|
337
|
+
if (args.account && args.accountEmails) {
|
|
338
|
+
throw new Error("Pass account or accountEmails, not both.");
|
|
339
|
+
}
|
|
340
|
+
const inventory =
|
|
341
|
+
args.format === "inventory" ||
|
|
342
|
+
(ctx?.caller === "mcp" && args.format === undefined);
|
|
343
|
+
if (inventory && (!Number.isInteger(limit) || limit < 1 || limit > 100)) {
|
|
344
|
+
throw new Error("Inventory limit must be an integer from 1 through 100.");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Inventory is deliberately resolved before any refresh/list call. Apart
|
|
348
|
+
// from preventing a cross-account data leak, this keeps a selected read
|
|
349
|
+
// from touching token state for accounts the caller did not choose.
|
|
350
|
+
const requestedAccounts =
|
|
351
|
+
args.accountEmails ?? (args.account ? [args.account] : undefined);
|
|
158
352
|
|
|
159
353
|
if (view === "snoozed" || view === "scheduled") {
|
|
160
354
|
let emails = await getSyntheticEmailsForView(ownerEmail, view);
|
|
355
|
+
const syntheticAccountsByLower = new Map<string, string>();
|
|
356
|
+
for (const email of emails) {
|
|
357
|
+
const accountEmail = String(email.accountEmail ?? ownerEmail);
|
|
358
|
+
syntheticAccountsByLower.set(accountEmail.toLowerCase(), accountEmail);
|
|
359
|
+
}
|
|
360
|
+
const syntheticSelectedAccounts = inventory
|
|
361
|
+
? Array.from(
|
|
362
|
+
new Set(
|
|
363
|
+
(
|
|
364
|
+
requestedAccounts ??
|
|
365
|
+
Array.from(syntheticAccountsByLower.values())
|
|
366
|
+
).map((email) => email.toLowerCase()),
|
|
367
|
+
),
|
|
368
|
+
).map((email) => {
|
|
369
|
+
const available = syntheticAccountsByLower.get(email);
|
|
370
|
+
if (!available) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`Account ${email} is not available in ${view} mail for this user.`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return available;
|
|
376
|
+
})
|
|
377
|
+
: undefined;
|
|
161
378
|
if (query) {
|
|
162
379
|
emails = emails.filter((e) => emailMessageMatchesSearch(e, query));
|
|
163
380
|
}
|
|
@@ -166,6 +383,35 @@ export default defineAction({
|
|
|
166
383
|
(e) => e.accountEmail?.toLowerCase() === accountFilter,
|
|
167
384
|
);
|
|
168
385
|
}
|
|
386
|
+
if (inventory) {
|
|
387
|
+
const selected = new Set(
|
|
388
|
+
(syntheticSelectedAccounts ?? []).map((email) => email.toLowerCase()),
|
|
389
|
+
);
|
|
390
|
+
if (selected.size > 0) {
|
|
391
|
+
emails = emails.filter((email) =>
|
|
392
|
+
selected.has(String(email.accountEmail ?? "").toLowerCase()),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const syntheticResolvedAccounts =
|
|
396
|
+
syntheticSelectedAccounts && syntheticSelectedAccounts.length > 0
|
|
397
|
+
? syntheticSelectedAccounts
|
|
398
|
+
: Array.from(
|
|
399
|
+
new Set(
|
|
400
|
+
emails.map((email) =>
|
|
401
|
+
String(email.accountEmail ?? ownerEmail).toLowerCase(),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
return await localInventoryEnvelope(
|
|
406
|
+
emails,
|
|
407
|
+
requestedAccounts,
|
|
408
|
+
syntheticResolvedAccounts,
|
|
409
|
+
{ view, ...(query ? { q: query } : {}) },
|
|
410
|
+
limit,
|
|
411
|
+
ownerEmail,
|
|
412
|
+
args.cursor,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
169
415
|
return JSON.stringify(
|
|
170
416
|
compact ? toCompact(emails.slice(0, limit)) : emails.slice(0, limit),
|
|
171
417
|
null,
|
|
@@ -173,8 +419,192 @@ export default defineAction({
|
|
|
173
419
|
);
|
|
174
420
|
}
|
|
175
421
|
|
|
176
|
-
|
|
177
|
-
|
|
422
|
+
const connectedAccounts = inventory
|
|
423
|
+
? await getConnectedAccounts(ownerEmail)
|
|
424
|
+
: [];
|
|
425
|
+
const connectedByLower = new Map(
|
|
426
|
+
connectedAccounts.map((email) => [email.toLowerCase(), email]),
|
|
427
|
+
);
|
|
428
|
+
const selectedAccounts =
|
|
429
|
+
inventory && connectedAccounts.length > 0
|
|
430
|
+
? Array.from(
|
|
431
|
+
new Set(
|
|
432
|
+
(requestedAccounts ?? connectedAccounts).map((email) =>
|
|
433
|
+
email.toLowerCase(),
|
|
434
|
+
),
|
|
435
|
+
),
|
|
436
|
+
).map((email) => {
|
|
437
|
+
const owned = connectedByLower.get(email);
|
|
438
|
+
if (!owned)
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Account ${email} is not connected for this user.`,
|
|
441
|
+
);
|
|
442
|
+
return owned;
|
|
443
|
+
})
|
|
444
|
+
: undefined;
|
|
445
|
+
|
|
446
|
+
if (
|
|
447
|
+
(inventory && selectedAccounts && selectedAccounts.length > 0) ||
|
|
448
|
+
(!inventory && (await isConnected(ownerEmail)))
|
|
449
|
+
) {
|
|
450
|
+
const inventoryAccounts = selectedAccounts ?? [];
|
|
451
|
+
const clients = inventory
|
|
452
|
+
? inventoryAccounts.map((email) => ({
|
|
453
|
+
email,
|
|
454
|
+
accessToken: "",
|
|
455
|
+
refreshToken: "",
|
|
456
|
+
}))
|
|
457
|
+
: await getClients(ownerEmail);
|
|
458
|
+
if (inventory) {
|
|
459
|
+
const normalizedRequested = requestedAccounts
|
|
460
|
+
? [...new Set(requestedAccounts.map((email) => email.toLowerCase()))]
|
|
461
|
+
: null;
|
|
462
|
+
const queryFingerprint = inventoryQueryFingerprint({
|
|
463
|
+
view,
|
|
464
|
+
q: query ?? null,
|
|
465
|
+
requestedAccounts: normalizedRequested,
|
|
466
|
+
limit,
|
|
467
|
+
});
|
|
468
|
+
let cursorState: MailInventoryCursorState;
|
|
469
|
+
let cursorClaim: MailInventoryCursorClaim | null = null;
|
|
470
|
+
if (args.cursor) {
|
|
471
|
+
const claimed = await claimInventoryCursor(
|
|
472
|
+
ownerEmail,
|
|
473
|
+
args.cursor,
|
|
474
|
+
queryFingerprint,
|
|
475
|
+
);
|
|
476
|
+
if (!claimed) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
"Inventory cursor is invalid, expired, or has already been used.",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
cursorClaim = claimed;
|
|
482
|
+
cursorState = claimed.state;
|
|
483
|
+
} else {
|
|
484
|
+
cursorState = {
|
|
485
|
+
queryFingerprint,
|
|
486
|
+
requestedAccounts: normalizedRequested,
|
|
487
|
+
firstPage: true,
|
|
488
|
+
accounts: inventoryAccounts.map((accountEmail) => ({
|
|
489
|
+
accountEmail,
|
|
490
|
+
status: "ok",
|
|
491
|
+
exhausted: false,
|
|
492
|
+
pending: [],
|
|
493
|
+
emittedCount: 0,
|
|
494
|
+
knownCount: 0,
|
|
495
|
+
emittedThreadIds: [],
|
|
496
|
+
})),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const fetchInventory = async (
|
|
501
|
+
requests: Array<{ accountEmail: string; pageToken?: string }>,
|
|
502
|
+
): Promise<MailInventoryFetchResult> => {
|
|
503
|
+
const accountEmails = requests.map((request) => request.accountEmail);
|
|
504
|
+
const pageTokens = Object.fromEntries(
|
|
505
|
+
requests
|
|
506
|
+
.filter((request) => request.pageToken)
|
|
507
|
+
.map((request) => [request.accountEmail, request.pageToken!]),
|
|
508
|
+
);
|
|
509
|
+
const listResult = await listInboxEmails({
|
|
510
|
+
ownerEmail,
|
|
511
|
+
view,
|
|
512
|
+
q: query,
|
|
513
|
+
limit,
|
|
514
|
+
pageTokens:
|
|
515
|
+
Object.keys(pageTokens).length > 0 ? pageTokens : undefined,
|
|
516
|
+
threadFormat: "metadata",
|
|
517
|
+
includeRecentMessageCandidates: false,
|
|
518
|
+
accountTokens: accountEmails.map((email) => ({
|
|
519
|
+
email,
|
|
520
|
+
accessToken: "",
|
|
521
|
+
})),
|
|
522
|
+
accountEmails,
|
|
523
|
+
labelMap: new Map(),
|
|
524
|
+
});
|
|
525
|
+
if (!listResult.ok) {
|
|
526
|
+
return {
|
|
527
|
+
items: [],
|
|
528
|
+
errors: Object.fromEntries(
|
|
529
|
+
accountEmails.map((email) => [
|
|
530
|
+
email.toLowerCase(),
|
|
531
|
+
inventoryError(listResult.message),
|
|
532
|
+
]),
|
|
533
|
+
),
|
|
534
|
+
nextPageTokens: {},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
items: latestPerThread(listResult.emails).map((email) =>
|
|
539
|
+
toInventoryItem(email, false),
|
|
540
|
+
),
|
|
541
|
+
errors: Object.fromEntries(
|
|
542
|
+
listResult.errors.map((error) => [
|
|
543
|
+
error.email.toLowerCase(),
|
|
544
|
+
inventoryError(error.error),
|
|
545
|
+
]),
|
|
546
|
+
),
|
|
547
|
+
nextPageTokens: Object.fromEntries(
|
|
548
|
+
Object.entries(listResult.nextPageTokens ?? {}).map(
|
|
549
|
+
([email, token]) => [email.toLowerCase(), token],
|
|
550
|
+
),
|
|
551
|
+
),
|
|
552
|
+
};
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
const { items, hasMore } = await buildMailInventoryPage(
|
|
557
|
+
cursorState,
|
|
558
|
+
limit,
|
|
559
|
+
fetchInventory,
|
|
560
|
+
);
|
|
561
|
+
const nextCursor = cursorClaim
|
|
562
|
+
? await settleInventoryCursorClaim(
|
|
563
|
+
cursorClaim,
|
|
564
|
+
cursorState,
|
|
565
|
+
hasMore,
|
|
566
|
+
)
|
|
567
|
+
: hasMore
|
|
568
|
+
? await createInventoryCursor(ownerEmail, cursorState)
|
|
569
|
+
: undefined;
|
|
570
|
+
const coverageComplete = cursorState.accounts.every(
|
|
571
|
+
(account) => account.status === "ok",
|
|
572
|
+
);
|
|
573
|
+
return {
|
|
574
|
+
version: 1,
|
|
575
|
+
query: { view, ...(query ? { q: query } : {}) },
|
|
576
|
+
requestedAccounts: cursorState.requestedAccounts,
|
|
577
|
+
resolvedAccounts: cursorState.accounts.map(
|
|
578
|
+
(account) => account.accountEmail,
|
|
579
|
+
),
|
|
580
|
+
queriedAccounts: cursorState.accounts.map(
|
|
581
|
+
(account) => account.accountEmail,
|
|
582
|
+
),
|
|
583
|
+
accounts: cursorState.accounts.map((account) => ({
|
|
584
|
+
accountEmail: account.accountEmail,
|
|
585
|
+
status: account.status,
|
|
586
|
+
count: account.knownCount ?? account.emittedCount,
|
|
587
|
+
emittedCount: account.emittedCount,
|
|
588
|
+
exhausted:
|
|
589
|
+
account.status === "ok" &&
|
|
590
|
+
account.exhausted &&
|
|
591
|
+
account.pending.length === 0,
|
|
592
|
+
...(account.error ? { error: account.error } : {}),
|
|
593
|
+
})),
|
|
594
|
+
coverageComplete,
|
|
595
|
+
complete: coverageComplete && !hasMore,
|
|
596
|
+
items,
|
|
597
|
+
page: {
|
|
598
|
+
returned: items.length,
|
|
599
|
+
hasMore,
|
|
600
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (cursorClaim) await releaseInventoryCursorClaim(cursorClaim);
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
178
608
|
const labelMap = new Map<string, string>();
|
|
179
609
|
await Promise.all(
|
|
180
610
|
clients.map(async ({ accessToken }) => {
|
|
@@ -239,6 +669,37 @@ export default defineAction({
|
|
|
239
669
|
|
|
240
670
|
// Fallback: local store
|
|
241
671
|
let emails = await readLocalEmails(ownerEmail);
|
|
672
|
+
const localAccountsByLower = new Map<string, string>();
|
|
673
|
+
for (const email of emails) {
|
|
674
|
+
const accountEmail = String(email.accountEmail ?? "local");
|
|
675
|
+
localAccountsByLower.set(accountEmail.toLowerCase(), accountEmail);
|
|
676
|
+
}
|
|
677
|
+
const localSelectedAccounts = inventory
|
|
678
|
+
? Array.from(
|
|
679
|
+
new Set(
|
|
680
|
+
(
|
|
681
|
+
requestedAccounts ?? Array.from(localAccountsByLower.values())
|
|
682
|
+
).map((email) => email.toLowerCase()),
|
|
683
|
+
),
|
|
684
|
+
).map((email) => {
|
|
685
|
+
const available = localAccountsByLower.get(email);
|
|
686
|
+
if (!available) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
`Account ${email} is not available in local mail for this user.`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
return available;
|
|
692
|
+
})
|
|
693
|
+
: undefined;
|
|
694
|
+
|
|
695
|
+
if (localSelectedAccounts && requestedAccounts) {
|
|
696
|
+
const selected = new Set(
|
|
697
|
+
localSelectedAccounts.map((email) => email.toLowerCase()),
|
|
698
|
+
);
|
|
699
|
+
emails = emails.filter((email) =>
|
|
700
|
+
selected.has(String(email.accountEmail ?? "local").toLowerCase()),
|
|
701
|
+
);
|
|
702
|
+
}
|
|
242
703
|
|
|
243
704
|
switch (view) {
|
|
244
705
|
case "inbox":
|
|
@@ -288,6 +749,27 @@ export default defineAction({
|
|
|
288
749
|
}
|
|
289
750
|
}
|
|
290
751
|
|
|
752
|
+
if (inventory) {
|
|
753
|
+
const localResolvedAccounts =
|
|
754
|
+
localSelectedAccounts && localSelectedAccounts.length > 0
|
|
755
|
+
? localSelectedAccounts
|
|
756
|
+
: Array.from(
|
|
757
|
+
new Set(
|
|
758
|
+
emails.map((email) =>
|
|
759
|
+
String(email.accountEmail ?? "local").toLowerCase(),
|
|
760
|
+
),
|
|
761
|
+
),
|
|
762
|
+
);
|
|
763
|
+
return await localInventoryEnvelope(
|
|
764
|
+
emails,
|
|
765
|
+
requestedAccounts,
|
|
766
|
+
localResolvedAccounts,
|
|
767
|
+
{ view, ...(query ? { q: query } : {}) },
|
|
768
|
+
limit,
|
|
769
|
+
ownerEmail,
|
|
770
|
+
args.cursor,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
291
773
|
emails = latestPerThread(emails).slice(0, limit);
|
|
292
774
|
const payload = compact ? toCompact(emails) : emails;
|
|
293
775
|
if (includeCounts) {
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { table, text, integer } from "@agent-native/core/db/schema";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Short-lived, owner-scoped continuation state for the external Mail
|
|
5
|
+
* inventory. It intentionally contains compact metadata only; credentials,
|
|
6
|
+
* bodies, HTML and attachments never enter this table.
|
|
7
|
+
*/
|
|
8
|
+
export const mailInventoryCursors = table("mail_inventory_cursors", {
|
|
9
|
+
id: text("id").primaryKey(),
|
|
10
|
+
ownerEmail: text("owner_email").notNull(),
|
|
11
|
+
queryFingerprint: text("query_fingerprint").notNull(),
|
|
12
|
+
state: text("state").notNull(),
|
|
13
|
+
version: integer("version").notNull().default(1),
|
|
14
|
+
claimId: text("claim_id"),
|
|
15
|
+
claimedAt: integer("claimed_at"),
|
|
16
|
+
expiresAt: integer("expires_at").notNull(),
|
|
17
|
+
updatedAt: integer("updated_at").notNull(),
|
|
18
|
+
});
|
|
19
|
+
|
|
3
20
|
export const scheduledJobs = table("scheduled_jobs", {
|
|
4
21
|
id: text("id").primaryKey(),
|
|
5
22
|
type: text("type", { enum: ["snooze", "send_later"] }).notNull(),
|
|
@@ -375,12 +375,23 @@ export async function getClients(
|
|
|
375
375
|
* handler uses this to return a 502 with the underlying reason instead
|
|
376
376
|
* of silently rendering an empty inbox.
|
|
377
377
|
*/
|
|
378
|
-
export async function getClientsWithErrors(
|
|
378
|
+
export async function getClientsWithErrors(
|
|
379
|
+
forEmail?: string,
|
|
380
|
+
accountEmails?: string[],
|
|
381
|
+
): Promise<{
|
|
379
382
|
clients: Array<{ email: string; accessToken: string; refreshToken: string }>;
|
|
380
383
|
errors: Array<{ email: string; error: string }>;
|
|
381
384
|
}> {
|
|
382
385
|
if (!forEmail) return { clients: [], errors: [] };
|
|
383
|
-
const
|
|
386
|
+
const requested = accountEmails
|
|
387
|
+
? new Set(accountEmails.map((email) => email.toLowerCase()))
|
|
388
|
+
: null;
|
|
389
|
+
// Filtering happens before getValidAccessToken. This is important: token
|
|
390
|
+
// refreshes are writes and an explicitly scoped inventory read must not
|
|
391
|
+
// refresh unrelated accounts.
|
|
392
|
+
const accounts = (await listOAuthAccountsByOwner("google", forEmail)).filter(
|
|
393
|
+
(account) => !requested || requested.has(account.accountId.toLowerCase()),
|
|
394
|
+
);
|
|
384
395
|
|
|
385
396
|
const clients: Array<{
|
|
386
397
|
email: string;
|
|
@@ -571,6 +582,13 @@ type ListOptions = {
|
|
|
571
582
|
* without hydrating a large metadata ranking window on every inbox poll.
|
|
572
583
|
*/
|
|
573
584
|
threadRecentMessageCandidateLimit?: number;
|
|
585
|
+
/**
|
|
586
|
+
* Restrict this read before OAuth refreshes or provider calls. This is
|
|
587
|
+
* deliberately an account-id allow-list rather than a post-fetch filter:
|
|
588
|
+
* a Mail user can have several connected inboxes and an explicitly scoped
|
|
589
|
+
* read must not wake up the others.
|
|
590
|
+
*/
|
|
591
|
+
accountEmails?: string[];
|
|
574
592
|
};
|
|
575
593
|
|
|
576
594
|
const LIST_CACHE_TTL = 45_000;
|
|
@@ -791,6 +809,10 @@ async function fetchThreadBatchWithRefill(
|
|
|
791
809
|
}
|
|
792
810
|
}
|
|
793
811
|
|
|
812
|
+
if (batchResults.some((result) => !result.data)) {
|
|
813
|
+
throw new Error("Gmail thread metadata response was incomplete");
|
|
814
|
+
}
|
|
815
|
+
|
|
794
816
|
return batchResults;
|
|
795
817
|
}
|
|
796
818
|
|
|
@@ -852,7 +874,11 @@ function listCacheKey(
|
|
|
852
874
|
.join("|")
|
|
853
875
|
: "";
|
|
854
876
|
const queryPart = query === undefined ? "<default>" : query;
|
|
855
|
-
|
|
877
|
+
const accounts = options?.accountEmails
|
|
878
|
+
?.map((email) => email.toLowerCase())
|
|
879
|
+
.sort()
|
|
880
|
+
.join(",");
|
|
881
|
+
return `${forEmail ?? ""}::${queryPart}::${maxResults}::${tokenPart}::${options?.mode ?? "messages"}::${options?.threadFormat ?? ""}::${options?.messageFormat ?? ""}::${options?.threadCandidateLimit ?? ""}::${options?.threadRecentMessageCandidateLimit ?? ""}::${accounts ?? ""}`;
|
|
856
882
|
}
|
|
857
883
|
|
|
858
884
|
export async function listGmailMessages(
|
|
@@ -1542,8 +1568,10 @@ async function listGmailMessagesUncached(
|
|
|
1542
1568
|
pageTokens?: Record<string, string>,
|
|
1543
1569
|
options?: ListOptions,
|
|
1544
1570
|
): Promise<ListResult> {
|
|
1545
|
-
const { clients, errors: refreshErrors } =
|
|
1546
|
-
|
|
1571
|
+
const { clients, errors: refreshErrors } = await getClientsWithErrors(
|
|
1572
|
+
forEmail,
|
|
1573
|
+
options?.accountEmails,
|
|
1574
|
+
);
|
|
1547
1575
|
// Seed the per-fetch error list with refresh failures so a fully-dead
|
|
1548
1576
|
// connection (every account's refresh_token revoked or invalidated by a
|
|
1549
1577
|
// GOOGLE_CLIENT_ID rotation) reaches the handler — otherwise the list
|