Add inbound email receiving support#20
Conversation
- Add inbound client, provider adapters, and Gmail polling helpers - Split docs into Send and Receive sections with new onboarding guides - Add a changeset for the new email-sdk minor release
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR introduces a first-class inbound email API (
Confidence Score: 4/5Safe to merge with the Mailgun replay-window fix in place; all other paths are well-structured. The
|
| Filename | Overview |
|---|---|
| packages/email-sdk/src/inbound-utils.ts | Core utility module with HMAC/ECDSA verification helpers and body parsing; verifyMailgunSignature lacks a timestamp replay-window check, making captured webhooks indefinitely replayable |
| packages/email-sdk/src/inbound-gmail.ts | Gmail BYO-OAuth polling adapter with sync/getMessage/parse; sync only retrieves the first API page and silently drops messages when results exceed the page size |
| packages/email-sdk/src/inbound-core.ts | Introduces createInboundEmailClient with adapter registry, parse delegation, and optional verify; logic is straightforward and safe |
| packages/email-sdk/src/inbound-types.ts | Type definitions for the inbound API; well-structured with appropriate optional fields |
| packages/email-sdk/src/inbound-resend.ts | Resend webhook adapter with Svix-style signature verification; normalizes envelope fields correctly |
| packages/email-sdk/src/inbound-mailgun.ts | Mailgun Routes adapter delegating to verifyMailgunSignature; affected by the replay-window gap in the shared utility |
| packages/email-sdk/src/inbound-postmark.ts | Postmark inbound adapter with no signing verification (correctly declared as "none"); field mapping looks correct |
| packages/email-sdk/src/inbound-sendgrid.ts | SendGrid Inbound Parse adapter with ECDSA signature verification; envelope JSON parsing and attachment collection look correct |
| packages/email-sdk/src/index.ts | Adds inbound core and type exports; exports look complete and consistent |
Sequence Diagram
sequenceDiagram
participant C as Caller
participant IC as InboundEmailClient
participant A as Adapter
participant U as inbound-utils
C->>IC: verify(request)
IC->>A: adapter.verify(request)
A->>U: parseInboundInput(request)
U-->>A: payload, rawBody, headers
A->>U: verifyXxxSignature(...)
U-->>A: boolean
A-->>IC: boolean
IC-->>C: boolean
C->>IC: parse(request, options)
IC->>A: adapter.parse(request, context)
A->>U: parseInboundInput(request)
U-->>A: payload, rawBody, headers
A-->>IC: InboundEmail (raw)
IC->>IC: normalizeInboundEmail(email, name)
IC-->>C: InboundEmail (normalized)
Reviews (2): Last reviewed commit: "Fix docs navigation for receiving expans..." | Re-trigger Greptile
| if (contentType.includes("application/json")) { | ||
| return { payload: rawBody ? JSON.parse(rawBody) : {}, rawBody, headers }; | ||
| } |
There was a problem hiding this comment.
Unhandled
JSON.parse error on malformed body. If a provider sends a request whose Content-Type is application/json but whose body is not valid JSON (e.g., a partial delivery, an empty body with the wrong header, or a misconfigured provider), JSON.parse throws a SyntaxError that propagates uncaught through every adapter that calls parseInboundInput. Most webhook providers treat a 500 response as a delivery failure and will retry — potentially in a loop. Wrapping this in a try/catch and returning an empty-object payload (or re-throwing a more descriptive error) keeps the handler in control. (Confidence this is an issue: 4/5)
| if (contentType.includes("application/json")) { | |
| return { payload: rawBody ? JSON.parse(rawBody) : {}, rawBody, headers }; | |
| } | |
| if (contentType.includes("application/json")) { | |
| let payload: unknown = {}; | |
| try { | |
| payload = rawBody ? JSON.parse(rawBody) : {}; | |
| } catch { | |
| payload = {}; | |
| } | |
| return { payload, rawBody, headers }; | |
| } |
| export function verifySvixSignature(input: { | ||
| secret: string; | ||
| id?: string | null; | ||
| timestamp?: string | null; | ||
| signature?: string | null; | ||
| payload?: string; | ||
| }): boolean { | ||
| if (!input.id || !input.timestamp || !input.signature || input.payload === undefined) { | ||
| return false; | ||
| } | ||
|
|
||
| const secret = input.secret.startsWith("whsec_") | ||
| ? Buffer.from(input.secret.slice("whsec_".length), "base64") | ||
| : input.secret; | ||
| const signedContent = `${input.id}.${input.timestamp}.${input.payload}`; | ||
| const signatures = input.signature | ||
| .split(/\s+/) | ||
| .map((part) => part.trim()) | ||
| .filter(Boolean) | ||
| .map((part) => { | ||
| if (part.startsWith("v1,") || part.startsWith("v1=")) { | ||
| return part.slice(3); | ||
| } | ||
|
|
||
| return part; | ||
| }); | ||
| const expected = hmacSha256Base64(secret, signedContent); | ||
|
|
||
| return signatures.some((signature) => constantTimeEqual(signature, expected)); | ||
| } |
There was a problem hiding this comment.
Missing timestamp replay-window check in
verifySvixSignature
The function verifies the HMAC signature but never validates that the timestamp in svix-timestamp is within an acceptable window of the current time. The official Svix SDK enforces a ±5-minute tolerance: without it, any party who captures a legitimately-signed webhook can replay it indefinitely and it will still pass verification. Adding a check like Math.abs(Date.now() / 1000 - Number(input.timestamp)) > 300 and returning false when exceeded closes this gap. (Confidence: 4/5)
| if (response.status === 401 && options.refreshAccessToken) { | ||
| accessToken = await options.refreshAccessToken(); | ||
| response = await fetchImpl(url, { | ||
| ...init, | ||
| headers: { | ||
| ...headersObject(init.headers), | ||
| authorization: `Bearer ${accessToken}`, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Concurrent token refresh race condition
accessToken is a mutable variable shared across all calls to gmailFetch within the same adapter instance. If two concurrent requests both receive a 401, both branches execute options.refreshAccessToken() simultaneously, issuing two parallel refresh calls. Depending on the OAuth provider, this can invalidate the first new token before it is used, trigger rate limiting, or simply waste calls. A simple in-flight-refresh guard (e.g., storing the in-progress promise and reusing it) would prevent the duplicate calls. (Confidence: 3/5)
| export function constantTimeEqual(a: string, b: string): boolean { | ||
| const left = Buffer.from(a); | ||
| const right = Buffer.from(b); | ||
|
|
||
| if (left.length !== right.length) { | ||
| return false; | ||
| } | ||
|
|
||
| return timingSafeEqual(left, right); | ||
| } |
There was a problem hiding this comment.
Early-return on length mismatch leaks expected signature length
Returning false immediately when left.length !== right.length reveals, via a timing side-channel, the byte length of the computed expected signature. For HMAC-SHA256 hex and Base64 outputs the lengths are deterministic, so an attacker already knows them; in practice this is low risk. However, the conventional hardened pattern is to always execute timingSafeEqual on two equal-length values (e.g., by encoding both sides to a fixed-width representation) rather than short-circuiting on length. (Confidence: 2/5)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
createInboundEmailClient, normalizedInboundEmailparsing, adapter lookup, and webhook verification.Testing