Sealpost
The self-hosted, UI-native email operating system, with a zero-dependency SMTP/DKIM engine.
Design an email visually, run it through an eight-check deliverability gate, sign it with real RFC 6376 DKIM, deliver it over a raw SMTP socket, and keep a tamper-evident, cryptographically receipted ledger of every send. No nodemailer, no SaaS, no per-email fee.
Getting started · Docs · Changelog · Contributing
Quick start
Requires Node.js 20 or newer.
npx @sealpost/cli init my-mail && cd my-mail
npx @sealpost/cli send magic_link_otp_verification --to you@example.org --auto-fill
npx @sealpost/cli studio --openThe default transport is a loopback SMTP sink, so the whole pipeline (compile, gate, MIME, DKIM, SMTP, receipt, ledger) runs end to end on your laptop without touching the internet.
Contents
Why · Packages · How it works · The gate · Use the SDK · Deploy · Docs · Security · Limits · Develop
Why
| Pain | Sealpost |
|---|---|
| Email is a SaaS tax: per-email pricing, vendor lock-in, a dashboard you cannot embed | Self-hosted. Your SMTP mailboxes, your DNS, your data |
| Hand-written table HTML breaks in Outlook | Blocks compile to table-based, inline-styled HTML plus a plain-text part |
| Sends fail silently into spam | Pre-flight gate EW-101…EW-108 blocks a bad send before a socket opens
|
| DKIM/SPF/DMARC is folklore | Real RFC 6376 signing, verify-at-the-sink, and the exact DNS records generated for you |
| Retries double-send | Idempotency key: the same template, recipient and variables never goes out twice |
| "Delivered?" is a guess | Every send gets an evw_ ECDSA P-256 receipt chained into a hash-linked ledger |
| One brand per install | Several sending domains, each with its own DKIM key, selected per send |
| No campaigns without another SaaS | Audiences, CSV import and scheduled, rate-limited broadcasts |
| Your app has to poll for results | Signed, retried webhooks for delivery, bounce, complaint, open and click |
| Bounces never reach your app | Relay webhooks (SES, SendGrid, Postmark, Mailgun) feed the suppression list and the stats |
| Transactional engines drag in a dependency tree |
@sealpost/core has zero runtime dependencies; MIME, DKIM and SMTP are written against RFCs |
| Variable typos reach customers | Undeclared or missing {{variables}} fail EW-101; values are HTML-escaped |
Packages
| Package | What it is |
|---|---|
@sealpost/core |
Zero-dependency compiler, pre-flight gate, MIME, DKIM sign/verify, receipts, ledger, suppression |
@sealpost/react |
VisualEmailStudio, DeliverabilityPreflightGate, DomainMailboxVault, SmtpTranscriptInspector
|
@sealpost/sdk |
Zero-dependency TypeScript client with Resend-style { data, error } results |
sdks/python, sdks/go, sdks/ruby, sdks/php
|
The same client for Python, Go, Ruby and PHP |
@sealpost/cli |
sealpost binary: raw SMTP client with failover and retry queue, sink, tracking, Studio server |
How it works
template (blocks + {{variables}})
│ compile → table-based HTML + plain text
▼
pre-flight gate EW-101…108 ──blocked──▶ nothing is sent
│ pass
▼
MIME + RFC 6376 DKIM signature
│
▼
raw SMTP (your mailboxes, failover, retry queue) ──▶ recipient
│
▼
evw_ receipt → hash-chained ledger → logs, webhooks, stats
Studio, the REST API, schedulers, the optional inbound MX listener and the SMTP submission listener run in one sealpost studio process. State lives in .sealpost/ or, for several instances, in Postgres.
What it looks like
$ sealpost compile magic_link_otp_verification --to you@example.org
BLOCKED deliverability score 40/100
✖ EW-101 Required variable “userName” is missing. …
✔ EW-102 No CR/LF/NUL injection and every address is well-formed.
✔ EW-103 From domain mail.example.com aligns with mail.example.com.
✖ EW-104 Unsafe or unresolved link(s): btn → {{signInUrl}}.
…
$ echo $?
2
$ sealpost send magic_link_otp_verification --to you@example.org --auto-fill
✔ Delivered via 127.0.0.1:50975 250 2.0.0 Ok: queued as e1ecd224f7102284
receipt evw_4ca0b9d1b917d00c ledger #1
$ sealpost send … (same again)
✖ EW-108 This exact send (template, recipient, variables) was already dispatched
✖ BLOCKED by the pre-flight gate: no socket was opened.
$ sealpost ledger verify
✔ Chain intact across 1 record(s). Chain and every delivery receipt verified.
The pre-flight gate
| Check | Guards against |
|---|---|
| EW-101 | Missing, undeclared or mistyped variables; token parity between subject and body |
| EW-102 | CR/LF/NUL header injection and malformed addresses |
| EW-103 | From domain not aligned with the configured sending domain (DMARC) |
| EW-104 |
javascript:/data: links and unresolved {{tokens}} in URLs |
| EW-105 | Subject length, empty or oversized preheader |
| EW-106 | Spam-trigger phrases, low text-to-HTML ratio |
| EW-107 | Recipients on the suppression list |
| EW-108 | Duplicate sends (SHA-256 idempotency key over template, sorted recipients, variables) |
Score is 100 minus 30 per block and 8 per warning. BLOCKED stops the send; compile exits 2.
First-run wizard and logs
A fresh Studio opens a five-step setup (domain, relay, DNS with live re-checking, real test send, authentication check). The Logs tab then shows every email with its delivery, bounce, complaint, open and click timeline, filterable by status, recipient and tag. The same data is at GET /api/emails, in the SDKs and in sealpost logs.
More ways in, more ways to run
- SMTP submission: let WordPress, Laravel or any SMTP-only app send through Sealpost (DKIM, failover, suppression, receipts): SMTP submission.
-
Scheduled sends and hosted unsubscribe:
sealpost send --at …/scheduledAt, one-click unsubscribe pages and RFC 8058 headers, a per-minute send limit. -
API keys: scopes (
send,read,write), expiry, revocation, and an audit log of every change and failed sign-in. - Postgres and several processes: a shared database with cross-process locking and leases: Postgres storage.
-
For AI assistants:
sealpost mcpexposes read tools, and an opt-in send tool, over MCP: Sealpost for AI assistants.
Import what you already have
sealpost template import welcome.mjml # MJML, a React Email .tsx file, or any email HTMLBlocks, variables, theme and preheader are mapped, a live preview shows the result before it is added, and anything that could not be carried over is listed. JSX is read, never executed. See Importing templates.
Receiving email
Run Sealpost as your MX target (or have your relay forward mail to it) and replies, bounces and abuse reports are parsed and stored. Replies appear on the original email's timeline, verified bounces and complaints suppress the address automatically, and email.received webhooks tell your app. Forged reports are ignored. You can reply in-thread with inReplyTo. See Receiving email.
Use the SDK
import { Sealpost } from "@sealpost/sdk";
const mail = new Sealpost({ apiKey: process.env.SEALPOST_KEY!, baseUrl: "https://mail.yourdomain.com" });
const { data, error } = await mail.emails.send({ template: "magic_link_otp_verification", to: "user@example.org", idempotencyKey: "signup-42", tags: [{ name: "campaign", value: "welcome" }], variables: { userName: "Ada", otpCode: "482913", signInUrl: "https://app.example.com/in" } });
if (error) console.error(error.code, error.preflight?.gate); // nothing was sent when code === "preflight_blocked"
else console.log(data.messageId, data.receipt); // evw_… receiptAlso emails.batch (up to 100), emails.preview, stats.get, events.list, suppressions.*, receipts.verify, ledger.verify. The full REST surface is described by an OpenAPI 3.1 document at /api/openapi.json.
One install, many brands
sealpost domain add news.example.org --from news@news.example.org --name "Acme News" --selector n1
sealpost domain check # live SPF / DKIM / DMARC lookup for every domain
sealpost send workspace_team_invitation --to you@example.org --auto-fill --domain news.example.orgEvery domain has its own From identity, DKIM selector and key, and its mail is verified end to end at the sink with the right key.
Audiences and broadcasts
sealpost audience create "Customers" && sealpost audience import aud_… people.csv
sealpost broadcast create "Launch" --audience aud_… --template workspace_team_invitation --at 2026-11-01T09:00:00Z --rate 300Scheduled, rate-limited, restart-safe sends to a list, with suppressed contacts skipped automatically. See Audiences and broadcasts.
Webhooks to your own app
sealpost webhook add app --url https://app.example.com/hooks/sealpost --secret-env SEALPOST_WHSECemail.delivered, email.bounced, email.complained, email.opened, email.clicked, broadcast.completed and more, HMAC-signed, persisted and retried for hours. See Webhooks.
SDKs
| Language | Install | Notes |
|---|---|---|
| TypeScript / JavaScript | npm i @sealpost/sdk |
Node 18+, browsers, Deno, Bun, edge |
| Ruby 2.6+ |
gem install sealpost (source: sdks/ruby) |
standard library only |
| PHP 7.4+ |
composer require sealpost/sealpost (source: sdks/php) |
needs ext-curl; not yet run by its author |
| Python 3.8+ |
pip install sealpost (source: sdks/python) |
standard library only |
| Go 1.20+ | go get github.com/isagarpinninti/sealpost/sdks/go |
standard library only |
Close the feedback loop
Point your relay's webhooks at Sealpost and bounces and complaints suppress recipients automatically:
export SEALPOST_WEBHOOK_SECRET="$(openssl rand -hex 24)"
# SES (via SNS HTTPS), SendGrid, Postmark and Mailgun are supported
# POST https://mail.yourdomain.com/hooks/relay/<ses|sendgrid|postmark|mailgun>?token=$SEALPOST_WEBHOOK_SECRETGET /api/stats then reports sent, opens, clicks, bounces, complaints and the rates.
Deploy with Docker
export SEALPOST_API_KEY="$(openssl rand -hex 24)"
docker compose up -d # http://localhost:4320, signed in with SEALPOST_API_KEYThe first start scaffolds a project in a volume. Back that volume up: it holds your DKIM private keys. (The Dockerfile and compose file have not been exercised on a Docker host yet.)
Use it in a React app
import { VisualEmailStudio, DeliverabilityPreflightGate } from "@sealpost/react";
import "@sealpost/react/styles.css";
import { evaluateDispatchPreflight, TEMPLATE_PACKS } from "@sealpost/core";See docs/CLI_AND_EMBEDDING.md for the full props and a working example.
Use the engine from code
import { evaluateDispatchPreflight, composeSignedMessage, createDkimKeyPair } from "@sealpost/core";
// evaluateDispatchPreflight(request, { template, domain }) -> composeSignedMessage({ preflight, domain, privateKey, … })composeSignedMessage is the same function the CLI and the browser demo use: preflight, MIME, DKIM.
Docs
- Getting started: ten minutes from zero to a sent email, then your own domain
-
Guide: a walkthrough from
initto production - Docs index: every document by task
-
Real-world test: prove it against Gmail and Outlook with
sealpost doctorandsealpost auth-check - Custom domain setup: SPF, DKIM, DMARC, MX and real mailboxes
- MIME, DKIM and SMTP spec: exactly what bytes go on the wire
- CLI and embedding: every command, the REST API, the React primitives
- Postgres storage, SMTP submission, Sealpost for AI assistants, Importing templates, Receiving email, Audiences and broadcasts, Webhooks
- Publishing, Contributing, Security, Changelog
Deploy
export SEALPOST_API_KEY="$(openssl rand -hex 24)"
docker compose up -d # http://localhost:4320Put it behind HTTPS and back up the data volume (it holds your DKIM private keys). See the Guide and Postgres storage for multi-instance setups.
Security notes
- STARTTLS is never silently downgraded; credentials are refused over plaintext to non-private hosts.
- SMTP passwords are never stored. A mailbox names an environment variable (
passwordEnv) instead. - Private and loopback targets are refused unless
allowPrivateNetworksis set (SSRF guard, enforced for IP literals and resolved hostnames). - Studio refuses to bind a non-loopback address without an API key. Click tracking redirects only to URLs recorded for that message.
- Keys live in
.sealpost/with mode0600. That directory is git-ignored.
Honest limits
- The browser-only demo cannot open SMTP sockets; delivery there is captured locally and flagged simulated. Its DKIM key lives in your browser's IndexedDB.
- Deliverability still depends on your domain's reputation and DNS. Sealpost gets the protocol side right; it cannot warm an IP for you.
- Storage is document-style (files on disk or in Postgres), which suits tens of thousands of records rather than millions.
- It is a sender, not a mailbox host: there are no mailboxes or IMAP, inbound mail has no SPF/DMARC evaluation or spam scoring, and broadcasts are a sender-side scheduler, not a marketing suite (no segmentation, A/B tests or drag-and-drop campaign analytics).
- Sealpost does not own sending IPs. Inbox placement depends on the relay and domain reputation you bring; there is no hosted dashboard, multi-tenant accounts or compliance certification.
- Relay webhooks are authenticated by a shared token. SNS message signatures are not verified, and SNS subscription confirmation URLs are surfaced, never fetched automatically.
Develop
npm install
npm run ci # build packages, studio, lint, typecheck, test, smoke
npm run dev # Studio on http://localhost:3000 (local demo mode)Project layout
packages/core compiler, gate, MIME, DKIM, receipts (zero dependencies)
packages/cli the `sealpost` binary, REST API, SMTP engine, schedulers
packages/react embeddable Studio components
packages/sdk TypeScript client
sdks/ Python, Go, Ruby and PHP clients
src/ Studio (Next.js, exported statically into the CLI)
docs/ examples/ scripts/
License
MIT © Sagar Pinninti. See LICENSE.