@open-mercato/channel-gmail 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7227.1.9248c14599

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.
@@ -1,2 +1,2 @@
1
- [build:channel-gmail] found 22 entry points
1
+ [build:channel-gmail] found 23 entry points
2
2
  [build:channel-gmail] built successfully
package/AGENTS.md CHANGED
@@ -24,10 +24,11 @@ Gmail email channel provider for the Communications Hub (`communication_channels
24
24
 
25
25
  ## Adapter Contract
26
26
 
27
- `lib/adapter.ts` implements `ChannelAdapter` from `@open-mercato/core/modules/communication_channels/lib/adapter`. Key methods: `sendMessage`, `normalizeInbound`, `convertOutbound`, `buildOAuthAuthorizeUrl`, `exchangeOAuthCode`, `refreshCredentials`, `fetchHistory`, `registerPush`/`unregisterPush`/`applyPushNotification`, `deleteMessage`, `resolveContact`.
27
+ `lib/adapter.ts` implements `ChannelAdapter` from `@open-mercato/core/modules/communication_channels/lib/adapter`. Key methods: `sendMessage`, `normalizeInbound`, `convertOutbound`, `buildOAuthAuthorizeUrl`, `exchangeOAuthCode`, `refreshCredentials`, `fetchHistory`, `registerPush`/`unregisterPush`/`applyPushNotification`, `deleteMessage`, `resolveContact`, `importHistory`.
28
28
 
29
29
  - Tenant OAuth client config (`{ clientId, clientSecret, scopes? }`) lives on `IntegrationCredentials` for provider `gmail`; per-user tokens live on `CommunicationChannel.credentials`.
30
30
  - `fetchHistory` is cursor-driven via `channelState.historyId`. The terminal `historyId` MUST only advance over messages actually normalized; a transient (non-404/410) fetch failure pins the cursor and re-fetches next tick (see the L3 fix in `fetchAndNormalize`).
31
+ - `importHistory` is the operator-triggered BACKLOG sweep and is independent of `fetchHistory`: it walks `users.messages.list?q=after:<epochSeconds>[ from:(… OR …)]` backwards and MUST NOT read or write `channelState.historyId`. Its cursor carries only Gmail's `nextPageToken`, the frozen query, the group index and the collected counter, so it stays compact for a multi-year mailbox. Senders are chunked into groups of 25 and walked sequentially across pages.
31
32
  - The clients are swappable via `setGmailApiClient` / `setGoogleOAuthClient` (test-only hooks).
32
33
 
33
34
  ## Health Check
@@ -39,6 +40,9 @@ Gmail email channel provider for the Communications Hub (`communication_channels
39
40
  | Var | Purpose |
40
41
  |-----|---------|
41
42
  | `OM_GMAIL_PUBSUB_TOPIC` | Fully-qualified Pub/Sub topic for `registerPush` (optional; polling works without it) |
43
+ | `OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS` | Per-attempt Gmail REST timeout (default 30000) |
44
+ | `OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE` | Messages fetched per `importHistory` page (default 100, hard max 500 — Gmail's `maxResults` ceiling) |
45
+ | `OM_CHANNEL_GMAIL_IMPORT_CONCURRENCY` | Parallel `messages.get` calls inside one `importHistory` page (default 5, hard max 20) |
42
46
 
43
47
  ## After Changes
44
48
 
@@ -0,0 +1,33 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { getAuthToken } from "@open-mercato/core/helpers/integration/authFixtures";
3
+ import { apiRequest } from "@open-mercato/core/helpers/integration/api";
4
+ test.describe("TC-CHANNEL-EMAIL-032: gmail import-history route wiring", () => {
5
+ const FAKE_CHANNEL_ID = "00000000-0000-0000-0000-000000000032";
6
+ const path = `/api/communication_channels/channels/${FAKE_CHANNEL_ID}/import-history`;
7
+ test("rejects unauthenticated requests", async ({ request }) => {
8
+ const response = await apiRequest(request, "POST", path, {
9
+ // Intentionally empty token — this test asserts the 401 unauth path.
10
+ token: "",
11
+ data: { sinceDays: 365 }
12
+ });
13
+ expect(response.status()).toBe(401);
14
+ });
15
+ test("rejects a sinceDays value beyond the ten-year ceiling", async ({ request }) => {
16
+ const token = await getAuthToken(request);
17
+ const response = await apiRequest(request, "POST", path, {
18
+ token,
19
+ data: { sinceDays: 3651 }
20
+ });
21
+ expect(response.status()).toBe(400);
22
+ });
23
+ test("accepts a multi-year window without a schema rejection", async ({ request }) => {
24
+ const token = await getAuthToken(request);
25
+ const response = await apiRequest(request, "POST", path, {
26
+ token,
27
+ data: { sinceDays: 1825, maxMessages: 5e4 }
28
+ });
29
+ expect(response.status(), "route should not 5xx").toBeLessThan(500);
30
+ expect([400, 403, 404]).toContain(response.status());
31
+ });
32
+ });
33
+ //# sourceMappingURL=TC-CHANNEL-EMAIL-032.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-032.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api'\n\n/**\n * TC-CHANNEL-EMAIL-032 \u2014 Gmail backlog import route wiring.\n *\n * The Gmail adapter now implements `importHistory`, so\n * `POST /api/communication_channels/channels/{id}/import-history` must no\n * longer short-circuit with the \"provider does not support history import\"\n * 400 for `gmail` channels. This spec covers the route surface reachable\n * without a live Google grant: auth, body validation (including the widened\n * 10-year / 50k ceilings), and the not-found branch.\n *\n * The Gmail query construction, sender chunking, cursor round-trip,\n * pagination, `maxMessages` cap and per-message error handling are covered by\n * `packages/channel-gmail/src/modules/channel_gmail/lib/__tests__/adapter.test.ts`\n * (`GmailChannelAdapter.importHistory`).\n *\n * The full end-to-end (connect a Gmail mailbox \u2192 POST /import-history \u2192\n * ProgressJob completes \u2192 imported messages on the Person timeline) needs a\n * real OAuth grant and stays manual, alongside\n * `.ai/qa/scenarios/TC-CHANNEL-EMAIL-029-import-history.md`.\n */\ntest.describe('TC-CHANNEL-EMAIL-032: gmail import-history route wiring', () => {\n const FAKE_CHANNEL_ID = '00000000-0000-0000-0000-000000000032'\n const path = `/api/communication_channels/channels/${FAKE_CHANNEL_ID}/import-history`\n\n test('rejects unauthenticated requests', async ({ request }) => {\n const response = await apiRequest(request, 'POST', path, {\n // Intentionally empty token \u2014 this test asserts the 401 unauth path.\n token: '',\n data: { sinceDays: 365 },\n })\n expect(response.status()).toBe(401)\n })\n\n test('rejects a sinceDays value beyond the ten-year ceiling', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(request, 'POST', path, {\n token,\n data: { sinceDays: 3651 },\n })\n expect(response.status()).toBe(400)\n })\n\n test('accepts a multi-year window without a schema rejection', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(request, 'POST', path, {\n token,\n data: { sinceDays: 1825, maxMessages: 50000 },\n })\n // The channel does not exist, so the request must fail on the lookup\n // branch (or the missing-org-scope branch) \u2014 never on body validation and\n // never with a 5xx.\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n expect([400, 403, 404]).toContain(response.status())\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAsB3B,KAAK,SAAS,2DAA2D,MAAM;AAC7E,QAAM,kBAAkB;AACxB,QAAM,OAAO,wCAAwC,eAAe;AAEpE,OAAK,oCAAoC,OAAO,EAAE,QAAQ,MAAM;AAC9D,UAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,MAAM;AAAA;AAAA,MAEvD,OAAO;AAAA,MACP,MAAM,EAAE,WAAW,IAAI;AAAA,IACzB,CAAC;AACD,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAAA,EACpC,CAAC;AAED,OAAK,yDAAyD,OAAO,EAAE,QAAQ,MAAM;AACnF,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,MAAM;AAAA,MACvD;AAAA,MACA,MAAM,EAAE,WAAW,KAAK;AAAA,IAC1B,CAAC;AACD,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAAA,EACpC,CAAC;AAED,OAAK,0DAA0D,OAAO,EAAE,QAAQ,MAAM;AACpF,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,MAAM;AAAA,MACvD;AAAA,MACA,MAAM,EAAE,WAAW,MAAM,aAAa,IAAM;AAAA,IAC9C,CAAC;AAID,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAClE,WAAO,CAAC,KAAK,KAAK,GAAG,CAAC,EAAE,UAAU,SAAS,OAAO,CAAC;AAAA,EACrD,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -20,7 +20,13 @@ import {
20
20
  } from "./convert-outbound.js";
21
21
  import { normalizeInboundGmailMessage } from "./normalize-inbound.js";
22
22
  import { emailResolveContact } from "@open-mercato/core/modules/communication_channels/lib/email-contact";
23
- import { encodeCursor } from "@open-mercato/core/modules/communication_channels/lib/email-mime";
23
+ import { decodeCursor, encodeCursor } from "@open-mercato/core/modules/communication_channels/lib/email-mime";
24
+ import {
25
+ IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
26
+ IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
27
+ getImportHistoryMaxSinceDays,
28
+ getImportHistoryMaxMessages
29
+ } from "@open-mercato/core/modules/communication_channels/lib/import-history-limits";
24
30
  import { createLogger } from "@open-mercato/shared/lib/logger";
25
31
  const logger = createLogger("channel_gmail");
26
32
  class GmailChannelAdapter {
@@ -419,6 +425,114 @@ class GmailChannelAdapter {
419
425
  hasMore: hardFailed || !drained
420
426
  };
421
427
  }
428
+ /**
429
+ * Operator-triggered backlog import (Spec B § Phase B6).
430
+ *
431
+ * Reaches BACKWARD in time over `gmail.users.messages.list?q=…`, independent
432
+ * of the forward-polling `historyId` cursor — nothing here reads or writes
433
+ * `GmailChannelState`, so a multi-year backfill can never move the live poll
434
+ * cursor.
435
+ *
436
+ * Scale contract: the cursor carries Gmail's own `nextPageToken` plus a
437
+ * collected counter and the frozen query, so it stays a few hundred bytes for
438
+ * a 50k-message mailbox. Message ids are never enumerated up front.
439
+ */
440
+ async importHistory(input) {
441
+ const userCredentials = parseUserCredentialsOrThrow(input.credentials);
442
+ const auth = { accessToken: userCredentials.accessToken };
443
+ const api = getGmailApiClient();
444
+ const accountIdentifier = userCredentials.email ?? "me";
445
+ const maxMessages = clampImportMaxMessages(input.maxMessages);
446
+ const pageSize = resolveImportPageSize();
447
+ const concurrency = resolveImportConcurrency();
448
+ const cursor = decodeGmailImportCursor(input.cursor);
449
+ const afterTerm = cursor?.after ?? buildAfterTerm(clampImportSinceDays(input.sinceDays));
450
+ const queries = buildImportQueries(afterTerm, input.contactEmails);
451
+ let queryIndex = cursor ? cursor.queryIndex : 0;
452
+ let pageToken = cursor?.pageToken;
453
+ const collectedSoFar = cursor?.collected ?? 0;
454
+ let activeQuery = cursor?.query ?? queries[queryIndex];
455
+ const budget = Math.max(0, maxMessages - collectedSoFar);
456
+ if (budget === 0 || queryIndex >= queries.length) {
457
+ return { messages: [], hasMore: false };
458
+ }
459
+ const perPage = Math.min(pageSize, budget);
460
+ const refs = [];
461
+ let listCalls = 0;
462
+ while (refs.length < perPage && queryIndex < queries.length && listCalls < IMPORT_MAX_LIST_CALLS_PER_PAGE) {
463
+ listCalls += 1;
464
+ const list = await api.listMessages(auth, {
465
+ query: activeQuery,
466
+ labelIds: ["INBOX"],
467
+ pageToken,
468
+ maxResults: perPage - refs.length
469
+ });
470
+ for (const message of list.messages ?? []) {
471
+ refs.push({ id: message.id, threadId: message.threadId });
472
+ }
473
+ if (list.nextPageToken) {
474
+ pageToken = list.nextPageToken;
475
+ continue;
476
+ }
477
+ queryIndex += 1;
478
+ pageToken = void 0;
479
+ activeQuery = queries[queryIndex];
480
+ }
481
+ const messages = await this.fetchAndNormalizeForImport(api, auth, refs, accountIdentifier, concurrency);
482
+ const collected = collectedSoFar + messages.length;
483
+ const exhausted = pageToken === void 0 && queryIndex >= queries.length;
484
+ const hasMore = !exhausted && collected < maxMessages;
485
+ const nextCursor = hasMore ? encodeCursor({
486
+ v: GMAIL_IMPORT_CURSOR_VERSION,
487
+ after: afterTerm,
488
+ query: queries[queryIndex] ?? activeQuery ?? queries[queries.length - 1],
489
+ queryIndex,
490
+ pageToken,
491
+ collected
492
+ }) : void 0;
493
+ return { messages, nextCursor, hasMore };
494
+ }
495
+ /**
496
+ * Import-path fetch. Diverges from `fetchAndNormalize` deliberately: the
497
+ * forward path stops at the first hard failure to protect the polling cursor,
498
+ * whereas a backlog import has no cursor to protect and must not abandon a
499
+ * multi-year sweep over one unreadable message — so a per-message failure is
500
+ * skipped and logged. Only credential/permission failures (401/403), which
501
+ * would fail every remaining message too, abort the page.
502
+ */
503
+ async fetchAndNormalizeForImport(api, auth, refs, accountIdentifier, concurrency) {
504
+ const results = new Array(refs.length).fill(null);
505
+ let nextIndex = 0;
506
+ const workerCount = Math.max(1, Math.min(concurrency, refs.length));
507
+ const runWorker = async () => {
508
+ while (true) {
509
+ const index = nextIndex;
510
+ nextIndex += 1;
511
+ if (index >= refs.length) return;
512
+ const ref = refs[index];
513
+ try {
514
+ const raw = await api.getMessageRaw(auth, ref.id);
515
+ const fallbackDate = raw.internalDate ? new Date(Number(raw.internalDate)) : void 0;
516
+ results[index] = await normalizeInboundGmailMessage({
517
+ rawMessage: decodeBase64Url(raw.raw),
518
+ gmailMessageId: raw.id,
519
+ gmailThreadId: raw.threadId,
520
+ gmailLabelIds: raw.labelIds ?? [],
521
+ accountIdentifier,
522
+ fallbackDate
523
+ });
524
+ } catch (error) {
525
+ if (isFatalGmailImportError(error)) throw error;
526
+ logger.warn("skipping unreadable message during history import", {
527
+ gmailMessageId: ref.id,
528
+ reason: error instanceof Error ? error.message.slice(0, 200) : "unknown error"
529
+ });
530
+ }
531
+ }
532
+ };
533
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
534
+ return results.filter((message) => message !== null);
535
+ }
422
536
  async deleteMessage(input) {
423
537
  const userCredentials = parseUserCredentialsOrThrow(input.credentials);
424
538
  const api = getGmailApiClient();
@@ -466,6 +580,83 @@ class GmailChannelAdapter {
466
580
  return { messages: out, hardFailed: false };
467
581
  }
468
582
  }
583
+ const GMAIL_IMPORT_CURSOR_VERSION = 1;
584
+ const IMPORT_SINCE_DAYS_MIN = 1;
585
+ const IMPORT_SINCE_DAYS_DEFAULT = IMPORT_HISTORY_DEFAULT_SINCE_DAYS;
586
+ const IMPORT_MAX_MESSAGES_MIN = 1;
587
+ const IMPORT_MAX_MESSAGES_DEFAULT = IMPORT_HISTORY_DEFAULT_MAX_MESSAGES;
588
+ const IMPORT_PAGE_SIZE_DEFAULT = 100;
589
+ const IMPORT_PAGE_SIZE_MAX = 500;
590
+ const IMPORT_CONCURRENCY_DEFAULT = 5;
591
+ const IMPORT_CONCURRENCY_MAX = 20;
592
+ const IMPORT_SENDER_CHUNK_SIZE = 25;
593
+ const IMPORT_SENDER_MAX = 200;
594
+ const IMPORT_MAX_LIST_CALLS_PER_PAGE = 25;
595
+ function clampImportSinceDays(value) {
596
+ const raw = Number.isFinite(value) ? Math.trunc(value) : IMPORT_SINCE_DAYS_DEFAULT;
597
+ return Math.max(IMPORT_SINCE_DAYS_MIN, Math.min(getImportHistoryMaxSinceDays(), raw));
598
+ }
599
+ function clampImportMaxMessages(value) {
600
+ const raw = Number.isFinite(value) ? Math.trunc(value) : IMPORT_MAX_MESSAGES_DEFAULT;
601
+ return Math.max(IMPORT_MAX_MESSAGES_MIN, Math.min(getImportHistoryMaxMessages(), raw));
602
+ }
603
+ function resolveEnvInt(name, fallback, max) {
604
+ const parsed = Number.parseInt(process.env[name] ?? "", 10);
605
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
606
+ return Math.min(parsed, max);
607
+ }
608
+ function resolveImportPageSize() {
609
+ return resolveEnvInt("OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE", IMPORT_PAGE_SIZE_DEFAULT, IMPORT_PAGE_SIZE_MAX);
610
+ }
611
+ function resolveImportConcurrency() {
612
+ return resolveEnvInt("OM_CHANNEL_GMAIL_IMPORT_CONCURRENCY", IMPORT_CONCURRENCY_DEFAULT, IMPORT_CONCURRENCY_MAX);
613
+ }
614
+ function buildAfterTerm(sinceDays) {
615
+ const epochSeconds = Math.floor((Date.now() - sinceDays * 24 * 60 * 60 * 1e3) / 1e3);
616
+ return `after:${Math.max(0, epochSeconds)}`;
617
+ }
618
+ function normalizeImportSenders(contactEmails) {
619
+ const seen = /* @__PURE__ */ new Set();
620
+ for (const raw of contactEmails ?? []) {
621
+ if (typeof raw !== "string") continue;
622
+ const trimmed = raw.trim().toLowerCase();
623
+ if (!trimmed.includes("@") || /[\s"()]/.test(trimmed)) continue;
624
+ seen.add(trimmed);
625
+ if (seen.size >= IMPORT_SENDER_MAX) break;
626
+ }
627
+ return Array.from(seen);
628
+ }
629
+ function buildImportQueries(afterTerm, contactEmails) {
630
+ const senders = normalizeImportSenders(contactEmails);
631
+ if (senders.length === 0) return [afterTerm];
632
+ const queries = [];
633
+ for (let index = 0; index < senders.length; index += IMPORT_SENDER_CHUNK_SIZE) {
634
+ const group = senders.slice(index, index + IMPORT_SENDER_CHUNK_SIZE);
635
+ queries.push(`${afterTerm} from:(${group.join(" OR ")})`);
636
+ }
637
+ return queries;
638
+ }
639
+ function decodeGmailImportCursor(value) {
640
+ const parsed = decodeCursor(value);
641
+ if (!parsed || typeof parsed !== "object") return null;
642
+ const candidate = parsed;
643
+ if (candidate.v !== GMAIL_IMPORT_CURSOR_VERSION) return null;
644
+ if (typeof candidate.after !== "string" || typeof candidate.query !== "string") return null;
645
+ const queryIndex = typeof candidate.queryIndex === "number" && candidate.queryIndex >= 0 ? Math.trunc(candidate.queryIndex) : 0;
646
+ const collected = typeof candidate.collected === "number" && candidate.collected >= 0 ? Math.trunc(candidate.collected) : 0;
647
+ const pageToken = typeof candidate.pageToken === "string" && candidate.pageToken.length > 0 ? candidate.pageToken : void 0;
648
+ return {
649
+ v: GMAIL_IMPORT_CURSOR_VERSION,
650
+ after: candidate.after,
651
+ query: candidate.query,
652
+ queryIndex,
653
+ pageToken,
654
+ collected
655
+ };
656
+ }
657
+ function isFatalGmailImportError(error) {
658
+ return error instanceof GmailApiError && (error.status === 401 || error.status === 403);
659
+ }
469
660
  function collectMessageRefs(history) {
470
661
  const seen = /* @__PURE__ */ new Set();
471
662
  const refs = [];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/channel_gmail/lib/adapter.ts"],
4
- "sourcesContent": ["import type {\n ApplyPushNotificationInput,\n ChannelAdapter,\n ChannelNativeContent,\n ConvertOutboundInput,\n BuildOAuthAuthorizeUrlInput,\n BuildOAuthAuthorizeUrlResult,\n DeleteChannelMessageInput,\n ExchangeOAuthCodeInput,\n ExchangeOAuthCodeResult,\n FetchHistoryInput,\n GetMessageStatusInput,\n HistoryPage,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n PushRegistration,\n RefreshCredentialsInput,\n RefreshedCredentials,\n RegisterPushInput,\n ResolveContactInput,\n ContactHint,\n SendMessageInput,\n SendMessageResult,\n UnregisterPushInput,\n VerifyWebhookInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { gmailCapabilities } from './capabilities'\nimport {\n gmailChannelStateSchema,\n gmailClientCredentialsSchema,\n gmailUserCredentialsSchema,\n parseScopes,\n type GmailChannelState,\n type GmailClientCredentials,\n type GmailUserCredentials,\n} from './credentials'\nimport {\n decodeBase64Url,\n encodeBase64Url,\n getGmailApiClient,\n GmailApiError,\n type GmailGetMessageRawResponse,\n} from './gmail-client'\nimport {\n getGoogleOAuthClient,\n tokenResponseToExpiresAt,\n} from './oauth'\nimport {\n convertOutboundForGmail,\n type GmailEmailNativeMetadata,\n} from './convert-outbound'\nimport { normalizeInboundGmailMessage } from './normalize-inbound'\nimport { emailResolveContact } from '@open-mercato/core/modules/communication_channels/lib/email-contact'\nimport { encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('channel_gmail')\n\n/**\n * Gmail `ChannelAdapter`. OAuth2-based, polling-driven (`realtimePush: false`).\n *\n * Credential shape on `CommunicationChannel.credentials`:\n * - Per-user: `{ accessToken, refreshToken?, expiresAt?, scopes?, email? }`\n * - Tenant OAuth client config sits on `IntegrationCredentials.credentials`\n * for the `gmail` provider: `{ clientId, clientSecret, scopes? }`. The hub\n * looks it up by `(tenantId, providerKey='gmail')` and passes it to\n * `buildOAuthAuthorizeUrl` + `exchangeOAuthCode` + `refreshCredentials`.\n *\n * Sync model:\n * - First poll on a fresh channel reads `gmail.users.getProfile.historyId`\n * and persists it; no message fetch is done on the bootstrap call (the\n * existing inbox is intentionally not back-filled, matching the spec).\n * - Subsequent polls call `gmail.users.history.list?startHistoryId=\u2026` and\n * fetch each `messagesAdded` entry's RAW payload.\n * - If the server returns `404` for the history id (Gmail keeps ~7 days),\n * we fall back to `gmail.users.messages.list?labelIds=INBOX` and persist\n * the new historyId from the next `getProfile` call.\n */\nclass GmailChannelAdapter implements ChannelAdapter {\n readonly providerKey = 'gmail'\n readonly channelType = 'email'\n readonly capabilities = gmailCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n let native: ChannelNativeContent\n try {\n native = await convertOutboundForGmail({\n body: input.content.html ?? input.content.text ?? '',\n bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),\n attachments: input.content.attachments,\n channelMetadata: input.metadata,\n fromAddress: userCredentials.email ?? 'me',\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Outbound conversion failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n\n const nativeMeta = native.metadata as unknown as GmailEmailNativeMetadata\n const rawBase64Url = encodeBase64Url(nativeMeta.rawMessage)\n\n try {\n const response = await getGmailApiClient().sendRawMessage(\n { accessToken: userCredentials.accessToken },\n { rawBase64Url, threadId: nativeMeta.threadId },\n )\n return {\n externalMessageId: nativeMeta.messageId ?? response.id,\n conversationId: response.threadId,\n status: 'sent',\n metadata: { gmailMessageId: response.id, gmailThreadId: response.threadId, labelIds: response.labelIds ?? [] },\n }\n } catch (error) {\n if (error instanceof GmailApiError && error.status === 401) {\n // `requires_reauth` is a protocol sentinel the hub keys on (see\n // communication_channels error-classification.isReauthError), not a\n // user/log message \u2014 do NOT prefix or translate it.\n return { externalMessageId: '', status: 'failed', error: 'requires_reauth' }\n }\n const message = error instanceof Error ? error.message : 'Gmail send failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n // Gmail Pub/Sub push (Spec C) is handled by the dedicated `/webhooks/gmail`\n // route + `applyPushNotification`, not this generic hub-webhook hook, so this\n // returns an unhandled event for the generic route to ack 2xx.\n return { raw: {}, eventType: 'other', metadata: { reason: 'gmail-uses-polling-not-push' } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n // Gmail exposes no per-message delivery-status API, so we return a\n // best-effort `'sent'` placeholder (a later bounce is not reflected here).\n // Mirrors the IMAP adapter's documented behavior.\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return convertOutboundForGmail({ ...input, fromAddress: 'me' })\n }\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n const payload = raw.raw as {\n rawBase64Url?: unknown\n rawBody?: unknown\n gmailMessageId?: unknown\n gmailThreadId?: unknown\n labelIds?: unknown\n accountIdentifier?: unknown\n }\n const rawMessage = pickRawMimeBuffer(payload)\n const gmailMessageId = typeof payload.gmailMessageId === 'string' ? payload.gmailMessageId : 'unknown'\n const gmailThreadId = typeof payload.gmailThreadId === 'string' ? payload.gmailThreadId : gmailMessageId\n const labelIds = Array.isArray(payload.labelIds) ? (payload.labelIds.filter((v) => typeof v === 'string') as string[]) : []\n const accountIdentifier = typeof payload.accountIdentifier === 'string' ? payload.accountIdentifier : 'unknown@gmail'\n return normalizeInboundGmailMessage({\n rawMessage,\n gmailMessageId,\n gmailThreadId,\n gmailLabelIds: labelIds,\n accountIdentifier,\n })\n }\n\n async buildOAuthAuthorizeUrl(input: BuildOAuthAuthorizeUrlInput): Promise<BuildOAuthAuthorizeUrlResult> {\n const client = parseClientCredentialsOrThrow(input.credentials)\n const scopes = parseScopes(client.scopes)\n const url = getGoogleOAuthClient().buildAuthorizeUrl({\n clientId: client.clientId,\n redirectUri: input.redirectUri,\n state: input.state,\n scopes,\n loginHint: input.loginHint,\n })\n return { authorizeUrl: url, extra: { scopes } }\n }\n\n async exchangeOAuthCode(input: ExchangeOAuthCodeInput): Promise<ExchangeOAuthCodeResult> {\n const client = parseClientCredentialsOrThrow(input.credentials)\n const token = await getGoogleOAuthClient().exchangeCode({\n clientId: client.clientId,\n clientSecret: client.clientSecret,\n redirectUri: input.redirectUri,\n code: input.code,\n })\n let email: string | undefined\n let displayName: string | undefined\n try {\n const userInfo = await getGoogleOAuthClient().fetchUserInfo(token.access_token)\n email = userInfo.email\n displayName = userInfo.name ?? userInfo.email\n } catch {\n // Userinfo failure is non-fatal; fall back to the optional `id_token` parser later.\n }\n const expiresAt = tokenResponseToExpiresAt(token)\n const credentials: GmailUserCredentials = {\n accessToken: token.access_token,\n refreshToken: token.refresh_token,\n expiresAt: expiresAt?.toISOString(),\n scopes: token.scope ? token.scope.split(' ').filter(Boolean) : undefined,\n email,\n }\n return {\n credentials: credentials as unknown as Record<string, unknown>,\n externalIdentifier: email,\n displayName: displayName ?? email,\n expiresAt,\n }\n }\n\n async refreshCredentials(input: RefreshCredentialsInput): Promise<RefreshedCredentials> {\n const current = parseUserCredentialsOrThrow(input.credentials)\n if (!current.refreshToken) {\n throw new Error('requires_reauth')\n }\n // Spec A: prefer the new `input.oauthClient` slot (resolved by the hub from\n // the `channel_gmail` integration's tenant-scoped client credentials). Fall\n // back to the deprecated `credentials._client` path for one minor release so\n // existing test fixtures keep working.\n const clientFromState = resolveGmailOAuthClient(input)\n const token = await getGoogleOAuthClient().refreshToken({\n clientId: clientFromState.clientId,\n clientSecret: clientFromState.clientSecret,\n refreshToken: current.refreshToken,\n })\n const expiresAt = tokenResponseToExpiresAt(token)\n const refreshed: GmailUserCredentials = {\n accessToken: token.access_token,\n // Google does NOT always return a new refresh token \u2014 keep the existing one.\n refreshToken: token.refresh_token ?? current.refreshToken,\n expiresAt: expiresAt?.toISOString(),\n scopes: token.scope ? token.scope.split(' ').filter(Boolean) : current.scopes,\n email: current.email,\n }\n return {\n credentials: refreshed as unknown as Record<string, unknown>,\n expiresAt,\n }\n }\n\n async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const channelState = gmailChannelStateSchema.parse(input.channelState ?? {})\n const auth = { accessToken: userCredentials.accessToken }\n const api = getGmailApiClient()\n const limit = input.limit ?? 50\n\n // L3 first-page retry: a prior fallback scan hard-failed on its FIRST page and\n // pinned only the history snapshot (no page token). Re-enter the fallback scan\n // from the first INBOX page so unprocessed messages are retried, not skipped by\n // the bootstrap path below.\n if (channelState.pendingMessagesHistoryIdSnapshot && !channelState.pendingMessagesPageToken) {\n return await this.startMessagesListFallback(\n api,\n auth,\n userCredentials.email ?? 'me',\n channelState.pendingMessagesHistoryIdSnapshot,\n limit,\n )\n }\n\n // Bootstrap path: no historyId yet \u2192 just persist current historyId and skip fetch.\n if (!channelState.historyId && !channelState.pendingMessagesPageToken) {\n const profile = await api.getProfile(auth)\n const nextState: GmailChannelState = {\n historyId: profile.historyId,\n lastSyncedAt: new Date().toISOString(),\n }\n return {\n messages: [],\n nextCursor: encodeCursor(nextState),\n hasMore: false,\n }\n }\n\n // Mid-drain fallback (404 path): we previously fell back to messages.list\n // for an expired historyId and still have pages to drain. Resume from the\n // stored pageToken without re-issuing the history.list call.\n if (channelState.pendingMessagesPageToken) {\n return await this.continueMessagesListDrain(\n api,\n auth,\n userCredentials.email ?? 'me',\n channelState,\n limit,\n )\n }\n\n // Incremental path: history.list since stored historyId, walking\n // nextPageToken until either (a) all pages drained, or (b) we've collected\n // `limit` messages.\n // CRITICAL: terminal historyId is ONLY advanced after full drain. While a\n // pending pageToken exists in channelState, the original startHistoryId is\n // retained so the next tick re-enters the same history window.\n let messages: NormalizedInboundMessage[] = []\n try {\n return await this.continueHistoryListDrain(\n api,\n auth,\n userCredentials.email ?? 'me',\n channelState,\n String(channelState.historyId),\n limit,\n )\n } catch (error) {\n if (error instanceof GmailApiError && error.status === 404) {\n // Gmail history expired (~7-day retention). Fall back to a paged inbox\n // scan via messages.list. Snapshot the post-fallback historyId now so\n // we can advance it once the messages.list drain completes.\n const profile = await api.getProfile(auth)\n return await this.startMessagesListFallback(\n api,\n auth,\n userCredentials.email ?? 'me',\n profile.historyId,\n limit,\n )\n }\n throw error\n }\n }\n\n /**\n * Spec C \u00A7 Phase C2 \u2014 Register Gmail Pub/Sub watch.\n *\n * Calls `gmail.users.watch` with the operator-configured Pub/Sub topic.\n * Returns `historyId` (cursor for subsequent `history.list` calls) and\n * `expiration` (ms since epoch \u2014 Gmail caps at ~7 days). Persists both\n * onto `CommunicationChannel.channelState` via the hub's\n * `push.register` command.\n */\n async registerPush(input: RegisterPushInput): Promise<PushRegistration> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const auth = { accessToken: userCredentials.accessToken }\n const api = getGmailApiClient()\n const topicName = (input.providerConfig?.pubsubTopic as string | undefined) ?? ''\n if (!topicName) {\n return {\n providerKey: this.providerKey,\n status: 'failed',\n channelStatePatch: {\n pushStatus: 'failed',\n lastPushError: {\n code: 'missing_topic',\n message: 'Pub/Sub topic not configured',\n at: new Date().toISOString(),\n },\n },\n error: {\n code: 'missing_topic',\n message: 'OM_GMAIL_PUBSUB_TOPIC not configured for this tenant',\n },\n }\n }\n try {\n const result = await api.watchInbox(auth, { topicName, labelIds: ['INBOX'] })\n const expirationMs = Number(result.expiration)\n return {\n providerKey: this.providerKey,\n status: 'active',\n channelStatePatch: {\n historyId: result.historyId,\n watchExpirationMs: Number.isFinite(expirationMs) ? expirationMs : Date.now() + 6 * 24 * 3600 * 1000,\n pubsubTopic: topicName,\n pushStatus: 'active',\n lastPushError: null,\n },\n recommendedPollIntervalSeconds: 1800,\n }\n } catch (error) {\n const status = error instanceof GmailApiError ? error.status : 0\n const detail = error instanceof Error ? error.message : 'watch failed'\n return {\n providerKey: this.providerKey,\n status: 'failed',\n channelStatePatch: {\n pushStatus: 'failed',\n lastPushError: {\n code: `gmail_watch_${status || 'error'}`,\n message: detail.slice(0, 500),\n at: new Date().toISOString(),\n },\n },\n error: { code: `gmail_watch_${status || 'error'}`, message: detail },\n }\n }\n }\n\n /**\n * Spec C \u00A7 Phase C2 \u2014 Tear down Gmail watch via `gmail.users.stop`.\n * Idempotent: a 404 (no active watch) is swallowed.\n */\n async unregisterPush(input: UnregisterPushInput): Promise<void> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const auth = { accessToken: userCredentials.accessToken }\n const api = getGmailApiClient()\n try {\n await api.stopWatch(auth)\n } catch (error) {\n if (error instanceof GmailApiError && error.status === 404) return\n throw error\n }\n }\n\n /**\n * Spec C \u00A7 Phase C2 \u2014 Convert a verified Pub/Sub notification into a\n * `HistoryPage`. The notification body itself is just `{ emailAddress,\n * historyId }`; the actual messages come from `history.list` against\n * `channelState.historyId`. We delegate to `fetchHistory` so the\n * pagination / 404-fallback logic stays in one place.\n */\n async applyPushNotification(input: ApplyPushNotificationInput): Promise<HistoryPage> {\n // The notification's `historyId` is informational \u2014 Gmail guarantees\n // it is `>= channelState.historyId`, but a multi-event batch may\n // advance further than what `history.list` returns in a single page.\n // Treat the call as \"drain whatever is new since the stored cursor\".\n // If `channelState.historyId` is absent (a push arrived before the cursor\n // was seeded), `fetchHistory` bootstraps \u2014 persists the current historyId\n // and returns zero messages \u2014 so this notification's delta is picked up on\n // the next call. In practice `registerPush` seeds the cursor before any\n // push flows, so this edge is not hit in the normal lifecycle.\n return this.fetchHistory({\n conversationId: 'INBOX',\n credentials: input.credentials,\n channelState: input.channelState,\n scope: input.scope,\n // Push notifications are bursty (1/s max per Gmail user); use a\n // smaller per-call limit so the worker drains quickly between\n // notifications without holding the API quota.\n limit: 50,\n } as FetchHistoryInput)\n }\n\n private async continueHistoryListDrain(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n accountIdentifier: string,\n channelState: GmailChannelState,\n startHistoryId: string,\n limit: number,\n ): Promise<HistoryPage> {\n const collected: Array<{ id: string; threadId: string; labelIds?: string[] }> = []\n const seen = new Set<string>()\n let pageToken: string | undefined = channelState.pendingHistoryPageToken\n let lastResponseHistoryId: string | undefined\n let drained = false\n\n // Fully consume each page before deciding to stop: `pageToken` must only ever\n // advance past refs we have actually collected, otherwise a page carrying more\n // than `limit` new refs would silently drop the overflow. `collected` may\n // therefore exceed `limit` by up to one page \u2014 bounded and intentional.\n while (true) {\n const history = await api.listHistory(auth, {\n startHistoryId,\n pageToken,\n })\n lastResponseHistoryId = history.historyId ?? lastResponseHistoryId\n for (const ref of collectMessageRefs(history.history ?? [])) {\n if (seen.has(ref.id)) continue\n seen.add(ref.id)\n collected.push(ref)\n }\n if (!history.nextPageToken) {\n drained = true\n break\n }\n pageToken = history.nextPageToken\n if (collected.length >= limit) break\n }\n\n const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, collected, accountIdentifier)\n const nextState: GmailChannelState = {\n lastSyncedAt: new Date().toISOString(),\n }\n if (hardFailed) {\n // L3: a message failed transiently. Restart the window from startHistoryId\n // on the next tick (drop any page token) so every page \u2014 including the one\n // carrying the failed message \u2014 is re-read; already-ingested messages dedup\n // at the hub. Do NOT advance the terminal historyId or pin a forward token,\n // which would skip the failed message's page.\n nextState.historyId = startHistoryId\n } else if (drained) {\n // All pages drained \u2014 advance the terminal historyId.\n nextState.historyId = lastResponseHistoryId ?? startHistoryId\n } else {\n // Mid-drain \u2014 keep the prior startHistoryId pinned + remember the next\n // unconsumed pageToken so the following tick resumes without re-walking.\n nextState.historyId = startHistoryId\n nextState.pendingHistoryPageToken = pageToken\n }\n return {\n messages,\n nextCursor: encodeCursor(nextState),\n // Re-enqueue immediately when a transient failure left work behind.\n hasMore: hardFailed || !drained,\n }\n }\n\n private async startMessagesListFallback(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n accountIdentifier: string,\n historyIdSnapshot: string,\n limit: number,\n ): Promise<HistoryPage> {\n const list = await api.listMessages(auth, {\n labelIds: ['INBOX'],\n maxResults: limit,\n })\n const refs = (list.messages ?? []).map((m) => ({\n id: m.id,\n threadId: m.threadId,\n labelIds: ['INBOX'],\n }))\n const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, refs, accountIdentifier)\n const drained = !list.nextPageToken\n const nextState: GmailChannelState = {\n lastSyncedAt: new Date().toISOString(),\n }\n if (hardFailed) {\n // L3: this is the FIRST fallback page (no prior page token), so there is\n // nothing to pin. Deliberately leave `historyId` unset so the cursor does\n // NOT advance past the unprocessed messages \u2014 the next tick re-enters the\n // same fallback scan and retries them.\n nextState.pendingMessagesHistoryIdSnapshot = historyIdSnapshot\n } else if (drained) {\n nextState.historyId = historyIdSnapshot\n } else {\n nextState.pendingMessagesPageToken = list.nextPageToken\n nextState.pendingMessagesHistoryIdSnapshot = historyIdSnapshot\n }\n return {\n messages,\n nextCursor: encodeCursor(nextState),\n hasMore: hardFailed || !drained,\n }\n }\n\n private async continueMessagesListDrain(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n accountIdentifier: string,\n channelState: GmailChannelState,\n limit: number,\n ): Promise<HistoryPage> {\n const list = await api.listMessages(auth, {\n labelIds: ['INBOX'],\n maxResults: limit,\n pageToken: channelState.pendingMessagesPageToken,\n })\n const refs = (list.messages ?? []).map((m) => ({\n id: m.id,\n threadId: m.threadId,\n labelIds: ['INBOX'],\n }))\n const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, refs, accountIdentifier)\n const drained = !list.nextPageToken\n const nextState: GmailChannelState = {\n lastSyncedAt: new Date().toISOString(),\n }\n if (hardFailed) {\n // L3: re-pin the SAME page token (not list.nextPageToken) so the next\n // tick re-fetches this page and retries the unprocessed messages.\n nextState.pendingMessagesPageToken = channelState.pendingMessagesPageToken\n nextState.pendingMessagesHistoryIdSnapshot = channelState.pendingMessagesHistoryIdSnapshot\n } else if (drained) {\n nextState.historyId = channelState.pendingMessagesHistoryIdSnapshot ?? channelState.historyId\n } else {\n nextState.pendingMessagesPageToken = list.nextPageToken\n nextState.pendingMessagesHistoryIdSnapshot = channelState.pendingMessagesHistoryIdSnapshot\n }\n return {\n messages,\n nextCursor: encodeCursor(nextState),\n hasMore: hardFailed || !drained,\n }\n }\n\n async deleteMessage(input: DeleteChannelMessageInput): Promise<void> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const api = getGmailApiClient()\n // Gmail's \"delete\" capability is delivered as Trash to match the user's\n // mental model and avoid permanent loss on accidental clicks. The user can\n // restore from Trash in the Gmail web UI within 30 days.\n await api.trashMessage({ accessToken: userCredentials.accessToken }, input.externalMessageId)\n }\n\n async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {\n return emailResolveContact(input)\n }\n\n /**\n * Fetch + normalize each collected message ref.\n *\n * L3 fix: a non-404/410 error on `getMessageRaw` (e.g. a transient 500/403)\n * used to re-throw and abort the whole tick, discarding messages that had\n * already normalized in the same page. Worse, because the cursor could be\n * advanced from a different source (a push notification carrying a higher\n * historyId), the transiently-failed messages could be skipped permanently.\n *\n * We now treat a hard failure as a stop point: keep the messages normalized\n * BEFORE the failure and signal `hardFailed: true` so the caller pins the\n * persisted `historyId` (does NOT advance past the failure) and re-fetches on\n * the next tick. 404/410 stay skipped (the message is genuinely gone).\n */\n private async fetchAndNormalize(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n refs: Array<{ id: string; threadId: string; labelIds?: string[] }>,\n accountIdentifier: string,\n ): Promise<{ messages: NormalizedInboundMessage[]; hardFailed: boolean }> {\n const out: NormalizedInboundMessage[] = []\n for (const ref of refs) {\n let raw: GmailGetMessageRawResponse\n try {\n raw = await api.getMessageRaw(auth, ref.id)\n } catch (error) {\n // 404/410: the message is gone \u2014 skip it and keep draining.\n if (error instanceof GmailApiError && (error.status === 404 || error.status === 410)) continue\n // Any other failure is potentially transient. Stop here without\n // advancing past the unprocessed messages so the next tick retries them.\n return { messages: out, hardFailed: true }\n }\n const rawBuffer = decodeBase64Url(raw.raw)\n const fallbackDate = raw.internalDate ? new Date(Number(raw.internalDate)) : undefined\n const normalized = await normalizeInboundGmailMessage({\n rawMessage: rawBuffer,\n gmailMessageId: raw.id,\n gmailThreadId: raw.threadId,\n gmailLabelIds: raw.labelIds ?? ref.labelIds ?? [],\n accountIdentifier,\n fallbackDate,\n })\n out.push(normalized)\n }\n return { messages: out, hardFailed: false }\n }\n}\n\nfunction collectMessageRefs(\n history: Array<{\n messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>\n }>,\n): Array<{ id: string; threadId: string; labelIds?: string[] }> {\n const seen = new Set<string>()\n const refs: Array<{ id: string; threadId: string; labelIds?: string[] }> = []\n for (const entry of history) {\n for (const added of entry.messagesAdded ?? []) {\n if (seen.has(added.message.id)) continue\n seen.add(added.message.id)\n refs.push({ id: added.message.id, threadId: added.message.threadId, labelIds: added.message.labelIds })\n }\n }\n return refs\n}\n\nfunction parseUserCredentialsOrThrow(value: unknown): GmailUserCredentials {\n const parsed = gmailUserCredentialsSchema.safeParse(value)\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n throw new Error(`Invalid Gmail credentials: ${first?.message ?? 'unknown validation error'}`)\n }\n return parsed.data\n}\n\nfunction parseClientCredentialsOrThrow(value: unknown): GmailClientCredentials {\n const parsed = gmailClientCredentialsSchema.safeParse(value)\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n throw new Error(`Invalid Gmail OAuth client credentials: ${first?.message ?? 'unknown validation error'}`)\n }\n return parsed.data\n}\n\nlet warnedLegacyClientPath = false\n\n/**\n * Resolve the OAuth client config for a Gmail refresh, preferring the new\n * `RefreshCredentialsInput.oauthClient` field (Spec A,\n * .ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md).\n *\n * Falls back to the deprecated `credentials._client` read path for one\n * minor release so existing tests keep working. The legacy path emits a\n * one-time deprecation warning per process so production logs stay quiet.\n */\nfunction resolveGmailOAuthClient(input: RefreshCredentialsInput): GmailClientCredentials {\n if (input.oauthClient) {\n const client = input.oauthClient\n if (!client.clientId) {\n throw new Error('[internal] Invalid Gmail OAuth client credentials: OAuth Client ID required')\n }\n if (!client.clientSecret) {\n throw new Error('[internal] Invalid Gmail OAuth client credentials: clientSecret required')\n }\n return {\n clientId: client.clientId,\n clientSecret: client.clientSecret,\n // `GmailClientCredentials.scopes` is the wire format the legacy\n // `credentials._client` blob carried \u2014 comma/space-separated string.\n // Spec A's `OAuthClientConfig.scopes` is the canonical `string[]`.\n // `parseScopes` accepts either separator, so join with a single space.\n ...(client.scopes !== undefined ? { scopes: client.scopes.join(' ') } : {}),\n }\n }\n // Legacy path \u2014 DEPRECATED. Remove in the next minor release.\n if (!warnedLegacyClientPath) {\n warnedLegacyClientPath = true\n logger.warn(\n 'reading OAuth client config from credentials._client is deprecated;' +\n ' pass via RefreshCredentialsInput.oauthClient instead (Spec A)',\n )\n }\n return parseClientCredentialsOrThrow(\n (input.credentials as unknown as { _client?: unknown })._client ?? input.credentials,\n )\n}\n\nfunction pickRawMimeBuffer(payload: { rawBase64Url?: unknown; rawBody?: unknown }): Buffer {\n if (typeof payload.rawBase64Url === 'string') return decodeBase64Url(payload.rawBase64Url)\n const value = payload.rawBody\n if (Buffer.isBuffer(value)) return value\n if (value instanceof Uint8Array) return Buffer.from(value)\n if (typeof value === 'string') return Buffer.from(value, 'utf-8')\n throw new Error('[internal] Gmail normalizeInbound requires `raw.rawBase64Url` or `raw.rawBody`')\n}\n\nlet cachedAdapter: GmailChannelAdapter | null = null\n\nexport function getGmailChannelAdapter(): GmailChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new GmailChannelAdapter()\n return cachedAdapter\n}\n\nexport { GmailChannelAdapter }\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type {\n ApplyPushNotificationInput,\n ChannelAdapter,\n ChannelNativeContent,\n ConvertOutboundInput,\n BuildOAuthAuthorizeUrlInput,\n BuildOAuthAuthorizeUrlResult,\n DeleteChannelMessageInput,\n ExchangeOAuthCodeInput,\n ExchangeOAuthCodeResult,\n FetchHistoryInput,\n GetMessageStatusInput,\n HistoryPage,\n ImportHistoryInput,\n ImportHistoryPage,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n PushRegistration,\n RefreshCredentialsInput,\n RefreshedCredentials,\n RegisterPushInput,\n ResolveContactInput,\n ContactHint,\n SendMessageInput,\n SendMessageResult,\n UnregisterPushInput,\n VerifyWebhookInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { gmailCapabilities } from './capabilities'\nimport {\n gmailChannelStateSchema,\n gmailClientCredentialsSchema,\n gmailUserCredentialsSchema,\n parseScopes,\n type GmailChannelState,\n type GmailClientCredentials,\n type GmailUserCredentials,\n} from './credentials'\nimport {\n decodeBase64Url,\n encodeBase64Url,\n getGmailApiClient,\n GmailApiError,\n type GmailGetMessageRawResponse,\n} from './gmail-client'\nimport {\n getGoogleOAuthClient,\n tokenResponseToExpiresAt,\n} from './oauth'\nimport {\n convertOutboundForGmail,\n type GmailEmailNativeMetadata,\n} from './convert-outbound'\nimport { normalizeInboundGmailMessage } from './normalize-inbound'\nimport { emailResolveContact } from '@open-mercato/core/modules/communication_channels/lib/email-contact'\nimport { decodeCursor, encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'\nimport {\n IMPORT_HISTORY_DEFAULT_SINCE_DAYS,\n IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,\n getImportHistoryMaxSinceDays,\n getImportHistoryMaxMessages,\n} from '@open-mercato/core/modules/communication_channels/lib/import-history-limits'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('channel_gmail')\n\n/**\n * Gmail `ChannelAdapter`. OAuth2-based, polling-driven (`realtimePush: false`).\n *\n * Credential shape on `CommunicationChannel.credentials`:\n * - Per-user: `{ accessToken, refreshToken?, expiresAt?, scopes?, email? }`\n * - Tenant OAuth client config sits on `IntegrationCredentials.credentials`\n * for the `gmail` provider: `{ clientId, clientSecret, scopes? }`. The hub\n * looks it up by `(tenantId, providerKey='gmail')` and passes it to\n * `buildOAuthAuthorizeUrl` + `exchangeOAuthCode` + `refreshCredentials`.\n *\n * Sync model:\n * - First poll on a fresh channel reads `gmail.users.getProfile.historyId`\n * and persists it; no message fetch is done on the bootstrap call (the\n * existing inbox is intentionally not back-filled, matching the spec).\n * - Subsequent polls call `gmail.users.history.list?startHistoryId=\u2026` and\n * fetch each `messagesAdded` entry's RAW payload.\n * - If the server returns `404` for the history id (Gmail keeps ~7 days),\n * we fall back to `gmail.users.messages.list?labelIds=INBOX` and persist\n * the new historyId from the next `getProfile` call.\n */\nclass GmailChannelAdapter implements ChannelAdapter {\n readonly providerKey = 'gmail'\n readonly channelType = 'email'\n readonly capabilities = gmailCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n let native: ChannelNativeContent\n try {\n native = await convertOutboundForGmail({\n body: input.content.html ?? input.content.text ?? '',\n bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),\n attachments: input.content.attachments,\n channelMetadata: input.metadata,\n fromAddress: userCredentials.email ?? 'me',\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Outbound conversion failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n\n const nativeMeta = native.metadata as unknown as GmailEmailNativeMetadata\n const rawBase64Url = encodeBase64Url(nativeMeta.rawMessage)\n\n try {\n const response = await getGmailApiClient().sendRawMessage(\n { accessToken: userCredentials.accessToken },\n { rawBase64Url, threadId: nativeMeta.threadId },\n )\n return {\n externalMessageId: nativeMeta.messageId ?? response.id,\n conversationId: response.threadId,\n status: 'sent',\n metadata: { gmailMessageId: response.id, gmailThreadId: response.threadId, labelIds: response.labelIds ?? [] },\n }\n } catch (error) {\n if (error instanceof GmailApiError && error.status === 401) {\n // `requires_reauth` is a protocol sentinel the hub keys on (see\n // communication_channels error-classification.isReauthError), not a\n // user/log message \u2014 do NOT prefix or translate it.\n return { externalMessageId: '', status: 'failed', error: 'requires_reauth' }\n }\n const message = error instanceof Error ? error.message : 'Gmail send failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n // Gmail Pub/Sub push (Spec C) is handled by the dedicated `/webhooks/gmail`\n // route + `applyPushNotification`, not this generic hub-webhook hook, so this\n // returns an unhandled event for the generic route to ack 2xx.\n return { raw: {}, eventType: 'other', metadata: { reason: 'gmail-uses-polling-not-push' } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n // Gmail exposes no per-message delivery-status API, so we return a\n // best-effort `'sent'` placeholder (a later bounce is not reflected here).\n // Mirrors the IMAP adapter's documented behavior.\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return convertOutboundForGmail({ ...input, fromAddress: 'me' })\n }\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n const payload = raw.raw as {\n rawBase64Url?: unknown\n rawBody?: unknown\n gmailMessageId?: unknown\n gmailThreadId?: unknown\n labelIds?: unknown\n accountIdentifier?: unknown\n }\n const rawMessage = pickRawMimeBuffer(payload)\n const gmailMessageId = typeof payload.gmailMessageId === 'string' ? payload.gmailMessageId : 'unknown'\n const gmailThreadId = typeof payload.gmailThreadId === 'string' ? payload.gmailThreadId : gmailMessageId\n const labelIds = Array.isArray(payload.labelIds) ? (payload.labelIds.filter((v) => typeof v === 'string') as string[]) : []\n const accountIdentifier = typeof payload.accountIdentifier === 'string' ? payload.accountIdentifier : 'unknown@gmail'\n return normalizeInboundGmailMessage({\n rawMessage,\n gmailMessageId,\n gmailThreadId,\n gmailLabelIds: labelIds,\n accountIdentifier,\n })\n }\n\n async buildOAuthAuthorizeUrl(input: BuildOAuthAuthorizeUrlInput): Promise<BuildOAuthAuthorizeUrlResult> {\n const client = parseClientCredentialsOrThrow(input.credentials)\n const scopes = parseScopes(client.scopes)\n const url = getGoogleOAuthClient().buildAuthorizeUrl({\n clientId: client.clientId,\n redirectUri: input.redirectUri,\n state: input.state,\n scopes,\n loginHint: input.loginHint,\n })\n return { authorizeUrl: url, extra: { scopes } }\n }\n\n async exchangeOAuthCode(input: ExchangeOAuthCodeInput): Promise<ExchangeOAuthCodeResult> {\n const client = parseClientCredentialsOrThrow(input.credentials)\n const token = await getGoogleOAuthClient().exchangeCode({\n clientId: client.clientId,\n clientSecret: client.clientSecret,\n redirectUri: input.redirectUri,\n code: input.code,\n })\n let email: string | undefined\n let displayName: string | undefined\n try {\n const userInfo = await getGoogleOAuthClient().fetchUserInfo(token.access_token)\n email = userInfo.email\n displayName = userInfo.name ?? userInfo.email\n } catch {\n // Userinfo failure is non-fatal; fall back to the optional `id_token` parser later.\n }\n const expiresAt = tokenResponseToExpiresAt(token)\n const credentials: GmailUserCredentials = {\n accessToken: token.access_token,\n refreshToken: token.refresh_token,\n expiresAt: expiresAt?.toISOString(),\n scopes: token.scope ? token.scope.split(' ').filter(Boolean) : undefined,\n email,\n }\n return {\n credentials: credentials as unknown as Record<string, unknown>,\n externalIdentifier: email,\n displayName: displayName ?? email,\n expiresAt,\n }\n }\n\n async refreshCredentials(input: RefreshCredentialsInput): Promise<RefreshedCredentials> {\n const current = parseUserCredentialsOrThrow(input.credentials)\n if (!current.refreshToken) {\n throw new Error('requires_reauth')\n }\n // Spec A: prefer the new `input.oauthClient` slot (resolved by the hub from\n // the `channel_gmail` integration's tenant-scoped client credentials). Fall\n // back to the deprecated `credentials._client` path for one minor release so\n // existing test fixtures keep working.\n const clientFromState = resolveGmailOAuthClient(input)\n const token = await getGoogleOAuthClient().refreshToken({\n clientId: clientFromState.clientId,\n clientSecret: clientFromState.clientSecret,\n refreshToken: current.refreshToken,\n })\n const expiresAt = tokenResponseToExpiresAt(token)\n const refreshed: GmailUserCredentials = {\n accessToken: token.access_token,\n // Google does NOT always return a new refresh token \u2014 keep the existing one.\n refreshToken: token.refresh_token ?? current.refreshToken,\n expiresAt: expiresAt?.toISOString(),\n scopes: token.scope ? token.scope.split(' ').filter(Boolean) : current.scopes,\n email: current.email,\n }\n return {\n credentials: refreshed as unknown as Record<string, unknown>,\n expiresAt,\n }\n }\n\n async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const channelState = gmailChannelStateSchema.parse(input.channelState ?? {})\n const auth = { accessToken: userCredentials.accessToken }\n const api = getGmailApiClient()\n const limit = input.limit ?? 50\n\n // L3 first-page retry: a prior fallback scan hard-failed on its FIRST page and\n // pinned only the history snapshot (no page token). Re-enter the fallback scan\n // from the first INBOX page so unprocessed messages are retried, not skipped by\n // the bootstrap path below.\n if (channelState.pendingMessagesHistoryIdSnapshot && !channelState.pendingMessagesPageToken) {\n return await this.startMessagesListFallback(\n api,\n auth,\n userCredentials.email ?? 'me',\n channelState.pendingMessagesHistoryIdSnapshot,\n limit,\n )\n }\n\n // Bootstrap path: no historyId yet \u2192 just persist current historyId and skip fetch.\n if (!channelState.historyId && !channelState.pendingMessagesPageToken) {\n const profile = await api.getProfile(auth)\n const nextState: GmailChannelState = {\n historyId: profile.historyId,\n lastSyncedAt: new Date().toISOString(),\n }\n return {\n messages: [],\n nextCursor: encodeCursor(nextState),\n hasMore: false,\n }\n }\n\n // Mid-drain fallback (404 path): we previously fell back to messages.list\n // for an expired historyId and still have pages to drain. Resume from the\n // stored pageToken without re-issuing the history.list call.\n if (channelState.pendingMessagesPageToken) {\n return await this.continueMessagesListDrain(\n api,\n auth,\n userCredentials.email ?? 'me',\n channelState,\n limit,\n )\n }\n\n // Incremental path: history.list since stored historyId, walking\n // nextPageToken until either (a) all pages drained, or (b) we've collected\n // `limit` messages.\n // CRITICAL: terminal historyId is ONLY advanced after full drain. While a\n // pending pageToken exists in channelState, the original startHistoryId is\n // retained so the next tick re-enters the same history window.\n let messages: NormalizedInboundMessage[] = []\n try {\n return await this.continueHistoryListDrain(\n api,\n auth,\n userCredentials.email ?? 'me',\n channelState,\n String(channelState.historyId),\n limit,\n )\n } catch (error) {\n if (error instanceof GmailApiError && error.status === 404) {\n // Gmail history expired (~7-day retention). Fall back to a paged inbox\n // scan via messages.list. Snapshot the post-fallback historyId now so\n // we can advance it once the messages.list drain completes.\n const profile = await api.getProfile(auth)\n return await this.startMessagesListFallback(\n api,\n auth,\n userCredentials.email ?? 'me',\n profile.historyId,\n limit,\n )\n }\n throw error\n }\n }\n\n /**\n * Spec C \u00A7 Phase C2 \u2014 Register Gmail Pub/Sub watch.\n *\n * Calls `gmail.users.watch` with the operator-configured Pub/Sub topic.\n * Returns `historyId` (cursor for subsequent `history.list` calls) and\n * `expiration` (ms since epoch \u2014 Gmail caps at ~7 days). Persists both\n * onto `CommunicationChannel.channelState` via the hub's\n * `push.register` command.\n */\n async registerPush(input: RegisterPushInput): Promise<PushRegistration> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const auth = { accessToken: userCredentials.accessToken }\n const api = getGmailApiClient()\n const topicName = (input.providerConfig?.pubsubTopic as string | undefined) ?? ''\n if (!topicName) {\n return {\n providerKey: this.providerKey,\n status: 'failed',\n channelStatePatch: {\n pushStatus: 'failed',\n lastPushError: {\n code: 'missing_topic',\n message: 'Pub/Sub topic not configured',\n at: new Date().toISOString(),\n },\n },\n error: {\n code: 'missing_topic',\n message: 'OM_GMAIL_PUBSUB_TOPIC not configured for this tenant',\n },\n }\n }\n try {\n const result = await api.watchInbox(auth, { topicName, labelIds: ['INBOX'] })\n const expirationMs = Number(result.expiration)\n return {\n providerKey: this.providerKey,\n status: 'active',\n channelStatePatch: {\n historyId: result.historyId,\n watchExpirationMs: Number.isFinite(expirationMs) ? expirationMs : Date.now() + 6 * 24 * 3600 * 1000,\n pubsubTopic: topicName,\n pushStatus: 'active',\n lastPushError: null,\n },\n recommendedPollIntervalSeconds: 1800,\n }\n } catch (error) {\n const status = error instanceof GmailApiError ? error.status : 0\n const detail = error instanceof Error ? error.message : 'watch failed'\n return {\n providerKey: this.providerKey,\n status: 'failed',\n channelStatePatch: {\n pushStatus: 'failed',\n lastPushError: {\n code: `gmail_watch_${status || 'error'}`,\n message: detail.slice(0, 500),\n at: new Date().toISOString(),\n },\n },\n error: { code: `gmail_watch_${status || 'error'}`, message: detail },\n }\n }\n }\n\n /**\n * Spec C \u00A7 Phase C2 \u2014 Tear down Gmail watch via `gmail.users.stop`.\n * Idempotent: a 404 (no active watch) is swallowed.\n */\n async unregisterPush(input: UnregisterPushInput): Promise<void> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const auth = { accessToken: userCredentials.accessToken }\n const api = getGmailApiClient()\n try {\n await api.stopWatch(auth)\n } catch (error) {\n if (error instanceof GmailApiError && error.status === 404) return\n throw error\n }\n }\n\n /**\n * Spec C \u00A7 Phase C2 \u2014 Convert a verified Pub/Sub notification into a\n * `HistoryPage`. The notification body itself is just `{ emailAddress,\n * historyId }`; the actual messages come from `history.list` against\n * `channelState.historyId`. We delegate to `fetchHistory` so the\n * pagination / 404-fallback logic stays in one place.\n */\n async applyPushNotification(input: ApplyPushNotificationInput): Promise<HistoryPage> {\n // The notification's `historyId` is informational \u2014 Gmail guarantees\n // it is `>= channelState.historyId`, but a multi-event batch may\n // advance further than what `history.list` returns in a single page.\n // Treat the call as \"drain whatever is new since the stored cursor\".\n // If `channelState.historyId` is absent (a push arrived before the cursor\n // was seeded), `fetchHistory` bootstraps \u2014 persists the current historyId\n // and returns zero messages \u2014 so this notification's delta is picked up on\n // the next call. In practice `registerPush` seeds the cursor before any\n // push flows, so this edge is not hit in the normal lifecycle.\n return this.fetchHistory({\n conversationId: 'INBOX',\n credentials: input.credentials,\n channelState: input.channelState,\n scope: input.scope,\n // Push notifications are bursty (1/s max per Gmail user); use a\n // smaller per-call limit so the worker drains quickly between\n // notifications without holding the API quota.\n limit: 50,\n } as FetchHistoryInput)\n }\n\n private async continueHistoryListDrain(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n accountIdentifier: string,\n channelState: GmailChannelState,\n startHistoryId: string,\n limit: number,\n ): Promise<HistoryPage> {\n const collected: Array<{ id: string; threadId: string; labelIds?: string[] }> = []\n const seen = new Set<string>()\n let pageToken: string | undefined = channelState.pendingHistoryPageToken\n let lastResponseHistoryId: string | undefined\n let drained = false\n\n // Fully consume each page before deciding to stop: `pageToken` must only ever\n // advance past refs we have actually collected, otherwise a page carrying more\n // than `limit` new refs would silently drop the overflow. `collected` may\n // therefore exceed `limit` by up to one page \u2014 bounded and intentional.\n while (true) {\n const history = await api.listHistory(auth, {\n startHistoryId,\n pageToken,\n })\n lastResponseHistoryId = history.historyId ?? lastResponseHistoryId\n for (const ref of collectMessageRefs(history.history ?? [])) {\n if (seen.has(ref.id)) continue\n seen.add(ref.id)\n collected.push(ref)\n }\n if (!history.nextPageToken) {\n drained = true\n break\n }\n pageToken = history.nextPageToken\n if (collected.length >= limit) break\n }\n\n const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, collected, accountIdentifier)\n const nextState: GmailChannelState = {\n lastSyncedAt: new Date().toISOString(),\n }\n if (hardFailed) {\n // L3: a message failed transiently. Restart the window from startHistoryId\n // on the next tick (drop any page token) so every page \u2014 including the one\n // carrying the failed message \u2014 is re-read; already-ingested messages dedup\n // at the hub. Do NOT advance the terminal historyId or pin a forward token,\n // which would skip the failed message's page.\n nextState.historyId = startHistoryId\n } else if (drained) {\n // All pages drained \u2014 advance the terminal historyId.\n nextState.historyId = lastResponseHistoryId ?? startHistoryId\n } else {\n // Mid-drain \u2014 keep the prior startHistoryId pinned + remember the next\n // unconsumed pageToken so the following tick resumes without re-walking.\n nextState.historyId = startHistoryId\n nextState.pendingHistoryPageToken = pageToken\n }\n return {\n messages,\n nextCursor: encodeCursor(nextState),\n // Re-enqueue immediately when a transient failure left work behind.\n hasMore: hardFailed || !drained,\n }\n }\n\n private async startMessagesListFallback(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n accountIdentifier: string,\n historyIdSnapshot: string,\n limit: number,\n ): Promise<HistoryPage> {\n const list = await api.listMessages(auth, {\n labelIds: ['INBOX'],\n maxResults: limit,\n })\n const refs = (list.messages ?? []).map((m) => ({\n id: m.id,\n threadId: m.threadId,\n labelIds: ['INBOX'],\n }))\n const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, refs, accountIdentifier)\n const drained = !list.nextPageToken\n const nextState: GmailChannelState = {\n lastSyncedAt: new Date().toISOString(),\n }\n if (hardFailed) {\n // L3: this is the FIRST fallback page (no prior page token), so there is\n // nothing to pin. Deliberately leave `historyId` unset so the cursor does\n // NOT advance past the unprocessed messages \u2014 the next tick re-enters the\n // same fallback scan and retries them.\n nextState.pendingMessagesHistoryIdSnapshot = historyIdSnapshot\n } else if (drained) {\n nextState.historyId = historyIdSnapshot\n } else {\n nextState.pendingMessagesPageToken = list.nextPageToken\n nextState.pendingMessagesHistoryIdSnapshot = historyIdSnapshot\n }\n return {\n messages,\n nextCursor: encodeCursor(nextState),\n hasMore: hardFailed || !drained,\n }\n }\n\n private async continueMessagesListDrain(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n accountIdentifier: string,\n channelState: GmailChannelState,\n limit: number,\n ): Promise<HistoryPage> {\n const list = await api.listMessages(auth, {\n labelIds: ['INBOX'],\n maxResults: limit,\n pageToken: channelState.pendingMessagesPageToken,\n })\n const refs = (list.messages ?? []).map((m) => ({\n id: m.id,\n threadId: m.threadId,\n labelIds: ['INBOX'],\n }))\n const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, refs, accountIdentifier)\n const drained = !list.nextPageToken\n const nextState: GmailChannelState = {\n lastSyncedAt: new Date().toISOString(),\n }\n if (hardFailed) {\n // L3: re-pin the SAME page token (not list.nextPageToken) so the next\n // tick re-fetches this page and retries the unprocessed messages.\n nextState.pendingMessagesPageToken = channelState.pendingMessagesPageToken\n nextState.pendingMessagesHistoryIdSnapshot = channelState.pendingMessagesHistoryIdSnapshot\n } else if (drained) {\n nextState.historyId = channelState.pendingMessagesHistoryIdSnapshot ?? channelState.historyId\n } else {\n nextState.pendingMessagesPageToken = list.nextPageToken\n nextState.pendingMessagesHistoryIdSnapshot = channelState.pendingMessagesHistoryIdSnapshot\n }\n return {\n messages,\n nextCursor: encodeCursor(nextState),\n hasMore: hardFailed || !drained,\n }\n }\n\n /**\n * Operator-triggered backlog import (Spec B \u00A7 Phase B6).\n *\n * Reaches BACKWARD in time over `gmail.users.messages.list?q=\u2026`, independent\n * of the forward-polling `historyId` cursor \u2014 nothing here reads or writes\n * `GmailChannelState`, so a multi-year backfill can never move the live poll\n * cursor.\n *\n * Scale contract: the cursor carries Gmail's own `nextPageToken` plus a\n * collected counter and the frozen query, so it stays a few hundred bytes for\n * a 50k-message mailbox. Message ids are never enumerated up front.\n */\n async importHistory(input: ImportHistoryInput): Promise<ImportHistoryPage> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const auth = { accessToken: userCredentials.accessToken }\n const api = getGmailApiClient()\n const accountIdentifier = userCredentials.email ?? 'me'\n\n const maxMessages = clampImportMaxMessages(input.maxMessages)\n const pageSize = resolveImportPageSize()\n const concurrency = resolveImportConcurrency()\n\n const cursor = decodeGmailImportCursor(input.cursor)\n const afterTerm = cursor?.after ?? buildAfterTerm(clampImportSinceDays(input.sinceDays))\n const queries = buildImportQueries(afterTerm, input.contactEmails)\n\n let queryIndex = cursor ? cursor.queryIndex : 0\n let pageToken = cursor?.pageToken\n const collectedSoFar = cursor?.collected ?? 0\n let activeQuery: string | undefined = cursor?.query ?? queries[queryIndex]\n\n const budget = Math.max(0, maxMessages - collectedSoFar)\n if (budget === 0 || queryIndex >= queries.length) {\n return { messages: [], hasMore: false }\n }\n\n const perPage = Math.min(pageSize, budget)\n const refs: Array<{ id: string; threadId: string }> = []\n let listCalls = 0\n while (refs.length < perPage && queryIndex < queries.length && listCalls < IMPORT_MAX_LIST_CALLS_PER_PAGE) {\n listCalls += 1\n const list = await api.listMessages(auth, {\n query: activeQuery,\n labelIds: ['INBOX'],\n pageToken,\n maxResults: perPage - refs.length,\n })\n for (const message of list.messages ?? []) {\n refs.push({ id: message.id, threadId: message.threadId })\n }\n if (list.nextPageToken) {\n pageToken = list.nextPageToken\n continue\n }\n queryIndex += 1\n pageToken = undefined\n activeQuery = queries[queryIndex]\n }\n\n const messages = await this.fetchAndNormalizeForImport(api, auth, refs, accountIdentifier, concurrency)\n\n const collected = collectedSoFar + messages.length\n const exhausted = pageToken === undefined && queryIndex >= queries.length\n const hasMore = !exhausted && collected < maxMessages\n const nextCursor = hasMore\n ? encodeCursor({\n v: GMAIL_IMPORT_CURSOR_VERSION,\n after: afterTerm,\n query: queries[queryIndex] ?? activeQuery ?? queries[queries.length - 1],\n queryIndex,\n pageToken,\n collected,\n } satisfies GmailImportCursor)\n : undefined\n\n return { messages, nextCursor, hasMore }\n }\n\n /**\n * Import-path fetch. Diverges from `fetchAndNormalize` deliberately: the\n * forward path stops at the first hard failure to protect the polling cursor,\n * whereas a backlog import has no cursor to protect and must not abandon a\n * multi-year sweep over one unreadable message \u2014 so a per-message failure is\n * skipped and logged. Only credential/permission failures (401/403), which\n * would fail every remaining message too, abort the page.\n */\n private async fetchAndNormalizeForImport(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n refs: Array<{ id: string; threadId: string }>,\n accountIdentifier: string,\n concurrency: number,\n ): Promise<NormalizedInboundMessage[]> {\n const results = new Array<NormalizedInboundMessage | null>(refs.length).fill(null)\n let nextIndex = 0\n const workerCount = Math.max(1, Math.min(concurrency, refs.length))\n\n const runWorker = async (): Promise<void> => {\n while (true) {\n const index = nextIndex\n nextIndex += 1\n if (index >= refs.length) return\n const ref = refs[index]\n try {\n const raw = await api.getMessageRaw(auth, ref.id)\n const fallbackDate = raw.internalDate ? new Date(Number(raw.internalDate)) : undefined\n results[index] = await normalizeInboundGmailMessage({\n rawMessage: decodeBase64Url(raw.raw),\n gmailMessageId: raw.id,\n gmailThreadId: raw.threadId,\n gmailLabelIds: raw.labelIds ?? [],\n accountIdentifier,\n fallbackDate,\n })\n } catch (error) {\n if (isFatalGmailImportError(error)) throw error\n logger.warn('skipping unreadable message during history import', {\n gmailMessageId: ref.id,\n reason: error instanceof Error ? error.message.slice(0, 200) : 'unknown error',\n })\n }\n }\n }\n\n await Promise.all(Array.from({ length: workerCount }, () => runWorker()))\n return results.filter((message): message is NormalizedInboundMessage => message !== null)\n }\n\n async deleteMessage(input: DeleteChannelMessageInput): Promise<void> {\n const userCredentials = parseUserCredentialsOrThrow(input.credentials)\n const api = getGmailApiClient()\n // Gmail's \"delete\" capability is delivered as Trash to match the user's\n // mental model and avoid permanent loss on accidental clicks. The user can\n // restore from Trash in the Gmail web UI within 30 days.\n await api.trashMessage({ accessToken: userCredentials.accessToken }, input.externalMessageId)\n }\n\n async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {\n return emailResolveContact(input)\n }\n\n /**\n * Fetch + normalize each collected message ref.\n *\n * L3 fix: a non-404/410 error on `getMessageRaw` (e.g. a transient 500/403)\n * used to re-throw and abort the whole tick, discarding messages that had\n * already normalized in the same page. Worse, because the cursor could be\n * advanced from a different source (a push notification carrying a higher\n * historyId), the transiently-failed messages could be skipped permanently.\n *\n * We now treat a hard failure as a stop point: keep the messages normalized\n * BEFORE the failure and signal `hardFailed: true` so the caller pins the\n * persisted `historyId` (does NOT advance past the failure) and re-fetches on\n * the next tick. 404/410 stay skipped (the message is genuinely gone).\n */\n private async fetchAndNormalize(\n api: ReturnType<typeof getGmailApiClient>,\n auth: { accessToken: string },\n refs: Array<{ id: string; threadId: string; labelIds?: string[] }>,\n accountIdentifier: string,\n ): Promise<{ messages: NormalizedInboundMessage[]; hardFailed: boolean }> {\n const out: NormalizedInboundMessage[] = []\n for (const ref of refs) {\n let raw: GmailGetMessageRawResponse\n try {\n raw = await api.getMessageRaw(auth, ref.id)\n } catch (error) {\n // 404/410: the message is gone \u2014 skip it and keep draining.\n if (error instanceof GmailApiError && (error.status === 404 || error.status === 410)) continue\n // Any other failure is potentially transient. Stop here without\n // advancing past the unprocessed messages so the next tick retries them.\n return { messages: out, hardFailed: true }\n }\n const rawBuffer = decodeBase64Url(raw.raw)\n const fallbackDate = raw.internalDate ? new Date(Number(raw.internalDate)) : undefined\n const normalized = await normalizeInboundGmailMessage({\n rawMessage: rawBuffer,\n gmailMessageId: raw.id,\n gmailThreadId: raw.threadId,\n gmailLabelIds: raw.labelIds ?? ref.labelIds ?? [],\n accountIdentifier,\n fallbackDate,\n })\n out.push(normalized)\n }\n return { messages: out, hardFailed: false }\n }\n}\n\n// \u2500\u2500 Backlog import (importHistory) helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst GMAIL_IMPORT_CURSOR_VERSION = 1\nconst IMPORT_SINCE_DAYS_MIN = 1\nconst IMPORT_SINCE_DAYS_DEFAULT = IMPORT_HISTORY_DEFAULT_SINCE_DAYS\nconst IMPORT_MAX_MESSAGES_MIN = 1\nconst IMPORT_MAX_MESSAGES_DEFAULT = IMPORT_HISTORY_DEFAULT_MAX_MESSAGES\nconst IMPORT_PAGE_SIZE_DEFAULT = 100\n/** `users.messages.list` rejects `maxResults` above 500. */\nconst IMPORT_PAGE_SIZE_MAX = 500\nconst IMPORT_CONCURRENCY_DEFAULT = 5\nconst IMPORT_CONCURRENCY_MAX = 20\n/** Senders per `from:(\u2026 OR \u2026)` group \u2014 keeps each `q` well inside Gmail's URL budget. */\nconst IMPORT_SENDER_CHUNK_SIZE = 25\n/** Matches the hub's `contactEmails` ceiling; bounds the number of query groups. */\nconst IMPORT_SENDER_MAX = 200\n/** Bounds `messages.list` round-trips inside a single page when groups come back empty. */\nconst IMPORT_MAX_LIST_CALLS_PER_PAGE = 25\n\ninterface GmailImportCursor {\n v: number\n /** Frozen `after:<epochSeconds>` term so later pages cannot drift with wall-clock. */\n after: string\n /** Frozen full query for the group the next page resumes on. */\n query: string\n queryIndex: number\n pageToken?: string\n collected: number\n}\n\nfunction clampImportSinceDays(value: number): number {\n const raw = Number.isFinite(value) ? Math.trunc(value) : IMPORT_SINCE_DAYS_DEFAULT\n return Math.max(IMPORT_SINCE_DAYS_MIN, Math.min(getImportHistoryMaxSinceDays(), raw))\n}\n\nfunction clampImportMaxMessages(value: number | undefined): number {\n const raw = Number.isFinite(value) ? Math.trunc(value as number) : IMPORT_MAX_MESSAGES_DEFAULT\n return Math.max(IMPORT_MAX_MESSAGES_MIN, Math.min(getImportHistoryMaxMessages(), raw))\n}\n\nfunction resolveEnvInt(name: string, fallback: number, max: number): number {\n const parsed = Number.parseInt(process.env[name] ?? '', 10)\n if (!Number.isFinite(parsed) || parsed <= 0) return fallback\n return Math.min(parsed, max)\n}\n\nfunction resolveImportPageSize(): number {\n return resolveEnvInt('OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE', IMPORT_PAGE_SIZE_DEFAULT, IMPORT_PAGE_SIZE_MAX)\n}\n\nfunction resolveImportConcurrency(): number {\n return resolveEnvInt('OM_CHANNEL_GMAIL_IMPORT_CONCURRENCY', IMPORT_CONCURRENCY_DEFAULT, IMPORT_CONCURRENCY_MAX)\n}\n\n/**\n * Gmail accepts `after:` as either `YYYY/MM/DD` \u2014 resolved in the mailbox\n * owner's display timezone, so the boundary day is ambiguous \u2014 or as a Unix\n * timestamp in seconds, which is not. We emit the timestamp form.\n *\n * Either form filters on Gmail's INTERNAL date (when Gmail received the\n * message), not the `Date:` header, so a message with an old header that was\n * recently delivered or migrated into the mailbox counts as recent.\n */\nfunction buildAfterTerm(sinceDays: number): string {\n const epochSeconds = Math.floor((Date.now() - sinceDays * 24 * 60 * 60 * 1000) / 1000)\n return `after:${Math.max(0, epochSeconds)}`\n}\n\nfunction normalizeImportSenders(contactEmails: string[] | undefined): string[] {\n const seen = new Set<string>()\n for (const raw of contactEmails ?? []) {\n if (typeof raw !== 'string') continue\n const trimmed = raw.trim().toLowerCase()\n if (!trimmed.includes('@') || /[\\s\"()]/.test(trimmed)) continue\n seen.add(trimmed)\n if (seen.size >= IMPORT_SENDER_MAX) break\n }\n return Array.from(seen)\n}\n\n/**\n * Build the ordered list of Gmail queries this import walks.\n *\n * Gmail's `q` rides in the request URL, so a 200-address `from:(\u2026 OR \u2026)` chain\n * would blow past a practical URL budget. Senders are therefore split into\n * groups of `IMPORT_SENDER_CHUNK_SIZE` and the groups are walked SEQUENTIALLY\n * across pages (the cursor records which group is active). Groups are disjoint\n * by construction \u2014 a message has exactly one `From` \u2014 so no cross-group dedup\n * is needed. With no sender hint a single date-only query is returned.\n */\nfunction buildImportQueries(afterTerm: string, contactEmails: string[] | undefined): string[] {\n const senders = normalizeImportSenders(contactEmails)\n if (senders.length === 0) return [afterTerm]\n const queries: string[] = []\n for (let index = 0; index < senders.length; index += IMPORT_SENDER_CHUNK_SIZE) {\n const group = senders.slice(index, index + IMPORT_SENDER_CHUNK_SIZE)\n queries.push(`${afterTerm} from:(${group.join(' OR ')})`)\n }\n return queries\n}\n\nfunction decodeGmailImportCursor(value: string | undefined): GmailImportCursor | null {\n const parsed = decodeCursor(value)\n if (!parsed || typeof parsed !== 'object') return null\n const candidate = parsed as Partial<Record<keyof GmailImportCursor, unknown>>\n if (candidate.v !== GMAIL_IMPORT_CURSOR_VERSION) return null\n if (typeof candidate.after !== 'string' || typeof candidate.query !== 'string') return null\n const queryIndex = typeof candidate.queryIndex === 'number' && candidate.queryIndex >= 0 ? Math.trunc(candidate.queryIndex) : 0\n const collected = typeof candidate.collected === 'number' && candidate.collected >= 0 ? Math.trunc(candidate.collected) : 0\n const pageToken = typeof candidate.pageToken === 'string' && candidate.pageToken.length > 0 ? candidate.pageToken : undefined\n return {\n v: GMAIL_IMPORT_CURSOR_VERSION,\n after: candidate.after,\n query: candidate.query,\n queryIndex,\n pageToken,\n collected,\n }\n}\n\n/**\n * A 401 (expired/revoked grant) or a 403 that survived the client's bounded\n * retry (permission denied, or exhausted per-user quota) will fail every\n * remaining message in the sweep, so it aborts the page instead of being\n * skipped message by message.\n */\nfunction isFatalGmailImportError(error: unknown): boolean {\n return error instanceof GmailApiError && (error.status === 401 || error.status === 403)\n}\n\nfunction collectMessageRefs(\n history: Array<{\n messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>\n }>,\n): Array<{ id: string; threadId: string; labelIds?: string[] }> {\n const seen = new Set<string>()\n const refs: Array<{ id: string; threadId: string; labelIds?: string[] }> = []\n for (const entry of history) {\n for (const added of entry.messagesAdded ?? []) {\n if (seen.has(added.message.id)) continue\n seen.add(added.message.id)\n refs.push({ id: added.message.id, threadId: added.message.threadId, labelIds: added.message.labelIds })\n }\n }\n return refs\n}\n\nfunction parseUserCredentialsOrThrow(value: unknown): GmailUserCredentials {\n const parsed = gmailUserCredentialsSchema.safeParse(value)\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n throw new Error(`Invalid Gmail credentials: ${first?.message ?? 'unknown validation error'}`)\n }\n return parsed.data\n}\n\nfunction parseClientCredentialsOrThrow(value: unknown): GmailClientCredentials {\n const parsed = gmailClientCredentialsSchema.safeParse(value)\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n throw new Error(`Invalid Gmail OAuth client credentials: ${first?.message ?? 'unknown validation error'}`)\n }\n return parsed.data\n}\n\nlet warnedLegacyClientPath = false\n\n/**\n * Resolve the OAuth client config for a Gmail refresh, preferring the new\n * `RefreshCredentialsInput.oauthClient` field (Spec A,\n * .ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md).\n *\n * Falls back to the deprecated `credentials._client` read path for one\n * minor release so existing tests keep working. The legacy path emits a\n * one-time deprecation warning per process so production logs stay quiet.\n */\nfunction resolveGmailOAuthClient(input: RefreshCredentialsInput): GmailClientCredentials {\n if (input.oauthClient) {\n const client = input.oauthClient\n if (!client.clientId) {\n throw new Error('[internal] Invalid Gmail OAuth client credentials: OAuth Client ID required')\n }\n if (!client.clientSecret) {\n throw new Error('[internal] Invalid Gmail OAuth client credentials: clientSecret required')\n }\n return {\n clientId: client.clientId,\n clientSecret: client.clientSecret,\n // `GmailClientCredentials.scopes` is the wire format the legacy\n // `credentials._client` blob carried \u2014 comma/space-separated string.\n // Spec A's `OAuthClientConfig.scopes` is the canonical `string[]`.\n // `parseScopes` accepts either separator, so join with a single space.\n ...(client.scopes !== undefined ? { scopes: client.scopes.join(' ') } : {}),\n }\n }\n // Legacy path \u2014 DEPRECATED. Remove in the next minor release.\n if (!warnedLegacyClientPath) {\n warnedLegacyClientPath = true\n logger.warn(\n 'reading OAuth client config from credentials._client is deprecated;' +\n ' pass via RefreshCredentialsInput.oauthClient instead (Spec A)',\n )\n }\n return parseClientCredentialsOrThrow(\n (input.credentials as unknown as { _client?: unknown })._client ?? input.credentials,\n )\n}\n\nfunction pickRawMimeBuffer(payload: { rawBase64Url?: unknown; rawBody?: unknown }): Buffer {\n if (typeof payload.rawBase64Url === 'string') return decodeBase64Url(payload.rawBase64Url)\n const value = payload.rawBody\n if (Buffer.isBuffer(value)) return value\n if (value instanceof Uint8Array) return Buffer.from(value)\n if (typeof value === 'string') return Buffer.from(value, 'utf-8')\n throw new Error('[internal] Gmail normalizeInbound requires `raw.rawBase64Url` or `raw.rawBody`')\n}\n\nlet cachedAdapter: GmailChannelAdapter | null = null\n\nexport function getGmailChannelAdapter(): GmailChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new GmailChannelAdapter()\n return cachedAdapter\n}\n\nexport { GmailChannelAdapter }\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/channel-gmail",
3
- "version": "0.8.1-develop.7221.1.ece7ca874e",
3
+ "version": "0.8.1-develop.7227.1.9248c14599",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,19 +62,19 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.8.1-develop.7221.1.ece7ca874e",
66
- "@open-mercato/ui": "0.8.1-develop.7221.1.ece7ca874e",
65
+ "@open-mercato/core": "0.8.1-develop.7227.1.9248c14599",
66
+ "@open-mercato/ui": "0.8.1-develop.7227.1.9248c14599",
67
67
  "@types/mailparser": "^3.4.5",
68
68
  "mailparser": "^3.9.17"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "@mikro-orm/postgresql": "^7.0.14",
72
- "@open-mercato/shared": "0.8.1-develop.7221.1.ece7ca874e",
72
+ "@open-mercato/shared": "0.8.1-develop.7227.1.9248c14599",
73
73
  "react": "^19.0.0",
74
74
  "react-dom": "^19.0.0"
75
75
  },
76
76
  "devDependencies": {
77
- "@open-mercato/shared": "0.8.1-develop.7221.1.ece7ca874e",
77
+ "@open-mercato/shared": "0.8.1-develop.7227.1.9248c14599",
78
78
  "@types/jest": "^30.0.0",
79
79
  "@types/react": "^19.2.18",
80
80
  "@types/react-dom": "^19.2.5",
@@ -0,0 +1,59 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+
5
+ /**
6
+ * TC-CHANNEL-EMAIL-032 — Gmail backlog import route wiring.
7
+ *
8
+ * The Gmail adapter now implements `importHistory`, so
9
+ * `POST /api/communication_channels/channels/{id}/import-history` must no
10
+ * longer short-circuit with the "provider does not support history import"
11
+ * 400 for `gmail` channels. This spec covers the route surface reachable
12
+ * without a live Google grant: auth, body validation (including the widened
13
+ * 10-year / 50k ceilings), and the not-found branch.
14
+ *
15
+ * The Gmail query construction, sender chunking, cursor round-trip,
16
+ * pagination, `maxMessages` cap and per-message error handling are covered by
17
+ * `packages/channel-gmail/src/modules/channel_gmail/lib/__tests__/adapter.test.ts`
18
+ * (`GmailChannelAdapter.importHistory`).
19
+ *
20
+ * The full end-to-end (connect a Gmail mailbox → POST /import-history →
21
+ * ProgressJob completes → imported messages on the Person timeline) needs a
22
+ * real OAuth grant and stays manual, alongside
23
+ * `.ai/qa/scenarios/TC-CHANNEL-EMAIL-029-import-history.md`.
24
+ */
25
+ test.describe('TC-CHANNEL-EMAIL-032: gmail import-history route wiring', () => {
26
+ const FAKE_CHANNEL_ID = '00000000-0000-0000-0000-000000000032'
27
+ const path = `/api/communication_channels/channels/${FAKE_CHANNEL_ID}/import-history`
28
+
29
+ test('rejects unauthenticated requests', async ({ request }) => {
30
+ const response = await apiRequest(request, 'POST', path, {
31
+ // Intentionally empty token — this test asserts the 401 unauth path.
32
+ token: '',
33
+ data: { sinceDays: 365 },
34
+ })
35
+ expect(response.status()).toBe(401)
36
+ })
37
+
38
+ test('rejects a sinceDays value beyond the ten-year ceiling', async ({ request }) => {
39
+ const token = await getAuthToken(request)
40
+ const response = await apiRequest(request, 'POST', path, {
41
+ token,
42
+ data: { sinceDays: 3651 },
43
+ })
44
+ expect(response.status()).toBe(400)
45
+ })
46
+
47
+ test('accepts a multi-year window without a schema rejection', async ({ request }) => {
48
+ const token = await getAuthToken(request)
49
+ const response = await apiRequest(request, 'POST', path, {
50
+ token,
51
+ data: { sinceDays: 1825, maxMessages: 50000 },
52
+ })
53
+ // The channel does not exist, so the request must fail on the lookup
54
+ // branch (or the missing-org-scope branch) — never on body validation and
55
+ // never with a 5xx.
56
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
57
+ expect([400, 403, 404]).toContain(response.status())
58
+ })
59
+ })
@@ -847,3 +847,332 @@ function stubOAuth(overrides: Partial<GoogleOAuthClient>): GoogleOAuthClient {
847
847
  fetchUserInfo: overrides.fetchUserInfo ?? (async () => ({})),
848
848
  }
849
849
  }
850
+
851
+ describe('GmailChannelAdapter.importHistory', () => {
852
+ const scope = { tenantId: 'tenant-1', organizationId: 'org-1' }
853
+
854
+ interface ListCall {
855
+ query?: string
856
+ labelIds?: string[]
857
+ pageToken?: string
858
+ maxResults?: number
859
+ }
860
+
861
+ function decodeImportCursor(cursor: string | undefined): Record<string, unknown> {
862
+ if (!cursor) throw new Error('expected a cursor')
863
+ return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record<string, unknown>
864
+ }
865
+
866
+ function buildImportApi(options: {
867
+ pages: Array<{ messages: Array<{ id: string; threadId: string }>; nextPageToken?: string }>
868
+ listCalls: ListCall[]
869
+ getMessageRaw?: GmailApiClient['getMessageRaw']
870
+ }): GmailApiClient {
871
+ let pageIndex = 0
872
+ return {
873
+ listHistory: async () => ({ historyId: '0' }),
874
+ listMessages: async (_auth, listInput) => {
875
+ options.listCalls.push({ ...listInput })
876
+ const page = options.pages[pageIndex] ?? { messages: [] }
877
+ pageIndex += 1
878
+ return { messages: page.messages, nextPageToken: page.nextPageToken, resultSizeEstimate: 9999 }
879
+ },
880
+ getMessageRaw:
881
+ options.getMessageRaw ??
882
+ (async (_auth, messageId) => ({
883
+ id: messageId,
884
+ threadId: `thread-${messageId}`,
885
+ labelIds: ['INBOX'],
886
+ raw: encodeBase64Url(buildRawMime(`${messageId}@gmail.com`, `body ${messageId}`)),
887
+ internalDate: String(Date.UTC(2026, 4, 21)),
888
+ })),
889
+ sendRawMessage: async () => ({ id: 'x', threadId: 'x' }),
890
+ getProfile: async () => ({ emailAddress: 'alice@gmail.com', historyId: '100' }),
891
+ trashMessage: async () => undefined,
892
+ watchInbox: async () => ({ historyId: '1', expiration: '1' }),
893
+ stopWatch: async () => undefined,
894
+ }
895
+ }
896
+
897
+ const importEnvKeys = ['OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE', 'OM_CHANNEL_GMAIL_IMPORT_CONCURRENCY'] as const
898
+
899
+ afterEach(() => {
900
+ for (const key of importEnvKeys) delete process.env[key]
901
+ })
902
+
903
+ it('is exposed on the adapter so the hub stops rejecting gmail imports', () => {
904
+ expect(typeof getGmailChannelAdapter().importHistory).toBe('function')
905
+ })
906
+
907
+ it('builds a date-only INBOX query when no sender hint is supplied', async () => {
908
+ const listCalls: ListCall[] = []
909
+ setGmailApiClient(buildImportApi({ listCalls, pages: [{ messages: [{ id: 'm1', threadId: 't1' }] }] }))
910
+ const before = Math.floor(Date.now() / 1000)
911
+
912
+ const page = await getGmailChannelAdapter().importHistory({
913
+ credentials: userCredentials,
914
+ scope,
915
+ sinceDays: 30,
916
+ })
917
+
918
+ expect(page.messages).toHaveLength(1)
919
+ expect(page.hasMore).toBe(false)
920
+ expect(page.nextCursor).toBeUndefined()
921
+ expect(listCalls[0].labelIds).toEqual(['INBOX'])
922
+ const match = /^after:(\d+)$/.exec(listCalls[0].query ?? '')
923
+ expect(match).not.toBeNull()
924
+ const afterSeconds = Number(match?.[1])
925
+ expect(afterSeconds).toBeLessThanOrEqual(before - 30 * 86400 + 5)
926
+ expect(afterSeconds).toBeGreaterThanOrEqual(before - 30 * 86400 - 5)
927
+ })
928
+
929
+ it('clamps sinceDays to the 1..3650 window', async () => {
930
+ const listCalls: ListCall[] = []
931
+ setGmailApiClient(buildImportApi({ listCalls, pages: [{ messages: [] }] }))
932
+ const adapter = getGmailChannelAdapter()
933
+ const now = Math.floor(Date.now() / 1000)
934
+
935
+ await adapter.importHistory({ credentials: userCredentials, scope, sinceDays: 99_999 })
936
+ await adapter.importHistory({ credentials: userCredentials, scope, sinceDays: 0 })
937
+
938
+ const clampedHigh = Number(/^after:(\d+)$/.exec(listCalls[0].query ?? '')?.[1])
939
+ const clampedLow = Number(/^after:(\d+)$/.exec(listCalls[1].query ?? '')?.[1])
940
+ expect(now - clampedHigh).toBeGreaterThanOrEqual(3650 * 86400 - 5)
941
+ expect(now - clampedHigh).toBeLessThanOrEqual(3650 * 86400 + 5)
942
+ expect(now - clampedLow).toBeGreaterThanOrEqual(86400 - 5)
943
+ expect(now - clampedLow).toBeLessThanOrEqual(86400 + 5)
944
+ })
945
+
946
+ it('chunks the sender hint into from:(… OR …) groups walked sequentially', async () => {
947
+ process.env.OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE = '2'
948
+ const senders = Array.from({ length: 30 }, (_, index) => `sender${index}@example.com`)
949
+ const listCalls: ListCall[] = []
950
+ setGmailApiClient(
951
+ buildImportApi({
952
+ listCalls,
953
+ pages: [
954
+ { messages: [{ id: 'm1', threadId: 't1' }, { id: 'm2', threadId: 't1' }], nextPageToken: 'gmail-token-1' },
955
+ { messages: [{ id: 'm3', threadId: 't2' }] },
956
+ { messages: [{ id: 'm4', threadId: 't3' }] },
957
+ ],
958
+ }),
959
+ )
960
+ const adapter = getGmailChannelAdapter()
961
+
962
+ const first = await adapter.importHistory({
963
+ credentials: userCredentials,
964
+ scope,
965
+ sinceDays: 365,
966
+ contactEmails: senders,
967
+ })
968
+
969
+ expect(first.messages).toHaveLength(2)
970
+ expect(first.hasMore).toBe(true)
971
+ const cursor = decodeImportCursor(first.nextCursor)
972
+ expect(cursor.pageToken).toBe('gmail-token-1')
973
+ expect(cursor.queryIndex).toBe(0)
974
+ expect(cursor.collected).toBe(2)
975
+ expect(String(cursor.query)).toContain('from:(sender0@example.com OR sender1@example.com')
976
+ expect(String(cursor.query).split(' OR ')).toHaveLength(25)
977
+
978
+ const second = await adapter.importHistory({
979
+ credentials: userCredentials,
980
+ scope,
981
+ sinceDays: 365,
982
+ contactEmails: senders,
983
+ cursor: first.nextCursor,
984
+ })
985
+
986
+ expect(second.messages).toHaveLength(2)
987
+ expect(second.hasMore).toBe(false)
988
+ expect(second.nextCursor).toBeUndefined()
989
+ expect(listCalls[1].pageToken).toBe('gmail-token-1')
990
+ expect(listCalls[2].pageToken).toBeUndefined()
991
+ expect(String(listCalls[2].query)).toContain('sender25@example.com')
992
+ expect(String(listCalls[2].query).split(' OR ')).toHaveLength(5)
993
+ })
994
+
995
+ it('freezes the after: term across pages so the window cannot drift', async () => {
996
+ process.env.OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE = '1'
997
+ const listCalls: ListCall[] = []
998
+ setGmailApiClient(
999
+ buildImportApi({
1000
+ listCalls,
1001
+ pages: [
1002
+ { messages: [{ id: 'm1', threadId: 't1' }], nextPageToken: 'tok' },
1003
+ { messages: [{ id: 'm2', threadId: 't1' }] },
1004
+ ],
1005
+ }),
1006
+ )
1007
+ const adapter = getGmailChannelAdapter()
1008
+
1009
+ const first = await adapter.importHistory({ credentials: userCredentials, scope, sinceDays: 400 })
1010
+ const second = await adapter.importHistory({
1011
+ credentials: userCredentials,
1012
+ scope,
1013
+ sinceDays: 400,
1014
+ cursor: first.nextCursor,
1015
+ })
1016
+
1017
+ expect(second.hasMore).toBe(false)
1018
+ expect(listCalls[0].query).toBe(listCalls[1].query)
1019
+ expect(decodeImportCursor(first.nextCursor).after).toBe(listCalls[0].query)
1020
+ })
1021
+
1022
+ it('caps the total across all pages at maxMessages and stops', async () => {
1023
+ process.env.OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE = '2'
1024
+ const listCalls: ListCall[] = []
1025
+ setGmailApiClient(
1026
+ buildImportApi({
1027
+ listCalls,
1028
+ pages: [
1029
+ { messages: [{ id: 'm1', threadId: 't1' }, { id: 'm2', threadId: 't1' }], nextPageToken: 'tok' },
1030
+ { messages: [{ id: 'm3', threadId: 't1' }], nextPageToken: 'tok2' },
1031
+ ],
1032
+ }),
1033
+ )
1034
+ const adapter = getGmailChannelAdapter()
1035
+
1036
+ const first = await adapter.importHistory({
1037
+ credentials: userCredentials,
1038
+ scope,
1039
+ sinceDays: 30,
1040
+ maxMessages: 3,
1041
+ })
1042
+ expect(first.messages).toHaveLength(2)
1043
+ expect(listCalls[0].maxResults).toBe(2)
1044
+ expect(first.hasMore).toBe(true)
1045
+
1046
+ const second = await adapter.importHistory({
1047
+ credentials: userCredentials,
1048
+ scope,
1049
+ sinceDays: 30,
1050
+ maxMessages: 3,
1051
+ cursor: first.nextCursor,
1052
+ })
1053
+ expect(second.messages).toHaveLength(1)
1054
+ expect(listCalls[1].maxResults).toBe(1)
1055
+ expect(second.hasMore).toBe(false)
1056
+ expect(second.nextCursor).toBeUndefined()
1057
+ })
1058
+
1059
+ it('clamps maxMessages to the 1..50000 window', async () => {
1060
+ const listCalls: ListCall[] = []
1061
+ setGmailApiClient(buildImportApi({ listCalls, pages: [{ messages: [] }, { messages: [] }] }))
1062
+ const adapter = getGmailChannelAdapter()
1063
+
1064
+ await adapter.importHistory({ credentials: userCredentials, scope, sinceDays: 30, maxMessages: 0 })
1065
+ expect(listCalls[0].maxResults).toBe(1)
1066
+
1067
+ await adapter.importHistory({
1068
+ credentials: userCredentials,
1069
+ scope,
1070
+ sinceDays: 30,
1071
+ maxMessages: 999_999,
1072
+ })
1073
+ expect(listCalls[1].maxResults).toBe(100)
1074
+ })
1075
+
1076
+ it('never reports totalCandidates from the unreliable resultSizeEstimate', async () => {
1077
+ const listCalls: ListCall[] = []
1078
+ setGmailApiClient(buildImportApi({ listCalls, pages: [{ messages: [{ id: 'm1', threadId: 't1' }] }] }))
1079
+
1080
+ const page = await getGmailChannelAdapter().importHistory({
1081
+ credentials: userCredentials,
1082
+ scope,
1083
+ sinceDays: 30,
1084
+ })
1085
+ expect(page.totalCandidates).toBeUndefined()
1086
+ })
1087
+
1088
+ it('rejects a cursor from a different version and restarts the sweep', async () => {
1089
+ const listCalls: ListCall[] = []
1090
+ setGmailApiClient(buildImportApi({ listCalls, pages: [{ messages: [{ id: 'm1', threadId: 't1' }] }] }))
1091
+ const stale = Buffer.from(JSON.stringify({ v: 99, after: 'after:1', query: 'after:1', queryIndex: 3, collected: 40 })).toString('base64')
1092
+
1093
+ const page = await getGmailChannelAdapter().importHistory({
1094
+ credentials: userCredentials,
1095
+ scope,
1096
+ sinceDays: 30,
1097
+ cursor: stale,
1098
+ })
1099
+
1100
+ expect(page.messages).toHaveLength(1)
1101
+ expect(listCalls[0].pageToken).toBeUndefined()
1102
+ })
1103
+
1104
+ it('skips messages that are gone or transiently unreadable without aborting the sweep', async () => {
1105
+ process.env.OM_CHANNEL_GMAIL_IMPORT_CONCURRENCY = '1'
1106
+ const listCalls: ListCall[] = []
1107
+ setGmailApiClient(
1108
+ buildImportApi({
1109
+ listCalls,
1110
+ pages: [
1111
+ {
1112
+ messages: [
1113
+ { id: 'gone', threadId: 't1' },
1114
+ { id: 'broken', threadId: 't1' },
1115
+ { id: 'ok', threadId: 't1' },
1116
+ ],
1117
+ },
1118
+ ],
1119
+ getMessageRaw: async (_auth, messageId) => {
1120
+ if (messageId === 'gone') throw new GmailApiError('not found', 404, 'not found')
1121
+ if (messageId === 'broken') throw new GmailApiError('server error', 500, 'server error')
1122
+ return {
1123
+ id: messageId,
1124
+ threadId: 't1',
1125
+ labelIds: ['INBOX'],
1126
+ raw: encodeBase64Url(buildRawMime(`${messageId}@gmail.com`, 'body')),
1127
+ }
1128
+ },
1129
+ }),
1130
+ )
1131
+
1132
+ const page = await getGmailChannelAdapter().importHistory({
1133
+ credentials: userCredentials,
1134
+ scope,
1135
+ sinceDays: 30,
1136
+ })
1137
+
1138
+ expect(page.messages).toHaveLength(1)
1139
+ expect(page.messages[0].channelMetadata?.gmailMessageId).toBe('ok')
1140
+ expect(page.hasMore).toBe(false)
1141
+ })
1142
+
1143
+ it('aborts the page when the grant is no longer valid', async () => {
1144
+ setGmailApiClient(
1145
+ buildImportApi({
1146
+ listCalls: [],
1147
+ pages: [{ messages: [{ id: 'm1', threadId: 't1' }] }],
1148
+ getMessageRaw: async () => {
1149
+ throw new GmailApiError('unauthorized', 401, 'unauthorized')
1150
+ },
1151
+ }),
1152
+ )
1153
+
1154
+ await expect(
1155
+ getGmailChannelAdapter().importHistory({ credentials: userCredentials, scope, sinceDays: 30 }),
1156
+ ).rejects.toThrow('unauthorized')
1157
+ })
1158
+
1159
+ it('keeps the cursor compact regardless of how many messages were collected', async () => {
1160
+ process.env.OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE = '1'
1161
+ setGmailApiClient(
1162
+ buildImportApi({
1163
+ listCalls: [],
1164
+ pages: [{ messages: [{ id: 'm1', threadId: 't1' }], nextPageToken: 'x'.repeat(64) }],
1165
+ }),
1166
+ )
1167
+
1168
+ const page = await getGmailChannelAdapter().importHistory({
1169
+ credentials: userCredentials,
1170
+ scope,
1171
+ sinceDays: 3650,
1172
+ maxMessages: 50_000,
1173
+ })
1174
+
1175
+ expect(page.hasMore).toBe(true)
1176
+ expect((page.nextCursor ?? '').length).toBeLessThan(512)
1177
+ })
1178
+ })
@@ -11,6 +11,8 @@ import type {
11
11
  FetchHistoryInput,
12
12
  GetMessageStatusInput,
13
13
  HistoryPage,
14
+ ImportHistoryInput,
15
+ ImportHistoryPage,
14
16
  InboundMessage,
15
17
  MessageStatus,
16
18
  NormalizedInboundMessage,
@@ -52,7 +54,13 @@ import {
52
54
  } from './convert-outbound'
53
55
  import { normalizeInboundGmailMessage } from './normalize-inbound'
54
56
  import { emailResolveContact } from '@open-mercato/core/modules/communication_channels/lib/email-contact'
55
- import { encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
57
+ import { decodeCursor, encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
58
+ import {
59
+ IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
60
+ IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
61
+ getImportHistoryMaxSinceDays,
62
+ getImportHistoryMaxMessages,
63
+ } from '@open-mercato/core/modules/communication_channels/lib/import-history-limits'
56
64
  import { createLogger } from '@open-mercato/shared/lib/logger'
57
65
 
58
66
  const logger = createLogger('channel_gmail')
@@ -579,6 +587,134 @@ class GmailChannelAdapter implements ChannelAdapter {
579
587
  }
580
588
  }
581
589
 
590
+ /**
591
+ * Operator-triggered backlog import (Spec B § Phase B6).
592
+ *
593
+ * Reaches BACKWARD in time over `gmail.users.messages.list?q=…`, independent
594
+ * of the forward-polling `historyId` cursor — nothing here reads or writes
595
+ * `GmailChannelState`, so a multi-year backfill can never move the live poll
596
+ * cursor.
597
+ *
598
+ * Scale contract: the cursor carries Gmail's own `nextPageToken` plus a
599
+ * collected counter and the frozen query, so it stays a few hundred bytes for
600
+ * a 50k-message mailbox. Message ids are never enumerated up front.
601
+ */
602
+ async importHistory(input: ImportHistoryInput): Promise<ImportHistoryPage> {
603
+ const userCredentials = parseUserCredentialsOrThrow(input.credentials)
604
+ const auth = { accessToken: userCredentials.accessToken }
605
+ const api = getGmailApiClient()
606
+ const accountIdentifier = userCredentials.email ?? 'me'
607
+
608
+ const maxMessages = clampImportMaxMessages(input.maxMessages)
609
+ const pageSize = resolveImportPageSize()
610
+ const concurrency = resolveImportConcurrency()
611
+
612
+ const cursor = decodeGmailImportCursor(input.cursor)
613
+ const afterTerm = cursor?.after ?? buildAfterTerm(clampImportSinceDays(input.sinceDays))
614
+ const queries = buildImportQueries(afterTerm, input.contactEmails)
615
+
616
+ let queryIndex = cursor ? cursor.queryIndex : 0
617
+ let pageToken = cursor?.pageToken
618
+ const collectedSoFar = cursor?.collected ?? 0
619
+ let activeQuery: string | undefined = cursor?.query ?? queries[queryIndex]
620
+
621
+ const budget = Math.max(0, maxMessages - collectedSoFar)
622
+ if (budget === 0 || queryIndex >= queries.length) {
623
+ return { messages: [], hasMore: false }
624
+ }
625
+
626
+ const perPage = Math.min(pageSize, budget)
627
+ const refs: Array<{ id: string; threadId: string }> = []
628
+ let listCalls = 0
629
+ while (refs.length < perPage && queryIndex < queries.length && listCalls < IMPORT_MAX_LIST_CALLS_PER_PAGE) {
630
+ listCalls += 1
631
+ const list = await api.listMessages(auth, {
632
+ query: activeQuery,
633
+ labelIds: ['INBOX'],
634
+ pageToken,
635
+ maxResults: perPage - refs.length,
636
+ })
637
+ for (const message of list.messages ?? []) {
638
+ refs.push({ id: message.id, threadId: message.threadId })
639
+ }
640
+ if (list.nextPageToken) {
641
+ pageToken = list.nextPageToken
642
+ continue
643
+ }
644
+ queryIndex += 1
645
+ pageToken = undefined
646
+ activeQuery = queries[queryIndex]
647
+ }
648
+
649
+ const messages = await this.fetchAndNormalizeForImport(api, auth, refs, accountIdentifier, concurrency)
650
+
651
+ const collected = collectedSoFar + messages.length
652
+ const exhausted = pageToken === undefined && queryIndex >= queries.length
653
+ const hasMore = !exhausted && collected < maxMessages
654
+ const nextCursor = hasMore
655
+ ? encodeCursor({
656
+ v: GMAIL_IMPORT_CURSOR_VERSION,
657
+ after: afterTerm,
658
+ query: queries[queryIndex] ?? activeQuery ?? queries[queries.length - 1],
659
+ queryIndex,
660
+ pageToken,
661
+ collected,
662
+ } satisfies GmailImportCursor)
663
+ : undefined
664
+
665
+ return { messages, nextCursor, hasMore }
666
+ }
667
+
668
+ /**
669
+ * Import-path fetch. Diverges from `fetchAndNormalize` deliberately: the
670
+ * forward path stops at the first hard failure to protect the polling cursor,
671
+ * whereas a backlog import has no cursor to protect and must not abandon a
672
+ * multi-year sweep over one unreadable message — so a per-message failure is
673
+ * skipped and logged. Only credential/permission failures (401/403), which
674
+ * would fail every remaining message too, abort the page.
675
+ */
676
+ private async fetchAndNormalizeForImport(
677
+ api: ReturnType<typeof getGmailApiClient>,
678
+ auth: { accessToken: string },
679
+ refs: Array<{ id: string; threadId: string }>,
680
+ accountIdentifier: string,
681
+ concurrency: number,
682
+ ): Promise<NormalizedInboundMessage[]> {
683
+ const results = new Array<NormalizedInboundMessage | null>(refs.length).fill(null)
684
+ let nextIndex = 0
685
+ const workerCount = Math.max(1, Math.min(concurrency, refs.length))
686
+
687
+ const runWorker = async (): Promise<void> => {
688
+ while (true) {
689
+ const index = nextIndex
690
+ nextIndex += 1
691
+ if (index >= refs.length) return
692
+ const ref = refs[index]
693
+ try {
694
+ const raw = await api.getMessageRaw(auth, ref.id)
695
+ const fallbackDate = raw.internalDate ? new Date(Number(raw.internalDate)) : undefined
696
+ results[index] = await normalizeInboundGmailMessage({
697
+ rawMessage: decodeBase64Url(raw.raw),
698
+ gmailMessageId: raw.id,
699
+ gmailThreadId: raw.threadId,
700
+ gmailLabelIds: raw.labelIds ?? [],
701
+ accountIdentifier,
702
+ fallbackDate,
703
+ })
704
+ } catch (error) {
705
+ if (isFatalGmailImportError(error)) throw error
706
+ logger.warn('skipping unreadable message during history import', {
707
+ gmailMessageId: ref.id,
708
+ reason: error instanceof Error ? error.message.slice(0, 200) : 'unknown error',
709
+ })
710
+ }
711
+ }
712
+ }
713
+
714
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
715
+ return results.filter((message): message is NormalizedInboundMessage => message !== null)
716
+ }
717
+
582
718
  async deleteMessage(input: DeleteChannelMessageInput): Promise<void> {
583
719
  const userCredentials = parseUserCredentialsOrThrow(input.credentials)
584
720
  const api = getGmailApiClient()
@@ -640,6 +776,136 @@ class GmailChannelAdapter implements ChannelAdapter {
640
776
  }
641
777
  }
642
778
 
779
+ // ── Backlog import (importHistory) helpers ───────────────────
780
+
781
+ const GMAIL_IMPORT_CURSOR_VERSION = 1
782
+ const IMPORT_SINCE_DAYS_MIN = 1
783
+ const IMPORT_SINCE_DAYS_DEFAULT = IMPORT_HISTORY_DEFAULT_SINCE_DAYS
784
+ const IMPORT_MAX_MESSAGES_MIN = 1
785
+ const IMPORT_MAX_MESSAGES_DEFAULT = IMPORT_HISTORY_DEFAULT_MAX_MESSAGES
786
+ const IMPORT_PAGE_SIZE_DEFAULT = 100
787
+ /** `users.messages.list` rejects `maxResults` above 500. */
788
+ const IMPORT_PAGE_SIZE_MAX = 500
789
+ const IMPORT_CONCURRENCY_DEFAULT = 5
790
+ const IMPORT_CONCURRENCY_MAX = 20
791
+ /** Senders per `from:(… OR …)` group — keeps each `q` well inside Gmail's URL budget. */
792
+ const IMPORT_SENDER_CHUNK_SIZE = 25
793
+ /** Matches the hub's `contactEmails` ceiling; bounds the number of query groups. */
794
+ const IMPORT_SENDER_MAX = 200
795
+ /** Bounds `messages.list` round-trips inside a single page when groups come back empty. */
796
+ const IMPORT_MAX_LIST_CALLS_PER_PAGE = 25
797
+
798
+ interface GmailImportCursor {
799
+ v: number
800
+ /** Frozen `after:<epochSeconds>` term so later pages cannot drift with wall-clock. */
801
+ after: string
802
+ /** Frozen full query for the group the next page resumes on. */
803
+ query: string
804
+ queryIndex: number
805
+ pageToken?: string
806
+ collected: number
807
+ }
808
+
809
+ function clampImportSinceDays(value: number): number {
810
+ const raw = Number.isFinite(value) ? Math.trunc(value) : IMPORT_SINCE_DAYS_DEFAULT
811
+ return Math.max(IMPORT_SINCE_DAYS_MIN, Math.min(getImportHistoryMaxSinceDays(), raw))
812
+ }
813
+
814
+ function clampImportMaxMessages(value: number | undefined): number {
815
+ const raw = Number.isFinite(value) ? Math.trunc(value as number) : IMPORT_MAX_MESSAGES_DEFAULT
816
+ return Math.max(IMPORT_MAX_MESSAGES_MIN, Math.min(getImportHistoryMaxMessages(), raw))
817
+ }
818
+
819
+ function resolveEnvInt(name: string, fallback: number, max: number): number {
820
+ const parsed = Number.parseInt(process.env[name] ?? '', 10)
821
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback
822
+ return Math.min(parsed, max)
823
+ }
824
+
825
+ function resolveImportPageSize(): number {
826
+ return resolveEnvInt('OM_CHANNEL_GMAIL_IMPORT_PAGE_SIZE', IMPORT_PAGE_SIZE_DEFAULT, IMPORT_PAGE_SIZE_MAX)
827
+ }
828
+
829
+ function resolveImportConcurrency(): number {
830
+ return resolveEnvInt('OM_CHANNEL_GMAIL_IMPORT_CONCURRENCY', IMPORT_CONCURRENCY_DEFAULT, IMPORT_CONCURRENCY_MAX)
831
+ }
832
+
833
+ /**
834
+ * Gmail accepts `after:` as either `YYYY/MM/DD` — resolved in the mailbox
835
+ * owner's display timezone, so the boundary day is ambiguous — or as a Unix
836
+ * timestamp in seconds, which is not. We emit the timestamp form.
837
+ *
838
+ * Either form filters on Gmail's INTERNAL date (when Gmail received the
839
+ * message), not the `Date:` header, so a message with an old header that was
840
+ * recently delivered or migrated into the mailbox counts as recent.
841
+ */
842
+ function buildAfterTerm(sinceDays: number): string {
843
+ const epochSeconds = Math.floor((Date.now() - sinceDays * 24 * 60 * 60 * 1000) / 1000)
844
+ return `after:${Math.max(0, epochSeconds)}`
845
+ }
846
+
847
+ function normalizeImportSenders(contactEmails: string[] | undefined): string[] {
848
+ const seen = new Set<string>()
849
+ for (const raw of contactEmails ?? []) {
850
+ if (typeof raw !== 'string') continue
851
+ const trimmed = raw.trim().toLowerCase()
852
+ if (!trimmed.includes('@') || /[\s"()]/.test(trimmed)) continue
853
+ seen.add(trimmed)
854
+ if (seen.size >= IMPORT_SENDER_MAX) break
855
+ }
856
+ return Array.from(seen)
857
+ }
858
+
859
+ /**
860
+ * Build the ordered list of Gmail queries this import walks.
861
+ *
862
+ * Gmail's `q` rides in the request URL, so a 200-address `from:(… OR …)` chain
863
+ * would blow past a practical URL budget. Senders are therefore split into
864
+ * groups of `IMPORT_SENDER_CHUNK_SIZE` and the groups are walked SEQUENTIALLY
865
+ * across pages (the cursor records which group is active). Groups are disjoint
866
+ * by construction — a message has exactly one `From` — so no cross-group dedup
867
+ * is needed. With no sender hint a single date-only query is returned.
868
+ */
869
+ function buildImportQueries(afterTerm: string, contactEmails: string[] | undefined): string[] {
870
+ const senders = normalizeImportSenders(contactEmails)
871
+ if (senders.length === 0) return [afterTerm]
872
+ const queries: string[] = []
873
+ for (let index = 0; index < senders.length; index += IMPORT_SENDER_CHUNK_SIZE) {
874
+ const group = senders.slice(index, index + IMPORT_SENDER_CHUNK_SIZE)
875
+ queries.push(`${afterTerm} from:(${group.join(' OR ')})`)
876
+ }
877
+ return queries
878
+ }
879
+
880
+ function decodeGmailImportCursor(value: string | undefined): GmailImportCursor | null {
881
+ const parsed = decodeCursor(value)
882
+ if (!parsed || typeof parsed !== 'object') return null
883
+ const candidate = parsed as Partial<Record<keyof GmailImportCursor, unknown>>
884
+ if (candidate.v !== GMAIL_IMPORT_CURSOR_VERSION) return null
885
+ if (typeof candidate.after !== 'string' || typeof candidate.query !== 'string') return null
886
+ const queryIndex = typeof candidate.queryIndex === 'number' && candidate.queryIndex >= 0 ? Math.trunc(candidate.queryIndex) : 0
887
+ const collected = typeof candidate.collected === 'number' && candidate.collected >= 0 ? Math.trunc(candidate.collected) : 0
888
+ const pageToken = typeof candidate.pageToken === 'string' && candidate.pageToken.length > 0 ? candidate.pageToken : undefined
889
+ return {
890
+ v: GMAIL_IMPORT_CURSOR_VERSION,
891
+ after: candidate.after,
892
+ query: candidate.query,
893
+ queryIndex,
894
+ pageToken,
895
+ collected,
896
+ }
897
+ }
898
+
899
+ /**
900
+ * A 401 (expired/revoked grant) or a 403 that survived the client's bounded
901
+ * retry (permission denied, or exhausted per-user quota) will fail every
902
+ * remaining message in the sweep, so it aborts the page instead of being
903
+ * skipped message by message.
904
+ */
905
+ function isFatalGmailImportError(error: unknown): boolean {
906
+ return error instanceof GmailApiError && (error.status === 401 || error.status === 403)
907
+ }
908
+
643
909
  function collectMessageRefs(
644
910
  history: Array<{
645
911
  messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>