inbound loops your local SES stub back into its own webhook route
email infrastructure for agent and indie devs. Inbound - Email Infrastructure Made Simple Stop juggling email providers.
At a glance
- What is it?
- A TypeScript email infrastructure platform where the SDK is generated from an OpenAPI spec rather than hand written, and where local development stubs out Postgres, Neon, Redis, SES and billing so that sending to your own domain makes mail arrive in your app. The root package is the web app, not the published SDK.
- Who is it for?
- Use inbound if you want email addresses that hand your application parsed content the moment a message arrives, and you would rather not assemble domain verification, DNS, attachment storage and spam checks yourself. Do not reach for it if you need outbound-only sending, since the design starts at the receiving end.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 1, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The SDK is generated from an OpenAPI spec
The most consequential thing in package.json is not a dependency. There is a `stainless:build` script that takes an OpenAPI spec and a Stainless config and produces SDK builds against a branch, and it waits for the build to finish. Alongside it sit `generate:openapi` and `verify:openapi`, and `prebuild` runs the generate step automatically before every `next build`.
That pipeline explains things you would otherwise find puzzling. The helper named `isInboundWebhookPayload` in the receive example is not hand written validation, it is generated from the spec. The same is true of the `InboundWebhookPayload` type and of the method namespaces such as `emails`, `domains` and `emailAddresses`. When the API surface changes, the spec changes, the SDK regenerates, and the type checker tells you what broke.
It also means the type definitions are the contract. If a field is optional in your editor, that is the spec talking, not an omission by the author.
Sending is four fields and a tag, not a message object
The send call is short enough to read as the whole API surface for outbound mail:
import { Inbound } from 'inboundemail'
const inbound = new Inbound(process.env.INBOUND_API_KEY!)
const email = await inbound.emails.send({
from: 'Inbound User <[email protected]>',
to: '[email protected]',
subject: 'Welcome!',
html: '<p>Thanks for signing up!</p>',
tags: [{ name: 'campaign', value: 'welcome' }]
})
console.log(`Email sent: ${email.id}`)A client is constructed once from an environment key, and every subsequent call hangs off it as a namespace. `from` is a full display name plus address, so the display name lives in the envelope rather than being a separate parameter. And `tags` is a list of name and value pairs rather than a flat object, which is what lets a tag be attached to more than one send without renaming anything.
The returned object carries an `id`, so a send is referenceable afterwards without you constructing an idempotency key.
The webhook URL is set when the address is created
The quick start is five steps and the ordering matters. Install the SDK:
npm install inboundemail
# or
bun add inboundemailThen construct the client from `process.env.INBOUND_API_KEY`, add a domain with `inbound.domains.create`, and create the address. The address call is where the webhook binds:
const emailAddress = await inbound.emailAddresses.create({
address: "[email protected]",
webhookUrl: "https://yourapp.com/webhook/email"
})So the destination is a property of the address rather than of the client or of a later subscription call. One address points at one endpoint, which means per address routing is the unit of configuration, and changing where mail lands means updating the address rather than reconfiguring your app.
The last step has no code at all: send a mail to that address and watch the webhook fire with the parsed content.
Local dev loops a stubbed SES back into the real webhook route
This is the part of the setup worth understanding rather than copying. `dev:local` starts Postgres, a Neon HTTP proxy and Redis in Docker, and it blanks every production credential the dev server would otherwise pick up. Outgoing mail goes to a local SES stub at `http://127.0.0.1:8780/_local/messages`, and that stub is looped back into `/api/inbound/webhook`. The consequence is that sending to your own local domain produces received mail in your app, through the same route production uses. Billing checks are answered by a local Autumn mock, configured through `autumn.config.ts` at the root.
There are four subcommands on that script. `seed` adds a demo domain, address, endpoint and received messages. `api-key` creates a local user with a verified `demo.localtest.me` domain and prints a key. `reset` wipes local data, which is what you run after editing `lib/db/schema.ts`.
Sign in at `http://localhost:3000/login` with any address; the magic link is printed in the terminal rather than emailed.
Plain bun run dev refuses a remote database unless you allow it
There is a safety rail in the scripts that is easy to miss. Plain `bun run dev` refuses to connect to a remote database unless `ALLOW_REMOTE_DB=true` is set. Given that the application owns domain records, DNS and the mail database, that default is the right way round: the failure mode of forgetting the flag is a refused connection, not a migration against production.
The deployment surface around it is broad. There are four deploy scripts: a quick deploy, a Lambda deploy, a CDK deploy through a script named for completing a CDK setup, and one dedicated to deploying the email system. Alongside them sit an `aws/` directory, a `vercel.json`, a `drizzle.config.ts` with a `drizzle/` directory for schema migrations, `docker-compose.dev.yml`, and `instrumentation.ts`.
The presence of both a Vercel config and a full CDK path means the same application is intended to run in two quite different places, which is worth deciding early rather than after you have written against one of them.
The tests are split by layer, and contributors get their own command
There are eight test shaped scripts, and the names describe the layers rather than the features. `test-api` and `test-sdk` sit beside each other in the API directory. `test:e2`, `test:e2:domains` and `test:e2e` cover the end to end suite, with `test:e2e` pointed at a single self contained file. `test:unit` runs a long explicit list of paths covering domains, emails, mailboxes, helpers, inbound handling, features and lib, then a second invocation for the retry test. `test:deployment` covers deployment.
Contributors are pointed somewhere else entirely. The contributing steps say to test with `bun run inbound-webhook-test`, which is not one of the eight above, so it is a separate harness aimed at the webhook path specifically.
Linting is Biome, with a `lint:noany` variant that raises the diagnostic level to error. Given the code is largely generated TypeScript, a stricter variant that rejects implicit `any` is the one that catches hand written mistakes the generator will not.
The root package is the web app, not the SDK you install
The repository root package.json is named `inbound.exon.dev`, is marked private, and sits at version 0.1.0. The package you actually install from a registry is `inboundemail`. So the thing you depend on and the thing you clone are two different artifacts, and this repository is the platform rather than the library.
The layout reflects that. `packages/` holds the SDK sources, with an `inbound` CLI entry point and a separate `inboundctl` binary. `app/`, `components/`, `features/`, `contexts/`, `hooks/`, `content/`, `emails/` and `functions/` are the web application. `lib/` holds shared logic including the database schema. `lib/db/schema.ts` is named explicitly as the file whose changes require a local reset.
Three AI tooling directories also sit at the root, `.cursor/`, `.opencode/` and a `SKILL.md` next to an `AGENTS.md`, plus a `security-review.md`. That is a meaningful signal about where the project's development workflow currently sits.
The clone URL in the docs points at a different owner
A small inconsistency worth knowing before you follow the setup instructions literally. The local development section tells you to run:
git clone https://github.com/R44VC0RP/inbound
cd inbound
bun installThe owner in that URL is not the owner of the repository you are reading. The project publishes under `inboundemail/inbound`, while the clone line names a different account. The rest of the sequence is ordinary: install with Bun, then `bun run dev:local` to bring up the Docker services against `next dev`, then `bun run test:e2e` for the self contained end to end suite.
If the clone fails, that is the first thing to change. It is the kind of detail that costs an afternoon once and never again, but it is also the kind of detail that makes a reader doubt the rest of the instructions.
The documented feature set behind all this is a REST API with an OpenAPI spec, webhooks with signature verification, email parsing with HTML and text extraction, attachment handling backed by S3 storage, spam filtering and security checks, domain verification and DNS management, and usage tracking with billing integration.
Editorial conclusion
Use inbound if you want email addresses that hand your application parsed content the moment a message arrives, and you would rather not assemble domain verification, DNS, attachment storage and spam checks yourself. Do not reach for it if you need outbound-only sending, since the design starts at the receiving end. Four things to check before you plan around it. That the thing you install is `inboundemail` while the repository root package is named something else entirely and marked private, so you are depending on a generated SDK, not on this repository's package.json. That you are willing to run the project under Bun rather than Node alone, since the scripts, the tests and the local stack are all Bun based. Where the domain records actually get created, since DNS management is claimed as a feature while the quick start stops at adding the domain and creating an address. And which side of the loopback you want in production, because locally a stub stands in for your sending provider. Licence is MIT, there are no GitHub releases, and the last push to main is dated 1 October 2026.
Frequently asked questions
What is inboundemail/inbound and what does it do?
It gives you programmable email addresses that automatically process incoming messages and trigger webhooks in your application, so you receive parsed content rather than raw MIME. The feature set covers a REST API with an OpenAPI spec, webhook signature verification, HTML and text extraction, S3 backed attachments, spam filtering, domain verification and DNS management, and usage tracking with billing.
How do I install and set up the inbound SDK?
Run npm install inboundemail or bun add inboundemail, construct a client with new Inbound(process.env.INBOUND_API_KEY), add a domain with inbound.domains.create, then create an address with inbound.emailAddresses.create passing the address and a webhookUrl. Send a message to that address and the webhook fires with the parsed content.
How does inbound local development work without real providers?
`dev:local` starts Postgres, a Neon HTTP proxy and Redis in Docker and blanks every production credential. Outgoing mail goes to a local SES stub at http://127.0.0.1:8780/_local/messages which is looped back into /api/inbound/webhook, so sending to your own local domain arrives as received mail. A local Autumn mock answers billing checks.
Is the inbound SDK written by hand?
No. There is a stainless:build script that generates SDK builds from public/openapi.json using stainless.yml, alongside generate:openapi and verify:openapi scripts. That is why helpers such as isInboundWebhookPayload and the method namespaces appear in the documentation as ready made utilities.
Why does plain bun run dev refuse to start?
Because it refuses to connect to a remote database unless ALLOW_REMOTE_DB=true is set. The application owns domain records, DNS and the mail database, so the default is a refused connection rather than an accidental migration against production. Local development uses `bun run dev:local` instead.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/inboundemail-inbound)