Start here
Mail Platform
A self-hosted transactional email service. Customers connect their own domains through DNS, get an API key, and send application email through a REST API — with delivery tracking, bounce handling, suppression lists and webhooks.
await mail.emails.send({
from: "hello@example.com",
to: "customer@gmail.com",
subject: "Welcome",
html: "<h1>Welcome!</h1>",
});
This is not "install Postfix and send email". Whether your mail reaches an inbox depends on correct SPF, DKIM and DMARC, a PTR record that matches your EHLO name, a clean IP, opportunistic TLS, low bounce and complaint rates, and a sending reputation built over weeks. The platform automates the parts that can be automated and tells you plainly about the parts that cannot. Before deploying, confirm your VPS provider permits outbound port 25 — most block it by default and Google Cloud blocks it permanently. See docs/deployment.md.
Architecture
┌──────────────────────┐
│ Dashboard │
│ Next.js 16 / TS │
└──────────┬───────────┘
│ HTTPS
▼
┌──────────────────────┐
│ Mail API │
│ Fastify 5 / TS │
└──────────┬───────────┘
┌─────────┴─────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis + Bull │
│ (Prisma) │ │ MQ │
└──────────────┘ └──────┬───────┘
┌───────┴────────┐
▼ ▼
┌────────────┐ ┌────────────┐
│Mail Worker │ │Mail Worker │
└─────┬──────┘ └─────┬──────┘
└────────┬───────┘
▼ DKIM-signed message
┌──────────────────┐
│ Postfix │◀── bounce reports
│ SMTP / MTA │ (port 25)
└────────┬─────────┘
▼
Gmail · Outlook · Yahoo · …
Three things are worth knowing about how this fits together:
The API never speaks SMTP. POST /v1/emails validates, checks the domain,
checks limits, writes a row, enqueues a job and returns 202. Remote delivery
takes seconds to minutes; that must never be your request's latency.
DKIM signing happens in the worker, not in the MTA. Each customer domain has its own key, encrypted at rest, decrypted in memory at signing time. The usual alternative — OpenDKIM with a key table — means plaintext private keys on the mail server's filesystem and an MTA reload every time a customer adds a domain.
Delivery confirmation comes from the MTA's own log. Postfix returns a queue
ID when it accepts a message; the worker tails mail.log and matches each
status=sent / bounced / deferred line back to the message. sent means
the MTA took it; delivered means the recipient's server did.
Quick start
Requires Docker and Node 20+.
git clone <this repo> mail-platform
cd mail-platform
cp .env.example .env
# Generate the four secrets:
for n in DKIM_PRIVATE_KEY_ENCRYPTION_KEY JWT_SECRET WEBHOOK_SIGNING_SECRET INTERNAL_API_TOKEN; do
printf '%s=%s\n' "$n" "$(openssl rand -hex 32)"
done
# Paste them into .env, and set POSTGRES_PASSWORD.
docker compose up -d --build
docker compose ps # everything should report healthy
- Dashboard → http://localhost:3000
- API → http://localhost:4000
- Health → http://localhost:4000/ready
The developer path
1. Create an account → /register
2. Add a domain → /dashboard/domains
3. Publish the DNS records → four records, generated for your domain
4. Click Verify DNS → SPF and DKIM must pass
5. Create an API key → shown once
6. Send → POST /v1/emails
Local development without Docker
npm install
npm run build # workspace packages must be built before the apps run
docker compose up -d postgres redis # or point DATABASE_URL/REDIS_URL elsewhere
npm run db:deploy
npm run dev:api # :4000
npm run dev:worker # :4001
npm run dev:dashboard # :3000
Project layout
apps/
api/ Fastify REST API — auth, domains, keys, sending, stats
worker/ BullMQ processors + the Postfix log tailer
dashboard/ Next.js 16 App Router dashboard
packages/
database/ Prisma schema, client, migrations
shared/ env, crypto, errors, logging, DNS record generation + checks
validation/ Zod schemas shared by the API and the dashboard
email-sdk/ @yourservice/node — the published client
infrastructure/
postfix/ MTA image, main.cf template, bounce pipe
nginx/ Reverse proxy templates, TLS example
docker/ Application Dockerfiles
dkim/ Key handling notes (no keys — they live encrypted in the DB)
docs/ API, DNS, deployment, operations, security, testing
tests/ Unit (no infra) and integration (Postgres + Redis)
What's implemented
Accounts — registration, login, scrypt password hashing, JWT sessions, audit log, role-based authorization, per-account suspension.
Domains — add a domain, get a per-domain DKIM key pair and four generated DNS records; verify SPF, DKIM, DMARC and the return path with specific diagnostics for each failure mode (missing, duplicate SPF, key mismatch, malformed, lookup failure); periodic re-verification that demotes a domain whose records disappear.
API keys — created from the dashboard only, shown once, stored as SHA-256, revocable, with last-used tracking.
Sending — POST /v1/emails with html/text, cc/bcc, reply-to, custom
headers, tags, metadata, scheduling and up to ten base64 attachments (with cid
for inline images). Validation, domain verification, suppression and quota checks
all happen before anything is queued.
Sender branding — per-tenant logo and footer, injected at send time. The footer is written separately for the HTML and plain-text parts, since the right markup for each differs; either can be left empty and is derived from the other. Note this is the logo inside the message; the avatar Gmail draws beside the sender name is BIMI, which needs a paid VMC.
Queues — email-send, email-retry, bounce-processing,
dns-verification, webhook-delivery. Exponential backoff, permanent-failure
detection that stops retrying 5xx, and a staged slow-retry lane for a sick MTA.
Delivery tracking — eight lifecycle statuses, a per-message event timeline, per-recipient outcomes, and the raw SMTP response.
Bounces — RFC 3464 DSN and RFC 5965 ARF parsing, hard/soft/complaint classification, automatic suppression on permanent failure, soft-bounce thresholds.
Suppressions — per-tenant, enforced at send time and re-checked in the worker in case the list changed while the message was queued.
Abuse prevention — layered rate limits (IP, user, domain, API key), conservative new-account allowances, per-recipient quota accounting, sender domain verification, configurable disposable/free-mailbox controls, and abuse signals for bounce rate, complaint rate, failure rate, volume spikes and recipient repetition.
Receiving — per-domain inbound mail on a subdomain by default, so a customer's existing mailbox is never diverted. Messages are parsed, stored with their original bytes and attachments, and reported with SPF/DKIM/DMARC verdicts rather than being silently filtered.
Webhooks — six events, HMAC-SHA256 signatures with a replay window, retries with backoff, automatic disabling of dead endpoints, delivery history.
Dashboard — overview with a delivery ledger, inbox and compose, sent log and message detail, domains with a zone-file view of the records, API keys, suppressions, webhooks, deliverability checklist, branding, profile and settings.
Operations — liveness and readiness probes, Prometheus metrics, structured JSON logs with secret redaction, health checks on every container.
Documentation
The running API also publishes all of this as plain Markdown, unauthenticated — handy for integrators without a login, and for coding agents:
https://mail-api.scanmypass.com/docs index
https://mail-api.scanmypass.com/docs/api any document by slug
https://mail-api.scanmypass.com/llms.txt llmstxt.org map for language models
| docs/api.md | Full API reference with cURL, JavaScript, Node, Python and PHP examples. |
| docs/dns.md | What each record does, how to publish it, and every common failure. |
| docs/deployment.md | VPS deployment, port 25, PTR, TLS, IP warm-up, backups, scaling. |
| docs/operations.md | Health checks, metrics, alerts, logs, abuse response, runbook. |
| docs/security.md | Threat model, secret handling, multi-tenancy, input validation. |
| docs/testing.md | Running the suites and what each one covers. |
| packages/email-sdk/README.md | The Node SDK. |
Commands
npm run build # all workspace packages and apps
npm run typecheck # tsc --noEmit everywhere
npm run lint
npm test # unit tests; integration too when TEST_DATABASE_URL is set
npm run db:migrate # create a migration from schema changes
npm run db:deploy # apply pending migrations
npm run db:studio # Prisma Studio
npm run docker:up
npm run docker:logs
npm run docker:down
Configuration
Every setting is an environment variable; see .env.example for the annotated list. The ones that decide how the platform presents itself:
| Variable | Purpose |
|---|---|
MAIL_DOMAIN | Your service's domain. Appears in Message-IDs. |
SMTP_HOSTNAME | EHLO name. Must match the PTR record of the sending IP. |
SPF_INCLUDE_HOST | What customers put in their include:. Add sending IPs here, not to customer DNS. |
BOUNCE_HOST | Where bounce reports are addressed. Needs an MX pointing at your server. |
DKIM_PRIVATE_KEY_ENCRYPTION_KEY | Encrypts every DKIM private key. Not recoverable — back it up separately from the database. |
Licence
MIT.