@msgly/gmail 0.2.1 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +25 -0
  2. package/dist/index.js +18 -6
  3. package/package.json +2 -2
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.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
- return value === config.pushAuth.token;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@msgly/gmail",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",
@@ -18,7 +18,7 @@
18
18
  "README.md"
19
19
  ],
20
20
  "dependencies": {
21
- "@msgly/core": "0.2.1"
21
+ "@msgly/core": "0.2.2"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^20.11.0",