# rail
> Transactional email server with mTLS authentication
rail is a pure-Go email server for service-to-service transactional email
(notifications, alerts, receipts). Not for bulk marketing.
## Auth
mTLS client certificates. No API keys. The certificate encodes:
- CN: client identity
- Email SANs: allowed sender addresses
- URI SANs: webhook URLs for inbound delivery
## Send email
POST /api/v1/send (mTLS required)
```json
{
"from": "agent@smtp.ataca.io",
"to": ["user@example.com"],
"subject": "Hello",
"body_text": "Plain text body",
"body_html": "
HTML body
"
}
```
Response: `{"id": "01jx...", "recipients": 1, "status": "queued"}`
Optional fields: request_id, cc, reply_to, unsubscribe_url, body_html.
When body_html is omitted, rail auto-generates an HTML alternative from body_text (multipart/alternative).
Also accepts SMTP on port 465 (implicit TLS, same mTLS cert).
## Receive email
Inbound mail to local domains is POSTed to webhook URLs as JSON:
```json
{
"type": "inbound",
"message_id": "01jx...",
"from": "sender@example.com",
"to": ["agent@smtp.ataca.io"],
"subject": "Re: Hello",
"raw_message": "",
"received_at": "2025-01-15T10:30:00Z"
}
```
Reply tracking: omit reply_to and rail injects a VERP address. Replies
arrive as type "reply" with in_reply_to set to the original message ID.
Hard bounces of messages you sent arrive as type "bounce" (no raw_message;
message_id is a unique bounce-event id, original_message_id correlates), with bounced_recipient, bounce_status
(e.g. "5.1.1"), bounce_diagnostic, and bounce_type "hard". The bounced
recipient is also auto-suppressed. Soft bounces do not fire a webhook.
Verified spam complaints against messages you sent arrive as type "complaint"
(no raw_message; message_id is a unique complaint-event id,
original_message_id correlates), with complained_recipient, feedback_type
(e.g. "abuse"), and complaint_source "arf". Fires when an ARF feedback report
to rail's abuse address MAC-verifies against the message's VERP tags; the
complained recipient is also auto-suppressed. Unverified reports and plain
abuse mail are recorded for review only and do not fire a webhook.
## Form submissions (public, opt-in)
POST /f/{token} — no client certificate. Accepts HTML form posts
(urlencoded or multipart) for a pre-configured form target and delivers
them as email to its recipients. Special fields: _replyto, _subject,
_redirect (origin-allowlisted), _gotcha (honeypot — leave empty).
Send "Accept: application/json" for a JSON response `{"ok":true,"id":"01jx..."}`;
browser posts get a 303 redirect. Only available when the operator enabled
forms. Targets are created with `rail forms create` or self-served over mTLS:
POST /api/v1/forms {"name","from","to","subject","redirect_url",
"allowed_origins","rate_limit","cc_submitter","field_labels"} → 201 with the
token; GET /api/v1/forms lists your targets; GET/PATCH/DELETE /api/v1/forms/{token}
(another client's token is 404). PATCH takes the same field set as POST, all
optional: an absent field keeps its current value, an explicit ""/[]/{}
clears it; from/token/client_id are immutable and ignored if sent. cc_submitter: true CC's the submitter's
email/_replyto address a copy (rate-limited per address, suppression-checked).
field_labels maps a submitted field name to a display label (operator-only,
set at create time); unlisted fields auto-prettify (first_name -> First Name).
When the operator configures forms captcha, a spam-likely submission is
answered with a challenge instead of being accepted: JSON callers get 403
{"ok":false,"error":"challenge_required","challenge":{"provider":"turnstile",
"site_key","nonce"}} — render the Turnstile widget, then re-POST the same
fields plus _challenge (nonce) and cf-turnstile-response (widget token).
Browser posts get a 200 HTML interstitial hosting the widget.
Every accepted submission (one that produced an outbound email — honeypot,
challenged, rate-limited, and all-suppressed submissions are never stored)
is persisted: GET /api/v1/forms/{token}/submissions lists them newest-first
(?before= keyset cursor, ?limit= default 50/max 100), and
GET /api/v1/forms/{token}/submissions/{id} fetches one. Both are owner-scoped
like the form target itself — 404 for a token or id you don't own.
## Check delivery status
GET /api/v1/messages/{id}/deliveries (mTLS required)
Returns every envelope recipient. Per-recipient status: pending, delivered,
bounced, or untracked (no delivery record, e.g. a local-domain recipient;
carries a note and attempts: 0).
## Endpoints
- POST /api/v1/send — send email (mTLS)
- GET /api/who — inspect your client cert (mTLS)
- GET /api/v1/messages/{id}/deliveries — delivery status (mTLS)
- POST /f/{token} — public form submission (opt-in)
- POST/GET /api/v1/forms, GET/PATCH/DELETE /api/v1/forms/{token} — manage form targets (mTLS)
- GET /api/v1/forms/{token}/submissions, GET .../submissions/{id} — persisted form submissions (mTLS)
- GET /healthz — liveness
- GET /readyz — readiness
- GET /stats — relay statistics (JSON)
- GET /dashboard — browser dashboard (opt-in; password login, not for agents)
- GET /openapi.yaml — OpenAPI 3.1 spec
- GET /agents — human-readable agent integration guide
- GET /.well-known/ai-plugin.json — AI plugin manifest
## Links
- Agent guide: https://smtp.ataca.io/agents
- API docs: https://smtp.ataca.io/docs
- OpenAPI spec: https://smtp.ataca.io/openapi.yaml