@msgly/gmail 0.2.1 → 0.2.3
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 +25 -0
- package/dist/index.cjs +544 -0
- package/dist/index.d.cts +98 -0
- package/dist/index.js +18 -6
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -224,6 +224,31 @@ Without any of these, the adapter still sends — just as a fresh email with sub
|
|
|
224
224
|
| typing | — |
|
|
225
225
|
| templates | — |
|
|
226
226
|
|
|
227
|
+
## Production notes
|
|
228
|
+
|
|
229
|
+
### Multi-instance deployments
|
|
230
|
+
|
|
231
|
+
The "last seen historyId" is held in adapter memory. If you horizontally scale (multiple Node processes behind a load balancer), each instance has its own historyId tracker, and notifications routed to a different instance than the previous one will see a stale baseline.
|
|
232
|
+
|
|
233
|
+
**Mitigations:**
|
|
234
|
+
- Pin one process per inbox (sticky routing for the `/webhook/gmail` path), OR
|
|
235
|
+
- Run a single worker for Gmail webhook handling (load balancer routes only one backend), OR
|
|
236
|
+
- Wait for v2 which will accept a `historyIdStore` callback so you can persist to Redis/Postgres.
|
|
237
|
+
|
|
238
|
+
In all cases, msgly's externalId-based idempotency means **duplicate fetches are deduplicated**, so the worst-case observable behavior is briefly missed messages (not duplicate emits). For a v1 deploy on a single instance, this is not a concern.
|
|
239
|
+
|
|
240
|
+
### Push subscription authentication
|
|
241
|
+
|
|
242
|
+
The three modes ranked by security:
|
|
243
|
+
|
|
244
|
+
1. **`{ kind: 'jwt' }` (recommended for production)** — Pub/Sub signs each push with an OIDC token, we verify against Google's JWKS. Strongest. Requires enabling authentication on the Pub/Sub subscription.
|
|
245
|
+
2. **`{ kind: 'token' }`** — A shared secret appended as `?token=...` to the push endpoint URL. The token lives in your Cloud Pub/Sub configuration and your env vars; if either leaks, an attacker can forge inbound notifications. Comparison is constant-time. **Acceptable for staging, not great for production.**
|
|
246
|
+
3. **`{ kind: 'none' }`** — Skips verification entirely. **Local development only.** Anyone who knows your URL can forge inbound messages.
|
|
247
|
+
|
|
248
|
+
### Header injection
|
|
249
|
+
|
|
250
|
+
The adapter strips CR/LF from every header value (`From`, `To`, `Subject`, `In-Reply-To`, `References`) before constructing the outgoing RFC 5322 email. Even if upstream metadata is adversarial (malicious sender, compromised user input), the outgoing message can't have additional injected headers like `Bcc:` or `Reply-To:`.
|
|
251
|
+
|
|
227
252
|
## Common pitfalls
|
|
228
253
|
|
|
229
254
|
- **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.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createGmailAdapter: () => createGmailAdapter
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
var DEFAULT_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
27
|
+
var DEFAULT_API_BASE = "https://gmail.googleapis.com";
|
|
28
|
+
var DEFAULT_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs";
|
|
29
|
+
var DEFAULT_CLOCK_SKEW_SEC = 300;
|
|
30
|
+
var DEFAULT_MAX_MESSAGES = 25;
|
|
31
|
+
var CAPABILITIES = {
|
|
32
|
+
text: true,
|
|
33
|
+
media: { image: false, video: false, audio: false, file: false },
|
|
34
|
+
interactive: { buttons: false, quickReplies: false },
|
|
35
|
+
templates: false,
|
|
36
|
+
reactions: false,
|
|
37
|
+
typing: false
|
|
38
|
+
};
|
|
39
|
+
function randomId() {
|
|
40
|
+
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
41
|
+
return globalThis.crypto.randomUUID();
|
|
42
|
+
}
|
|
43
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
44
|
+
}
|
|
45
|
+
function headerValue(headers, name) {
|
|
46
|
+
const v = headers[name] ?? headers[name.toLowerCase()];
|
|
47
|
+
if (Array.isArray(v)) return v[0];
|
|
48
|
+
return v;
|
|
49
|
+
}
|
|
50
|
+
function b64urlEncode(input) {
|
|
51
|
+
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
52
|
+
let binary = "";
|
|
53
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
54
|
+
return btoa(binary).replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
55
|
+
}
|
|
56
|
+
function b64urlDecodeToBytes(input) {
|
|
57
|
+
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
58
|
+
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
59
|
+
const binary = atob(padded);
|
|
60
|
+
const out = new Uint8Array(binary.length);
|
|
61
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function b64urlDecodeToString(input) {
|
|
65
|
+
return new TextDecoder().decode(b64urlDecodeToBytes(input));
|
|
66
|
+
}
|
|
67
|
+
function createJwksCache(jwksUrl, ttlMs) {
|
|
68
|
+
let cache = null;
|
|
69
|
+
let inflight = null;
|
|
70
|
+
async function load(force = false) {
|
|
71
|
+
if (!force && cache && Date.now() - cache.fetchedAt < ttlMs) return cache;
|
|
72
|
+
if (inflight) return inflight;
|
|
73
|
+
inflight = (async () => {
|
|
74
|
+
const res = await fetch(jwksUrl);
|
|
75
|
+
if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
|
|
76
|
+
const data = await res.json();
|
|
77
|
+
const keys = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const jwk of data.keys ?? []) {
|
|
79
|
+
if (!jwk.kid || jwk.kty !== "RSA") continue;
|
|
80
|
+
try {
|
|
81
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
82
|
+
"jwk",
|
|
83
|
+
jwk,
|
|
84
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
85
|
+
false,
|
|
86
|
+
["verify"]
|
|
87
|
+
);
|
|
88
|
+
keys.set(jwk.kid, key);
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const entry = { keys, fetchedAt: Date.now() };
|
|
93
|
+
cache = entry;
|
|
94
|
+
return entry;
|
|
95
|
+
})();
|
|
96
|
+
try {
|
|
97
|
+
return await inflight;
|
|
98
|
+
} finally {
|
|
99
|
+
inflight = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function getKey(kid) {
|
|
103
|
+
let jwks = await load();
|
|
104
|
+
if (jwks.keys.has(kid)) return jwks.keys.get(kid);
|
|
105
|
+
jwks = await load(true);
|
|
106
|
+
return jwks.keys.get(kid) ?? null;
|
|
107
|
+
}
|
|
108
|
+
return { getKey };
|
|
109
|
+
}
|
|
110
|
+
async function verifyGoogleJwt(token, getKey, expectedAudience, expectedServiceAccountEmail, clockSkewSec) {
|
|
111
|
+
const parts = token.split(".");
|
|
112
|
+
if (parts.length !== 3) return false;
|
|
113
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
114
|
+
let header;
|
|
115
|
+
let claims;
|
|
116
|
+
try {
|
|
117
|
+
header = JSON.parse(b64urlDecodeToString(headerB64));
|
|
118
|
+
claims = JSON.parse(b64urlDecodeToString(payloadB64));
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (header.alg !== "RS256" || !header.kid) return false;
|
|
123
|
+
const key = await getKey(header.kid);
|
|
124
|
+
if (!key) return false;
|
|
125
|
+
const signature = b64urlDecodeToBytes(sigB64);
|
|
126
|
+
const signedInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
127
|
+
const ok = await globalThis.crypto.subtle.verify(
|
|
128
|
+
"RSASSA-PKCS1-v1_5",
|
|
129
|
+
key,
|
|
130
|
+
signature,
|
|
131
|
+
signedInput
|
|
132
|
+
);
|
|
133
|
+
if (!ok) return false;
|
|
134
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
135
|
+
if (typeof claims.exp === "number" && nowSec > claims.exp + clockSkewSec) return false;
|
|
136
|
+
if (typeof claims.nbf === "number" && nowSec + clockSkewSec < claims.nbf) return false;
|
|
137
|
+
if (claims.iss !== "https://accounts.google.com" && claims.iss !== "accounts.google.com") {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (claims.aud !== expectedAudience) return false;
|
|
141
|
+
if (expectedServiceAccountEmail && claims.email !== expectedServiceAccountEmail) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
function createTokenCache(tokenUrl, clientId, clientSecret, refreshToken) {
|
|
147
|
+
let accessToken = null;
|
|
148
|
+
let expiresAt = 0;
|
|
149
|
+
let inflight = null;
|
|
150
|
+
async function fetchToken() {
|
|
151
|
+
const res = await fetch(tokenUrl, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
154
|
+
body: new URLSearchParams({
|
|
155
|
+
grant_type: "refresh_token",
|
|
156
|
+
client_id: clientId,
|
|
157
|
+
client_secret: clientSecret,
|
|
158
|
+
refresh_token: refreshToken
|
|
159
|
+
}).toString()
|
|
160
|
+
});
|
|
161
|
+
const data = await res.json().catch(() => ({}));
|
|
162
|
+
if (!res.ok || !data.access_token) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Google token refresh failed (${res.status}): ${data.error_description ?? data.error ?? "no body"}`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
accessToken = data.access_token;
|
|
168
|
+
expiresAt = Date.now() + (data.expires_in ?? 3600) * 1e3 - 6e4;
|
|
169
|
+
return accessToken;
|
|
170
|
+
}
|
|
171
|
+
async function get() {
|
|
172
|
+
if (accessToken && Date.now() < expiresAt) return accessToken;
|
|
173
|
+
if (inflight) return inflight;
|
|
174
|
+
inflight = fetchToken();
|
|
175
|
+
try {
|
|
176
|
+
return await inflight;
|
|
177
|
+
} finally {
|
|
178
|
+
inflight = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { get };
|
|
182
|
+
}
|
|
183
|
+
function findHeader(payload, name) {
|
|
184
|
+
const lower = name.toLowerCase();
|
|
185
|
+
for (const h of payload?.headers ?? []) {
|
|
186
|
+
if (h.name.toLowerCase() === lower) return h.value;
|
|
187
|
+
}
|
|
188
|
+
return void 0;
|
|
189
|
+
}
|
|
190
|
+
function findBodyByMimeType(payload, mimeType) {
|
|
191
|
+
if (!payload) return null;
|
|
192
|
+
if (payload.mimeType === mimeType && payload.body?.data) {
|
|
193
|
+
try {
|
|
194
|
+
return b64urlDecodeToString(payload.body.data);
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const part of payload.parts ?? []) {
|
|
200
|
+
const t = findBodyByMimeType(part, mimeType);
|
|
201
|
+
if (t !== null) return t;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
function extractPlainText(payload) {
|
|
206
|
+
const plain = findBodyByMimeType(payload, "text/plain");
|
|
207
|
+
if (plain !== null) return plain.trim() || null;
|
|
208
|
+
const html = findBodyByMimeType(payload, "text/html");
|
|
209
|
+
if (html === null) return null;
|
|
210
|
+
return html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() || null;
|
|
211
|
+
}
|
|
212
|
+
function parseEmailAddress(header) {
|
|
213
|
+
if (!header) return null;
|
|
214
|
+
const trimmed = header.trim();
|
|
215
|
+
const angle = trimmed.match(/^(?:"?([^"<]+?)"?\s*)?<([^>]+)>$/);
|
|
216
|
+
if (angle) {
|
|
217
|
+
return {
|
|
218
|
+
address: angle[2].trim(),
|
|
219
|
+
displayName: angle[1]?.trim() || void 0
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (/^\S+@\S+$/.test(trimmed)) return { address: trimmed };
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
function sanitizeHeaderValue(value) {
|
|
226
|
+
return value.replace(/[\r\n]/g, "");
|
|
227
|
+
}
|
|
228
|
+
function buildReplyEmail(opts) {
|
|
229
|
+
const headers = [
|
|
230
|
+
`From: ${sanitizeHeaderValue(opts.from)}`,
|
|
231
|
+
`To: ${sanitizeHeaderValue(opts.to)}`,
|
|
232
|
+
`Subject: ${sanitizeHeaderValue(opts.subject)}`,
|
|
233
|
+
"MIME-Version: 1.0",
|
|
234
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
235
|
+
"Content-Transfer-Encoding: 8bit",
|
|
236
|
+
`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`
|
|
237
|
+
];
|
|
238
|
+
if (opts.inReplyTo) headers.push(`In-Reply-To: ${sanitizeHeaderValue(opts.inReplyTo)}`);
|
|
239
|
+
if (opts.references) headers.push(`References: ${sanitizeHeaderValue(opts.references)}`);
|
|
240
|
+
return `${headers.join("\r\n")}\r
|
|
241
|
+
\r
|
|
242
|
+
${opts.body}`;
|
|
243
|
+
}
|
|
244
|
+
function constantTimeEqual(a, b) {
|
|
245
|
+
if (a.length !== b.length) return false;
|
|
246
|
+
let diff = 0;
|
|
247
|
+
for (let i = 0; i < a.length; i++) {
|
|
248
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
249
|
+
}
|
|
250
|
+
return diff === 0;
|
|
251
|
+
}
|
|
252
|
+
function stripReplyPrefix(subject) {
|
|
253
|
+
return subject.replace(/^(?:re|RE|Re)\s*:\s*/i, "").trim();
|
|
254
|
+
}
|
|
255
|
+
function createGmailAdapter(config) {
|
|
256
|
+
const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
|
|
257
|
+
const apiBase = config.apiBase ?? DEFAULT_API_BASE;
|
|
258
|
+
const jwksUrl = config.jwksUrl ?? DEFAULT_JWKS_URL;
|
|
259
|
+
const clockSkewSec = config.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
|
|
260
|
+
const maxMessages = config.maxMessagesPerNotification ?? DEFAULT_MAX_MESSAGES;
|
|
261
|
+
const tokens = createTokenCache(tokenUrl, config.clientId, config.clientSecret, config.refreshToken);
|
|
262
|
+
const jwks = createJwksCache(jwksUrl, 24 * 60 * 60 * 1e3);
|
|
263
|
+
let lastHistoryId = null;
|
|
264
|
+
async function authedFetch(path, init = {}) {
|
|
265
|
+
const token = await tokens.get();
|
|
266
|
+
const headers = new Headers(init.headers);
|
|
267
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
268
|
+
if (!headers.has("content-type") && init.body) {
|
|
269
|
+
headers.set("content-type", "application/json");
|
|
270
|
+
}
|
|
271
|
+
return fetch(`${apiBase}${path}`, { ...init, headers });
|
|
272
|
+
}
|
|
273
|
+
async function watch(topicName, labelIds = ["INBOX"]) {
|
|
274
|
+
const res = await authedFetch("/gmail/v1/users/me/watch", {
|
|
275
|
+
method: "POST",
|
|
276
|
+
body: JSON.stringify({ topicName, labelIds, labelFilterAction: "include" })
|
|
277
|
+
});
|
|
278
|
+
const data = await res.json().catch(() => ({}));
|
|
279
|
+
if (!res.ok || !data.historyId) {
|
|
280
|
+
throw new Error(`Gmail watch failed (${res.status}): ${data.error?.message ?? "no historyId"}`);
|
|
281
|
+
}
|
|
282
|
+
lastHistoryId = data.historyId;
|
|
283
|
+
return { historyId: data.historyId };
|
|
284
|
+
}
|
|
285
|
+
async function stopWatch() {
|
|
286
|
+
await authedFetch("/gmail/v1/users/me/stop", { method: "POST" });
|
|
287
|
+
}
|
|
288
|
+
async function fetchMessage(messageId) {
|
|
289
|
+
const res = await authedFetch(
|
|
290
|
+
`/gmail/v1/users/me/messages/${encodeURIComponent(messageId)}?format=full`
|
|
291
|
+
);
|
|
292
|
+
if (!res.ok) return null;
|
|
293
|
+
return await res.json();
|
|
294
|
+
}
|
|
295
|
+
async function listRecentInboxMessageIds(limit) {
|
|
296
|
+
const res = await authedFetch(
|
|
297
|
+
`/gmail/v1/users/me/messages?maxResults=${limit}&q=${encodeURIComponent("in:inbox -in:drafts is:unread")}`
|
|
298
|
+
);
|
|
299
|
+
if (!res.ok) return [];
|
|
300
|
+
const data = await res.json();
|
|
301
|
+
return (data.messages ?? []).map((m) => m.id);
|
|
302
|
+
}
|
|
303
|
+
async function listMessageIdsSince(startHistoryId) {
|
|
304
|
+
const res = await authedFetch(
|
|
305
|
+
`/gmail/v1/users/me/history?startHistoryId=${encodeURIComponent(startHistoryId)}&historyTypes=messageAdded&labelId=INBOX`
|
|
306
|
+
);
|
|
307
|
+
if (!res.ok) return [];
|
|
308
|
+
const data = await res.json();
|
|
309
|
+
const ids = /* @__PURE__ */ new Set();
|
|
310
|
+
for (const entry of data.history ?? []) {
|
|
311
|
+
for (const added of entry.messagesAdded ?? []) {
|
|
312
|
+
if (added.message?.id) ids.add(added.message.id);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return [...ids].slice(0, maxMessages);
|
|
316
|
+
}
|
|
317
|
+
function messageToInbound(msg) {
|
|
318
|
+
const text = extractPlainText(msg.payload);
|
|
319
|
+
if (!text) return null;
|
|
320
|
+
const from = parseEmailAddress(findHeader(msg.payload, "From"));
|
|
321
|
+
if (!from) return null;
|
|
322
|
+
const messageIdHeader = findHeader(msg.payload, "Message-ID") ?? findHeader(msg.payload, "Message-Id");
|
|
323
|
+
const subject = findHeader(msg.payload, "Subject") ?? "";
|
|
324
|
+
const references = findHeader(msg.payload, "References");
|
|
325
|
+
const dateHeader = findHeader(msg.payload, "Date");
|
|
326
|
+
const timestamp = (() => {
|
|
327
|
+
if (msg.internalDate) {
|
|
328
|
+
const ms = Number(msg.internalDate);
|
|
329
|
+
if (Number.isFinite(ms)) return new Date(ms).toISOString();
|
|
330
|
+
}
|
|
331
|
+
if (dateHeader) {
|
|
332
|
+
const parsed = Date.parse(dateHeader);
|
|
333
|
+
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
|
|
334
|
+
}
|
|
335
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
336
|
+
})();
|
|
337
|
+
return {
|
|
338
|
+
id: randomId(),
|
|
339
|
+
externalId: msg.id,
|
|
340
|
+
channel: "gmail",
|
|
341
|
+
direction: "inbound",
|
|
342
|
+
account: { channel: "gmail", channelAccountId: config.emailAddress },
|
|
343
|
+
contact: {
|
|
344
|
+
channel: "gmail",
|
|
345
|
+
channelUserId: from.address,
|
|
346
|
+
...from.displayName ? { displayName: from.displayName } : {}
|
|
347
|
+
},
|
|
348
|
+
content: { type: "text", text },
|
|
349
|
+
timestamp,
|
|
350
|
+
raw: msg,
|
|
351
|
+
metadata: {
|
|
352
|
+
...msg.threadId ? { threadId: msg.threadId } : {},
|
|
353
|
+
...messageIdHeader ? { messageId: messageIdHeader } : {},
|
|
354
|
+
...subject ? { subject } : {},
|
|
355
|
+
...references ? { references } : {}
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
async function handleWebhook(req) {
|
|
360
|
+
const body = req.body;
|
|
361
|
+
const dataB64 = body?.message?.data;
|
|
362
|
+
if (!dataB64) return [];
|
|
363
|
+
let notification;
|
|
364
|
+
try {
|
|
365
|
+
notification = JSON.parse(b64urlDecodeToString(dataB64.replace(/=+$/g, "")));
|
|
366
|
+
} catch {
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
if (!notification.historyId) return [];
|
|
370
|
+
let messageIds;
|
|
371
|
+
if (lastHistoryId) {
|
|
372
|
+
messageIds = await listMessageIdsSince(lastHistoryId);
|
|
373
|
+
} else {
|
|
374
|
+
messageIds = await listRecentInboxMessageIds(maxMessages);
|
|
375
|
+
}
|
|
376
|
+
lastHistoryId = notification.historyId;
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const id of messageIds) {
|
|
379
|
+
const msg = await fetchMessage(id);
|
|
380
|
+
if (!msg) continue;
|
|
381
|
+
const inbound = messageToInbound(msg);
|
|
382
|
+
if (inbound) out.push(inbound);
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
async function verifySignature(req) {
|
|
387
|
+
switch (config.pushAuth.kind) {
|
|
388
|
+
case "none":
|
|
389
|
+
return true;
|
|
390
|
+
case "token": {
|
|
391
|
+
const provided = req.query["token"];
|
|
392
|
+
const value = Array.isArray(provided) ? provided[0] : provided;
|
|
393
|
+
if (typeof value !== "string") return false;
|
|
394
|
+
return constantTimeEqual(value, config.pushAuth.token);
|
|
395
|
+
}
|
|
396
|
+
case "jwt": {
|
|
397
|
+
const auth = headerValue(req.headers, "authorization");
|
|
398
|
+
if (!auth || !auth.toLowerCase().startsWith("bearer ")) return false;
|
|
399
|
+
const token = auth.slice(7).trim();
|
|
400
|
+
if (!token) return false;
|
|
401
|
+
try {
|
|
402
|
+
return await verifyGoogleJwt(
|
|
403
|
+
token,
|
|
404
|
+
(kid) => jwks.getKey(kid),
|
|
405
|
+
config.pushAuth.expectedAudience,
|
|
406
|
+
config.pushAuth.expectedServiceAccountEmail,
|
|
407
|
+
clockSkewSec
|
|
408
|
+
);
|
|
409
|
+
} catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async function send(message) {
|
|
416
|
+
if (message.content.type !== "text") {
|
|
417
|
+
return {
|
|
418
|
+
messageId: message.id,
|
|
419
|
+
status: "failed",
|
|
420
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
421
|
+
error: {
|
|
422
|
+
code: "gmail_unsupported_content",
|
|
423
|
+
message: `Gmail adapter only supports text content in v1 (received: ${message.content.type})`
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const subjectMeta = message.metadata?.["subject"];
|
|
428
|
+
const baseSubject = subjectMeta ? stripReplyPrefix(subjectMeta) : "";
|
|
429
|
+
const subject = baseSubject ? `Re: ${baseSubject}` : "(no subject)";
|
|
430
|
+
const inReplyTo = message.metadata?.["messageId"];
|
|
431
|
+
const referencesPrev = message.metadata?.["references"];
|
|
432
|
+
const references = inReplyTo ? referencesPrev ? `${referencesPrev} ${inReplyTo}` : inReplyTo : void 0;
|
|
433
|
+
const raw = buildReplyEmail({
|
|
434
|
+
from: config.emailAddress,
|
|
435
|
+
to: message.contact.channelUserId,
|
|
436
|
+
subject,
|
|
437
|
+
body: message.content.text,
|
|
438
|
+
inReplyTo,
|
|
439
|
+
references
|
|
440
|
+
});
|
|
441
|
+
const payload = { raw: b64urlEncode(raw) };
|
|
442
|
+
const threadId = message.metadata?.["threadId"];
|
|
443
|
+
if (threadId) payload["threadId"] = threadId;
|
|
444
|
+
const res = await authedFetch("/gmail/v1/users/me/messages/send", {
|
|
445
|
+
method: "POST",
|
|
446
|
+
body: JSON.stringify(payload)
|
|
447
|
+
});
|
|
448
|
+
const data = await res.json().catch(() => ({}));
|
|
449
|
+
if (res.status >= 200 && res.status < 300 && data.id) {
|
|
450
|
+
return {
|
|
451
|
+
messageId: message.id,
|
|
452
|
+
externalId: data.id,
|
|
453
|
+
status: "sent",
|
|
454
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
messageId: message.id,
|
|
459
|
+
status: "failed",
|
|
460
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
461
|
+
error: {
|
|
462
|
+
code: `gmail_${data.error?.code ?? res.status}`,
|
|
463
|
+
message: data.error?.message ?? `HTTP ${res.status}`
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
async function verifyCredentials() {
|
|
468
|
+
if (!config.clientId || !config.clientSecret) {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
reason: "unauthorized",
|
|
472
|
+
hint: "GmailConfig.clientId / clientSecret missing. Generate them in Google Cloud Console \u2192 APIs & Services \u2192 Credentials \u2192 OAuth 2.0 Client ID."
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (!config.refreshToken) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
reason: "unauthorized",
|
|
479
|
+
hint: "GmailConfig.refreshToken missing. Run the consent flow once with prompt=consent and access_type=offline to obtain a long-lived refresh token."
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (!config.emailAddress) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: "unauthorized",
|
|
486
|
+
hint: "GmailConfig.emailAddress missing. Set this to the mailbox the refresh token belongs to (e.g. agent@yourcompany.com)."
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const token = await tokens.get();
|
|
491
|
+
const res = await fetch(`${apiBase}/gmail/v1/users/me/profile`, {
|
|
492
|
+
headers: { authorization: `Bearer ${token}` }
|
|
493
|
+
});
|
|
494
|
+
if (res.status === 401 || res.status === 403) {
|
|
495
|
+
return {
|
|
496
|
+
ok: false,
|
|
497
|
+
reason: "unauthorized",
|
|
498
|
+
hint: "Google rejected the access token. Check scopes (need gmail.modify) and re-run consent with prompt=consent."
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (!res.ok) {
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
reason: "unknown",
|
|
505
|
+
hint: `Gmail profile lookup returned ${res.status}`
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const data = await res.json();
|
|
509
|
+
return { ok: true, accountInfo: data.emailAddress ?? config.emailAddress };
|
|
510
|
+
} catch (err) {
|
|
511
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
512
|
+
if (/401|invalid_grant|invalid_client/i.test(msg)) {
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
reason: "unauthorized",
|
|
516
|
+
hint: `Google rejected credentials: ${msg}. Re-check clientId/clientSecret/refreshToken.`
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return { ok: false, reason: "network_error", hint: msg };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function uploadMedia(_file) {
|
|
523
|
+
throw new Error("Gmail uploadMedia is not yet implemented in v1.");
|
|
524
|
+
}
|
|
525
|
+
async function downloadMedia(_ref) {
|
|
526
|
+
throw new Error("Gmail downloadMedia is not yet implemented in v1.");
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
channel: "gmail",
|
|
530
|
+
capabilities: CAPABILITIES,
|
|
531
|
+
send,
|
|
532
|
+
handleWebhook,
|
|
533
|
+
verifySignature,
|
|
534
|
+
verifyCredentials,
|
|
535
|
+
uploadMedia,
|
|
536
|
+
downloadMedia,
|
|
537
|
+
watch,
|
|
538
|
+
stopWatch
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
542
|
+
0 && (module.exports = {
|
|
543
|
+
createGmailAdapter
|
|
544
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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
CHANGED
|
@@ -198,22 +198,33 @@ function parseEmailAddress(header) {
|
|
|
198
198
|
if (/^\S+@\S+$/.test(trimmed)) return { address: trimmed };
|
|
199
199
|
return null;
|
|
200
200
|
}
|
|
201
|
+
function sanitizeHeaderValue(value) {
|
|
202
|
+
return value.replace(/[\r\n]/g, "");
|
|
203
|
+
}
|
|
201
204
|
function buildReplyEmail(opts) {
|
|
202
205
|
const headers = [
|
|
203
|
-
`From: ${opts.from}`,
|
|
204
|
-
`To: ${opts.to}`,
|
|
205
|
-
`Subject: ${opts.subject}`,
|
|
206
|
+
`From: ${sanitizeHeaderValue(opts.from)}`,
|
|
207
|
+
`To: ${sanitizeHeaderValue(opts.to)}`,
|
|
208
|
+
`Subject: ${sanitizeHeaderValue(opts.subject)}`,
|
|
206
209
|
"MIME-Version: 1.0",
|
|
207
210
|
"Content-Type: text/plain; charset=utf-8",
|
|
208
211
|
"Content-Transfer-Encoding: 8bit",
|
|
209
212
|
`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`
|
|
210
213
|
];
|
|
211
|
-
if (opts.inReplyTo) headers.push(`In-Reply-To: ${opts.inReplyTo}`);
|
|
212
|
-
if (opts.references) headers.push(`References: ${opts.references}`);
|
|
214
|
+
if (opts.inReplyTo) headers.push(`In-Reply-To: ${sanitizeHeaderValue(opts.inReplyTo)}`);
|
|
215
|
+
if (opts.references) headers.push(`References: ${sanitizeHeaderValue(opts.references)}`);
|
|
213
216
|
return `${headers.join("\r\n")}\r
|
|
214
217
|
\r
|
|
215
218
|
${opts.body}`;
|
|
216
219
|
}
|
|
220
|
+
function constantTimeEqual(a, b) {
|
|
221
|
+
if (a.length !== b.length) return false;
|
|
222
|
+
let diff = 0;
|
|
223
|
+
for (let i = 0; i < a.length; i++) {
|
|
224
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
225
|
+
}
|
|
226
|
+
return diff === 0;
|
|
227
|
+
}
|
|
217
228
|
function stripReplyPrefix(subject) {
|
|
218
229
|
return subject.replace(/^(?:re|RE|Re)\s*:\s*/i, "").trim();
|
|
219
230
|
}
|
|
@@ -355,7 +366,8 @@ function createGmailAdapter(config) {
|
|
|
355
366
|
case "token": {
|
|
356
367
|
const provided = req.query["token"];
|
|
357
368
|
const value = Array.isArray(provided) ? provided[0] : provided;
|
|
358
|
-
|
|
369
|
+
if (typeof value !== "string") return false;
|
|
370
|
+
return constantTimeEqual(value, config.pushAuth.token);
|
|
359
371
|
}
|
|
360
372
|
case "jwt": {
|
|
361
373
|
const auth = headerValue(req.headers, "authorization");
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msgly/gmail",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Gmail adapter for Msgly — receive and reply to emails via Gmail API + Pub/Sub push",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"main": "./dist/index.
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
8
|
"module": "./dist/index.js",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
|
-
"import": "./dist/index.js"
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
14
15
|
}
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
"README.md"
|
|
19
20
|
],
|
|
20
21
|
"dependencies": {
|
|
21
|
-
"@msgly/core": "0.2.
|
|
22
|
+
"@msgly/core": "0.2.3"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@types/node": "^20.11.0",
|
|
@@ -44,8 +45,8 @@
|
|
|
44
45
|
"webhook"
|
|
45
46
|
],
|
|
46
47
|
"scripts": {
|
|
47
|
-
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
48
|
-
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
48
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
49
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
49
50
|
"test": "vitest run",
|
|
50
51
|
"lint": "eslint src --ext .ts",
|
|
51
52
|
"typecheck": "tsc --noEmit",
|