Documentation
Rail is a transactional email relay with mTLS authentication, built-in DNS, DKIM signing, and webhook-based inbound delivery.
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).
GET /api/who — inspect your client cert (mTLS required)
curl --cert client.crt --key client.key \
https://smtp.ataca.io/api/who
# Response:
{"client_id": "myapp", "senders": ["noreply@ataca.io"],
"valid": true, "key_type": "ed25519", ...}
POST /api/who — inspect any cert (no auth; anonymous responses omit the valid/revoked verdicts)
curl -X POST --data-binary @client.crt \
https://smtp.ataca.io/api/who
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).
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
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"
}
# 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/api/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/api/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?;