@open-mercato/channel-gmail 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +47 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.js +17 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.js +16 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.js +16 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.js +17 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.js +26 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.js.map +7 -0
- package/dist/modules/channel_gmail/acl.js +10 -0
- package/dist/modules/channel_gmail/acl.js.map +7 -0
- package/dist/modules/channel_gmail/di.js +23 -0
- package/dist/modules/channel_gmail/di.js.map +7 -0
- package/dist/modules/channel_gmail/index.js +9 -0
- package/dist/modules/channel_gmail/index.js.map +7 -0
- package/dist/modules/channel_gmail/integration.js +69 -0
- package/dist/modules/channel_gmail/integration.js.map +7 -0
- package/dist/modules/channel_gmail/lib/adapter.js +542 -0
- package/dist/modules/channel_gmail/lib/adapter.js.map +7 -0
- package/dist/modules/channel_gmail/lib/capabilities.js +10 -0
- package/dist/modules/channel_gmail/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_gmail/lib/convert-outbound.js +84 -0
- package/dist/modules/channel_gmail/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_gmail/lib/credentials.js +48 -0
- package/dist/modules/channel_gmail/lib/credentials.js.map +7 -0
- package/dist/modules/channel_gmail/lib/gmail-client.js +160 -0
- package/dist/modules/channel_gmail/lib/gmail-client.js.map +7 -0
- package/dist/modules/channel_gmail/lib/health.js +10 -0
- package/dist/modules/channel_gmail/lib/health.js.map +7 -0
- package/dist/modules/channel_gmail/lib/normalize-inbound.js +28 -0
- package/dist/modules/channel_gmail/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_gmail/lib/oauth.js +77 -0
- package/dist/modules/channel_gmail/lib/oauth.js.map +7 -0
- package/dist/modules/channel_gmail/setup.js +25 -0
- package/dist/modules/channel_gmail/setup.js.map +7 -0
- package/dist/modules/channel_gmail/widgets/injection/connect/widget.client.js +24 -0
- package/dist/modules/channel_gmail/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_gmail/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_gmail/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_gmail/widgets/injection-table.js +14 -0
- package/dist/modules/channel_gmail/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +95 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.ts +24 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.ts +23 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.ts +23 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.ts +39 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.ts +48 -0
- package/src/modules/channel_gmail/acl.ts +6 -0
- package/src/modules/channel_gmail/di.ts +21 -0
- package/src/modules/channel_gmail/index.ts +6 -0
- package/src/modules/channel_gmail/integration.ts +67 -0
- package/src/modules/channel_gmail/lib/__tests__/adapter.test.ts +838 -0
- package/src/modules/channel_gmail/lib/__tests__/convert-outbound.test.ts +128 -0
- package/src/modules/channel_gmail/lib/__tests__/credentials.test.ts +76 -0
- package/src/modules/channel_gmail/lib/__tests__/gmail-client.test.ts +209 -0
- package/src/modules/channel_gmail/lib/__tests__/normalize-inbound.test.ts +106 -0
- package/src/modules/channel_gmail/lib/__tests__/oauth.test.ts +148 -0
- package/src/modules/channel_gmail/lib/adapter.ts +734 -0
- package/src/modules/channel_gmail/lib/capabilities.ts +22 -0
- package/src/modules/channel_gmail/lib/convert-outbound.ts +136 -0
- package/src/modules/channel_gmail/lib/credentials.ts +90 -0
- package/src/modules/channel_gmail/lib/gmail-client.ts +305 -0
- package/src/modules/channel_gmail/lib/health.ts +14 -0
- package/src/modules/channel_gmail/lib/normalize-inbound.ts +57 -0
- package/src/modules/channel_gmail/lib/oauth.ts +128 -0
- package/src/modules/channel_gmail/setup.ts +36 -0
- package/src/modules/channel_gmail/widgets/injection/connect/widget.client.tsx +28 -0
- package/src/modules/channel_gmail/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_gmail/widgets/injection-table.ts +12 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assembleRfc2822,
|
|
3
|
+
escapeQuotes,
|
|
4
|
+
generateMessageId,
|
|
5
|
+
htmlToText,
|
|
6
|
+
referencesFromMeta,
|
|
7
|
+
stringOrUndefined,
|
|
8
|
+
toAddressList
|
|
9
|
+
} from "@open-mercato/core/modules/communication_channels/lib/email-mime";
|
|
10
|
+
import { GMAIL_THREAD_REF_PREFIX } from "./normalize-inbound.js";
|
|
11
|
+
function deriveGmailThreadId(meta) {
|
|
12
|
+
const explicit = stringOrUndefined(meta.gmailThreadId) ?? stringOrUndefined(meta.threadId);
|
|
13
|
+
if (explicit) return explicit;
|
|
14
|
+
const conversationRef = stringOrUndefined(meta.thread_id);
|
|
15
|
+
if (conversationRef && conversationRef.startsWith(GMAIL_THREAD_REF_PREFIX)) {
|
|
16
|
+
const rawThreadId = conversationRef.slice(GMAIL_THREAD_REF_PREFIX.length);
|
|
17
|
+
return rawThreadId.length > 0 ? rawThreadId : void 0;
|
|
18
|
+
}
|
|
19
|
+
return void 0;
|
|
20
|
+
}
|
|
21
|
+
async function convertOutboundForGmail(input) {
|
|
22
|
+
const meta = input.channelMetadata ?? {};
|
|
23
|
+
const subject = stringOrUndefined(meta.subject);
|
|
24
|
+
const to = toAddressList(meta.to);
|
|
25
|
+
if (to.length === 0) {
|
|
26
|
+
throw new Error("[internal] Gmail outbound conversion requires at least one recipient (channelMetadata.to)");
|
|
27
|
+
}
|
|
28
|
+
const cc = toAddressList(meta.cc);
|
|
29
|
+
const bcc = toAddressList(meta.bcc);
|
|
30
|
+
const inReplyTo = stringOrUndefined(meta.inReplyTo);
|
|
31
|
+
const references = referencesFromMeta(meta.references);
|
|
32
|
+
const messageId = stringOrUndefined(meta.messageId) ?? generateMessageId(input.fromAddress, "gmail.com");
|
|
33
|
+
const threadId = deriveGmailThreadId(meta);
|
|
34
|
+
const html = input.bodyFormat === "html" ? input.body : void 0;
|
|
35
|
+
const text = input.bodyFormat === "html" ? htmlToText(input.body) : input.body;
|
|
36
|
+
const rawMessage = assembleRfc2822({
|
|
37
|
+
from: input.fromName ? `"${escapeQuotes(input.fromName)}" <${input.fromAddress}>` : input.fromAddress,
|
|
38
|
+
to,
|
|
39
|
+
cc,
|
|
40
|
+
bcc,
|
|
41
|
+
subject,
|
|
42
|
+
text,
|
|
43
|
+
html,
|
|
44
|
+
inReplyTo,
|
|
45
|
+
references,
|
|
46
|
+
messageId
|
|
47
|
+
});
|
|
48
|
+
const metadata = {
|
|
49
|
+
subject,
|
|
50
|
+
to,
|
|
51
|
+
cc: cc.length ? cc : void 0,
|
|
52
|
+
bcc: bcc.length ? bcc : void 0,
|
|
53
|
+
inReplyTo,
|
|
54
|
+
references,
|
|
55
|
+
messageId,
|
|
56
|
+
threadId,
|
|
57
|
+
fromAddress: input.fromAddress,
|
|
58
|
+
fromName: input.fromName,
|
|
59
|
+
rawMessage
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
content: {
|
|
63
|
+
text,
|
|
64
|
+
html,
|
|
65
|
+
bodyFormat: input.bodyFormat,
|
|
66
|
+
attachments: input.attachments,
|
|
67
|
+
raw: {
|
|
68
|
+
subject,
|
|
69
|
+
to,
|
|
70
|
+
cc,
|
|
71
|
+
bcc,
|
|
72
|
+
inReplyTo,
|
|
73
|
+
references,
|
|
74
|
+
messageId,
|
|
75
|
+
threadId
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
metadata
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
convertOutboundForGmail
|
|
83
|
+
};
|
|
84
|
+
//# sourceMappingURL=convert-outbound.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_gmail/lib/convert-outbound.ts"],
|
|
4
|
+
"sourcesContent": ["import type {\n ChannelNativeContent,\n ConvertOutboundInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport {\n assembleRfc2822,\n escapeQuotes,\n generateMessageId,\n htmlToText,\n referencesFromMeta,\n stringOrUndefined,\n toAddressList,\n} from '@open-mercato/core/modules/communication_channels/lib/email-mime'\nimport { GMAIL_THREAD_REF_PREFIX } from './normalize-inbound'\n\n/**\n * Convert a hub-canonical outbound payload to a Gmail-ready native content shape.\n *\n * Unlike IMAP/SMTP (which hands the message to nodemailer), the Gmail adapter\n * builds the RFC2822 message itself and sends via `gmail.users.messages.send`.\n * The converter pre-builds the raw message so `sendMessage` is a pure\n * \"base64url-encode + POST\" call, with no SMTP transport involved.\n *\n * Output metadata fields:\n * - rawMessage: Buffer \u2014 the assembled RFC2822 message\n * - threadId: string? \u2014 Gmail thread id (resolved by deriveGmailThreadId)\n * - subject / to / cc / bcc / inReplyTo / references \u2014 diagnostic copies\n */\n\nexport interface GmailEmailNativeMetadata {\n subject?: string\n to: string[]\n cc?: string[]\n bcc?: string[]\n inReplyTo?: string\n references?: string[]\n messageId?: string\n threadId?: string\n fromAddress: string\n fromName?: string\n rawMessage: Buffer\n}\n\nexport interface ConvertOutboundForGmailInput extends ConvertOutboundInput {\n fromAddress: string\n fromName?: string\n}\n\n/**\n * Resolve the Gmail native `threadId` so replies attach to the original Gmail\n * conversation. Order of precedence:\n * 1. `gmailThreadId` \u2014 explicit, set by inbound normalization metadata.\n * 2. `threadId` \u2014 survives the hub's convert\u2192send double-conversion, where\n * `sendMessage` re-converts the already-converted metadata.\n * 3. `thread_id` \u2014 the hub's generic conversation ref; for an existing Gmail\n * thread it is `gmail-thread:<rawId>` (see `normalize-inbound`). A new\n * outbound thread uses an `outbound:<uuid>` ref with no Gmail thread yet,\n * so `threadId` stays unset and Gmail opens a fresh server-side thread.\n */\nfunction deriveGmailThreadId(meta: Record<string, unknown>): string | undefined {\n const explicit = stringOrUndefined(meta.gmailThreadId) ?? stringOrUndefined(meta.threadId)\n if (explicit) return explicit\n const conversationRef = stringOrUndefined(meta.thread_id)\n if (conversationRef && conversationRef.startsWith(GMAIL_THREAD_REF_PREFIX)) {\n const rawThreadId = conversationRef.slice(GMAIL_THREAD_REF_PREFIX.length)\n return rawThreadId.length > 0 ? rawThreadId : undefined\n }\n return undefined\n}\n\nexport async function convertOutboundForGmail(\n input: ConvertOutboundForGmailInput,\n): Promise<ChannelNativeContent> {\n const meta = (input.channelMetadata ?? {}) as Record<string, unknown>\n const subject = stringOrUndefined(meta.subject)\n const to = toAddressList(meta.to)\n if (to.length === 0) {\n throw new Error('[internal] Gmail outbound conversion requires at least one recipient (channelMetadata.to)')\n }\n const cc = toAddressList(meta.cc)\n const bcc = toAddressList(meta.bcc)\n const inReplyTo = stringOrUndefined(meta.inReplyTo)\n const references = referencesFromMeta(meta.references)\n const messageId = stringOrUndefined(meta.messageId) ?? generateMessageId(input.fromAddress, 'gmail.com')\n const threadId = deriveGmailThreadId(meta)\n\n const html = input.bodyFormat === 'html' ? input.body : undefined\n const text = input.bodyFormat === 'html' ? htmlToText(input.body) : input.body\n\n const rawMessage = assembleRfc2822({\n from: input.fromName ? `\"${escapeQuotes(input.fromName)}\" <${input.fromAddress}>` : input.fromAddress,\n to,\n cc,\n bcc,\n subject,\n text,\n html,\n inReplyTo,\n references,\n messageId,\n })\n\n const metadata: GmailEmailNativeMetadata = {\n subject,\n to,\n cc: cc.length ? cc : undefined,\n bcc: bcc.length ? bcc : undefined,\n inReplyTo,\n references,\n messageId,\n threadId,\n fromAddress: input.fromAddress,\n fromName: input.fromName,\n rawMessage,\n }\n\n return {\n content: {\n text,\n html,\n bodyFormat: input.bodyFormat,\n attachments: input.attachments,\n raw: {\n subject,\n to,\n cc,\n bcc,\n inReplyTo,\n references,\n messageId,\n threadId,\n },\n },\n metadata: metadata as unknown as Record<string, unknown>,\n }\n}\n"],
|
|
5
|
+
"mappings": "AAIA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,+BAA+B;AA8CxC,SAAS,oBAAoB,MAAmD;AAC9E,QAAM,WAAW,kBAAkB,KAAK,aAAa,KAAK,kBAAkB,KAAK,QAAQ;AACzF,MAAI,SAAU,QAAO;AACrB,QAAM,kBAAkB,kBAAkB,KAAK,SAAS;AACxD,MAAI,mBAAmB,gBAAgB,WAAW,uBAAuB,GAAG;AAC1E,UAAM,cAAc,gBAAgB,MAAM,wBAAwB,MAAM;AACxE,WAAO,YAAY,SAAS,IAAI,cAAc;AAAA,EAChD;AACA,SAAO;AACT;AAEA,eAAsB,wBACpB,OAC+B;AAC/B,QAAM,OAAQ,MAAM,mBAAmB,CAAC;AACxC,QAAM,UAAU,kBAAkB,KAAK,OAAO;AAC9C,QAAM,KAAK,cAAc,KAAK,EAAE;AAChC,MAAI,GAAG,WAAW,GAAG;AACnB,UAAM,IAAI,MAAM,2FAA2F;AAAA,EAC7G;AACA,QAAM,KAAK,cAAc,KAAK,EAAE;AAChC,QAAM,MAAM,cAAc,KAAK,GAAG;AAClC,QAAM,YAAY,kBAAkB,KAAK,SAAS;AAClD,QAAM,aAAa,mBAAmB,KAAK,UAAU;AACrD,QAAM,YAAY,kBAAkB,KAAK,SAAS,KAAK,kBAAkB,MAAM,aAAa,WAAW;AACvG,QAAM,WAAW,oBAAoB,IAAI;AAEzC,QAAM,OAAO,MAAM,eAAe,SAAS,MAAM,OAAO;AACxD,QAAM,OAAO,MAAM,eAAe,SAAS,WAAW,MAAM,IAAI,IAAI,MAAM;AAE1E,QAAM,aAAa,gBAAgB;AAAA,IACjC,MAAM,MAAM,WAAW,IAAI,aAAa,MAAM,QAAQ,CAAC,MAAM,MAAM,WAAW,MAAM,MAAM;AAAA,IAC1F;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AAED,QAAM,WAAqC;AAAA,IACzC;AAAA,IACA;AAAA,IACA,IAAI,GAAG,SAAS,KAAK;AAAA,IACrB,KAAK,IAAI,SAAS,MAAM;AAAA,IACxB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,aAAa,MAAM;AAAA,IACnB,UAAU,MAAM;AAAA,IAChB;AAAA,EACF;AAEA,SAAO;AAAA,IACL,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA,YAAY,MAAM;AAAA,MAClB,aAAa,MAAM;AAAA,MACnB,KAAK;AAAA,QACH;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,IACA;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const gmailClientCredentialsSchema = z.object({
|
|
3
|
+
clientId: z.string().min(1, "OAuth Client ID required"),
|
|
4
|
+
clientSecret: z.string().min(1, "OAuth Client Secret required"),
|
|
5
|
+
/** Comma-separated scopes; blank uses defaults. */
|
|
6
|
+
scopes: z.string().optional()
|
|
7
|
+
}).strict();
|
|
8
|
+
const gmailUserCredentialsSchema = z.object({
|
|
9
|
+
accessToken: z.string({ error: "Access token required" }).min(1, "Access token required"),
|
|
10
|
+
/**
|
|
11
|
+
* Gmail issues a refresh token only on the first consent. If the user
|
|
12
|
+
* re-authorises, Google does NOT send a new refresh token unless we pass
|
|
13
|
+
* `prompt=consent` and `access_type=offline`; we always do. We still mark
|
|
14
|
+
* the field optional so legacy migrations from accounts that never received
|
|
15
|
+
* one don't fail the schema — the runtime treats absence as "requires_reauth".
|
|
16
|
+
*/
|
|
17
|
+
refreshToken: z.string().optional(),
|
|
18
|
+
/** ISO timestamp of access-token expiry. */
|
|
19
|
+
expiresAt: z.string().datetime().optional(),
|
|
20
|
+
/** Scopes that were actually granted (we may have requested a subset). */
|
|
21
|
+
scopes: z.array(z.string()).optional(),
|
|
22
|
+
/** Email address from the linked Google account. */
|
|
23
|
+
email: z.string().email().optional()
|
|
24
|
+
}).passthrough();
|
|
25
|
+
const gmailChannelStateSchema = z.object({
|
|
26
|
+
historyId: z.union([z.string(), z.number()]).optional(),
|
|
27
|
+
lastSyncedAt: z.string().datetime().optional(),
|
|
28
|
+
pendingHistoryPageToken: z.string().optional(),
|
|
29
|
+
pendingMessagesPageToken: z.string().optional(),
|
|
30
|
+
pendingMessagesHistoryIdSnapshot: z.string().optional()
|
|
31
|
+
}).partial().passthrough();
|
|
32
|
+
const GMAIL_DEFAULT_SCOPES = [
|
|
33
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
|
34
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
35
|
+
"https://www.googleapis.com/auth/userinfo.profile"
|
|
36
|
+
];
|
|
37
|
+
function parseScopes(value) {
|
|
38
|
+
if (!value || !value.trim()) return [...GMAIL_DEFAULT_SCOPES];
|
|
39
|
+
return value.split(/[\s,]+/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
GMAIL_DEFAULT_SCOPES,
|
|
43
|
+
gmailChannelStateSchema,
|
|
44
|
+
gmailClientCredentialsSchema,
|
|
45
|
+
gmailUserCredentialsSchema,
|
|
46
|
+
parseScopes
|
|
47
|
+
};
|
|
48
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_gmail/lib/credentials.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\n\n/**\n * Tenant-level OAuth client configuration. Stored on `IntegrationCredentials`\n * for the `gmail` provider when the tenant has set up their own Google Cloud\n * project. Per-user OAuth tokens layer on top via `userCredentialsSchema`.\n */\nexport const gmailClientCredentialsSchema = z\n .object({\n clientId: z.string().min(1, 'OAuth Client ID required'),\n clientSecret: z.string().min(1, 'OAuth Client Secret required'),\n /** Comma-separated scopes; blank uses defaults. */\n scopes: z.string().optional(),\n })\n .strict()\n\nexport type GmailClientCredentials = z.infer<typeof gmailClientCredentialsSchema>\n\n/**\n * Per-user OAuth tokens stored on `CommunicationChannel.credentials` (encrypted).\n * The hub injects the tenant client_id / client_secret at exchange/refresh time;\n * the per-channel blob only persists the user-bound tokens.\n */\nexport const gmailUserCredentialsSchema = z\n .object({\n accessToken: z.string({ error: 'Access token required' }).min(1, 'Access token required'),\n /**\n * Gmail issues a refresh token only on the first consent. If the user\n * re-authorises, Google does NOT send a new refresh token unless we pass\n * `prompt=consent` and `access_type=offline`; we always do. We still mark\n * the field optional so legacy migrations from accounts that never received\n * one don't fail the schema \u2014 the runtime treats absence as \"requires_reauth\".\n */\n refreshToken: z.string().optional(),\n /** ISO timestamp of access-token expiry. */\n expiresAt: z.string().datetime().optional(),\n /** Scopes that were actually granted (we may have requested a subset). */\n scopes: z.array(z.string()).optional(),\n /** Email address from the linked Google account. */\n email: z.string().email().optional(),\n })\n .passthrough()\n\nexport type GmailUserCredentials = z.infer<typeof gmailUserCredentialsSchema>\n\n/**\n * Per-channel sync state stored on `CommunicationChannel.channelState`.\n *\n * historyId \u2014 Gmail's per-mailbox monotonic cursor used by `history.list`\n * to fetch only changes since the previous poll. If history has\n * expired (Gmail keeps roughly 7 days), we fall back to a full\n * list using `gmail.users.messages.list`.\n *\n * pendingHistoryPageToken \u2014 mid-drain resumption state when a single tick\n * can't ingest every page of `history.list` (e.g. a high-volume\n * mailbox returned more than our per-tick budget). The terminal\n * `historyId` is NOT advanced until the pages drain. The next tick\n * resumes via the stored `historyId` + this `pageToken`.\n *\n * pendingMessagesPageToken / pendingMessagesHistoryIdSnapshot \u2014 same\n * contract for the 404-fallback path (`messages.list`).\n *\n * See https://developers.google.com/gmail/api/guides/sync for the contract.\n */\nexport const gmailChannelStateSchema = z\n .object({\n historyId: z.union([z.string(), z.number()]).optional(),\n lastSyncedAt: z.string().datetime().optional(),\n pendingHistoryPageToken: z.string().optional(),\n pendingMessagesPageToken: z.string().optional(),\n pendingMessagesHistoryIdSnapshot: z.string().optional(),\n })\n .partial()\n .passthrough()\n\nexport type GmailChannelState = z.infer<typeof gmailChannelStateSchema>\n\nexport const GMAIL_DEFAULT_SCOPES = [\n 'https://www.googleapis.com/auth/gmail.modify',\n 'https://www.googleapis.com/auth/userinfo.email',\n 'https://www.googleapis.com/auth/userinfo.profile',\n]\n\nexport function parseScopes(value: string | undefined): string[] {\n if (!value || !value.trim()) return [...GMAIL_DEFAULT_SCOPES]\n return value\n .split(/[\\s,]+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 0)\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAOX,MAAM,+BAA+B,EACzC,OAAO;AAAA,EACN,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,0BAA0B;AAAA,EACtD,cAAc,EAAE,OAAO,EAAE,IAAI,GAAG,8BAA8B;AAAA;AAAA,EAE9D,QAAQ,EAAE,OAAO,EAAE,SAAS;AAC9B,CAAC,EACA,OAAO;AASH,MAAM,6BAA6B,EACvC,OAAO;AAAA,EACN,aAAa,EAAE,OAAO,EAAE,OAAO,wBAAwB,CAAC,EAAE,IAAI,GAAG,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQxF,cAAc,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA,EAElC,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA,EAE1C,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA;AAAA,EAErC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS;AACrC,CAAC,EACA,YAAY;AAuBR,MAAM,0BAA0B,EACpC,OAAO;AAAA,EACN,WAAW,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS;AAAA,EACtD,cAAc,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC7C,yBAAyB,EAAE,OAAO,EAAE,SAAS;AAAA,EAC7C,0BAA0B,EAAE,OAAO,EAAE,SAAS;AAAA,EAC9C,kCAAkC,EAAE,OAAO,EAAE,SAAS;AACxD,CAAC,EACA,QAAQ,EACR,YAAY;AAIR,MAAM,uBAAuB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF;AAEO,SAAS,YAAY,OAAqC;AAC/D,MAAI,CAAC,SAAS,CAAC,MAAM,KAAK,EAAG,QAAO,CAAC,GAAG,oBAAoB;AAC5D,SAAO,MACJ,MAAM,QAAQ,EACd,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC;AAC/B;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const GMAIL_API_BASE = "https://gmail.googleapis.com/gmail/v1";
|
|
2
|
+
const GMAIL_MAX_RETRIES = 3;
|
|
3
|
+
const GMAIL_BACKOFF_BASE_MS = 500;
|
|
4
|
+
const GMAIL_BACKOFF_CAP_MS = 8e3;
|
|
5
|
+
class FetchGmailApiClient {
|
|
6
|
+
async listHistory(auth, input) {
|
|
7
|
+
const url = new URL(`${GMAIL_API_BASE}/users/me/history`);
|
|
8
|
+
url.searchParams.set("startHistoryId", input.startHistoryId);
|
|
9
|
+
if (input.pageToken) url.searchParams.set("pageToken", input.pageToken);
|
|
10
|
+
url.searchParams.set("labelId", input.labelId ?? "INBOX");
|
|
11
|
+
url.searchParams.set("historyTypes", "messageAdded");
|
|
12
|
+
return this.requestJson(auth, url, "GET");
|
|
13
|
+
}
|
|
14
|
+
async listMessages(auth, input) {
|
|
15
|
+
const url = new URL(`${GMAIL_API_BASE}/users/me/messages`);
|
|
16
|
+
if (input.query) url.searchParams.set("q", input.query);
|
|
17
|
+
for (const label of input.labelIds ?? []) url.searchParams.append("labelIds", label);
|
|
18
|
+
if (input.pageToken) url.searchParams.set("pageToken", input.pageToken);
|
|
19
|
+
if (input.maxResults) url.searchParams.set("maxResults", String(input.maxResults));
|
|
20
|
+
return this.requestJson(auth, url, "GET");
|
|
21
|
+
}
|
|
22
|
+
async getMessageRaw(auth, messageId) {
|
|
23
|
+
const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}`);
|
|
24
|
+
url.searchParams.set("format", "raw");
|
|
25
|
+
return this.requestJson(auth, url, "GET");
|
|
26
|
+
}
|
|
27
|
+
async sendRawMessage(auth, input) {
|
|
28
|
+
const url = new URL(`${GMAIL_API_BASE}/users/me/messages/send`);
|
|
29
|
+
return this.requestJson(auth, url, "POST", {
|
|
30
|
+
raw: input.rawBase64Url,
|
|
31
|
+
threadId: input.threadId
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async getProfile(auth) {
|
|
35
|
+
const url = new URL(`${GMAIL_API_BASE}/users/me/profile`);
|
|
36
|
+
return this.requestJson(auth, url, "GET");
|
|
37
|
+
}
|
|
38
|
+
async trashMessage(auth, messageId) {
|
|
39
|
+
const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}/trash`);
|
|
40
|
+
await this.requestJson(auth, url, "POST");
|
|
41
|
+
}
|
|
42
|
+
async watchInbox(auth, input) {
|
|
43
|
+
const url = new URL(`${GMAIL_API_BASE}/users/me/watch`);
|
|
44
|
+
return this.requestJson(auth, url, "POST", {
|
|
45
|
+
topicName: input.topicName,
|
|
46
|
+
labelIds: input.labelIds ?? ["INBOX"],
|
|
47
|
+
labelFilterAction: input.labelFilterAction ?? "include"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async stopWatch(auth) {
|
|
51
|
+
const url = new URL(`${GMAIL_API_BASE}/users/me/stop`);
|
|
52
|
+
await this.requestJson(auth, url, "POST");
|
|
53
|
+
}
|
|
54
|
+
async requestJson(auth, url, method, body) {
|
|
55
|
+
const headers = {
|
|
56
|
+
Authorization: `Bearer ${auth.accessToken}`
|
|
57
|
+
};
|
|
58
|
+
let payload;
|
|
59
|
+
if (body !== void 0) {
|
|
60
|
+
headers["Content-Type"] = "application/json";
|
|
61
|
+
payload = JSON.stringify(body);
|
|
62
|
+
}
|
|
63
|
+
let attempt = 0;
|
|
64
|
+
let lastError = null;
|
|
65
|
+
while (attempt <= GMAIL_MAX_RETRIES) {
|
|
66
|
+
const res = await fetch(url.toString(), { method, headers, body: payload });
|
|
67
|
+
const text = await res.text();
|
|
68
|
+
if (res.ok) {
|
|
69
|
+
if (!text) return void 0;
|
|
70
|
+
return JSON.parse(text);
|
|
71
|
+
}
|
|
72
|
+
const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`;
|
|
73
|
+
const apiError = new GmailApiError(
|
|
74
|
+
`Gmail API ${method} ${url.pathname} failed: ${detail}`,
|
|
75
|
+
res.status,
|
|
76
|
+
detail
|
|
77
|
+
);
|
|
78
|
+
const transient = res.status === 429 || res.status >= 500 && res.status < 600 || // Gmail signals per-user/project quota exhaustion with HTTP 403 +
|
|
79
|
+
// `rateLimitExceeded`/`userRateLimitExceeded` (not only 429).
|
|
80
|
+
res.status === 403 && isRateLimit403(text);
|
|
81
|
+
if (!transient || attempt === GMAIL_MAX_RETRIES) {
|
|
82
|
+
throw apiError;
|
|
83
|
+
}
|
|
84
|
+
lastError = apiError;
|
|
85
|
+
const retryAfterHeader = res.headers.get("retry-after");
|
|
86
|
+
const waitMs = parseRetryAfter(retryAfterHeader) ?? computeBackoff(attempt);
|
|
87
|
+
await sleep(waitMs);
|
|
88
|
+
attempt += 1;
|
|
89
|
+
}
|
|
90
|
+
throw lastError ?? new GmailApiError(`Gmail API ${method} ${url.pathname} exhausted retries`, 599, "retries exhausted");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isRateLimit403(body) {
|
|
94
|
+
return /rateLimitExceeded|userRateLimitExceeded/i.test(body);
|
|
95
|
+
}
|
|
96
|
+
function parseRetryAfter(value) {
|
|
97
|
+
if (!value) return null;
|
|
98
|
+
const asNumber = Number(value);
|
|
99
|
+
if (Number.isFinite(asNumber) && asNumber >= 0) {
|
|
100
|
+
return Math.min(asNumber * 1e3, GMAIL_BACKOFF_CAP_MS);
|
|
101
|
+
}
|
|
102
|
+
const asDate = Date.parse(value);
|
|
103
|
+
if (Number.isFinite(asDate)) {
|
|
104
|
+
const delta = asDate - Date.now();
|
|
105
|
+
if (delta > 0) return Math.min(delta, GMAIL_BACKOFF_CAP_MS);
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function computeBackoff(attempt) {
|
|
110
|
+
const raw = GMAIL_BACKOFF_BASE_MS * Math.pow(2, attempt);
|
|
111
|
+
const jitter = Math.floor(Math.random() * 100);
|
|
112
|
+
return Math.min(raw + jitter, GMAIL_BACKOFF_CAP_MS);
|
|
113
|
+
}
|
|
114
|
+
function sleep(ms) {
|
|
115
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
116
|
+
}
|
|
117
|
+
class GmailApiError extends Error {
|
|
118
|
+
constructor(message, status, detail) {
|
|
119
|
+
super(message);
|
|
120
|
+
this.name = "GmailApiError";
|
|
121
|
+
this.status = status;
|
|
122
|
+
this.detail = detail;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function parseErrorMessage(text) {
|
|
126
|
+
if (!text) return null;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(text);
|
|
129
|
+
if (parsed && typeof parsed.error === "object" && parsed.error && typeof parsed.error.message === "string") {
|
|
130
|
+
return parsed.error.message;
|
|
131
|
+
}
|
|
132
|
+
if (typeof parsed?.error === "string") return parsed.error;
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
return text.length > 200 ? text.slice(0, 200) : text;
|
|
136
|
+
}
|
|
137
|
+
let cachedClient = null;
|
|
138
|
+
function getGmailApiClient() {
|
|
139
|
+
if (!cachedClient) cachedClient = new FetchGmailApiClient();
|
|
140
|
+
return cachedClient;
|
|
141
|
+
}
|
|
142
|
+
function setGmailApiClient(client) {
|
|
143
|
+
cachedClient = client;
|
|
144
|
+
}
|
|
145
|
+
function encodeBase64Url(buffer) {
|
|
146
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
147
|
+
}
|
|
148
|
+
function decodeBase64Url(value) {
|
|
149
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
150
|
+
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - normalized.length % 4);
|
|
151
|
+
return Buffer.from(normalized + padding, "base64");
|
|
152
|
+
}
|
|
153
|
+
export {
|
|
154
|
+
GmailApiError,
|
|
155
|
+
decodeBase64Url,
|
|
156
|
+
encodeBase64Url,
|
|
157
|
+
getGmailApiClient,
|
|
158
|
+
setGmailApiClient
|
|
159
|
+
};
|
|
160
|
+
//# sourceMappingURL=gmail-client.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 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": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { makeClientConfigHealthCheck } from "@open-mercato/core/modules/communication_channels/lib/provider-health";
|
|
2
|
+
import { gmailClientCredentialsSchema } from "./credentials.js";
|
|
3
|
+
const channelGmailHealthCheck = makeClientConfigHealthCheck({
|
|
4
|
+
schema: gmailClientCredentialsSchema,
|
|
5
|
+
providerLabel: "Gmail"
|
|
6
|
+
});
|
|
7
|
+
export {
|
|
8
|
+
channelGmailHealthCheck
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=health.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_gmail/lib/health.ts"],
|
|
4
|
+
"sourcesContent": ["import { makeClientConfigHealthCheck } from '@open-mercato/core/modules/communication_channels/lib/provider-health'\nimport { gmailClientCredentialsSchema } from './credentials'\n\n/**\n * Liveness probe for the Gmail integration. The hub resolves it by the service\n * name declared in `integration.ts` (`channelGmailHealthCheck`) and passes the\n * tenant-scoped OAuth client config (`clientId` / `clientSecret`), NOT per-user\n * channel tokens \u2014 so the probe just confirms the client config is well-formed.\n * Per-user token validity surfaces on the channel itself (`requires_reauth`).\n */\nexport const channelGmailHealthCheck = makeClientConfigHealthCheck({\n schema: gmailClientCredentialsSchema,\n providerLabel: 'Gmail',\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,mCAAmC;AAC5C,SAAS,oCAAoC;AAStC,MAAM,0BAA0B,4BAA4B;AAAA,EACjE,QAAQ;AAAA,EACR,eAAe;AACjB,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeMimeInbound
|
|
3
|
+
} from "@open-mercato/core/modules/communication_channels/lib/email-mime";
|
|
4
|
+
const GMAIL_THREAD_REF_PREFIX = "gmail-thread:";
|
|
5
|
+
async function normalizeInboundGmailMessage(options) {
|
|
6
|
+
const mailparser = await import("mailparser");
|
|
7
|
+
const parsed = await mailparser.simpleParser(options.rawMessage);
|
|
8
|
+
const gmailFields = {
|
|
9
|
+
gmailMessageId: options.gmailMessageId,
|
|
10
|
+
gmailThreadId: options.gmailThreadId,
|
|
11
|
+
gmailLabelIds: options.gmailLabelIds ?? []
|
|
12
|
+
};
|
|
13
|
+
return normalizeMimeInbound({
|
|
14
|
+
parsed,
|
|
15
|
+
accountIdentifier: options.accountIdentifier,
|
|
16
|
+
fallbackMessageId: `gmail:${options.gmailMessageId}@${options.accountIdentifier}`,
|
|
17
|
+
// Gmail's threadId is authoritative for conversation grouping.
|
|
18
|
+
resolveConversationId: () => `${GMAIL_THREAD_REF_PREFIX}${options.gmailThreadId}`,
|
|
19
|
+
fallbackDate: options.fallbackDate,
|
|
20
|
+
channelMetadata: () => gmailFields,
|
|
21
|
+
channelPayload: () => gmailFields
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
GMAIL_THREAD_REF_PREFIX,
|
|
26
|
+
normalizeInboundGmailMessage
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=normalize-inbound.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_gmail/lib/normalize-inbound.ts"],
|
|
4
|
+
"sourcesContent": ["import type { NormalizedInboundMessage } from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport {\n normalizeMimeInbound,\n type ParsedMail,\n} from '@open-mercato/core/modules/communication_channels/lib/email-mime'\n\n/**\n * Prefix for the hub conversation ref of a Gmail-threaded conversation; the raw\n * Gmail `threadId` follows it. Single-sourced here (where the ref is formed) and\n * re-used by the outbound converter to recover the thread id for replies.\n */\nexport const GMAIL_THREAD_REF_PREFIX = 'gmail-thread:'\n\n/**\n * Convert a Gmail `messages.get?format=raw` response to the hub's canonical\n * `NormalizedInboundMessage`. Gmail returns the full RFC2822 message base64url-encoded,\n * so we parse with `mailparser` (same library the IMAP provider uses) and let the\n * shared `normalizeMimeInbound` helper handle threading / attachments / headers,\n * layering in Gmail-specific metadata (`threadId`, `labelIds`, Gmail message id).\n *\n * Threading uses Gmail's `threadId` (more reliable than In-Reply-To inside\n * Gmail's mailbox).\n */\n\nexport interface NormalizeInboundGmailOptions {\n rawMessage: Buffer\n gmailMessageId: string\n gmailThreadId: string\n gmailLabelIds?: string[]\n accountIdentifier: string\n fallbackDate?: Date\n}\n\nexport async function normalizeInboundGmailMessage(\n options: NormalizeInboundGmailOptions,\n): Promise<NormalizedInboundMessage> {\n const mailparser = (await import('mailparser')) as unknown as {\n simpleParser: (buf: Buffer | string) => Promise<ParsedMail>\n }\n const parsed = await mailparser.simpleParser(options.rawMessage)\n\n const gmailFields = {\n gmailMessageId: options.gmailMessageId,\n gmailThreadId: options.gmailThreadId,\n gmailLabelIds: options.gmailLabelIds ?? [],\n }\n return normalizeMimeInbound({\n parsed,\n accountIdentifier: options.accountIdentifier,\n fallbackMessageId: `gmail:${options.gmailMessageId}@${options.accountIdentifier}`,\n // Gmail's threadId is authoritative for conversation grouping.\n resolveConversationId: () => `${GMAIL_THREAD_REF_PREFIX}${options.gmailThreadId}`,\n fallbackDate: options.fallbackDate,\n channelMetadata: () => gmailFields,\n channelPayload: () => gmailFields,\n })\n}\n"],
|
|
5
|
+
"mappings": "AACA;AAAA,EACE;AAAA,OAEK;AAOA,MAAM,0BAA0B;AAsBvC,eAAsB,6BACpB,SACmC;AACnC,QAAM,aAAc,MAAM,OAAO,YAAY;AAG7C,QAAM,SAAS,MAAM,WAAW,aAAa,QAAQ,UAAU;AAE/D,QAAM,cAAc;AAAA,IAClB,gBAAgB,QAAQ;AAAA,IACxB,eAAe,QAAQ;AAAA,IACvB,eAAe,QAAQ,iBAAiB,CAAC;AAAA,EAC3C;AACA,SAAO,qBAAqB;AAAA,IAC1B;AAAA,IACA,mBAAmB,QAAQ;AAAA,IAC3B,mBAAmB,SAAS,QAAQ,cAAc,IAAI,QAAQ,iBAAiB;AAAA;AAAA,IAE/E,uBAAuB,MAAM,GAAG,uBAAuB,GAAG,QAAQ,aAAa;AAAA,IAC/E,cAAc,QAAQ;AAAA,IACtB,iBAAiB,MAAM;AAAA,IACvB,gBAAgB,MAAM;AAAA,EACxB,CAAC;AACH;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requestOAuthToken,
|
|
3
|
+
tokenResponseToExpiresAt
|
|
4
|
+
} from "@open-mercato/core/modules/communication_channels/lib/oauth-token";
|
|
5
|
+
import { parseScopes } from "./credentials.js";
|
|
6
|
+
const GMAIL_OAUTH_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
7
|
+
const GMAIL_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
8
|
+
const GMAIL_OAUTH_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo";
|
|
9
|
+
class RealGoogleOAuthClient {
|
|
10
|
+
buildAuthorizeUrl(input) {
|
|
11
|
+
const url = new URL(GMAIL_OAUTH_AUTHORIZE_URL);
|
|
12
|
+
url.searchParams.set("client_id", input.clientId);
|
|
13
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
14
|
+
url.searchParams.set("response_type", "code");
|
|
15
|
+
url.searchParams.set("scope", (input.scopes.length ? input.scopes : parseScopes(void 0)).join(" "));
|
|
16
|
+
url.searchParams.set("state", input.state);
|
|
17
|
+
url.searchParams.set("access_type", "offline");
|
|
18
|
+
url.searchParams.set("prompt", "consent");
|
|
19
|
+
url.searchParams.set("include_granted_scopes", "true");
|
|
20
|
+
if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
|
|
21
|
+
return url.toString();
|
|
22
|
+
}
|
|
23
|
+
async exchangeCode(input) {
|
|
24
|
+
const params = new URLSearchParams();
|
|
25
|
+
params.set("grant_type", "authorization_code");
|
|
26
|
+
params.set("code", input.code);
|
|
27
|
+
params.set("redirect_uri", input.redirectUri);
|
|
28
|
+
params.set("client_id", input.clientId);
|
|
29
|
+
params.set("client_secret", input.clientSecret);
|
|
30
|
+
return requestOAuthToken(GMAIL_OAUTH_TOKEN_URL, params, {
|
|
31
|
+
errorLabel: "Gmail OAuth code exchange failed"
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async refreshToken(input) {
|
|
35
|
+
const params = new URLSearchParams();
|
|
36
|
+
params.set("grant_type", "refresh_token");
|
|
37
|
+
params.set("refresh_token", input.refreshToken);
|
|
38
|
+
params.set("client_id", input.clientId);
|
|
39
|
+
params.set("client_secret", input.clientSecret);
|
|
40
|
+
return requestOAuthToken(GMAIL_OAUTH_TOKEN_URL, params, {
|
|
41
|
+
errorLabel: "Gmail OAuth refresh failed"
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async fetchUserInfo(accessToken) {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(GMAIL_OAUTH_USERINFO_URL, {
|
|
49
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
50
|
+
signal: controller.signal
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
throw new Error(`Gmail userinfo fetch failed: ${res.status} ${res.statusText}`);
|
|
54
|
+
}
|
|
55
|
+
return await res.json();
|
|
56
|
+
} finally {
|
|
57
|
+
clearTimeout(timeout);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let cachedClient = null;
|
|
62
|
+
function getGoogleOAuthClient() {
|
|
63
|
+
if (!cachedClient) cachedClient = new RealGoogleOAuthClient();
|
|
64
|
+
return cachedClient;
|
|
65
|
+
}
|
|
66
|
+
function setGoogleOAuthClient(client) {
|
|
67
|
+
cachedClient = client;
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
GMAIL_OAUTH_AUTHORIZE_URL,
|
|
71
|
+
GMAIL_OAUTH_TOKEN_URL,
|
|
72
|
+
GMAIL_OAUTH_USERINFO_URL,
|
|
73
|
+
getGoogleOAuthClient,
|
|
74
|
+
setGoogleOAuthClient,
|
|
75
|
+
tokenResponseToExpiresAt
|
|
76
|
+
};
|
|
77
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_gmail/lib/oauth.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Thin Gmail OAuth client wrapper. Uses raw `fetch` against Google's well-known\n * endpoints so the adapter can stay agnostic of the `googleapis` SDK and tests\n * can stub `setGoogleOAuthClient(...)` without loading the SDK at all.\n *\n * Endpoints \u2014 locked to Google's documented OAuth2 v2 surface:\n * - Authorize https://accounts.google.com/o/oauth2/v2/auth\n * - Token https://oauth2.googleapis.com/token\n * - Userinfo https://www.googleapis.com/oauth2/v3/userinfo\n */\n\nimport {\n requestOAuthToken,\n tokenResponseToExpiresAt,\n type OAuthTokenResponse,\n} from '@open-mercato/core/modules/communication_channels/lib/oauth-token'\nimport { parseScopes } from './credentials'\n\nexport { tokenResponseToExpiresAt }\n\nexport const GMAIL_OAUTH_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'\nexport const GMAIL_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token'\nexport const GMAIL_OAUTH_USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'\n\nexport interface BuildAuthorizeUrlInput {\n clientId: string\n redirectUri: string\n state: string\n scopes: string[]\n loginHint?: string\n}\n\nexport interface ExchangeCodeInput {\n clientId: string\n clientSecret: string\n redirectUri: string\n code: string\n}\n\nexport interface RefreshTokenInput {\n clientId: string\n clientSecret: string\n refreshToken: string\n}\n\nexport type TokenResponse = OAuthTokenResponse\n\nexport interface UserInfoResponse {\n sub?: string\n email?: string\n email_verified?: boolean\n name?: string\n picture?: string\n}\n\nexport interface GoogleOAuthClient {\n buildAuthorizeUrl(input: BuildAuthorizeUrlInput): string\n exchangeCode(input: ExchangeCodeInput): Promise<TokenResponse>\n refreshToken(input: RefreshTokenInput): Promise<TokenResponse>\n fetchUserInfo(accessToken: string): Promise<UserInfoResponse>\n}\n\nclass RealGoogleOAuthClient implements GoogleOAuthClient {\n buildAuthorizeUrl(input: BuildAuthorizeUrlInput): string {\n const url = new URL(GMAIL_OAUTH_AUTHORIZE_URL)\n url.searchParams.set('client_id', input.clientId)\n url.searchParams.set('redirect_uri', input.redirectUri)\n url.searchParams.set('response_type', 'code')\n url.searchParams.set('scope', (input.scopes.length ? input.scopes : parseScopes(undefined)).join(' '))\n url.searchParams.set('state', input.state)\n url.searchParams.set('access_type', 'offline')\n url.searchParams.set('prompt', 'consent')\n url.searchParams.set('include_granted_scopes', 'true')\n if (input.loginHint) url.searchParams.set('login_hint', input.loginHint)\n return url.toString()\n }\n\n async exchangeCode(input: ExchangeCodeInput): Promise<TokenResponse> {\n const params = new URLSearchParams()\n params.set('grant_type', 'authorization_code')\n params.set('code', input.code)\n params.set('redirect_uri', input.redirectUri)\n params.set('client_id', input.clientId)\n params.set('client_secret', input.clientSecret)\n return requestOAuthToken(GMAIL_OAUTH_TOKEN_URL, params, {\n errorLabel: 'Gmail OAuth code exchange failed',\n })\n }\n\n async refreshToken(input: RefreshTokenInput): Promise<TokenResponse> {\n const params = new URLSearchParams()\n params.set('grant_type', 'refresh_token')\n params.set('refresh_token', input.refreshToken)\n params.set('client_id', input.clientId)\n params.set('client_secret', input.clientSecret)\n return requestOAuthToken(GMAIL_OAUTH_TOKEN_URL, params, {\n errorLabel: 'Gmail OAuth refresh failed',\n })\n }\n\n async fetchUserInfo(accessToken: string): Promise<UserInfoResponse> {\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), 10_000)\n try {\n const res = await fetch(GMAIL_OAUTH_USERINFO_URL, {\n headers: { Authorization: `Bearer ${accessToken}` },\n signal: controller.signal,\n })\n if (!res.ok) {\n throw new Error(`Gmail userinfo fetch failed: ${res.status} ${res.statusText}`)\n }\n return (await res.json()) as UserInfoResponse\n } finally {\n clearTimeout(timeout)\n }\n }\n}\n\nlet cachedClient: GoogleOAuthClient | null = null\n\nexport function getGoogleOAuthClient(): GoogleOAuthClient {\n if (!cachedClient) cachedClient = new RealGoogleOAuthClient()\n return cachedClient\n}\n\nexport function setGoogleOAuthClient(client: GoogleOAuthClient | null): void {\n cachedClient = client\n}\n"],
|
|
5
|
+
"mappings": "AAWA;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP,SAAS,mBAAmB;AAIrB,MAAM,4BAA4B;AAClC,MAAM,wBAAwB;AAC9B,MAAM,2BAA2B;AAwCxC,MAAM,sBAAmD;AAAA,EACvD,kBAAkB,OAAuC;AACvD,UAAM,MAAM,IAAI,IAAI,yBAAyB;AAC7C,QAAI,aAAa,IAAI,aAAa,MAAM,QAAQ;AAChD,QAAI,aAAa,IAAI,gBAAgB,MAAM,WAAW;AACtD,QAAI,aAAa,IAAI,iBAAiB,MAAM;AAC5C,QAAI,aAAa,IAAI,UAAU,MAAM,OAAO,SAAS,MAAM,SAAS,YAAY,MAAS,GAAG,KAAK,GAAG,CAAC;AACrG,QAAI,aAAa,IAAI,SAAS,MAAM,KAAK;AACzC,QAAI,aAAa,IAAI,eAAe,SAAS;AAC7C,QAAI,aAAa,IAAI,UAAU,SAAS;AACxC,QAAI,aAAa,IAAI,0BAA0B,MAAM;AACrD,QAAI,MAAM,UAAW,KAAI,aAAa,IAAI,cAAc,MAAM,SAAS;AACvE,WAAO,IAAI,SAAS;AAAA,EACtB;AAAA,EAEA,MAAM,aAAa,OAAkD;AACnE,UAAM,SAAS,IAAI,gBAAgB;AACnC,WAAO,IAAI,cAAc,oBAAoB;AAC7C,WAAO,IAAI,QAAQ,MAAM,IAAI;AAC7B,WAAO,IAAI,gBAAgB,MAAM,WAAW;AAC5C,WAAO,IAAI,aAAa,MAAM,QAAQ;AACtC,WAAO,IAAI,iBAAiB,MAAM,YAAY;AAC9C,WAAO,kBAAkB,uBAAuB,QAAQ;AAAA,MACtD,YAAY;AAAA,IACd,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,aAAa,OAAkD;AACnE,UAAM,SAAS,IAAI,gBAAgB;AACnC,WAAO,IAAI,cAAc,eAAe;AACxC,WAAO,IAAI,iBAAiB,MAAM,YAAY;AAC9C,WAAO,IAAI,aAAa,MAAM,QAAQ;AACtC,WAAO,IAAI,iBAAiB,MAAM,YAAY;AAC9C,WAAO,kBAAkB,uBAAuB,QAAQ;AAAA,MACtD,YAAY;AAAA,IACd,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,cAAc,aAAgD;AAClE,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,UAAU,WAAW,MAAM,WAAW,MAAM,GAAG,GAAM;AAC3D,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,0BAA0B;AAAA,QAChD,SAAS,EAAE,eAAe,UAAU,WAAW,GAAG;AAAA,QAClD,QAAQ,WAAW;AAAA,MACrB,CAAC;AACD,UAAI,CAAC,IAAI,IAAI;AACX,cAAM,IAAI,MAAM,gCAAgC,IAAI,MAAM,IAAI,IAAI,UAAU,EAAE;AAAA,MAChF;AACA,aAAQ,MAAM,IAAI,KAAK;AAAA,IACzB,UAAE;AACA,mBAAa,OAAO;AAAA,IACtB;AAAA,EACF;AACF;AAEA,IAAI,eAAyC;AAEtC,SAAS,uBAA0C;AACxD,MAAI,CAAC,aAAc,gBAAe,IAAI,sBAAsB;AAC5D,SAAO;AACT;AAEO,SAAS,qBAAqB,QAAwC;AAC3E,iBAAe;AACjB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasChannelAdapter,
|
|
3
|
+
registerChannelAdapter
|
|
4
|
+
} from "@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton";
|
|
5
|
+
import { getGmailChannelAdapter } from "./lib/adapter.js";
|
|
6
|
+
function ensureGmailAdapterRegistered() {
|
|
7
|
+
if (hasChannelAdapter("gmail")) return;
|
|
8
|
+
registerChannelAdapter(getGmailChannelAdapter());
|
|
9
|
+
}
|
|
10
|
+
ensureGmailAdapterRegistered();
|
|
11
|
+
const setup = {
|
|
12
|
+
defaultRoleFeatures: {
|
|
13
|
+
superadmin: ["channel_gmail.view", "channel_gmail.configure"],
|
|
14
|
+
admin: ["channel_gmail.view", "channel_gmail.configure"]
|
|
15
|
+
},
|
|
16
|
+
async onTenantCreated() {
|
|
17
|
+
ensureGmailAdapterRegistered();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var setup_default = setup;
|
|
21
|
+
export {
|
|
22
|
+
setup_default as default,
|
|
23
|
+
setup
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=setup.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/channel_gmail/setup.ts"],
|
|
4
|
+
"sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport {\n hasChannelAdapter,\n registerChannelAdapter,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'\nimport { getGmailChannelAdapter } from './lib/adapter'\n\n/**\n * Register the Gmail `ChannelAdapter` once per process at import time. The\n * registry is process-wide so the underlying `setRegister` call is idempotent;\n * we guard with `hasChannelAdapter` to silence the registry's duplicate error\n * on dev-mode HMR + repeated test imports.\n *\n * Tenant-level OAuth client config (Client ID + Client Secret) is persisted via\n * the standard `IntegrationCredentials` flow for the `gmail` provider; this\n * module does not preconfigure per-tenant credentials from env (Google Cloud\n * Console projects are explicit per-tenant).\n */\nfunction ensureGmailAdapterRegistered(): void {\n if (hasChannelAdapter('gmail')) return\n registerChannelAdapter(getGmailChannelAdapter())\n}\n\nensureGmailAdapterRegistered()\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n superadmin: ['channel_gmail.view', 'channel_gmail.configure'],\n admin: ['channel_gmail.view', 'channel_gmail.configure'],\n },\n async onTenantCreated() {\n ensureGmailAdapterRegistered()\n },\n}\n\nexport default setup\n"],
|
|
5
|
+
"mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,8BAA8B;AAavC,SAAS,+BAAqC;AAC5C,MAAI,kBAAkB,OAAO,EAAG;AAChC,yBAAuB,uBAAuB,CAAC;AACjD;AAEA,6BAA6B;AAEtB,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,YAAY,CAAC,sBAAsB,yBAAyB;AAAA,IAC5D,OAAO,CAAC,sBAAsB,yBAAyB;AAAA,EACzD;AAAA,EACA,MAAM,kBAAkB;AACtB,iCAA6B;AAAA,EAC/B;AACF;AAEA,IAAO,gBAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
4
|
+
import { SocialButton } from "@open-mercato/ui/primitives/social-button";
|
|
5
|
+
import { useConnectChannel } from "@open-mercato/core/modules/communication_channels/lib/use-connect-channel";
|
|
6
|
+
function ConnectGmailWidget(_props) {
|
|
7
|
+
const t = useT();
|
|
8
|
+
const { connect, pending } = useConnectChannel({ providerKey: "gmail" });
|
|
9
|
+
return /* @__PURE__ */ jsx(
|
|
10
|
+
SocialButton,
|
|
11
|
+
{
|
|
12
|
+
type: "button",
|
|
13
|
+
brand: "google",
|
|
14
|
+
appearance: "stroke",
|
|
15
|
+
onClick: () => void connect(),
|
|
16
|
+
disabled: pending,
|
|
17
|
+
children: pending ? t("communication_channels.profile.connect.connecting", "Connecting...") : t("communication_channels.profile.connect.gmail", "Connect Gmail")
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
ConnectGmailWidget as default
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=widget.client.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../../src/modules/channel_gmail/widgets/injection/connect/widget.client.tsx"],
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { SocialButton } from '@open-mercato/ui/primitives/social-button'\nimport { useConnectChannel } from '@open-mercato/core/modules/communication_channels/lib/use-connect-channel'\n\nexport default function ConnectGmailWidget(\n _props: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>,\n) {\n const t = useT()\n const { connect, pending } = useConnectChannel({ providerKey: 'gmail' })\n\n return (\n <SocialButton\n type=\"button\"\n brand=\"google\"\n appearance=\"stroke\"\n onClick={() => void connect()}\n disabled={pending}\n >\n {pending\n ? t('communication_channels.profile.connect.connecting', 'Connecting...')\n : t('communication_channels.profile.connect.gmail', 'Connect Gmail')}\n </SocialButton>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AAeI;AAXJ,SAAS,YAAY;AACrB,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAEnB,SAAR,mBACL,QACA;AACA,QAAM,IAAI,KAAK;AACf,QAAM,EAAE,SAAS,QAAQ,IAAI,kBAAkB,EAAE,aAAa,QAAQ,CAAC;AAEvE,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,OAAM;AAAA,MACN,YAAW;AAAA,MACX,SAAS,MAAM,KAAK,QAAQ;AAAA,MAC5B,UAAU;AAAA,MAET,oBACG,EAAE,qDAAqD,eAAe,IACtE,EAAE,gDAAgD,eAAe;AAAA;AAAA,EACvE;AAEJ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import ConnectGmailWidget from "./widget.client.js";
|
|
2
|
+
const widget = {
|
|
3
|
+
metadata: {
|
|
4
|
+
id: "channel_gmail.injection.connect",
|
|
5
|
+
title: "Connect Gmail",
|
|
6
|
+
description: "Starts the per-user Gmail OAuth connection flow.",
|
|
7
|
+
features: ["communication_channels.connect_user_channel"],
|
|
8
|
+
priority: 120,
|
|
9
|
+
enabled: true
|
|
10
|
+
},
|
|
11
|
+
Widget: ConnectGmailWidget
|
|
12
|
+
};
|
|
13
|
+
var widget_default = widget;
|
|
14
|
+
export {
|
|
15
|
+
widget_default as default
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=widget.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../../src/modules/channel_gmail/widgets/injection/connect/widget.ts"],
|
|
4
|
+
"sourcesContent": ["import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport ConnectGmailWidget from './widget.client'\n\nconst widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {\n metadata: {\n id: 'channel_gmail.injection.connect',\n title: 'Connect Gmail',\n description: 'Starts the per-user Gmail OAuth connection flow.',\n features: ['communication_channels.connect_user_channel'],\n priority: 120,\n enabled: true,\n },\n Widget: ConnectGmailWidget,\n}\n\nexport default widget\n"],
|
|
5
|
+
"mappings": "AACA,OAAO,wBAAwB;AAE/B,MAAM,SAAkF;AAAA,EACtF,UAAU;AAAA,IACR,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU,CAAC,6CAA6C;AAAA,IACxD,UAAU;AAAA,IACV,SAAS;AAAA,EACX;AAAA,EACA,QAAQ;AACV;AAEA,IAAO,iBAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|