Skip to content

Add inbound email receiving support#20

Open
leoisadev1 wants to merge 2 commits into
mainfrom
t3code/81b41603
Open

Add inbound email receiving support#20
leoisadev1 wants to merge 2 commits into
mainfrom
t3code/81b41603

Conversation

@leoisadev1
Copy link
Copy Markdown
Member

Summary

  • Add a first-class inbound email API with createInboundEmailClient, normalized InboundEmail parsing, adapter lookup, and webhook verification.
  • Introduce inbound adapters for Resend, Mailgun Routes, Postmark Inbound, SendGrid Inbound Parse, plus a Gmail BYO OAuth polling adapter.
  • Update package exports, README examples, and add coverage for inbound core, adapters, Gmail helpers, and utilities.
  • Restructure the Fumadocs docs into clear Send / Receive sections with quickstarts, provider tables, threading guidance, and verification docs.
  • Add Changesets metadata so the inbound support ships as a minor release.

Testing

  • Not run (not requested).
  • Added/updated unit tests for inbound core, inbound adapters, Gmail helpers, and utilities.
  • Documentation and route tree updates were included to keep the docs site in sync with the new send/receive split.

- 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
@vercel
Copy link
Copy Markdown

vercel Bot commented May 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
email-sdk-fumadocs Ready Ready Preview, Comment May 30, 2026 3:23pm

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 30, 2026

Greptile Summary

This PR introduces a first-class inbound email API (createInboundEmailClient) with normalized parsing, webhook verification, and adapters for Resend, Mailgun, Postmark, SendGrid, and a Gmail BYO-OAuth polling adapter. It also restructures the Fumadocs docs into Send/Receive sections and adds changeset metadata for a minor release.

  • Inbound core & types (inbound-core.ts, inbound-types.ts): Clean adapter registry, parse/verify delegation, and normalized InboundEmail shape.
  • Webhook adapters (inbound-resend.ts, inbound-mailgun.ts, inbound-postmark.ts, inbound-sendgrid.ts): Each adapter handles provider-specific field mapping and optional signature verification; ECDSA (SendGrid) and Svix HMAC (Resend) paths look correct.
  • Gmail adapter (inbound-gmail.ts): Polling-based adapter with sync/getMessage; sync only fetches the first API page and silently truncates results when the inbox exceeds the page size.

Confidence Score: 4/5

Safe to merge with the Mailgun replay-window fix in place; all other paths are well-structured.

The verifyMailgunSignature function in inbound-utils.ts accepts any timestamp regardless of age, so a legitimately-signed webhook captured in transit can be replayed indefinitely against any endpoint using the Mailgun adapter with a signing key configured. The fix is a one-liner freshness check on the Unix timestamp, but until it lands the Mailgun verification path has a real security gap.

packages/email-sdk/src/inbound-utils.ts — the verifyMailgunSignature function needs a timestamp freshness check before this ships.

Security Review

  • Replay attack — verifyMailgunSignature (inbound-utils.ts:341): No timestamp freshness check. A captured, legitimately-signed Mailgun webhook can be replayed indefinitely. Mailgun docs recommend rejecting requests whose timestamp is more than 15 minutes old.

Important Files Changed

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)
Loading

Fix All in Claude Code

Reviews (2): Last reviewed commit: "Fix docs navigation for receiving expans..." | Re-trigger Greptile

Comment on lines +22 to +24
if (contentType.includes("application/json")) {
return { payload: rawBody ? JSON.parse(rawBody) : {}, rawBody, headers };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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)

Suggested change
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 };
}

Fix in Claude Code

Comment on lines +355 to +384
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));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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)

Fix in Claude Code

Comment on lines +91 to +99
if (response.status === 401 && options.refreshAccessToken) {
accessToken = await options.refreshAccessToken();
response = await fetchImpl(url, {
...init,
headers: {
...headersObject(init.headers),
authorization: `Bearer ${accessToken}`,
},
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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)

Fix in Claude Code

Comment on lines +330 to +339
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant