@agent-native/core 0.100.0 → 0.100.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +4 -0
  5. package/corpus/core/src/client/composer/PromptComposer.tsx +4 -0
  6. package/corpus/core/src/client/composer/TiptapComposer.tsx +30 -3
  7. package/corpus/core/src/integrations/webhook-handler.ts +4 -2
  8. package/corpus/core/src/secrets/crypto.ts +98 -47
  9. package/corpus/core/src/secrets/index.ts +2 -1
  10. package/corpus/core/src/secrets/schema.ts +3 -0
  11. package/corpus/core/src/secrets/storage.ts +164 -31
  12. package/corpus/templates/assets/app/components/layout/Layout.tsx +4 -0
  13. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +3 -1
  14. package/corpus/templates/assets/changelog/2026-07-13-assets-keeps-the-right-chat-sidebar-closed-when-the-full-pag.md +6 -0
  15. package/corpus/templates/calendar/AGENTS.md +9 -0
  16. package/corpus/templates/calendar/actions/list-events.ts +659 -44
  17. package/corpus/templates/calendar/changelog/2026-07-13-calendar-inventory-reads-report-source-coverage.md +6 -0
  18. package/corpus/templates/calendar/server/lib/calendar-connector-catalog.ts +8 -0
  19. package/corpus/templates/calendar/server/lib/google-calendar.ts +173 -51
  20. package/corpus/templates/calendar/server/lib/ical-fetcher.ts +13 -2
  21. package/corpus/templates/calendar/server/plugins/agent-chat.ts +5 -3
  22. package/corpus/templates/calendar/shared/api.ts +2 -0
  23. package/corpus/templates/mail/AGENTS.md +12 -0
  24. package/corpus/templates/mail/actions/list-emails.ts +486 -4
  25. package/corpus/templates/mail/changelog/2026-07-13-coverage-aware-connected-inbox-inventory.md +6 -0
  26. package/corpus/templates/mail/server/db/schema.ts +17 -0
  27. package/corpus/templates/mail/server/lib/google-auth.ts +33 -5
  28. package/corpus/templates/mail/server/lib/inventory-cursor.ts +406 -0
  29. package/corpus/templates/mail/server/lib/list-inbox-emails.ts +21 -2
  30. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -0
  31. package/corpus/templates/mail/server/plugins/agent-chat.ts +4 -9
  32. package/corpus/templates/mail/server/plugins/db.ts +20 -0
  33. package/dist/client/AssistantChat.d.ts +2 -0
  34. package/dist/client/AssistantChat.d.ts.map +1 -1
  35. package/dist/client/AssistantChat.js +2 -2
  36. package/dist/client/AssistantChat.js.map +1 -1
  37. package/dist/client/composer/PromptComposer.d.ts +2 -0
  38. package/dist/client/composer/PromptComposer.d.ts.map +1 -1
  39. package/dist/client/composer/PromptComposer.js +2 -2
  40. package/dist/client/composer/PromptComposer.js.map +1 -1
  41. package/dist/client/composer/TiptapComposer.d.ts +3 -1
  42. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  43. package/dist/client/composer/TiptapComposer.js +20 -5
  44. package/dist/client/composer/TiptapComposer.js.map +1 -1
  45. package/dist/collab/struct-routes.d.ts +1 -1
  46. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  47. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  48. package/dist/integrations/webhook-handler.js +3 -2
  49. package/dist/integrations/webhook-handler.js.map +1 -1
  50. package/dist/notifications/routes.d.ts +3 -3
  51. package/dist/resources/handlers.d.ts +2 -2
  52. package/dist/secrets/crypto.d.ts +35 -13
  53. package/dist/secrets/crypto.d.ts.map +1 -1
  54. package/dist/secrets/crypto.js +78 -41
  55. package/dist/secrets/crypto.js.map +1 -1
  56. package/dist/secrets/index.d.ts.map +1 -1
  57. package/dist/secrets/index.js +2 -1
  58. package/dist/secrets/index.js.map +1 -1
  59. package/dist/secrets/schema.d.ts +20 -1
  60. package/dist/secrets/schema.d.ts.map +1 -1
  61. package/dist/secrets/schema.js +3 -0
  62. package/dist/secrets/schema.js.map +1 -1
  63. package/dist/secrets/storage.d.ts +7 -7
  64. package/dist/secrets/storage.d.ts.map +1 -1
  65. package/dist/secrets/storage.js +121 -30
  66. package/dist/secrets/storage.js.map +1 -1
  67. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  68. 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
- if (await isConnected(ownerEmail)) {
177
- const clients = await getClients(ownerEmail);
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) {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-07-13
4
+ ---
5
+
6
+ Mail can now return a compact coverage-aware direct inventory across selected connected inboxes.
@@ -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(forEmail?: string): Promise<{
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 accounts = await listOAuthAccountsByOwner("google", forEmail);
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
- return `${forEmail ?? ""}::${queryPart}::${maxResults}::${tokenPart}::${options?.mode ?? "messages"}::${options?.threadFormat ?? ""}::${options?.messageFormat ?? ""}::${options?.threadCandidateLimit ?? ""}::${options?.threadRecentMessageCandidateLimit ?? ""}`;
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
- await getClientsWithErrors(forEmail);
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