Documentation
Rail is a transactional email relay with mTLS authentication, built-in DNS, DKIM signing, and webhook-based inbound delivery.
Opt-in browser signup requires initial admin approval and domain ownership proof before application credentials become usable. Renewal also requires approval, durable enrollment, and activated sender ownership.
Overview
Architecture
# Outbound (your app → recipient)
Your App → :465 (mTLS) → Rail → MX delivery + DKIM signing
# Inbound (recipient → your app)
Internet MTA → :25 (STARTTLS) → Rail → Webhook POST to your app
Ports
465 SMTP submission (mTLS, client cert required)
25 Inbound SMTP (STARTTLS, receives mail from the internet)
80 HTTP (ACME challenges, redirect to HTTPS)
443 HTTPS (management API, cert inspection, stats)
53 DNS (authoritative: MX, SPF, DKIM, DMARC)
Client Certificates
Issuance
# Issue a client cert with sender addresses and webhook URL
rail certs issue \
--config /etc/rail/rail.yaml \
--name myapp \
--email noreply@ataca.io \
--email alerts@smtp.ataca.io \
--webhook https://myapp.com/hooks/email
What's in the cert
CN Client name (e.g. "myapp") — used for audit logging
Email SANs Allowed sender addresses — MAIL FROM must match one of these
URI SANs Webhook URL (https://) — where inbound mail is delivered
Sender Authorization
# The From address must match an email SAN in the client cert.
# Local part is case-sensitive, domain is case-insensitive (RFC 5321).
# Cert has: noreply@ataca.io, alerts@smtp.ataca.io
MAIL FROM:<noreply@ataca.io> # ✓ allowed
MAIL FROM:<alerts@smtp.ataca.io> # ✓ allowed
MAIL FROM:<other@ataca.io> # ✗ 550 sender not authorized
API Reference
POST /api/v1/send — send email (mTLS required)
curl --cert client.crt --key client.key \
-X POST https://smtp.ataca.io/api/v1/send \
-H "Content-Type: application/json" \
-d '{
"request_id": "unique-id-123",
"from": "noreply@ataca.io",
"to": ["user@example.com"],
"subject": "Your order shipped",
"body_text": "Your package is on the way.",
"unsubscribe_url": "https://ataca.io/unsubscribe?id=123"
}'
# Response (202 Accepted):
{"id": "01KMT...", "status": "queued", "recipients": 1}
Optional fields:
reply_to (override VERP reply address),
body_html (HTML body part; when omitted, an HTML alternative is auto-generated from body_text),
cc (CC recipients),
unsubscribe_url (injects List-Unsubscribe and
List-Unsubscribe-Post: List-Unsubscribe=One-Click per RFC 8058; DKIM-signed),
attachments (array of depot file ids from
POST /api/v1/upload-grants, max 20; each resolves to a download link appended to the body; requires depot to be configured),
files (array of {filename, content_type, content} with content as padded base64;
counts with attachments toward the limit of 20; rail uploads each to depot and links it; requires smtp.attachments.enabled and depot; the call waits for upload and scan; a retry with the same request_id uploads nothing;
errors attachment_too_large 413 and attachment_rejected 422).
POST /api/v1/upload-grants — mint a depot upload grant (mTLS required)
# Depot scopes an upload to the certificate that made it, so a file you
# upload yourself is invisible to rail. Mint a grant on rail's identity,
# upload through it (no cert needed there), then pass the id in "attachments".
# Also requires depot's own grants.enabled (which itself needs scan.enabled).
# max_files defaults to 1, max 10 (depot's grants.max_files may be lower).
curl --cert client.crt --key client.key \
-X POST https://smtp.ataca.io/api/v1/upload-grants \
-H "Content-Type: application/json" -d '{"max_bytes": 10485760}'
# Response (201): {"upload_url":"https://depot.ataca.io/u/<token>","expires_at":"...","max_files":1,"max_bytes":10485760}
curl -X POST "$UPLOAD_URL?filename=report.pdf" \
-H "Content-Type: application/pdf" --data-binary @report.pdf
# Response: {"id": "f_123"} -- the grant expires 15 minutes after minting
GET /api/v1/messages — list your own messages (mTLS required)
# Newest first, scoped to your certificate's CN -- never to a parameter.
# Filters: status, sender, recipient, since, until. limit defaults to 50, max 200.
curl --cert client.crt --key client.key \
"https://smtp.ataca.io/api/v1/messages?status=bounced&limit=20"
# Response:
{"messages": [{"id": "01jx...", "status": "bounced",
"from": "noreply@ataca.io", "to": ["user@example.com"],
"size": 2048, "created_at": "2026-09-19T10:00:00Z",
"attachments": 0}],
"next_before": "01jw..."}
# Keyset paging: pass next_before back as ?before= for the next page.
# It is absent on the last page.
# open_summary appears once a message is opened: {"total","client","proxy","prefetch","unknown"}.
GET /api/v1/messages/{id} — one of your messages (mTLS required)
# A list row plus updated_at; attachments lists the files instead of counting them.
# Each entry: filename, content_type, size, and verdict (absent for a form's files).
curl --cert client.crt --key client.key \
https://smtp.ataca.io/api/v1/messages/01jx...
GET /who (Accept: application/json) — inspect your client cert (mTLS required)
curl --cert client.crt --key client.key \
https://smtp.ataca.io/who
# Response:
{"client_id": "myapp", "senders": ["noreply@ataca.io"],
"valid": true, "key_type": "ed25519",
"expires_in_days": 41, "renew_recommended": false, ...}
# expires_in_days is negative once expired; renew_recommended turns true
# inside pki.renew_window (default 720h). Both fields are on POST /who too.
POST /who — inspect any cert (no auth; anonymous responses omit the valid/revoked verdicts)
curl -X POST --data-binary @client.crt \
https://smtp.ataca.io/who
POST /api/v1/certs/renew — renew your own certificate (mTLS required)
# CSR for a new key, never a key. Copies your CN, email SANs and webhook URI
# SANs onto the new cert. Must be your current cert (403 renew_not_current),
# not disabled (403 cert_invalid), and the CSR must carry a new key (400).
# At least pki.renew_min_interval old, default 24h (429 renew_too_soon).
openssl req -new -key new.key -subj /CN=myapp -out r.csr
jq -Rs '{csr_pem: .}' r.csr | curl --cert client.crt --key client.key \
-H 'Content-Type: application/json' -d @- https://smtp.ataca.io/api/v1/certs/renew
# → 200 {"cert_pem":"...","serial":"...","not_after":"...","previous_serial":"...","active_certs":2}
POST /certs/renew — renew an already-expired certificate (no client cert)
# Port 443 rejects an expired client cert at the handshake. cert_pem is your
# expired certificate; csr_pem's key must match cert_pem's key (the opposite
# of /api/v1/certs/renew) -- your only proof of identity here. Must be
# within pki.renew_grace of expiry, default 168h. Every refusal reason
# answers identically: 400 invalid_request.
jq -n --rawfile c old.crt --rawfile r r.csr '{cert_pem:$c, csr_pem:$r}' | curl \
-H 'Content-Type: application/json' -d @- https://smtp.ataca.io/certs/renew
POST /f/{token} — public form submission (opt-in, no cert)
# HTML form posts (urlencoded/multipart) become email to the form target's
# recipients. Created with `rail forms create`; special fields _replyto,
# _subject, _redirect, _gotcha (honeypot). JSON with Accept: application/json.
# With forms captcha configured, spam-likely posts get a Turnstile challenge
# (HTML interstitial, or 403 challenge_required for JSON callers).
curl -d 'name=Alice&message=hi' https://smtp.ataca.io/f/01jx…
POST/GET /api/v1/forms, GET/PATCH/DELETE /api/v1/forms/{token} — manage form targets (mTLS)
# Create (from must match a cert email SAN) → 201 with the public token.
# "cc_submitter": true CC's the submitter's email/_replyto address a copy.
# "field_labels": {"first_name":"First Name"} sets display labels for the
# rendered email; unlisted fields auto-prettify (first_name → First Name).
# "uploads": {"max_files":3,"max_bytes":10485760,"content_types":["application/pdf"]}
# accepts files via depot (requires depot:): the page POSTs /f/{token}/upload-grant,
# uploads to the returned upload_url, submits the ids as _attachment fields.
curl --cert client.crt --key client.key -X POST https://smtp.ataca.io/api/v1/forms \
-H 'Content-Type: application/json' -d '{"name":"contact","from":"noreply@…","to":["you@…"],"subject":"Contact"}'
# PATCH updates one target: send only the fields to change (absent = keep,
# ""/[]/{} = clear); from/token/client_id are immutable and ignored if sent.
curl --cert client.crt --key client.key -X PATCH https://smtp.ataca.io/api/v1/forms/01jx… \
-H 'Content-Type: application/json' -d '{"rate_limit":60,"enabled":false}'
GET /api/v1/forms/{token}/submissions, GET .../submissions/{id} — persisted submissions (mTLS)
# Every ACCEPTED submission is stored (honeypot/challenged/rate-limited/
# all-suppressed ones never are). Newest-first; ?before=<id> keyset-pages,
# ?limit= default 50/max 100. Owner-scoped like the form target — a
# token or id you don't own answers 404.
curl --cert client.crt --key client.key https://smtp.ataca.io/api/v1/forms/01jx…/submissions
# ?format=csv (or Accept: text/csv) streams every submission as CSV: columns
# are id,received_at,outcome,source_ip,user_agent,origin,subject,reply_to,
# then one column per field name. UTF-8 BOM + CRLF; every cell is
# OWASP-guarded against CSV injection.
curl --cert client.crt --key client.key 'https://smtp.ataca.io/api/v1/forms/01jx…/submissions?format=csv' -o submissions.csv
GET /healthz, /readyz, /stats
curl https://smtp.ataca.io/healthz # 200 "ok"
curl https://smtp.ataca.io/readyz # 200 or 503 (draining)
curl https://smtp.ataca.io/stats # JSON: uptime, delivered, rate, latency
GET /dashboard
# Opt-in browser dashboard: password login (admin or client CN),
# stats overview + per-client message/delivery views.
Inbound Email
How it works
# Internet MTA connects to port 25 with STARTTLS
# RCPT TO must be a local domain — non-local is rejected (no open relay)
# Message is stored and POSTed to the client's webhook URL
# Local domains are configured in rail.yaml:
local_domains: [ataca.io, smtp.ataca.io]
# Additional domains auto-discovered from client cert email SANs
# Webhook URL from client cert URI SAN (set at cert issuance)
Webhook payload
# POST to your webhook URL with JSON body:
{
"message_id": "01KMT1GZ...",
"from": "sender@gmail.com",
"to": ["noreply@ataca.io"],
"subject": "Re: Your order shipped",
"raw_message": "<base64-encoded RFC 5322 message>",
"received_at": "2026-03-28T12:00:00Z"
}
# A domain with attachment stripping enabled (rail domains attachments --strip)
# removes attachment parts from raw_message and adds "files": [{id, name, size, content_type, status, url}]
# 2xx = delivered, 4xx = permanent failure, 5xx = retry with backoff
Webhook payload (bounce)
# When a message you sent hard-bounces, rail POSTs a bounce notification.
# message_id is the ORIGINAL message; no raw_message. Soft bounces send nothing.
{
"type": "bounce",
"message_id": "01KMT1GZ...",
"received_at": "2026-03-28T12:00:00Z",
"original_message_id": "01jw...",
"bounced_recipient": "user@example.com",
"bounce_status": "5.1.1",
"bounce_diagnostic": "smtp; 550 5.1.1 User unknown",
"bounce_type": "hard"
}
Webhook payload (complaint)
# A verified spam complaint (ARF report MAC-verified against the message's VERP
# tags) POSTs a complaint notification and auto-suppresses the recipient.
# message_id is a unique event id; no raw_message.
{
"type": "complaint",
"message_id": "01KMT1GZ...",
"received_at": "2026-03-28T12:00:00Z",
"original_message_id": "01jw...",
"complained_recipient": "user@example.com",
"feedback_type": "abuse",
"complaint_source": "arf"
}
Sending Examples
Send email via SMTP or the HTTP API. Both require a client certificate (mTLS).
Go
SMTP
import (
"crypto/tls"
"net/smtp"
)
func sendMail() error {
cert, _ := tls.LoadX509KeyPair("client.crt", "client.key")
conn, _ := tls.Dial("tcp", "smtp.ataca.io:465", &tls.Config{
Certificates: []tls.Certificate{cert},
})
c, _ := smtp.NewClient(conn, "smtp.ataca.io")
c.Mail("noreply@ataca.io")
c.Rcpt("user@example.com")
w, _ := c.Data()
w.Write([]byte("Subject: Hello\r\n\r\nHi!"))
w.Close()
return c.Quit()
}
HTTP API
cert, _ := tls.LoadX509KeyPair("client.crt", "client.key")
client := &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
}}
body := `{"from":"noreply@ataca.io",` +
`"to":["user@example.com"],` +
`"subject":"Hello","body_text":"Hi!"}`
resp, _ := client.Post(
"https://smtp.ataca.io/v1/send",
"application/json",
strings.NewReader(body),
)
Python
SMTP
import smtplib, ssl
ctx = ssl.create_default_context()
ctx.load_cert_chain("client.crt", "client.key")
with smtplib.SMTP_SSL("smtp.ataca.io", 465, context=ctx) as s:
s.sendmail(
"noreply@ataca.io",
["user@example.com"],
"Subject: Hello\r\n\r\nHi!",
)
HTTP API
import requests
resp = requests.post(
"https://smtp.ataca.io/v1/send",
cert=("client.crt", "client.key"),
json={
"from": "noreply@ataca.io",
"to": ["user@example.com"],
"subject": "Hello",
"body_text": "Hi!",
},
)
Certificate Inspection
mTLS GET — inspect your own client certificate
# Identify yourself: rail inspects the client cert you present
curl --cert client.crt --key client.key \
https://smtp.ataca.io/who
PEM POST — inspect any certificate by uploading it
# Upload a .crt or .pem file; no client cert required.
# Anonymous responses omit the valid/revoked verdicts (mTLS shows them).
curl -X POST --data-binary @client.crt \
https://smtp.ataca.io/who
Rust
SMTP
use lettre::{Transport, SmtpTransport, Message};
use lettre::transport::smtp::client::TlsParameters;
let email = Message::builder()
.from("noreply@ataca.io".parse()?)
.to("user@example.com".parse()?)
.subject("Hello")
.body("Hi!".to_string())?;
// Load client cert for mTLS
let tls = TlsParameters::builder("smtp.ataca.io".into())
.add_root_certificate(ca_cert)
.identity(client_identity)
.build()?;
let mailer = SmtpTransport::relay("smtp.ataca.io")?
.port(465)
.tls(lettre::transport::smtp::client::Tls::Wrapper(tls))
.build();
mailer.send(&email)?;
HTTP API
let client = reqwest::Client::builder()
.identity(identity) // mTLS client cert
.build()?;
let resp = client.post("https://smtp.ataca.io/v1/send")
.json(&serde_json::json!({
"from": "noreply@ataca.io",
"to": ["user@example.com"],
"subject": "Hello",
"body_text": "Hi!",
}))
.send().await?;