A self-hosted SMTP inbox for agents. One command, one VPS, incoming mail on disk as JSON.
Landing page: primitive.dev/mail.
- One command install. Fresh Ubuntu VPS to working inbox in a few minutes.
- Your domain, your IP, or a free
*.primitive.emailsubdomain. Bring DNS or skip it. - Email on disk as canonical JSON. Tailable journal, per-email files, attachments bundled.
- Pull or push.
tail -f emails.jsonl, or HMAC-signed webhooks to your endpoint. - Inbound auth built in. SPF, DKIM, DMARC verification. Sender and recipient allowlists. Optional Spamhaus DNSBL.
- Agent-scriptable installer.
--no-prompt, NDJSON progress on stdout, preflight check.
One-liner on a fresh Linux VPS with a public IPv4 and inbound TCP 25 reachable:
curl -fsSL https://get.primitive.dev | bashPrefer to read the script before running it? Download, inspect, then execute:
curl -fsSL https://get.primitive.dev -o install.sh
less install.sh
bash install.shWant to pin a checksum? get.primitive.dev publishes a sha256sum-compatible hash alongside every served install.sh. They are generated from the same response body, so the hash matches whatever bytes you would have downloaded:
curl -fsSL https://get.primitive.dev -o install.sh && \
curl -fsSL https://get.primitive.dev/install.sh.sha256 | sha256sum -c && \
bash install.shThe && chain is load-bearing: if sha256sum -c exits non-zero (hash mismatch, network error, or a corrupted .sha256 response), bash install.sh never runs. For branch-served installs (get.primitive.dev/<branch>), fetch get.primitive.dev/<branch>/install.sh.sha256 instead.
For agents driving the install in a single pass:
curl -fsSL https://get.primitive.dev | bash -s -- \
--no-prompt --json \
--claim-subdomain \
--event-webhook-url=https://your-endpoint--no-prompt runs non-interactively. --json streams NDJSON progress events on stdout, with human-readable progress on stderr. Redirect them separately (>events.ndjson 2>install.log) if you want stdout to parse cleanly with jq. --claim-subdomain grabs a free *.primitive.email name. --event-webhook-url wires push delivery. Run curl -fsSL https://get.primitive.dev | bash -s -- --preflight first to check RAM, disk, inbound 25, and outbound HTTPS.
Once the installer finishes, send yourself a real external email to confirm the whole pipeline works:
primitive emails testprimitive.dev sends a test email to your claimed subdomain and the CLI waits for it to land in the local journal (default 30s). A successful run exercises DNS, inbound port 25, postfix, the watcher, and the journal writer in one call.
By default, Postfix uses an auto-generated self-signed certificate, which is fine for receiving mail (most senders accept any cert on port 25). If you want a publicly trusted certificate, you have two options.
Let's Encrypt (issued during install):
curl -fsSL https://get.primitive.dev | bash -s -- \
--hostname mx.example.com \
--domain example.com \
--enable-letsencrypt --le-email you@example.comPrerequisites:
- Public DNS A record for
--hostnamepointing at this server's IPv4. - Inbound TCP 80 reachable (HTTP-01 challenge). The installer opens it in UFW or firewalld where active. Cloud security groups are out of scope; open them yourself.
- The hostname in DNS must match
--hostname.
The installer issues the cert with certbot's --standalone plugin, writes LETSENCRYPT_HOST_DIR=/etc/letsencrypt to .env so docker-compose bind-mounts the host's cert tree read-only into the container, sets TLS_CERT and TLS_KEY in .env, and installs a deploy hook at /etc/letsencrypt/renewal-hooks/deploy/reload-postfix.sh that reloads Postfix after every renewal. Renewals run automatically through certbot's systemd timer; no operator action is required.
Automatic renewal is scheduled via a systemd timer (certbot-renew.timer on RPM-based distros, certbot.timer on Debian/Ubuntu), enabled by the installer.
The committed docker-compose.yml is never modified by the installer. The bind mount uses ${LETSENCRYPT_HOST_DIR:-/var/empty} so an unset value mounts an empty directory (a harmless no-op), and git pull updates stay clean across re-installs.
Add --le-skip-verify to skip the post-issuance certbot renew --dry-run (saves 10-20s on slow / CI networks).
Bring your own cert (corporate CA, hand-managed bundle):
curl -fsSL https://get.primitive.dev | bash -s -- \
--tls-cert /opt/certs/fullchain.pem \
--tls-key /opt/certs/privkey.pemMount the cert files into the container yourself, or pass --letsencrypt-host-dir <path> so docker-compose bind-mounts your cert directory at /etc/letsencrypt read-only. The same paths must be readable by the running container.
Re-running install.sh later without --enable-letsencrypt or --tls-cert preserves the existing TLS configuration when the file at TLS_CERT still exists on disk.
PrimitiveMail works fine on AWS for its stated use case. Two things to know:
- Attach an Elastic IP before publishing any address. A claimed
*.primitive.emailsubdomain is anchored to the instance's current public IPv4. Instance stop/start without an Elastic IP rotates the public IP and silently detaches the subdomain; a new install on the new IP gets a different name. - EC2 and Lightsail block outbound TCP 25 by default. This does not affect inbound mail (PrimitiveMail is inbound-only by design), so the receive pipeline works out of the box. It matters only if you later want to send from the same box: AWS requires a port 25 removal request, or you use a relay.
See AGENTS.md for the journal format, file layout, and webhook contract. Full docs at primitive.dev/docs.
MIT. Made by primitive.dev.