@open-mercato/channel-gmail 0.6.6-develop.5509.1.006f4d4f24 → 0.6.6-develop.5531.1.ab1959dfae
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.
|
@@ -2,6 +2,11 @@ const GMAIL_API_BASE = "https://gmail.googleapis.com/gmail/v1";
|
|
|
2
2
|
const GMAIL_MAX_RETRIES = 3;
|
|
3
3
|
const GMAIL_BACKOFF_BASE_MS = 500;
|
|
4
4
|
const GMAIL_BACKOFF_CAP_MS = 8e3;
|
|
5
|
+
const GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
6
|
+
function resolveGmailRequestTimeoutMs() {
|
|
7
|
+
const fromEnv = Number.parseInt(process.env.OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS ?? "", 10);
|
|
8
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : GMAIL_DEFAULT_REQUEST_TIMEOUT_MS;
|
|
9
|
+
}
|
|
5
10
|
class FetchGmailApiClient {
|
|
6
11
|
async listHistory(auth, input) {
|
|
7
12
|
const url = new URL(`${GMAIL_API_BASE}/users/me/history`);
|
|
@@ -63,7 +68,31 @@ class FetchGmailApiClient {
|
|
|
63
68
|
let attempt = 0;
|
|
64
69
|
let lastError = null;
|
|
65
70
|
while (attempt <= GMAIL_MAX_RETRIES) {
|
|
66
|
-
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetch(url.toString(), {
|
|
74
|
+
method,
|
|
75
|
+
headers,
|
|
76
|
+
body: payload,
|
|
77
|
+
// Bound each attempt so a stalled connection fails fast instead of
|
|
78
|
+
// hanging on undici's multi-minute default and pinning the worker slot.
|
|
79
|
+
signal: AbortSignal.timeout(resolveGmailRequestTimeoutMs())
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const errName = err?.name;
|
|
83
|
+
const aborted = errName === "TimeoutError" || errName === "AbortError";
|
|
84
|
+
if (!aborted) throw err;
|
|
85
|
+
const timeoutError = new GmailApiError(
|
|
86
|
+
`Gmail API ${method} ${url.pathname} timed out`,
|
|
87
|
+
599,
|
|
88
|
+
"request timed out"
|
|
89
|
+
);
|
|
90
|
+
if (attempt === GMAIL_MAX_RETRIES) throw timeoutError;
|
|
91
|
+
lastError = timeoutError;
|
|
92
|
+
await sleep(computeBackoff(attempt));
|
|
93
|
+
attempt += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
67
96
|
const text = await res.text();
|
|
68
97
|
if (res.ok) {
|
|
69
98
|
if (!text) return void 0;
|
|
@@ -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\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 const res = await fetch(url.toString(), { method, headers, body: payload })\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": "AAgBA,MAAM,iBAAiB;AAmGvB,MAAM,oBAAoB;AAC1B,MAAM,wBAAwB;AAC9B,MAAM,uBAAuB;
|
|
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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",
|
|
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.5531.1.ab1959dfae",
|
|
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.5531.1.ab1959dfae",
|
|
65
|
+
"@open-mercato/ui": "0.6.6-develop.5531.1.ab1959dfae",
|
|
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.5531.1.ab1959dfae",
|
|
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.5531.1.ab1959dfae",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"@types/react": "^19.2.17",
|
|
79
79
|
"@types/react-dom": "^19.2.3",
|
|
@@ -206,4 +206,78 @@ describe('FetchGmailApiClient.requestJson retry/backoff', () => {
|
|
|
206
206
|
expect(calls).toBe(1)
|
|
207
207
|
expect(capturedDelays).toEqual([])
|
|
208
208
|
})
|
|
209
|
+
|
|
210
|
+
it('attaches an AbortSignal to each request so a stalled connection cannot hang the worker (issue #2976)', async () => {
|
|
211
|
+
let capturedSignal: unknown
|
|
212
|
+
globalThis.fetch = ((_url: string, init?: RequestInit) => {
|
|
213
|
+
capturedSignal = init?.signal
|
|
214
|
+
return Promise.resolve(
|
|
215
|
+
fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '1' }) }),
|
|
216
|
+
)
|
|
217
|
+
}) as unknown as typeof globalThis.fetch
|
|
218
|
+
|
|
219
|
+
await getGmailApiClient().getProfile({ accessToken: 'token' })
|
|
220
|
+
|
|
221
|
+
expect(capturedSignal).toBeInstanceOf(AbortSignal)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('treats a timed-out (TimeoutError) fetch as transient and retries it (issue #2976)', async () => {
|
|
225
|
+
Math.random = () => 0
|
|
226
|
+
let calls = 0
|
|
227
|
+
globalThis.fetch = (() => {
|
|
228
|
+
calls += 1
|
|
229
|
+
if (calls === 1) {
|
|
230
|
+
// `AbortSignal.timeout()` rejects fetch with a `TimeoutError` DOMException,
|
|
231
|
+
// not an `AbortError` — exercise the real production failure shape.
|
|
232
|
+
return Promise.reject(new DOMException('The operation timed out', 'TimeoutError'))
|
|
233
|
+
}
|
|
234
|
+
return Promise.resolve(
|
|
235
|
+
fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '5' }) }),
|
|
236
|
+
)
|
|
237
|
+
}) as unknown as typeof globalThis.fetch
|
|
238
|
+
|
|
239
|
+
const profile = await getGmailApiClient().getProfile({ accessToken: 'token' })
|
|
240
|
+
|
|
241
|
+
expect(calls).toBe(2)
|
|
242
|
+
expect(profile.historyId).toBe('5')
|
|
243
|
+
// computeBackoff(0) = 500ms (jitter stripped) — the timeout took the retry path.
|
|
244
|
+
expect(capturedDelays).toEqual([500])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('also retries an externally-aborted (AbortError) fetch (issue #2976)', async () => {
|
|
248
|
+
Math.random = () => 0
|
|
249
|
+
let calls = 0
|
|
250
|
+
globalThis.fetch = (() => {
|
|
251
|
+
calls += 1
|
|
252
|
+
if (calls === 1) return Promise.reject(new DOMException('The operation was aborted', 'AbortError'))
|
|
253
|
+
return Promise.resolve(
|
|
254
|
+
fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '7' }) }),
|
|
255
|
+
)
|
|
256
|
+
}) as unknown as typeof globalThis.fetch
|
|
257
|
+
|
|
258
|
+
const profile = await getGmailApiClient().getProfile({ accessToken: 'token' })
|
|
259
|
+
|
|
260
|
+
expect(calls).toBe(2)
|
|
261
|
+
expect(profile.historyId).toBe('7')
|
|
262
|
+
expect(capturedDelays).toEqual([500])
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('throws a GmailApiError after timeouts exhaust the retry budget (issue #2976)', async () => {
|
|
266
|
+
Math.random = () => 0
|
|
267
|
+
let calls = 0
|
|
268
|
+
globalThis.fetch = (() => {
|
|
269
|
+
calls += 1
|
|
270
|
+
return Promise.reject(new DOMException('The operation timed out', 'TimeoutError'))
|
|
271
|
+
}) as unknown as typeof globalThis.fetch
|
|
272
|
+
|
|
273
|
+
const thrown = await getGmailApiClient()
|
|
274
|
+
.getProfile({ accessToken: 'token' })
|
|
275
|
+
.catch((error: unknown) => error)
|
|
276
|
+
|
|
277
|
+
expect(thrown).toBeInstanceOf(GmailApiError)
|
|
278
|
+
expect((thrown as GmailApiError).status).toBe(599)
|
|
279
|
+
// 1 initial + 3 retries = 4 attempts; backoff fired on the first 3.
|
|
280
|
+
expect(calls).toBe(4)
|
|
281
|
+
expect(capturedDelays).toEqual([500, 1000, 2000])
|
|
282
|
+
})
|
|
209
283
|
})
|
|
@@ -116,6 +116,12 @@ export interface GmailApiClient {
|
|
|
116
116
|
const GMAIL_MAX_RETRIES = 3
|
|
117
117
|
const GMAIL_BACKOFF_BASE_MS = 500
|
|
118
118
|
const GMAIL_BACKOFF_CAP_MS = 8_000
|
|
119
|
+
const GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 30_000
|
|
120
|
+
|
|
121
|
+
function resolveGmailRequestTimeoutMs(): number {
|
|
122
|
+
const fromEnv = Number.parseInt(process.env.OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS ?? '', 10)
|
|
123
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : GMAIL_DEFAULT_REQUEST_TIMEOUT_MS
|
|
124
|
+
}
|
|
119
125
|
|
|
120
126
|
class FetchGmailApiClient implements GmailApiClient {
|
|
121
127
|
async listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse> {
|
|
@@ -190,7 +196,37 @@ class FetchGmailApiClient implements GmailApiClient {
|
|
|
190
196
|
let attempt = 0
|
|
191
197
|
let lastError: GmailApiError | null = null
|
|
192
198
|
while (attempt <= GMAIL_MAX_RETRIES) {
|
|
193
|
-
|
|
199
|
+
let res: Response
|
|
200
|
+
try {
|
|
201
|
+
res = await fetch(url.toString(), {
|
|
202
|
+
method,
|
|
203
|
+
headers,
|
|
204
|
+
body: payload,
|
|
205
|
+
// Bound each attempt so a stalled connection fails fast instead of
|
|
206
|
+
// hanging on undici's multi-minute default and pinning the worker slot.
|
|
207
|
+
signal: AbortSignal.timeout(resolveGmailRequestTimeoutMs()),
|
|
208
|
+
})
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// A timed-out/aborted connection is transient — let the bounded retry
|
|
211
|
+
// loop retry it rather than propagating a raw error. `AbortSignal.timeout`
|
|
212
|
+
// rejects fetch with a `TimeoutError` DOMException; an externally-aborted
|
|
213
|
+
// request surfaces as `AbortError`. Match on the `name` field (not
|
|
214
|
+
// `instanceof Error`, since DOMException does not subclass Error across
|
|
215
|
+
// realms) — treat both as transient.
|
|
216
|
+
const errName = (err as { name?: unknown } | null)?.name
|
|
217
|
+
const aborted = errName === 'TimeoutError' || errName === 'AbortError'
|
|
218
|
+
if (!aborted) throw err
|
|
219
|
+
const timeoutError = new GmailApiError(
|
|
220
|
+
`Gmail API ${method} ${url.pathname} timed out`,
|
|
221
|
+
599,
|
|
222
|
+
'request timed out',
|
|
223
|
+
)
|
|
224
|
+
if (attempt === GMAIL_MAX_RETRIES) throw timeoutError
|
|
225
|
+
lastError = timeoutError
|
|
226
|
+
await sleep(computeBackoff(attempt))
|
|
227
|
+
attempt += 1
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
194
230
|
const text = await res.text()
|
|
195
231
|
if (res.ok) {
|
|
196
232
|
if (!text) return undefined as unknown as T
|