@open-mercato/channel-gmail 0.6.6-develop.5654.1.ca21e35f26 → 0.6.6-develop.5672.1.11e27afad2
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,3 +1,4 @@
|
|
|
1
|
+
import { fetchWithTimeout, FetchTimeoutError } from "@open-mercato/shared/lib/http/fetchWithTimeout";
|
|
1
2
|
const GMAIL_API_BASE = "https://gmail.googleapis.com/gmail/v1";
|
|
2
3
|
const GMAIL_MAX_RETRIES = 3;
|
|
3
4
|
const GMAIL_BACKOFF_BASE_MS = 500;
|
|
@@ -70,17 +71,17 @@ class FetchGmailApiClient {
|
|
|
70
71
|
while (attempt <= GMAIL_MAX_RETRIES) {
|
|
71
72
|
let res;
|
|
72
73
|
try {
|
|
73
|
-
res = await
|
|
74
|
+
res = await fetchWithTimeout(url.toString(), {
|
|
74
75
|
method,
|
|
75
76
|
headers,
|
|
76
77
|
body: payload,
|
|
77
78
|
// Bound each attempt so a stalled connection fails fast instead of
|
|
78
79
|
// hanging on undici's multi-minute default and pinning the worker slot.
|
|
79
|
-
|
|
80
|
+
timeoutMs: resolveGmailRequestTimeoutMs()
|
|
80
81
|
});
|
|
81
82
|
} catch (err) {
|
|
82
83
|
const errName = err?.name;
|
|
83
|
-
const aborted = errName === "TimeoutError" || errName === "AbortError";
|
|
84
|
+
const aborted = err instanceof FetchTimeoutError || errName === "TimeoutError" || errName === "AbortError";
|
|
84
85
|
if (!aborted) throw err;
|
|
85
86
|
const timeoutError = new GmailApiError(
|
|
86
87
|
`Gmail API ${method} ${url.pathname} timed out`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_gmail/lib/gmail-client.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Thin Gmail REST API wrapper. Same trade-off as `oauth.ts`: we use `fetch`\n * directly so the adapter doesn't import the `googleapis` SDK at runtime in\n * environments that don't need it (tests, build-only checks). Production code\n * paths still allow swapping to the SDK via `setGmailApiClient(...)` if a\n * downstream package wants the SDK's extra ergonomics.\n *\n * Only the endpoints the adapter actually calls are exposed:\n * - listHistory \u2192 gmail.users.history.list\n * - listMessages \u2192 gmail.users.messages.list (fallback when historyId expired)\n * - getMessageRaw \u2192 gmail.users.messages.get?format=raw\n * - sendRawMessage \u2192 gmail.users.messages.send\n * - getProfile \u2192 gmail.users.getProfile (health + initial historyId)\n * - deleteMessage \u2192 gmail.users.messages.trash (move to trash; matches `deleteMessage: true` capability)\n */\n\nconst GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1'\n\nexport interface GmailApiAuth {\n accessToken: string\n}\n\nexport interface GmailHistoryListInput {\n startHistoryId: string\n /** Optional page token for paging through history results. */\n pageToken?: string\n /** Optional label filter; defaults to INBOX-only changes. */\n labelId?: string\n}\n\nexport interface GmailHistoryRecord {\n id: string\n messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>\n messagesDeleted?: Array<{ message: { id: string; threadId: string } }>\n labelsAdded?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>\n labelsRemoved?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>\n}\n\nexport interface GmailHistoryListResponse {\n history?: GmailHistoryRecord[]\n nextPageToken?: string\n historyId: string\n}\n\nexport interface GmailMessagesListInput {\n query?: string\n labelIds?: string[]\n pageToken?: string\n maxResults?: number\n}\n\nexport interface GmailMessagesListResponse {\n messages?: Array<{ id: string; threadId: string }>\n nextPageToken?: string\n resultSizeEstimate?: number\n}\n\nexport interface GmailGetMessageRawResponse {\n id: string\n threadId: string\n labelIds?: string[]\n /** Base64URL-encoded RFC2822 message. */\n raw: string\n internalDate?: string\n sizeEstimate?: number\n}\n\nexport interface GmailSendRawInput {\n /** Base64URL-encoded RFC2822 message body. */\n rawBase64Url: string\n /** Optional thread to attach to. */\n threadId?: string\n}\n\nexport interface GmailSendResponse {\n id: string\n threadId: string\n labelIds?: string[]\n}\n\nexport interface GmailProfileResponse {\n emailAddress: string\n messagesTotal?: number\n threadsTotal?: number\n historyId: string\n}\n\nexport interface GmailWatchInput {\n /** Fully-qualified Pub/Sub topic, e.g. `projects/myproj/topics/gmail-inbound`. */\n topicName: string\n /** Defaults to `['INBOX']` so only inbox changes generate notifications. */\n labelIds?: string[]\n /** `include` (default) or `exclude`. */\n labelFilterAction?: 'include' | 'exclude'\n}\n\nexport interface GmailWatchResponse {\n historyId: string\n /** Watch expiration timestamp, ms since epoch. Gmail caps at ~7 days. */\n expiration: string\n}\n\nexport interface GmailApiClient {\n listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse>\n listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse>\n getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse>\n sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse>\n getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse>\n trashMessage(auth: GmailApiAuth, messageId: string): Promise<void>\n /** Spec C \u2014 `gmail.users.watch` registers a Pub/Sub topic for push delivery. */\n watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse>\n /** Spec C \u2014 `gmail.users.stop` tears down the Pub/Sub registration. */\n stopWatch(auth: GmailApiAuth): Promise<void>\n}\n\nconst GMAIL_MAX_RETRIES = 3\nconst GMAIL_BACKOFF_BASE_MS = 500\nconst GMAIL_BACKOFF_CAP_MS = 8_000\nconst GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 30_000\n\nfunction resolveGmailRequestTimeoutMs(): number {\n const fromEnv = Number.parseInt(process.env.OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS ?? '', 10)\n return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : GMAIL_DEFAULT_REQUEST_TIMEOUT_MS\n}\n\nclass FetchGmailApiClient implements GmailApiClient {\n async listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/history`)\n url.searchParams.set('startHistoryId', input.startHistoryId)\n if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)\n url.searchParams.set('labelId', input.labelId ?? 'INBOX')\n url.searchParams.set('historyTypes', 'messageAdded')\n return this.requestJson<GmailHistoryListResponse>(auth, url, 'GET')\n }\n\n async listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages`)\n if (input.query) url.searchParams.set('q', input.query)\n for (const label of input.labelIds ?? []) url.searchParams.append('labelIds', label)\n if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)\n if (input.maxResults) url.searchParams.set('maxResults', String(input.maxResults))\n return this.requestJson<GmailMessagesListResponse>(auth, url, 'GET')\n }\n\n async getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}`)\n url.searchParams.set('format', 'raw')\n return this.requestJson<GmailGetMessageRawResponse>(auth, url, 'GET')\n }\n\n async sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/send`)\n return this.requestJson<GmailSendResponse>(auth, url, 'POST', {\n raw: input.rawBase64Url,\n threadId: input.threadId,\n })\n }\n\n async getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/profile`)\n return this.requestJson<GmailProfileResponse>(auth, url, 'GET')\n }\n\n async trashMessage(auth: GmailApiAuth, messageId: string): Promise<void> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}/trash`)\n await this.requestJson(auth, url, 'POST')\n }\n\n async watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/watch`)\n return this.requestJson<GmailWatchResponse>(auth, url, 'POST', {\n topicName: input.topicName,\n labelIds: input.labelIds ?? ['INBOX'],\n labelFilterAction: input.labelFilterAction ?? 'include',\n })\n }\n\n async stopWatch(auth: GmailApiAuth): Promise<void> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/stop`)\n await this.requestJson(auth, url, 'POST')\n }\n\n private async requestJson<T>(auth: GmailApiAuth, url: URL, method: 'GET' | 'POST', body?: unknown): Promise<T> {\n const headers: Record<string, string> = {\n Authorization: `Bearer ${auth.accessToken}`,\n }\n let payload: BodyInit | undefined\n if (body !== undefined) {\n headers['Content-Type'] = 'application/json'\n payload = JSON.stringify(body)\n }\n // Retry transient failures (429, 5xx) with exponential backoff + jitter,\n // honoring `Retry-After` when present. Per Gmail API docs at\n // https://developers.google.com/gmail/api/guides/handle-errors this is the\n // documented mitigation for rate-limit + server-side transient errors.\n let attempt = 0\n let lastError: GmailApiError | null = null\n while (attempt <= GMAIL_MAX_RETRIES) {\n let res: Response\n try {\n res = await fetch(url.toString(), {\n method,\n headers,\n body: payload,\n // Bound each attempt so a stalled connection fails fast instead of\n // hanging on undici's multi-minute default and pinning the worker slot.\n signal: AbortSignal.timeout(resolveGmailRequestTimeoutMs()),\n })\n } catch (err) {\n // A timed-out/aborted connection is transient \u2014 let the bounded retry\n // loop retry it rather than propagating a raw error. `AbortSignal.timeout`\n // rejects fetch with a `TimeoutError` DOMException; an externally-aborted\n // request surfaces as `AbortError`. Match on the `name` field (not\n // `instanceof Error`, since DOMException does not subclass Error across\n // realms) \u2014 treat both as transient.\n const errName = (err as { name?: unknown } | null)?.name\n const aborted = errName === 'TimeoutError' || errName === 'AbortError'\n if (!aborted) throw err\n const timeoutError = new GmailApiError(\n `Gmail API ${method} ${url.pathname} timed out`,\n 599,\n 'request timed out',\n )\n if (attempt === GMAIL_MAX_RETRIES) throw timeoutError\n lastError = timeoutError\n await sleep(computeBackoff(attempt))\n attempt += 1\n continue\n }\n const text = await res.text()\n if (res.ok) {\n if (!text) return undefined as unknown as T\n return JSON.parse(text) as T\n }\n const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`\n const apiError = new GmailApiError(\n `Gmail API ${method} ${url.pathname} failed: ${detail}`,\n res.status,\n detail,\n )\n const transient =\n res.status === 429 ||\n (res.status >= 500 && res.status < 600) ||\n // Gmail signals per-user/project quota exhaustion with HTTP 403 +\n // `rateLimitExceeded`/`userRateLimitExceeded` (not only 429).\n (res.status === 403 && isRateLimit403(text))\n if (!transient || attempt === GMAIL_MAX_RETRIES) {\n throw apiError\n }\n lastError = apiError\n const retryAfterHeader = res.headers.get('retry-after')\n const waitMs =\n parseRetryAfter(retryAfterHeader) ?? computeBackoff(attempt)\n await sleep(waitMs)\n attempt += 1\n }\n throw lastError ?? new GmailApiError(`Gmail API ${method} ${url.pathname} exhausted retries`, 599, 'retries exhausted')\n }\n}\n\n/**\n * Gmail signals quota exhaustion with HTTP 403 + an error reason of\n * `rateLimitExceeded` / `userRateLimitExceeded` (not only 429). Treat those as\n * transient so the backoff/retry path applies; a genuine permission 403 (no\n * rate-limit reason) stays non-retryable.\n */\nfunction isRateLimit403(body: string): boolean {\n return /rateLimitExceeded|userRateLimitExceeded/i.test(body)\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null\n const asNumber = Number(value)\n if (Number.isFinite(asNumber) && asNumber >= 0) {\n return Math.min(asNumber * 1000, GMAIL_BACKOFF_CAP_MS)\n }\n const asDate = Date.parse(value)\n if (Number.isFinite(asDate)) {\n const delta = asDate - Date.now()\n if (delta > 0) return Math.min(delta, GMAIL_BACKOFF_CAP_MS)\n }\n return null\n}\n\nfunction computeBackoff(attempt: number): number {\n const raw = GMAIL_BACKOFF_BASE_MS * Math.pow(2, attempt)\n const jitter = Math.floor(Math.random() * 100)\n return Math.min(raw + jitter, GMAIL_BACKOFF_CAP_MS)\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nexport class GmailApiError extends Error {\n readonly status: number\n readonly detail: string\n constructor(message: string, status: number, detail: string) {\n super(message)\n this.name = 'GmailApiError'\n this.status = status\n this.detail = detail\n }\n}\n\nfunction parseErrorMessage(text: string): string | null {\n if (!text) return null\n try {\n const parsed = JSON.parse(text) as { error?: { message?: string } | string }\n if (parsed && typeof parsed.error === 'object' && parsed.error && typeof parsed.error.message === 'string') {\n return parsed.error.message\n }\n if (typeof parsed?.error === 'string') return parsed.error\n } catch {\n /* fall through */\n }\n return text.length > 200 ? text.slice(0, 200) : text\n}\n\nlet cachedClient: GmailApiClient | null = null\n\nexport function getGmailApiClient(): GmailApiClient {\n if (!cachedClient) cachedClient = new FetchGmailApiClient()\n return cachedClient\n}\n\nexport function setGmailApiClient(client: GmailApiClient | null): void {\n cachedClient = client\n}\n\n/** Encode an RFC2822 message buffer to base64url as required by gmail.users.messages.send. */\nexport function encodeBase64Url(buffer: Buffer): string {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\n/** Decode a base64url payload (e.g. `gmail.users.messages.get?format=raw`) to a buffer. */\nexport function decodeBase64Url(value: string): Buffer {\n const normalized = value.replace(/-/g, '+').replace(/_/g, '/')\n const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))\n return Buffer.from(normalized + padding, 'base64')\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["/**\n * Thin Gmail REST API wrapper. Same trade-off as `oauth.ts`: we use `fetch`\n * directly so the adapter doesn't import the `googleapis` SDK at runtime in\n * environments that don't need it (tests, build-only checks). Production code\n * paths still allow swapping to the SDK via `setGmailApiClient(...)` if a\n * downstream package wants the SDK's extra ergonomics.\n *\n * Only the endpoints the adapter actually calls are exposed:\n * - listHistory \u2192 gmail.users.history.list\n * - listMessages \u2192 gmail.users.messages.list (fallback when historyId expired)\n * - getMessageRaw \u2192 gmail.users.messages.get?format=raw\n * - sendRawMessage \u2192 gmail.users.messages.send\n * - getProfile \u2192 gmail.users.getProfile (health + initial historyId)\n * - deleteMessage \u2192 gmail.users.messages.trash (move to trash; matches `deleteMessage: true` capability)\n */\n\nimport { fetchWithTimeout, FetchTimeoutError } from '@open-mercato/shared/lib/http/fetchWithTimeout'\n\nconst GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1'\n\nexport interface GmailApiAuth {\n accessToken: string\n}\n\nexport interface GmailHistoryListInput {\n startHistoryId: string\n /** Optional page token for paging through history results. */\n pageToken?: string\n /** Optional label filter; defaults to INBOX-only changes. */\n labelId?: string\n}\n\nexport interface GmailHistoryRecord {\n id: string\n messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>\n messagesDeleted?: Array<{ message: { id: string; threadId: string } }>\n labelsAdded?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>\n labelsRemoved?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>\n}\n\nexport interface GmailHistoryListResponse {\n history?: GmailHistoryRecord[]\n nextPageToken?: string\n historyId: string\n}\n\nexport interface GmailMessagesListInput {\n query?: string\n labelIds?: string[]\n pageToken?: string\n maxResults?: number\n}\n\nexport interface GmailMessagesListResponse {\n messages?: Array<{ id: string; threadId: string }>\n nextPageToken?: string\n resultSizeEstimate?: number\n}\n\nexport interface GmailGetMessageRawResponse {\n id: string\n threadId: string\n labelIds?: string[]\n /** Base64URL-encoded RFC2822 message. */\n raw: string\n internalDate?: string\n sizeEstimate?: number\n}\n\nexport interface GmailSendRawInput {\n /** Base64URL-encoded RFC2822 message body. */\n rawBase64Url: string\n /** Optional thread to attach to. */\n threadId?: string\n}\n\nexport interface GmailSendResponse {\n id: string\n threadId: string\n labelIds?: string[]\n}\n\nexport interface GmailProfileResponse {\n emailAddress: string\n messagesTotal?: number\n threadsTotal?: number\n historyId: string\n}\n\nexport interface GmailWatchInput {\n /** Fully-qualified Pub/Sub topic, e.g. `projects/myproj/topics/gmail-inbound`. */\n topicName: string\n /** Defaults to `['INBOX']` so only inbox changes generate notifications. */\n labelIds?: string[]\n /** `include` (default) or `exclude`. */\n labelFilterAction?: 'include' | 'exclude'\n}\n\nexport interface GmailWatchResponse {\n historyId: string\n /** Watch expiration timestamp, ms since epoch. Gmail caps at ~7 days. */\n expiration: string\n}\n\nexport interface GmailApiClient {\n listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse>\n listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse>\n getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse>\n sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse>\n getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse>\n trashMessage(auth: GmailApiAuth, messageId: string): Promise<void>\n /** Spec C \u2014 `gmail.users.watch` registers a Pub/Sub topic for push delivery. */\n watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse>\n /** Spec C \u2014 `gmail.users.stop` tears down the Pub/Sub registration. */\n stopWatch(auth: GmailApiAuth): Promise<void>\n}\n\nconst GMAIL_MAX_RETRIES = 3\nconst GMAIL_BACKOFF_BASE_MS = 500\nconst GMAIL_BACKOFF_CAP_MS = 8_000\nconst GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 30_000\n\nfunction resolveGmailRequestTimeoutMs(): number {\n const fromEnv = Number.parseInt(process.env.OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS ?? '', 10)\n return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : GMAIL_DEFAULT_REQUEST_TIMEOUT_MS\n}\n\nclass FetchGmailApiClient implements GmailApiClient {\n async listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/history`)\n url.searchParams.set('startHistoryId', input.startHistoryId)\n if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)\n url.searchParams.set('labelId', input.labelId ?? 'INBOX')\n url.searchParams.set('historyTypes', 'messageAdded')\n return this.requestJson<GmailHistoryListResponse>(auth, url, 'GET')\n }\n\n async listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages`)\n if (input.query) url.searchParams.set('q', input.query)\n for (const label of input.labelIds ?? []) url.searchParams.append('labelIds', label)\n if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)\n if (input.maxResults) url.searchParams.set('maxResults', String(input.maxResults))\n return this.requestJson<GmailMessagesListResponse>(auth, url, 'GET')\n }\n\n async getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}`)\n url.searchParams.set('format', 'raw')\n return this.requestJson<GmailGetMessageRawResponse>(auth, url, 'GET')\n }\n\n async sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/send`)\n return this.requestJson<GmailSendResponse>(auth, url, 'POST', {\n raw: input.rawBase64Url,\n threadId: input.threadId,\n })\n }\n\n async getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/profile`)\n return this.requestJson<GmailProfileResponse>(auth, url, 'GET')\n }\n\n async trashMessage(auth: GmailApiAuth, messageId: string): Promise<void> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}/trash`)\n await this.requestJson(auth, url, 'POST')\n }\n\n async watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/watch`)\n return this.requestJson<GmailWatchResponse>(auth, url, 'POST', {\n topicName: input.topicName,\n labelIds: input.labelIds ?? ['INBOX'],\n labelFilterAction: input.labelFilterAction ?? 'include',\n })\n }\n\n async stopWatch(auth: GmailApiAuth): Promise<void> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/stop`)\n await this.requestJson(auth, url, 'POST')\n }\n\n private async requestJson<T>(auth: GmailApiAuth, url: URL, method: 'GET' | 'POST', body?: unknown): Promise<T> {\n const headers: Record<string, string> = {\n Authorization: `Bearer ${auth.accessToken}`,\n }\n let payload: BodyInit | undefined\n if (body !== undefined) {\n headers['Content-Type'] = 'application/json'\n payload = JSON.stringify(body)\n }\n // Retry transient failures (429, 5xx) with exponential backoff + jitter,\n // honoring `Retry-After` when present. Per Gmail API docs at\n // https://developers.google.com/gmail/api/guides/handle-errors this is the\n // documented mitigation for rate-limit + server-side transient errors.\n let attempt = 0\n let lastError: GmailApiError | null = null\n while (attempt <= GMAIL_MAX_RETRIES) {\n let res: Response\n try {\n res = await fetchWithTimeout(url.toString(), {\n method,\n headers,\n body: payload,\n // Bound each attempt so a stalled connection fails fast instead of\n // hanging on undici's multi-minute default and pinning the worker slot.\n timeoutMs: resolveGmailRequestTimeoutMs(),\n })\n } catch (err) {\n // A timed-out/aborted connection is transient \u2014 let the bounded retry\n // loop retry it rather than propagating a raw error. `fetchWithTimeout`\n // surfaces an elapsed timeout as `FetchTimeoutError`; an externally-aborted\n // request still surfaces as an `AbortError` DOMException. Match the\n // `FetchTimeoutError` type and the abort `name` field (DOMException does\n // not subclass Error across realms) \u2014 treat both as transient.\n const errName = (err as { name?: unknown } | null)?.name\n const aborted = err instanceof FetchTimeoutError || errName === 'TimeoutError' || errName === 'AbortError'\n if (!aborted) throw err\n const timeoutError = new GmailApiError(\n `Gmail API ${method} ${url.pathname} timed out`,\n 599,\n 'request timed out',\n )\n if (attempt === GMAIL_MAX_RETRIES) throw timeoutError\n lastError = timeoutError\n await sleep(computeBackoff(attempt))\n attempt += 1\n continue\n }\n const text = await res.text()\n if (res.ok) {\n if (!text) return undefined as unknown as T\n return JSON.parse(text) as T\n }\n const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`\n const apiError = new GmailApiError(\n `Gmail API ${method} ${url.pathname} failed: ${detail}`,\n res.status,\n detail,\n )\n const transient =\n res.status === 429 ||\n (res.status >= 500 && res.status < 600) ||\n // Gmail signals per-user/project quota exhaustion with HTTP 403 +\n // `rateLimitExceeded`/`userRateLimitExceeded` (not only 429).\n (res.status === 403 && isRateLimit403(text))\n if (!transient || attempt === GMAIL_MAX_RETRIES) {\n throw apiError\n }\n lastError = apiError\n const retryAfterHeader = res.headers.get('retry-after')\n const waitMs =\n parseRetryAfter(retryAfterHeader) ?? computeBackoff(attempt)\n await sleep(waitMs)\n attempt += 1\n }\n throw lastError ?? new GmailApiError(`Gmail API ${method} ${url.pathname} exhausted retries`, 599, 'retries exhausted')\n }\n}\n\n/**\n * Gmail signals quota exhaustion with HTTP 403 + an error reason of\n * `rateLimitExceeded` / `userRateLimitExceeded` (not only 429). Treat those as\n * transient so the backoff/retry path applies; a genuine permission 403 (no\n * rate-limit reason) stays non-retryable.\n */\nfunction isRateLimit403(body: string): boolean {\n return /rateLimitExceeded|userRateLimitExceeded/i.test(body)\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null\n const asNumber = Number(value)\n if (Number.isFinite(asNumber) && asNumber >= 0) {\n return Math.min(asNumber * 1000, GMAIL_BACKOFF_CAP_MS)\n }\n const asDate = Date.parse(value)\n if (Number.isFinite(asDate)) {\n const delta = asDate - Date.now()\n if (delta > 0) return Math.min(delta, GMAIL_BACKOFF_CAP_MS)\n }\n return null\n}\n\nfunction computeBackoff(attempt: number): number {\n const raw = GMAIL_BACKOFF_BASE_MS * Math.pow(2, attempt)\n const jitter = Math.floor(Math.random() * 100)\n return Math.min(raw + jitter, GMAIL_BACKOFF_CAP_MS)\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nexport class GmailApiError extends Error {\n readonly status: number\n readonly detail: string\n constructor(message: string, status: number, detail: string) {\n super(message)\n this.name = 'GmailApiError'\n this.status = status\n this.detail = detail\n }\n}\n\nfunction parseErrorMessage(text: string): string | null {\n if (!text) return null\n try {\n const parsed = JSON.parse(text) as { error?: { message?: string } | string }\n if (parsed && typeof parsed.error === 'object' && parsed.error && typeof parsed.error.message === 'string') {\n return parsed.error.message\n }\n if (typeof parsed?.error === 'string') return parsed.error\n } catch {\n /* fall through */\n }\n return text.length > 200 ? text.slice(0, 200) : text\n}\n\nlet cachedClient: GmailApiClient | null = null\n\nexport function getGmailApiClient(): GmailApiClient {\n if (!cachedClient) cachedClient = new FetchGmailApiClient()\n return cachedClient\n}\n\nexport function setGmailApiClient(client: GmailApiClient | null): void {\n cachedClient = client\n}\n\n/** Encode an RFC2822 message buffer to base64url as required by gmail.users.messages.send. */\nexport function encodeBase64Url(buffer: Buffer): string {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\n/** Decode a base64url payload (e.g. `gmail.users.messages.get?format=raw`) to a buffer. */\nexport function decodeBase64Url(value: string): Buffer {\n const normalized = value.replace(/-/g, '+').replace(/_/g, '/')\n const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))\n return Buffer.from(normalized + padding, 'base64')\n}\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.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5672.1.11e27afad2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -61,19 +61,19 @@
|
|
|
61
61
|
}
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@open-mercato/core": "0.6.6-develop.
|
|
65
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
64
|
+
"@open-mercato/core": "0.6.6-develop.5672.1.11e27afad2",
|
|
65
|
+
"@open-mercato/ui": "0.6.6-develop.5672.1.11e27afad2",
|
|
66
66
|
"@types/mailparser": "^3.4.5",
|
|
67
67
|
"mailparser": "^3.7.1"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
71
|
+
"@open-mercato/shared": "0.6.6-develop.5672.1.11e27afad2",
|
|
72
72
|
"react": "^19.0.0",
|
|
73
73
|
"react-dom": "^19.0.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.6-develop.5672.1.11e27afad2",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"@types/react": "^19.2.17",
|
|
79
79
|
"@types/react-dom": "^19.2.3",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FetchTimeoutError } from '@open-mercato/shared/lib/http/fetchWithTimeout'
|
|
1
2
|
import { decodeBase64Url, encodeBase64Url, getGmailApiClient, GmailApiError, setGmailApiClient } from '../gmail-client'
|
|
2
3
|
|
|
3
4
|
describe('base64url encoding helpers', () => {
|
|
@@ -53,6 +54,10 @@ function fakeResponse(init: FakeResponseInit): Response {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
describe('FetchGmailApiClient.requestJson retry/backoff', () => {
|
|
57
|
+
// Mirrors GMAIL_DEFAULT_REQUEST_TIMEOUT_MS in gmail-client.ts: the per-request
|
|
58
|
+
// timeout `fetchWithTimeout` schedules when no OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS
|
|
59
|
+
// override is set (none of these tests set it).
|
|
60
|
+
const GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 30_000
|
|
56
61
|
const originalFetch = globalThis.fetch
|
|
57
62
|
const originalSetTimeout = globalThis.setTimeout
|
|
58
63
|
const originalRandom = Math.random
|
|
@@ -65,8 +70,15 @@ describe('FetchGmailApiClient.requestJson retry/backoff', () => {
|
|
|
65
70
|
capturedDelays = []
|
|
66
71
|
// Replace the backoff sleep with a synchronous no-wait shim that records the
|
|
67
72
|
// requested delay and fires the callback immediately, so the retry loop runs
|
|
68
|
-
// without real timers while we assert the computed wait durations.
|
|
73
|
+
// without real timers while we assert the computed wait durations. The
|
|
74
|
+
// shared `fetchWithTimeout` helper also schedules a per-request timeout timer
|
|
75
|
+
// (`GMAIL_DEFAULT_REQUEST_TIMEOUT_MS`); delegate that one to the real timer —
|
|
76
|
+
// the helper clears it in its `finally` before the mocked fetch resolves, so
|
|
77
|
+
// it never fires and never pollutes the recorded backoff delays.
|
|
69
78
|
globalThis.setTimeout = ((callback: () => void, ms?: number) => {
|
|
79
|
+
if (ms === GMAIL_DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
80
|
+
return originalSetTimeout(callback, ms)
|
|
81
|
+
}
|
|
70
82
|
capturedDelays.push(typeof ms === 'number' ? ms : 0)
|
|
71
83
|
callback()
|
|
72
84
|
return 0 as unknown as ReturnType<typeof setTimeout>
|
|
@@ -244,6 +256,29 @@ describe('FetchGmailApiClient.requestJson retry/backoff', () => {
|
|
|
244
256
|
expect(capturedDelays).toEqual([500])
|
|
245
257
|
})
|
|
246
258
|
|
|
259
|
+
it('treats a shared-helper FetchTimeoutError as transient and retries it (issue #3068)', async () => {
|
|
260
|
+
Math.random = () => 0
|
|
261
|
+
let calls = 0
|
|
262
|
+
globalThis.fetch = (() => {
|
|
263
|
+
calls += 1
|
|
264
|
+
if (calls === 1) {
|
|
265
|
+
// After consolidating onto `fetchWithTimeout`, an elapsed per-request
|
|
266
|
+
// timeout surfaces as `FetchTimeoutError` (a real Error subclass), not a
|
|
267
|
+
// `TimeoutError` DOMException — exercise that production failure shape.
|
|
268
|
+
return Promise.reject(new FetchTimeoutError('https://gmail.googleapis.com/gmail/v1/users/me/profile', 30_000))
|
|
269
|
+
}
|
|
270
|
+
return Promise.resolve(
|
|
271
|
+
fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '11' }) }),
|
|
272
|
+
)
|
|
273
|
+
}) as unknown as typeof globalThis.fetch
|
|
274
|
+
|
|
275
|
+
const profile = await getGmailApiClient().getProfile({ accessToken: 'token' })
|
|
276
|
+
|
|
277
|
+
expect(calls).toBe(2)
|
|
278
|
+
expect(profile.historyId).toBe('11')
|
|
279
|
+
expect(capturedDelays).toEqual([500])
|
|
280
|
+
})
|
|
281
|
+
|
|
247
282
|
it('also retries an externally-aborted (AbortError) fetch (issue #2976)', async () => {
|
|
248
283
|
Math.random = () => 0
|
|
249
284
|
let calls = 0
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* - deleteMessage → gmail.users.messages.trash (move to trash; matches `deleteMessage: true` capability)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { fetchWithTimeout, FetchTimeoutError } from '@open-mercato/shared/lib/http/fetchWithTimeout'
|
|
18
|
+
|
|
17
19
|
const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1'
|
|
18
20
|
|
|
19
21
|
export interface GmailApiAuth {
|
|
@@ -198,23 +200,23 @@ class FetchGmailApiClient implements GmailApiClient {
|
|
|
198
200
|
while (attempt <= GMAIL_MAX_RETRIES) {
|
|
199
201
|
let res: Response
|
|
200
202
|
try {
|
|
201
|
-
res = await
|
|
203
|
+
res = await fetchWithTimeout(url.toString(), {
|
|
202
204
|
method,
|
|
203
205
|
headers,
|
|
204
206
|
body: payload,
|
|
205
207
|
// Bound each attempt so a stalled connection fails fast instead of
|
|
206
208
|
// hanging on undici's multi-minute default and pinning the worker slot.
|
|
207
|
-
|
|
209
|
+
timeoutMs: resolveGmailRequestTimeoutMs(),
|
|
208
210
|
})
|
|
209
211
|
} catch (err) {
|
|
210
212
|
// A timed-out/aborted connection is transient — let the bounded retry
|
|
211
|
-
// loop retry it rather than propagating a raw error. `
|
|
212
|
-
//
|
|
213
|
-
// request surfaces as `AbortError
|
|
214
|
-
// `
|
|
215
|
-
// realms) — treat both as transient.
|
|
213
|
+
// loop retry it rather than propagating a raw error. `fetchWithTimeout`
|
|
214
|
+
// surfaces an elapsed timeout as `FetchTimeoutError`; an externally-aborted
|
|
215
|
+
// request still surfaces as an `AbortError` DOMException. Match the
|
|
216
|
+
// `FetchTimeoutError` type and the abort `name` field (DOMException does
|
|
217
|
+
// not subclass Error across realms) — treat both as transient.
|
|
216
218
|
const errName = (err as { name?: unknown } | null)?.name
|
|
217
|
-
const aborted = errName === 'TimeoutError' || errName === 'AbortError'
|
|
219
|
+
const aborted = err instanceof FetchTimeoutError || errName === 'TimeoutError' || errName === 'AbortError'
|
|
218
220
|
if (!aborted) throw err
|
|
219
221
|
const timeoutError = new GmailApiError(
|
|
220
222
|
`Gmail API ${method} ${url.pathname} timed out`,
|