Skip to content
AxomblePublic

About

No description or website provided.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

401 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
██╗ ███╗   ██╗ ██████╗   ██████╗   █████╗  ██████╗ 
██║ ████╗  ██║ ██╔══██╗ ██╔═══██╗ ██╔══██╗ ██╔══██╗
██║ ██╔██╗ ██║ ██████╔╝ ██║   ██║ ███████║ ██║  ██║
██║ ██║╚██╗██║ ██╔══██╗ ██║   ██║ ██╔══██║ ██║  ██║
██║ ██║ ╚████║ ██║  ██║ ╚██████╔╝ ██║  ██║ ██████╔╝
╚═╝ ╚═╝  ╚═══╝ ╚═╝  ╚═╝  ╚═════╝  ╚═╝  ╚═╝ ╚═════╝ 

The self-hostable cold email sequencing and mailbox warm-up platform.

License Go React Postgres Self-hosted

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.

The Inroad console: sending capacity, sender health, and what needs attention

Quick start

Run it (self-hosting, only Docker required):

git clone https://github.com/Axomble/Inroad && cd Inroad
docker compose up -d

That'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 -d

Go 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 / demodemo

Prefer 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.


Features

  • 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=send workers 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.

The unified inbox: every reply from every mailbox, classified and labelled


How it works

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.

Zoomed out — the pieces and what moves between them

                         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.

Zoomed in — one campaign step, end to end

  [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/cadence computes 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 KeyProvider seam, and the send path holds a decrypted transport only for the one send, zeroizing it after use. A role=send worker builds no crypto.Keyring at all and refuses to start if it is given INROAD_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: every coreapi call 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 — every send worker consumes the shared send queue 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 for sequence: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.


Documentation

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.


Status

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.


Contributing

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.


Security

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.


License

Apache License 2.0. Copyright 2026 Ahmed Mustufa Malik.

About

No description or website provided.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages