@msgly/gmail 0.2.1

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/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # @msgly/gmail
2
+
3
+ > Gmail adapter for [Msgly](https://github.com/AyushJain070401/msgly). Receive new messages as `hub.on('message')` events via Google Cloud Pub/Sub push, send threaded replies via the Gmail REST API. **Built for "agent on email channel" use cases — one bot mailbox, OAuth refresh token, pure WebCrypto.**
4
+
5
+ ## Scope (v1)
6
+
7
+ This release ships **text-only send + receive** for a **single mailbox per adapter** (the bot's own inbox). It is the right shape for shared support inboxes, reply-bots, and agent automations on a dedicated mailbox.
8
+
9
+ Out of scope for v1 — these are planned, but absent today:
10
+ - Sending attachments / inline images
11
+ - Surfacing inbound attachments as media (the body text comes through; attachment bytes are not extracted)
12
+ - Multi-user mailbox routing (one adapter = one mailbox)
13
+ - Reading historical mail (only push-triggered fetch)
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @msgly/core @msgly/gmail
19
+ ```
20
+
21
+ ## How Gmail receive works
22
+
23
+ Gmail does not push email bodies to webhooks. The flow is:
24
+
25
+ 1. You call `users.watch()` **once** with a Pub/Sub topic name. Gmail starts publishing notifications to that topic whenever the inbox changes.
26
+ 2. Your Pub/Sub push subscription forwards each event to your webhook (`<PUBLIC_URL>/webhook/gmail`). The payload contains `{ emailAddress, historyId }` — just the historyId, no message body.
27
+ 3. The adapter calls `users.history.list` from the previously-seen historyId, finds new message ids, fetches each via `users.messages.get?format=full`, and emits an inbound message per item.
28
+
29
+ The "last seen historyId" is held in adapter memory. On first notification after process boot, the adapter falls back to fetching recent unread INBOX messages so nothing is lost across deploys.
30
+
31
+ ## Quick start
32
+
33
+ ```typescript
34
+ import express from 'express';
35
+ import { createHub } from '@msgly/core';
36
+ import { createGmailAdapter } from '@msgly/gmail';
37
+
38
+ const hub = createHub();
39
+
40
+ hub.register(
41
+ createGmailAdapter({
42
+ clientId: process.env.GMAIL_CLIENT_ID!,
43
+ clientSecret: process.env.GMAIL_CLIENT_SECRET!,
44
+ refreshToken: process.env.GMAIL_REFRESH_TOKEN!,
45
+ emailAddress: process.env.GMAIL_EMAIL!,
46
+ pushAuth: {
47
+ kind: 'jwt',
48
+ expectedAudience: 'https://yourdomain.com/webhook/gmail',
49
+ },
50
+ }),
51
+ );
52
+
53
+ await hub.connect({ throwOnFailure: true });
54
+
55
+ hub.on('message', async (msg) => {
56
+ if (msg.content.type === 'text') {
57
+ await hub.send({
58
+ channel: 'gmail',
59
+ account: msg.account,
60
+ contact: msg.contact,
61
+ content: { type: 'text', text: `Auto-reply: I received "${msg.content.text}"` },
62
+ // Thread the reply onto the original conversation.
63
+ metadata: {
64
+ threadId: msg.metadata?.threadId,
65
+ messageId: msg.metadata?.messageId,
66
+ subject: msg.metadata?.subject,
67
+ references: msg.metadata?.references,
68
+ },
69
+ });
70
+ }
71
+ });
72
+
73
+ const app = express();
74
+ app.use(express.json({ verify: (req, _r, buf) => ((req as any).rawBody = new Uint8Array(buf)) }));
75
+
76
+ const handlers = hub.createWebhookHandler();
77
+ app.post('/webhook/:channel', handlers.post);
78
+
79
+ app.listen(3000);
80
+ ```
81
+
82
+ ## Config
83
+
84
+ ```typescript
85
+ interface GmailConfig {
86
+ /** OAuth client (Google Cloud Console → Credentials → OAuth 2.0 Client ID). */
87
+ clientId: string;
88
+ clientSecret: string;
89
+ /**
90
+ * Long-lived refresh token for the agent mailbox. Run the OAuth consent
91
+ * flow once with `prompt=consent&access_type=offline` to obtain.
92
+ */
93
+ refreshToken: string;
94
+ /** The mailbox email (used as From: and account.channelAccountId). */
95
+ emailAddress: string;
96
+
97
+ /** How to verify inbound Pub/Sub webhooks. Pick one. */
98
+ pushAuth:
99
+ | { kind: 'jwt'; expectedAudience: string; expectedServiceAccountEmail?: string }
100
+ | { kind: 'token'; token: string }
101
+ | { kind: 'none' }; // dev only — DO NOT use in production
102
+
103
+ maxMessagesPerNotification?: number; // default 25
104
+ // overrides for testing / private clouds:
105
+ tokenUrl?: string;
106
+ apiBase?: string;
107
+ jwksUrl?: string;
108
+ clockSkewSec?: number; // default 300
109
+ }
110
+ ```
111
+
112
+ ## Setup (one-time, ~30 minutes)
113
+
114
+ The setup is more involved than the chat channels because Pub/Sub needs to be wired up. Walk through it once and the runtime is just two env vars + a webhook URL.
115
+
116
+ ### 1. Create an OAuth client
117
+
118
+ [Google Cloud Console](https://console.cloud.google.com/apis/credentials):
119
+
120
+ - **APIs & Services → Library** → enable **Gmail API**
121
+ - **APIs & Services → OAuth consent screen** → External or Internal — fill in basics, add scope `https://www.googleapis.com/auth/gmail.modify`. Add the bot's email as a test user.
122
+ - **Credentials → Create credentials → OAuth client ID** → Web application → add `http://localhost:8080/oauth-callback` (or whatever you'll use) as a redirect URI
123
+ - Copy **Client ID** → `GMAIL_CLIENT_ID`
124
+ - Copy **Client secret** → `GMAIL_CLIENT_SECRET`
125
+
126
+ ### 2. Get a refresh token for the agent mailbox
127
+
128
+ Run the consent flow once. Quickest path locally:
129
+
130
+ ```bash
131
+ # Open in your browser, signed in as the agent mailbox:
132
+ https://accounts.google.com/o/oauth2/v2/auth\
133
+ ?client_id=YOUR_CLIENT_ID\
134
+ &response_type=code\
135
+ &scope=https://www.googleapis.com/auth/gmail.modify\
136
+ &redirect_uri=http://localhost:8080/oauth-callback\
137
+ &access_type=offline\
138
+ &prompt=consent
139
+ ```
140
+
141
+ After consenting, you'll be redirected to your localhost URL with `?code=...`. Exchange that code for a refresh token:
142
+
143
+ ```bash
144
+ curl https://oauth2.googleapis.com/token \
145
+ -d code=THE_CODE \
146
+ -d client_id=$GMAIL_CLIENT_ID \
147
+ -d client_secret=$GMAIL_CLIENT_SECRET \
148
+ -d redirect_uri=http://localhost:8080/oauth-callback \
149
+ -d grant_type=authorization_code
150
+ ```
151
+
152
+ Copy `refresh_token` from the JSON response → `GMAIL_REFRESH_TOKEN`.
153
+
154
+ > Note: Google issues a refresh token **only on the first consent**. If you need to re-issue, revoke the app at [myaccount.google.com/permissions](https://myaccount.google.com/permissions) and consent again.
155
+
156
+ ### 3. Create the Pub/Sub topic and subscription
157
+
158
+ In the same Google Cloud project:
159
+
160
+ - **Pub/Sub → Topics → Create topic** — name it `gmail-inbox` (or anything).
161
+ - On the topic → **Permissions** → **Add principal** → `gmail-api-push@system.gserviceaccount.com` → role `Pub/Sub Publisher`. **This is what lets Gmail publish into your topic.**
162
+ - **Subscriptions → Create subscription** on that topic:
163
+ - Delivery type: **Push**
164
+ - Endpoint: `<PUBLIC_URL>/webhook/gmail`
165
+ - **Authentication** (recommended): tick "Enable authentication", create or pick a service account, and set audience to `<PUBLIC_URL>/webhook/gmail` (this matches `pushAuth.expectedAudience` in your config).
166
+ - Or simpler-but-less-secure: append `?token=YOUR_RANDOM_SECRET` to the endpoint URL and use `pushAuth: { kind: 'token', token: '...' }` instead.
167
+
168
+ ### 4. Call `watch()` on the mailbox
169
+
170
+ Once at deploy time (and on a periodic schedule — watches expire after ~7 days):
171
+
172
+ ```typescript
173
+ const adapter = createGmailAdapter({ /* ... */ });
174
+ hub.register(adapter);
175
+
176
+ await adapter.watch('projects/your-project-id/topics/gmail-inbox');
177
+ // Returns { historyId } — the baseline.
178
+ ```
179
+
180
+ Schedule a cron to call `watch()` daily so the subscription never expires.
181
+
182
+ ### 5. Test
183
+
184
+ Send an email to the agent mailbox from another account. Your `hub.on('message')` handler should receive it.
185
+
186
+ ## Inbound shape
187
+
188
+ | Email field | msgly mapping |
189
+ | --------------------- | ----------------------------------------------------- |
190
+ | From `<addr>` | `contact.channelUserId` |
191
+ | From "Name" | `contact.displayName` |
192
+ | To (bot's address) | `account.channelAccountId` |
193
+ | text/plain body | `content.text` |
194
+ | Subject | `metadata.subject` |
195
+ | Message-ID | `metadata.messageId` |
196
+ | Gmail threadId | `metadata.threadId` |
197
+ | References | `metadata.references` |
198
+ | internalDate | `timestamp` |
199
+
200
+ When inbound has only HTML, the adapter strips tags into a best-effort plain-text body. Attachments are not yet surfaced as `MediaContent` (planned for v2).
201
+
202
+ ## Reply path
203
+
204
+ Pass any combination of these through `metadata` and the adapter does the right thing:
205
+
206
+ | metadata field | Effect on outbound |
207
+ | ----------------- | ------------------------------------------------------------ |
208
+ | `threadId` | Sent in the API call body — Gmail keeps the reply in-thread. |
209
+ | `messageId` | Becomes the `In-Reply-To` header on the outgoing email. |
210
+ | `references` | Becomes the `References` header (chain preservation). |
211
+ | `subject` | Used (with auto `Re:` prefix) as the reply subject. |
212
+
213
+ Without any of these, the adapter still sends — just as a fresh email with subject `(no subject)` to the contact address.
214
+
215
+ ## Capabilities
216
+
217
+ | Feature | Supported |
218
+ | ------------- | --------- |
219
+ | text | ✓ |
220
+ | image / video / audio / file | — (v2) |
221
+ | location | — |
222
+ | buttons | — |
223
+ | reactions | — |
224
+ | typing | — |
225
+ | templates | — |
226
+
227
+ ## Common pitfalls
228
+
229
+ - **No notifications arriving**: confirm the Pub/Sub topic grants `gmail-api-push@system.gserviceaccount.com` publish access. Confirm `users.watch()` returned a `historyId` (didn't error). Watches expire after ~7 days — schedule a daily re-call.
230
+ - **`401 unauthorized` on the webhook**: if you used `pushAuth: { kind: 'jwt' }`, the Pub/Sub subscription must have authentication enabled and the audience must match `expectedAudience` exactly (case-sensitive, no trailing slash mismatch).
231
+ - **`invalid_grant` from the token endpoint**: refresh token revoked or never had `access_type=offline`. Re-run consent with `prompt=consent`.
232
+ - **Inbound shows wrong sender**: this adapter parses `From:` as `"Name" <addr>` or bare address. Exotic header forms (group syntax, etc.) fall back to the raw header — file a bug if you hit one.
233
+ - **Reply doesn't thread correctly in some clients**: pass through both `metadata.threadId` AND `metadata.messageId`. Gmail uses threadId; other clients honor In-Reply-To.
234
+
235
+ ## Documentation
236
+
237
+ Full multi-channel docs: https://github.com/AyushJain070401/msgly
238
+
239
+ ## License
240
+
241
+ MIT
@@ -0,0 +1,98 @@
1
+ import { Adapter } from '@msgly/core';
2
+
3
+ interface GmailConfig {
4
+ /** OAuth client id from Google Cloud Console → Credentials → OAuth 2.0 Client ID. */
5
+ clientId: string;
6
+ /** OAuth client secret from the same place. */
7
+ clientSecret: string;
8
+ /**
9
+ * Long-lived refresh token for the agent's mailbox. Generated once via the
10
+ * OAuth 2.0 consent flow with `prompt=consent` and `access_type=offline`.
11
+ * Required scopes: `https://www.googleapis.com/auth/gmail.modify` (read +
12
+ * send + watch).
13
+ */
14
+ refreshToken: string;
15
+ /**
16
+ * The email address of the mailbox the refresh token belongs to.
17
+ * Used as the `From:` header on outgoing replies and as
18
+ * `account.channelAccountId`.
19
+ */
20
+ emailAddress: string;
21
+ /**
22
+ * How Pub/Sub push requests prove they came from Google. Pick ONE:
23
+ *
24
+ * { kind: 'jwt', expectedAudience }
25
+ * — Verify the OIDC JWT in `Authorization: Bearer <token>`.
26
+ * expectedAudience should match the audience you configured on the
27
+ * Pub/Sub push subscription (often the webhook URL itself).
28
+ *
29
+ * { kind: 'token', token }
30
+ * — Simpler: configure your push subscription with
31
+ * `?token=...` and we'll match it against `req.query.token`.
32
+ *
33
+ * { kind: 'none' } — DEV ONLY. No verification.
34
+ */
35
+ pushAuth: {
36
+ kind: 'jwt';
37
+ expectedAudience: string;
38
+ expectedServiceAccountEmail?: string;
39
+ } | {
40
+ kind: 'token';
41
+ token: string;
42
+ } | {
43
+ kind: 'none';
44
+ };
45
+ /** Cap how many messages we fetch per Pub/Sub notification. Default: 25. */
46
+ maxMessagesPerNotification?: number;
47
+ /** Override the Google OAuth token endpoint. Default: oauth2.googleapis.com. */
48
+ tokenUrl?: string;
49
+ /** Override the Gmail API base. Default: gmail.googleapis.com. */
50
+ apiBase?: string;
51
+ /** Override the JWKS URL used when `pushAuth.kind === 'jwt'`. Default: Google certs. */
52
+ jwksUrl?: string;
53
+ /** Allowed clock skew (sec) when validating JWT exp/nbf. Default: 300. */
54
+ clockSkewSec?: number;
55
+ }
56
+ interface GmailAdapter extends Adapter {
57
+ readonly channel: 'gmail';
58
+ /**
59
+ * Call once at deploy time to subscribe the mailbox to a Pub/Sub topic.
60
+ * The topic must already grant publish permission to
61
+ * `gmail-api-push@system.gserviceaccount.com`. Returns the historyId
62
+ * baseline.
63
+ */
64
+ watch(topicName: string, labelIds?: string[]): Promise<{
65
+ historyId: string;
66
+ }>;
67
+ /** Stop the existing watch. Mailbox stops emitting notifications. */
68
+ stopWatch(): Promise<void>;
69
+ }
70
+ /**
71
+ * Gmail adapter for Msgly — receives via Pub/Sub push notifications,
72
+ * sends via the Gmail REST API.
73
+ *
74
+ * **Receive flow.** Gmail publishes change notifications to a Pub/Sub topic
75
+ * (you set this up once via `users.watch`). Pub/Sub forwards each event to
76
+ * your webhook with body `{ message: { data: base64, ... }, subscription }`.
77
+ * `data` decodes to `{ emailAddress, historyId }`. The adapter calls
78
+ * `history.list` from the previously-seen historyId to discover new message
79
+ * IDs, fetches each via `messages.get?format=full`, and emits an inbound
80
+ * message per item.
81
+ *
82
+ * **State.** The "last seen historyId" is held in adapter memory. On the
83
+ * very first notification (cold start), the adapter falls back to fetching
84
+ * recent unread INBOX messages so nothing gets lost between deploys.
85
+ *
86
+ * **Send flow.** Builds an RFC 5322 email with proper In-Reply-To /
87
+ * References headers and posts to `users.messages.send`. If you pass
88
+ * `metadata.threadId` from the inbound message, Gmail keeps the reply in
89
+ * the original thread.
90
+ *
91
+ * **Auth.** Pub/Sub authenticates inbound webhooks via OIDC JWT
92
+ * (Authorization: Bearer) or a shared verification token, configurable via
93
+ * `pushAuth`. JWT verification uses Google's public JWKS and runs on pure
94
+ * WebCrypto (Node 18+, Bun, Deno, browsers).
95
+ */
96
+ declare function createGmailAdapter(config: GmailConfig): GmailAdapter;
97
+
98
+ export { type GmailAdapter, type GmailConfig, createGmailAdapter };
package/dist/index.js ADDED
@@ -0,0 +1,507 @@
1
+ // src/index.ts
2
+ var DEFAULT_TOKEN_URL = "https://oauth2.googleapis.com/token";
3
+ var DEFAULT_API_BASE = "https://gmail.googleapis.com";
4
+ var DEFAULT_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs";
5
+ var DEFAULT_CLOCK_SKEW_SEC = 300;
6
+ var DEFAULT_MAX_MESSAGES = 25;
7
+ var CAPABILITIES = {
8
+ text: true,
9
+ media: { image: false, video: false, audio: false, file: false },
10
+ interactive: { buttons: false, quickReplies: false },
11
+ templates: false,
12
+ reactions: false,
13
+ typing: false
14
+ };
15
+ function randomId() {
16
+ if (typeof globalThis.crypto?.randomUUID === "function") {
17
+ return globalThis.crypto.randomUUID();
18
+ }
19
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
20
+ }
21
+ function headerValue(headers, name) {
22
+ const v = headers[name] ?? headers[name.toLowerCase()];
23
+ if (Array.isArray(v)) return v[0];
24
+ return v;
25
+ }
26
+ function b64urlEncode(input) {
27
+ const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
28
+ let binary = "";
29
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
30
+ return btoa(binary).replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
31
+ }
32
+ function b64urlDecodeToBytes(input) {
33
+ const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
34
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
35
+ const binary = atob(padded);
36
+ const out = new Uint8Array(binary.length);
37
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
38
+ return out;
39
+ }
40
+ function b64urlDecodeToString(input) {
41
+ return new TextDecoder().decode(b64urlDecodeToBytes(input));
42
+ }
43
+ function createJwksCache(jwksUrl, ttlMs) {
44
+ let cache = null;
45
+ let inflight = null;
46
+ async function load(force = false) {
47
+ if (!force && cache && Date.now() - cache.fetchedAt < ttlMs) return cache;
48
+ if (inflight) return inflight;
49
+ inflight = (async () => {
50
+ const res = await fetch(jwksUrl);
51
+ if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
52
+ const data = await res.json();
53
+ const keys = /* @__PURE__ */ new Map();
54
+ for (const jwk of data.keys ?? []) {
55
+ if (!jwk.kid || jwk.kty !== "RSA") continue;
56
+ try {
57
+ const key = await globalThis.crypto.subtle.importKey(
58
+ "jwk",
59
+ jwk,
60
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
61
+ false,
62
+ ["verify"]
63
+ );
64
+ keys.set(jwk.kid, key);
65
+ } catch {
66
+ }
67
+ }
68
+ const entry = { keys, fetchedAt: Date.now() };
69
+ cache = entry;
70
+ return entry;
71
+ })();
72
+ try {
73
+ return await inflight;
74
+ } finally {
75
+ inflight = null;
76
+ }
77
+ }
78
+ async function getKey(kid) {
79
+ let jwks = await load();
80
+ if (jwks.keys.has(kid)) return jwks.keys.get(kid);
81
+ jwks = await load(true);
82
+ return jwks.keys.get(kid) ?? null;
83
+ }
84
+ return { getKey };
85
+ }
86
+ async function verifyGoogleJwt(token, getKey, expectedAudience, expectedServiceAccountEmail, clockSkewSec) {
87
+ const parts = token.split(".");
88
+ if (parts.length !== 3) return false;
89
+ const [headerB64, payloadB64, sigB64] = parts;
90
+ let header;
91
+ let claims;
92
+ try {
93
+ header = JSON.parse(b64urlDecodeToString(headerB64));
94
+ claims = JSON.parse(b64urlDecodeToString(payloadB64));
95
+ } catch {
96
+ return false;
97
+ }
98
+ if (header.alg !== "RS256" || !header.kid) return false;
99
+ const key = await getKey(header.kid);
100
+ if (!key) return false;
101
+ const signature = b64urlDecodeToBytes(sigB64);
102
+ const signedInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
103
+ const ok = await globalThis.crypto.subtle.verify(
104
+ "RSASSA-PKCS1-v1_5",
105
+ key,
106
+ signature,
107
+ signedInput
108
+ );
109
+ if (!ok) return false;
110
+ const nowSec = Math.floor(Date.now() / 1e3);
111
+ if (typeof claims.exp === "number" && nowSec > claims.exp + clockSkewSec) return false;
112
+ if (typeof claims.nbf === "number" && nowSec + clockSkewSec < claims.nbf) return false;
113
+ if (claims.iss !== "https://accounts.google.com" && claims.iss !== "accounts.google.com") {
114
+ return false;
115
+ }
116
+ if (claims.aud !== expectedAudience) return false;
117
+ if (expectedServiceAccountEmail && claims.email !== expectedServiceAccountEmail) {
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+ function createTokenCache(tokenUrl, clientId, clientSecret, refreshToken) {
123
+ let accessToken = null;
124
+ let expiresAt = 0;
125
+ let inflight = null;
126
+ async function fetchToken() {
127
+ const res = await fetch(tokenUrl, {
128
+ method: "POST",
129
+ headers: { "content-type": "application/x-www-form-urlencoded" },
130
+ body: new URLSearchParams({
131
+ grant_type: "refresh_token",
132
+ client_id: clientId,
133
+ client_secret: clientSecret,
134
+ refresh_token: refreshToken
135
+ }).toString()
136
+ });
137
+ const data = await res.json().catch(() => ({}));
138
+ if (!res.ok || !data.access_token) {
139
+ throw new Error(
140
+ `Google token refresh failed (${res.status}): ${data.error_description ?? data.error ?? "no body"}`
141
+ );
142
+ }
143
+ accessToken = data.access_token;
144
+ expiresAt = Date.now() + (data.expires_in ?? 3600) * 1e3 - 6e4;
145
+ return accessToken;
146
+ }
147
+ async function get() {
148
+ if (accessToken && Date.now() < expiresAt) return accessToken;
149
+ if (inflight) return inflight;
150
+ inflight = fetchToken();
151
+ try {
152
+ return await inflight;
153
+ } finally {
154
+ inflight = null;
155
+ }
156
+ }
157
+ return { get };
158
+ }
159
+ function findHeader(payload, name) {
160
+ const lower = name.toLowerCase();
161
+ for (const h of payload?.headers ?? []) {
162
+ if (h.name.toLowerCase() === lower) return h.value;
163
+ }
164
+ return void 0;
165
+ }
166
+ function findBodyByMimeType(payload, mimeType) {
167
+ if (!payload) return null;
168
+ if (payload.mimeType === mimeType && payload.body?.data) {
169
+ try {
170
+ return b64urlDecodeToString(payload.body.data);
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+ for (const part of payload.parts ?? []) {
176
+ const t = findBodyByMimeType(part, mimeType);
177
+ if (t !== null) return t;
178
+ }
179
+ return null;
180
+ }
181
+ function extractPlainText(payload) {
182
+ const plain = findBodyByMimeType(payload, "text/plain");
183
+ if (plain !== null) return plain.trim() || null;
184
+ const html = findBodyByMimeType(payload, "text/html");
185
+ if (html === null) return null;
186
+ return html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() || null;
187
+ }
188
+ function parseEmailAddress(header) {
189
+ if (!header) return null;
190
+ const trimmed = header.trim();
191
+ const angle = trimmed.match(/^(?:"?([^"<]+?)"?\s*)?<([^>]+)>$/);
192
+ if (angle) {
193
+ return {
194
+ address: angle[2].trim(),
195
+ displayName: angle[1]?.trim() || void 0
196
+ };
197
+ }
198
+ if (/^\S+@\S+$/.test(trimmed)) return { address: trimmed };
199
+ return null;
200
+ }
201
+ function buildReplyEmail(opts) {
202
+ const headers = [
203
+ `From: ${opts.from}`,
204
+ `To: ${opts.to}`,
205
+ `Subject: ${opts.subject}`,
206
+ "MIME-Version: 1.0",
207
+ "Content-Type: text/plain; charset=utf-8",
208
+ "Content-Transfer-Encoding: 8bit",
209
+ `Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`
210
+ ];
211
+ if (opts.inReplyTo) headers.push(`In-Reply-To: ${opts.inReplyTo}`);
212
+ if (opts.references) headers.push(`References: ${opts.references}`);
213
+ return `${headers.join("\r\n")}\r
214
+ \r
215
+ ${opts.body}`;
216
+ }
217
+ function stripReplyPrefix(subject) {
218
+ return subject.replace(/^(?:re|RE|Re)\s*:\s*/i, "").trim();
219
+ }
220
+ function createGmailAdapter(config) {
221
+ const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
222
+ const apiBase = config.apiBase ?? DEFAULT_API_BASE;
223
+ const jwksUrl = config.jwksUrl ?? DEFAULT_JWKS_URL;
224
+ const clockSkewSec = config.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
225
+ const maxMessages = config.maxMessagesPerNotification ?? DEFAULT_MAX_MESSAGES;
226
+ const tokens = createTokenCache(tokenUrl, config.clientId, config.clientSecret, config.refreshToken);
227
+ const jwks = createJwksCache(jwksUrl, 24 * 60 * 60 * 1e3);
228
+ let lastHistoryId = null;
229
+ async function authedFetch(path, init = {}) {
230
+ const token = await tokens.get();
231
+ const headers = new Headers(init.headers);
232
+ headers.set("authorization", `Bearer ${token}`);
233
+ if (!headers.has("content-type") && init.body) {
234
+ headers.set("content-type", "application/json");
235
+ }
236
+ return fetch(`${apiBase}${path}`, { ...init, headers });
237
+ }
238
+ async function watch(topicName, labelIds = ["INBOX"]) {
239
+ const res = await authedFetch("/gmail/v1/users/me/watch", {
240
+ method: "POST",
241
+ body: JSON.stringify({ topicName, labelIds, labelFilterAction: "include" })
242
+ });
243
+ const data = await res.json().catch(() => ({}));
244
+ if (!res.ok || !data.historyId) {
245
+ throw new Error(`Gmail watch failed (${res.status}): ${data.error?.message ?? "no historyId"}`);
246
+ }
247
+ lastHistoryId = data.historyId;
248
+ return { historyId: data.historyId };
249
+ }
250
+ async function stopWatch() {
251
+ await authedFetch("/gmail/v1/users/me/stop", { method: "POST" });
252
+ }
253
+ async function fetchMessage(messageId) {
254
+ const res = await authedFetch(
255
+ `/gmail/v1/users/me/messages/${encodeURIComponent(messageId)}?format=full`
256
+ );
257
+ if (!res.ok) return null;
258
+ return await res.json();
259
+ }
260
+ async function listRecentInboxMessageIds(limit) {
261
+ const res = await authedFetch(
262
+ `/gmail/v1/users/me/messages?maxResults=${limit}&q=${encodeURIComponent("in:inbox -in:drafts is:unread")}`
263
+ );
264
+ if (!res.ok) return [];
265
+ const data = await res.json();
266
+ return (data.messages ?? []).map((m) => m.id);
267
+ }
268
+ async function listMessageIdsSince(startHistoryId) {
269
+ const res = await authedFetch(
270
+ `/gmail/v1/users/me/history?startHistoryId=${encodeURIComponent(startHistoryId)}&historyTypes=messageAdded&labelId=INBOX`
271
+ );
272
+ if (!res.ok) return [];
273
+ const data = await res.json();
274
+ const ids = /* @__PURE__ */ new Set();
275
+ for (const entry of data.history ?? []) {
276
+ for (const added of entry.messagesAdded ?? []) {
277
+ if (added.message?.id) ids.add(added.message.id);
278
+ }
279
+ }
280
+ return [...ids].slice(0, maxMessages);
281
+ }
282
+ function messageToInbound(msg) {
283
+ const text = extractPlainText(msg.payload);
284
+ if (!text) return null;
285
+ const from = parseEmailAddress(findHeader(msg.payload, "From"));
286
+ if (!from) return null;
287
+ const messageIdHeader = findHeader(msg.payload, "Message-ID") ?? findHeader(msg.payload, "Message-Id");
288
+ const subject = findHeader(msg.payload, "Subject") ?? "";
289
+ const references = findHeader(msg.payload, "References");
290
+ const dateHeader = findHeader(msg.payload, "Date");
291
+ const timestamp = (() => {
292
+ if (msg.internalDate) {
293
+ const ms = Number(msg.internalDate);
294
+ if (Number.isFinite(ms)) return new Date(ms).toISOString();
295
+ }
296
+ if (dateHeader) {
297
+ const parsed = Date.parse(dateHeader);
298
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
299
+ }
300
+ return (/* @__PURE__ */ new Date()).toISOString();
301
+ })();
302
+ return {
303
+ id: randomId(),
304
+ externalId: msg.id,
305
+ channel: "gmail",
306
+ direction: "inbound",
307
+ account: { channel: "gmail", channelAccountId: config.emailAddress },
308
+ contact: {
309
+ channel: "gmail",
310
+ channelUserId: from.address,
311
+ ...from.displayName ? { displayName: from.displayName } : {}
312
+ },
313
+ content: { type: "text", text },
314
+ timestamp,
315
+ raw: msg,
316
+ metadata: {
317
+ ...msg.threadId ? { threadId: msg.threadId } : {},
318
+ ...messageIdHeader ? { messageId: messageIdHeader } : {},
319
+ ...subject ? { subject } : {},
320
+ ...references ? { references } : {}
321
+ }
322
+ };
323
+ }
324
+ async function handleWebhook(req) {
325
+ const body = req.body;
326
+ const dataB64 = body?.message?.data;
327
+ if (!dataB64) return [];
328
+ let notification;
329
+ try {
330
+ notification = JSON.parse(b64urlDecodeToString(dataB64.replace(/=+$/g, "")));
331
+ } catch {
332
+ return [];
333
+ }
334
+ if (!notification.historyId) return [];
335
+ let messageIds;
336
+ if (lastHistoryId) {
337
+ messageIds = await listMessageIdsSince(lastHistoryId);
338
+ } else {
339
+ messageIds = await listRecentInboxMessageIds(maxMessages);
340
+ }
341
+ lastHistoryId = notification.historyId;
342
+ const out = [];
343
+ for (const id of messageIds) {
344
+ const msg = await fetchMessage(id);
345
+ if (!msg) continue;
346
+ const inbound = messageToInbound(msg);
347
+ if (inbound) out.push(inbound);
348
+ }
349
+ return out;
350
+ }
351
+ async function verifySignature(req) {
352
+ switch (config.pushAuth.kind) {
353
+ case "none":
354
+ return true;
355
+ case "token": {
356
+ const provided = req.query["token"];
357
+ const value = Array.isArray(provided) ? provided[0] : provided;
358
+ return value === config.pushAuth.token;
359
+ }
360
+ case "jwt": {
361
+ const auth = headerValue(req.headers, "authorization");
362
+ if (!auth || !auth.toLowerCase().startsWith("bearer ")) return false;
363
+ const token = auth.slice(7).trim();
364
+ if (!token) return false;
365
+ try {
366
+ return await verifyGoogleJwt(
367
+ token,
368
+ (kid) => jwks.getKey(kid),
369
+ config.pushAuth.expectedAudience,
370
+ config.pushAuth.expectedServiceAccountEmail,
371
+ clockSkewSec
372
+ );
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+ }
378
+ }
379
+ async function send(message) {
380
+ if (message.content.type !== "text") {
381
+ return {
382
+ messageId: message.id,
383
+ status: "failed",
384
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
385
+ error: {
386
+ code: "gmail_unsupported_content",
387
+ message: `Gmail adapter only supports text content in v1 (received: ${message.content.type})`
388
+ }
389
+ };
390
+ }
391
+ const subjectMeta = message.metadata?.["subject"];
392
+ const baseSubject = subjectMeta ? stripReplyPrefix(subjectMeta) : "";
393
+ const subject = baseSubject ? `Re: ${baseSubject}` : "(no subject)";
394
+ const inReplyTo = message.metadata?.["messageId"];
395
+ const referencesPrev = message.metadata?.["references"];
396
+ const references = inReplyTo ? referencesPrev ? `${referencesPrev} ${inReplyTo}` : inReplyTo : void 0;
397
+ const raw = buildReplyEmail({
398
+ from: config.emailAddress,
399
+ to: message.contact.channelUserId,
400
+ subject,
401
+ body: message.content.text,
402
+ inReplyTo,
403
+ references
404
+ });
405
+ const payload = { raw: b64urlEncode(raw) };
406
+ const threadId = message.metadata?.["threadId"];
407
+ if (threadId) payload["threadId"] = threadId;
408
+ const res = await authedFetch("/gmail/v1/users/me/messages/send", {
409
+ method: "POST",
410
+ body: JSON.stringify(payload)
411
+ });
412
+ const data = await res.json().catch(() => ({}));
413
+ if (res.status >= 200 && res.status < 300 && data.id) {
414
+ return {
415
+ messageId: message.id,
416
+ externalId: data.id,
417
+ status: "sent",
418
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
419
+ };
420
+ }
421
+ return {
422
+ messageId: message.id,
423
+ status: "failed",
424
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
425
+ error: {
426
+ code: `gmail_${data.error?.code ?? res.status}`,
427
+ message: data.error?.message ?? `HTTP ${res.status}`
428
+ }
429
+ };
430
+ }
431
+ async function verifyCredentials() {
432
+ if (!config.clientId || !config.clientSecret) {
433
+ return {
434
+ ok: false,
435
+ reason: "unauthorized",
436
+ hint: "GmailConfig.clientId / clientSecret missing. Generate them in Google Cloud Console \u2192 APIs & Services \u2192 Credentials \u2192 OAuth 2.0 Client ID."
437
+ };
438
+ }
439
+ if (!config.refreshToken) {
440
+ return {
441
+ ok: false,
442
+ reason: "unauthorized",
443
+ hint: "GmailConfig.refreshToken missing. Run the consent flow once with prompt=consent and access_type=offline to obtain a long-lived refresh token."
444
+ };
445
+ }
446
+ if (!config.emailAddress) {
447
+ return {
448
+ ok: false,
449
+ reason: "unauthorized",
450
+ hint: "GmailConfig.emailAddress missing. Set this to the mailbox the refresh token belongs to (e.g. agent@yourcompany.com)."
451
+ };
452
+ }
453
+ try {
454
+ const token = await tokens.get();
455
+ const res = await fetch(`${apiBase}/gmail/v1/users/me/profile`, {
456
+ headers: { authorization: `Bearer ${token}` }
457
+ });
458
+ if (res.status === 401 || res.status === 403) {
459
+ return {
460
+ ok: false,
461
+ reason: "unauthorized",
462
+ hint: "Google rejected the access token. Check scopes (need gmail.modify) and re-run consent with prompt=consent."
463
+ };
464
+ }
465
+ if (!res.ok) {
466
+ return {
467
+ ok: false,
468
+ reason: "unknown",
469
+ hint: `Gmail profile lookup returned ${res.status}`
470
+ };
471
+ }
472
+ const data = await res.json();
473
+ return { ok: true, accountInfo: data.emailAddress ?? config.emailAddress };
474
+ } catch (err) {
475
+ const msg = err instanceof Error ? err.message : String(err);
476
+ if (/401|invalid_grant|invalid_client/i.test(msg)) {
477
+ return {
478
+ ok: false,
479
+ reason: "unauthorized",
480
+ hint: `Google rejected credentials: ${msg}. Re-check clientId/clientSecret/refreshToken.`
481
+ };
482
+ }
483
+ return { ok: false, reason: "network_error", hint: msg };
484
+ }
485
+ }
486
+ async function uploadMedia(_file) {
487
+ throw new Error("Gmail uploadMedia is not yet implemented in v1.");
488
+ }
489
+ async function downloadMedia(_ref) {
490
+ throw new Error("Gmail downloadMedia is not yet implemented in v1.");
491
+ }
492
+ return {
493
+ channel: "gmail",
494
+ capabilities: CAPABILITIES,
495
+ send,
496
+ handleWebhook,
497
+ verifySignature,
498
+ verifyCredentials,
499
+ uploadMedia,
500
+ downloadMedia,
501
+ watch,
502
+ stopWatch
503
+ };
504
+ }
505
+ export {
506
+ createGmailAdapter
507
+ };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@msgly/gmail",
3
+ "version": "0.2.1",
4
+ "description": "Gmail adapter for Msgly — receive and reply to emails via Gmail API + Pub/Sub push",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "dependencies": {
21
+ "@msgly/core": "0.2.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20.11.0",
25
+ "tsup": "^8.0.0",
26
+ "typescript": "^5.4.0",
27
+ "vitest": "^1.4.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/AyushJain070401/msgly.git",
35
+ "directory": "packages/adapter-gmail"
36
+ },
37
+ "keywords": [
38
+ "gmail",
39
+ "email",
40
+ "google",
41
+ "pubsub",
42
+ "agent",
43
+ "messaging",
44
+ "webhook"
45
+ ],
46
+ "scripts": {
47
+ "build": "tsup src/index.ts --format esm --dts --clean",
48
+ "dev": "tsup src/index.ts --format esm --dts --watch",
49
+ "test": "vitest run",
50
+ "lint": "eslint src --ext .ts",
51
+ "typecheck": "tsc --noEmit",
52
+ "clean": "rm -rf dist"
53
+ }
54
+ }