██╗ ███╗ ██╗ ██████╗ ██████╗ █████╗ ██████╗ ██║ ████╗ ██║ ██╔══██╗ ██╔═══██╗ ██╔══██╗ ██╔══██╗ ██║ ██╔██╗ ██║ ██████╔╝ ██║ ██║ ███████║ ██║ ██║ ██║ ██║╚██╗██║ ██╔══██╗ ██║ ██║ ██╔══██║ ██║ ██║ ██║ ██║ ╚████║ ██║ ██║ ╚██████╔╝ ██║ ██║ ██████╔╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝
The self-hostable cold email sequencing and mailbox warm-up platform.
Quick start · Features · How it works · Docs · Security
⭐ If Inroad saves you a per-seat subscription, star the repo. It's the cheapest way to help.
Inroad sends cold email sequences from the mailboxes you already own (Gmail, Microsoft 365, or plain SMTP) and paces them on a warm-up ramp so they keep landing in the inbox. Replies, bounces, and opt-outs are polled back in, classified, and used to stop the sequence automatically. Everything runs on your own hardware: one Postgres, one Redis, two Go binaries, and a React SPA. No SaaS account, no per-seat pricing, no third party holding your mailbox credentials.
It's an open-source alternative to Instantly and Smartlead, built for people who would rather run the infrastructure than rent it.
Run it (self-hosting, only Docker required):
git clone https://github.com/Axomble/Inroad && cd Inroad
docker compose up -dThat's the whole install. Secrets are generated on first boot, migrations run automatically, and the app is on http://localhost. Deployment options (env vars, Gmail/M365 OAuth setup, Terraform, Helm) are in docs/self-hosting.md.
The API image also ships inroadctl, the operator CLI. It talks to Postgres directly rather than
through the API, which is what makes it work when sign-in itself is broken — create the first
account, reset a password, or restore a lost owner from the shell. See
the operator CLI reference; the zero-config compose
stack above needs one extra step, because its generated secrets live in a volume rather than in the
container's environment.
Hack on it (live-reloading dev stack, still only Docker required):
docker compose -f docker-compose.dev.yml up -dGo hot-reloads via air, the SPA runs Vite HMR on http://localhost:5173, Mailpit catches every
transactional email on http://localhost:8025, and the docs site serves on http://localhost:4321.
Seed a demo workspace to log into:
docker compose -f docker-compose.dev.yml exec api go run ./cmd/seed
# → login demo@inroad.test / demodemoPrefer running Go and Node natively? See CONTRIBUTING.md. make dev (or
.\scripts\dev.ps1 on Windows) does the same thing without containers for the binaries.
- Sequencing: multi-step campaigns with per-step delays, merge fields, A/B variants per step, timezone-aware send windows, and a natural send cadence that never emits on a uniform interval.
- Sending infrastructure: Gmail API, Microsoft Graph, and SMTP/IMAP behind one seam; sender pools with round-robin / LRU / weighted rotation; ramped daily caps and campaign-wide limits enforced on the send path.
- Multi-IP sending fleet:
role=sendworkers take an IP-derived identity and each mailbox is scored onto one of them — packing, blast radius, provider crowding and health, with incumbency outranking all of it because IP trust accrues per mailbox at the provider. Per-worker provider verdicts are collected as window deltas; a worker the provider has blocked or that stops heartbeating has its mailboxes rotated off it. Every placement, refusal and move is explained in an append-only decision log, surfaced in an admin-only fleet view. - Warm-up: opted-in mailboxes exchange real threaded mail on a ramping volume; placement is measured (inbox vs spam), health is recomputed from it, and a mailbox that turns bad is paused instead of pushed. Warmup health gates cold sending.
- Replies & deliverability: reply polling across all three transports with deterministic, offline classification plus a user-definable taxonomy driving automation; DSN bounce handling; suppression and one-click unsubscribe; SPF/DKIM/DMARC checks per sending domain; a deliverability dashboard and cross-campaign reporting.
- Unified inbox & CRM: every reply from every mailbox in one threaded view, reply-from-inbox through the owning mailbox; companies, deals, pipelines, notes, tasks and an activity feed; typed custom fields validated at import and preflight; contacts at scale (trigram search, keyset paging).
- AI, human-in-the-loop: an in-app agent and AI-drafted replies behind an approval queue; bring your own key, and the entire feature is off (and makes no calls) until you add one.
- Platform: multi-workspace teams and roles; passkeys, TOTP, Google sign-in, refresh-token rotation; scoped API keys, an OAuth 2.0 provider, signed outbound webhooks, and an MCP server exposing the agent's typed tools; envelope-encrypted credentials with per-workspace crypto-shredding.
Inroad splits into a control plane that owns all state and an execution plane that owns all
outbound network I/O. They meet at exactly one interface, coreapi.Client.
Whether the split is physical depends on the role, and that is the design. Worker packages
reach relational data only through coreapi, enforced mechanically: a depguard rule in
.golangci.yml fails golangci-lint run (and therefore CI) if a non-test file under
internal/worker/ imports internal/platform/db.
A role=send worker — the role meant for a fleet host — opens no database connection at all.
cmd/worker does not call db.ConnectSized on that role; its coreapi.Client is
*coreapi/remote.Client, an authenticated HTTP hop to the control plane, and a type with no
*pgxpool.Pool field. It holds no INROAD_MASTER_KEY and refuses to start if it is given one; it
holds no INROAD_DATABASE_URL and refuses to start if it is given one of those either. So a
compromised fleet host cannot read the tenant database: it names one subject at a time over a seam
whose request shapes cannot express a filter, a pattern, a limit or a cursor.
The honest limit, stated as plainly as the claim. While that worker is running it can still
obtain the decrypted credential of any mailbox it names — every send worker consumes the shared
send queue and may legitimately be handed any mailbox's job, so per-mailbox scoping needs
per-mailbox routing first — and it can fetch any job whose id it can name, which carries real
content. It cannot enumerate anything. docs/security.md invariant 80 is the full statement.
role=control keeps its pool, and must: it runs the cross-tenant sweeps, which have no remote
transport by design. The single-process self-host topology (role=all) is entirely unaffected and
keeps its pool and its local keyring.
CONTROL PLANE EXECUTION PLANE
┌───────────────────────────────────────────────┐ ┌──────────────────────────────────┐
│ web/ SPA ──REST──▶ cmd/inroad │ │ cmd/worker │
│ │ │ │ one binary, INROAD_WORKER_ROLE │
│ auth · mailbox · campaign · contact · │ │ │
│ enrollment · inbox · crm · deliverability │ │ role=control ─┐ │
│ │ │ │ role=send ─┤ │
│ ┌────────┴────────┐ │ │ role=all (default, self-host) │
│ │ │ │ └────────┬─────────────────────────┘
│ ┌─────▼─────┐ ┌──────▼──────┐ │ │
│ │ Postgres │ │ Redis │ │ │ outbound, per mailbox
│ └─────┬─────┘ │ (asynq) │ │ ▼
│ │ └──────┬──────┘ │ SMTP · Gmail API · MS Graph
│ │ │ │
│ └── coreapi.Client┼──────────────┼────────────┘
│ in-process for role=all │ │ role=send holds NO pool: its
│ and role=control │ │ coreapi is an HTTP hop (see above)
└────────────────────────────────┼──────────────┘
│
queues, and who consumes them
┌───────────────────┴───────────────────────────────┐
│ control → 8 reconciles + the campaign breaker │ role=control, role=all
│ send → sends, polls, webhooks │ role=send, role=all
│ w:<id> → one worker's warmup ticks │ role=send, role=all
│ default → transitional drain only │ role=send, role=all
└───────────────────────────────────────────────────┘
A queue is not decoration: asynq claims a task before consulting the handler table, so a process
that consumes a queue it cannot serve takes the task and fails it. control therefore consumes only
control — that single omission is what stops a control host eating sends.
control is a role queue, not a "scheduled work" queue. Eight of its nine task types are the
periodic reconciles the scheduler fires; the ninth, deliverability:evaluate, is enqueued by the
send role after each finalised send, because re-scoring a campaign's breaker is a cross-campaign
decision rather than one message's delivery. Routing is one table — queueForTaskType in
internal/platform/queue.
[control role] [redis] ┃ [send role] — one process, NO pool, NO keyring
│ │ ┃
sweep finds a │ ┃
due enrollment │ ┃
│ enqueue ─────▶│ ┃
│ │ queue: send ┃
│ │───────────────▶┃ claim (the sends row lock is the idempotency
│ │ ┃ guarantee; queue dedup is defence in depth)
│ │ ┃ │
│ │ ┃ ▼
│ │ ┃ coreapi.GetStepSendJob — an authenticated HTTP call to
│ │ ┃ the control plane (role=all makes the same call in
│ │ ┃ process, against its own pool). The response carries no
│ │ ┃ secret: unwrapping the DEK and refreshing the OAuth
│ │ ┃ token is a SECOND call, to the credential broker on the
│ │ ┃ same listener, which is the only place the key lives.
│ │ ┃ │
│ │ ┃ ▼
│ │ ┃ send ─────▶ provider (SMTP · Gmail API · MS Graph)
│ │ ┃ │
│ │ ┃ ▼
│ │ ┃ coreapi.MarkStepDelivered (its own committed statement)
│ │ ┃ │
│ │ ┃ ▼
│ │ ┃ coreapi.AdvanceStepCursor (separate, idempotent, and
│ │ ┃ strictly after the mark)
│ │ ┃
│ a retry after delivery sees 'sent' and recover-forwards, never re-sends
On role=send, every coreapi step right of the heavy bar crosses a process line; on role=all
none of them does, and the same code runs either way because both are the same
coreapi.Client interface. GetStepSendJob is reached from AdvanceHandler, registered under the
per-message handler set — role=send and role=all only. (The similar ResolveSenderTransport is
a different path: ad hoc sends with no enrollment row, such as the test send and inbox replies.)
Three properties worth knowing because they shape everything else:
- Determinism.
platform/cadencecomputes a send instant as a seeded hash of stable ids, so a retry recomputes the identical time. Placement, scheduling and A/B assignment are computed, not coordinated — which is why there is no central assignment service to keep consistent. - Credentials are envelope-encrypted, and a fleet host no longer holds the wrapping key. Every
stored secret is sealed under a per-workspace DEK behind a
KeyProviderseam, and the send path holds a decrypted transport only for the one send, zeroizing it after use. Arole=sendworker builds nocrypto.Keyringat all and refuses to start if it is givenINROAD_MASTER_KEY; it asks the control plane to open each credential, one mailbox at a time (internal/platform/credbroker). A stolen worker disk or environment file therefore decrypts nothing, and revoking a fleet's access is rotating one token rather than re-encrypting every DEK. It also holds no pool: everycoreapicall it makes is an HTTP hop to the control plane, so it cannot read another tenant's rows at all. The limit worth stating just as plainly: while that worker is running it can still ask for any mailbox's credential — everysendworker consumes the sharedsendqueue and may legitimately be handed any mailbox's job — and it can fetch any job whose id it can name. Per-mailbox scoping needs per-mailbox routing forsequence:advance, which does not exist yet.role=all(self-host) keeps its local keyring and is unchanged. - Send windows are unrepresentable-if-overlapping via a GiST exclusion constraint — an illegal state made impossible at the schema rather than validated in application code.
Outbound mail leaves through each mailbox's own provider, not the worker's IP. The full write-up is in docs/architecture.md; the non-negotiables — the ones that must never be broken — are in docs/security.md.
| Read this | To learn |
|---|---|
| docs/architecture.md | The control/execution split, transports, encryption, reply classification |
| docs/security.md | The security invariants. Read before touching credentials, dials, or tenant queries |
| docs/self-hosting.md | Deploying, env vars, and connecting Gmail / M365 (OAuth setup, scopes, redirect URIs) |
| api/openapi.yaml | The REST contract. The SPA's typed client is generated from it |
| CONTRIBUTING.md | Dev loop, native setup, tests, and what a good PR looks like |
The same docs ship as a browsable site (docs/, Astro/Starlight). The dev stack serves it on
http://localhost:4321.
Pre-1.0 and under active development. Everything in Features works today and is covered by unit and integration tests. Notable roadmap items: lead-flow throttling, list verification before send, soft-bounce retry and FBL ingestion, custom tracking domains, cloud KMS, and billing for the open-core split. Open work is tracked as GitHub issues.
Pull requests are welcome. Keep each one to a single logical change, open an issue first for anything
architectural, branch by type (feature/…, fix/…, chore/…), and keep make test /
make test-integration / make lint green. Details in CONTRIBUTING.md; repo
conventions live in CLAUDE.md.
Inroad handles mailbox credentials, so security is a hard requirement rather than a feature:
credentials are envelope-encrypted and never appear in a response or a log, every tenant-scoped query
is pinned to a workspace_id, user-supplied hosts are dialed only through the SSRF guard, and TLS is
enforced by default on SMTP and IMAP. The invariants are written down in
docs/security.md.
Found a vulnerability? Please report it privately: open a GitHub security advisory rather than a public issue. Responsible disclosure is credited in the release notes.
Apache License 2.0. Copyright 2026 Ahmed Mustufa Malik.

