Agent Integration Guide
How AI agents send and receive email via rail. Authentication is mTLS client certificates — no API keys, no OAuth, no shared secrets. See also: API docs · OpenAPI spec · ai-plugin.json
Setup
1. Get a client certificate
rail certs issue \
--name my-agent \
--email agent@smtp.ataca.io \
--webhook https://my-agent.example.com/hooks/email
What's in the cert
| Field | Meaning |
|---|---|
CN | Agent identity — appears in logs, rate-limit buckets |
Email SANs | Addresses the agent is allowed to send as |
URI SANs | Webhook URLs where inbound mail is POSTed |
Sending Email — HTTP API (recommended)
POST /api/v1/send (mTLS required)
curl -s --cert my-agent.crt --key my-agent.key --cacert ca.crt \
https://smtp.ataca.io/api/v1/send \
-H "Content-Type: application/json" \
-d '{
"from": "agent@smtp.ataca.io",
"to": ["user@example.com"],
"subject": "Task complete",
"body_text": "Your report is ready.",
"body_html": "<p>Your report is <b>ready</b>.</p>"
}'
# Response (200):
{"id": "01jx...", "recipients": 1, "status": "queued"}
Optional fields
| Field | Purpose |
|---|---|
request_id | Idempotency key, echoed in response |
cc | CC recipients (array) |
reply_to | Override Reply-To (disables VERP tracking) |
unsubscribe_url | Injects List-Unsubscribe per RFC 8058 |
attachments | Depot file ids (max 20), resolved to download links (requires depot) |
Python
import requests
resp = requests.post(
"https://smtp.ataca.io/api/v1/send",
cert=("my-agent.crt", "my-agent.key"),
verify="ca.crt",
json={
"from": "agent@smtp.ataca.io",
"to": ["user@example.com"],
"subject": "Task complete",
"body_text": "Your report is ready.",
},
)
resp.raise_for_status()
print(resp.json()) # {"id": "01jx...", "recipients": 1, "status": "queued"}
Node.js
import { readFileSync } from "fs";
import https from "https";
const resp = await fetch("https://smtp.ataca.io/api/v1/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: "agent@smtp.ataca.io",
to: ["user@example.com"],
subject: "Task complete",
body_text: "Your report is ready.",
}),
agent: new https.Agent({
cert: readFileSync("my-agent.crt"),
key: readFileSync("my-agent.key"),
ca: readFileSync("ca.crt"),
}),
});
console.log(await resp.json());
Go
cert, _ := tls.LoadX509KeyPair("my-agent.crt", "my-agent.key")
caPEM, _ := os.ReadFile("ca.crt")
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)
client := &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
},
}}
body := `{"from":"agent@smtp.ataca.io","to":["user@example.com"],` +
`"subject":"Task complete","body_text":"Your report is ready."}`
resp, _ := client.Post("https://smtp.ataca.io/api/v1/send",
"application/json", strings.NewReader(body))
Sending Email — SMTP (port 465)
Python SMTP (implicit TLS)
import smtplib, ssl
from email.message import EmailMessage
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_cert_chain("my-agent.crt", "my-agent.key")
ctx.load_verify_locations("ca.crt")
msg = EmailMessage()
msg["From"] = "agent@smtp.ataca.io"
msg["To"] = "user@example.com"
msg["Subject"] = "Task complete"
msg.set_content("Your report is ready.")
with smtplib.SMTP_SSL("smtp.ataca.io", 465, context=ctx) as smtp:
smtp.send_message(msg)
Receiving Email
How it works
Sender → internet MTA → rail :25 (STARTTLS) → local domain check → HTTP POST to your webhook
Webhook payload (inbound)
{
"type": "inbound",
"message_id": "01jx...",
"from": "user@example.com",
"to": ["agent@smtp.ataca.io"],
"subject": "Re: Task complete",
"raw_message": "<base64-encoded RFC 5322 message>",
"received_at": "2025-01-15T10:30:00Z"
}
// On a domain with attachment stripping enabled (rail domains attachments --strip),
// raw_message loses its attachment parts and a "files" array lists them instead:
// [{id, name, size, content_type, status: "clean"|"infected"|"skipped", url}]. See docs/webhooks.md#attachments.
Webhook payload (reply — VERP tracked)
{
"type": "reply",
"message_id": "01jy...",
"in_reply_to": "01jx...", // original message ID
"from": "user@example.com",
"to": ["agent@smtp.ataca.io"],
"subject": "Re: Task complete",
"raw_message": "<base64>",
"received_at": "2025-01-15T10:30:00Z",
"original_sender": "agent@smtp.ataca.io",
"original_recipients": ["user@example.com"]
}
Webhook payload (bounce — hard bounce of a sent message)
{
"type": "bounce",
"message_id": "01jx...", // the ORIGINAL message that bounced
"received_at": "2025-01-15T10:30: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"
}
// Only hard (5.x.x) bounces fire a webhook; the recipient is also auto-suppressed.
// Soft bounces are retried by rail and send no webhook.
Webhook payload (complaint — verified spam complaint against a sent message)
{
"type": "complaint",
"message_id": "01jx...", // unique id for this complaint event
"received_at": "2025-01-15T10:30:00Z",
"original_message_id": "01jw...",
"complained_recipient": "user@example.com",
"feedback_type": "abuse",
"complaint_source": "arf"
}
// Fires when an ARF feedback report to rail's abuse address MAC-verifies against
// the message's VERP tags; the recipient is also auto-suppressed. Unverified reports
// and plain abuse mail are recorded for review only, with no webhook.
Python receiver (Flask)
from flask import Flask, request
app = Flask(__name__)
@app.route("/hooks/email", methods=["POST"])
def receive_email():
payload = request.json
print(f"From: {payload['from']}, Subject: {payload['subject']}")
if payload["type"] == "reply":
print(f"Reply to: {payload['in_reply_to']}")
elif payload["type"] == "bounce":
print(f"Bounced: {payload['bounced_recipient']} ({payload['bounce_status']})")
elif payload["type"] == "complaint":
print(f"Complaint: {payload['complained_recipient']} ({payload['feedback_type']})")
return "", 200 # 2xx = delivered; 4xx/5xx = rail retries
Node.js receiver (Express)
import express from "express";
const app = express();
app.use(express.json());
app.post("/hooks/email", (req, res) => {
const { type, from, subject, in_reply_to } = req.body;
console.log(`From: ${from}, Subject: ${subject}`);
if (type === "reply") console.log(`Reply to: ${in_reply_to}`);
res.sendStatus(200);
});
app.listen(3000);
Reply Tracking (VERP)
# When your agent sends without setting reply_to, rail injects:
Reply-To: reply+<msg_id>.<hmac>@smtp.ataca.io
# When the recipient replies:
# 1. Rail receives the reply on :25
# 2. Matches it to the original message via the signed tag
# 3. Delivers to your webhook with type:"reply" and in_reply_to set
# To disable tracking for a specific message, set reply_to explicitly:
{"reply_to": "support@example.com", ...}
Checking Delivery Status
curl -s --cert my-agent.crt --key my-agent.key --cacert ca.crt \
https://smtp.ataca.io/api/v1/messages/01jx.../deliveries
# Returns every envelope recipient's outcome: pending, delivered, bounced,
# or untracked (no delivery record — carries a note and attempts: 0)
Renewing Your Certificate
GET /who — when to renew
# expires_in_days counts whole days to expiry, negative once expired.
# renew_recommended turns true inside pki.renew_window, 30 days by default.
# rail also mails your contact address 7 and 1 days before expiry.
POST /api/v1/certs/renew — CSR for a new key, never a key
openssl genpkey -algorithm ed25519 -out new.key
openssl req -new -key new.key -subj /CN=my-agent -out r.csr
jq -Rs '{csr_pem: .}' r.csr | curl --cert my-agent.crt --key my-agent.key --cacert ca.crt \
-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}
# CN, email SANs and webhook URI SANs are copied onto the new cert unchanged.
# The CSR must carry a new key (400 invalid_request if it reuses the old one).
# The presented cert must be your current one (403 renew_not_current if not),
# the client must not be disabled (403 cert_invalid), and it must be at least
# pki.renew_min_interval old, default 24h (429 renew_too_soon, Retry-After
# set). You hold at most two active certs; a third supersedes the oldest,
# same as `rail certs issue|sign`.
Renewing an Already-Expired Certificate
POST /certs/renew — no client certificate on the connection
# Port 443 rejects an expired client cert at the TLS handshake, so use this
# route instead. cert_pem is your expired certificate; csr_pem's key must
# match cert_pem's key (the opposite of /api/v1/certs/renew) -- that match
# is your only proof of identity here. Must be within pki.renew_grace of
# expiry, default 168h/7 days.
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
# Every refusal (bad key match, wrong CA, revoked, disabled, outside the
# grace window, not actually expired) answers identically: 400
# invalid_request "certificate and CSR do not match or the certificate
# cannot be renewed". Only a too-frequent renewal gets its own 429
# renew_too_soon, reachable only after that proof.
Form Submissions (public endpoint)
POST /f/{token} (no client certificate; opt-in)
# Operator creates a form target bound to your client:
rail forms create --client my-agent --name contact \
--from noreply@smtp.ataca.io --to you@example.com \
--subject "Contact form" --redirect https://example.com/thanks
# → token 01jx… ; any static page can then post to /f/01jx…
Self-serve over mTLS — POST /api/v1/forms
curl --cert my-agent.crt --key my-agent.key --cacert ca.crt \
-X POST https://smtp.ataca.io/api/v1/forms -H 'Content-Type: application/json' \
-d '{"name":"contact","from":"noreply@smtp.ataca.io","to":["you@example.com"],"subject":"Contact form"}'
# → 201 {"token":"01jx…", …}. GET /api/v1/forms lists yours;
# GET/PATCH/DELETE /api/v1/forms/{token} manage one (another client's token is 404).
# "cc_submitter": true (CLI: --cc-submitter) CC's the submitter a copy —
# rate-limited per address across all forms, suppression-checked.
# "field_labels": {"first_name":"First Name"} (CLI: repeatable --label
# name=Label) sets display labels; unlisted fields auto-prettify.
# "uploads": {"max_files":3,"max_bytes":10485760,"content_types":["application/pdf"],"ttl":"168h"}
# accepts files via depot: the page POSTs /f/{token}/upload-grant, uploads to
# the returned upload_url, and submits the ids as _attachment fields. rail
# links only files depot reports clean; a file part in the post is 422.
# Spam-likely posts may answer 403 challenge_required (Turnstile site_key +
# nonce): render the widget, re-POST with _challenge + cf-turnstile-response.
Partial update — PATCH /api/v1/forms/{token}
curl --cert my-agent.crt --key my-agent.key --cacert ca.crt \
-X PATCH https://smtp.ataca.io/api/v1/forms/01jx… -H 'Content-Type: application/json' \
-d '{"rate_limit":60,"enabled":false}'
# → 200 {"token":"01jx…", "rate_limit":60, "enabled":false, …}
# Send only the fields to change: an absent key keeps the current value,
# an explicit ""/[]/{} clears it. from/token/client are immutable and
# ignored if sent -- from stays bound to the cert that created the form.
HTML embed
<form action="https://smtp.ataca.io/f/01jx…" method="POST">
<input name="name"><input name="email"><textarea name="message"></textarea>
<input name="_gotcha" style="display:none"> <!-- honeypot: keep empty -->
<button>Send</button>
</form>
| Special field | Effect |
|---|---|
_replyto | Reply-To (falls back to a field named email) |
_subject | Subject override (line breaks rejected) |
_redirect | Redirect override — origin-allowlisted only |
_gotcha | Honeypot — a filled value drops the submission |
Reviewing submissions — GET /api/v1/forms/{token}/submissions
curl --cert my-agent.crt --key my-agent.key --cacert ca.crt \
https://smtp.ataca.io/api/v1/forms/01jx…/submissions
# → 200 {"submissions":[{"id":"01k…","fields":[…],"outcome":"clean",…}],"count":1}
# Every ACCEPTED submission is persisted (honeypot/challenged/rate-limited/
# all-suppressed ones never are). Newest-first; ?before=<id> pages (keyset,
# next_before in the response), ?limit= default 50, max 100.
# GET .../submissions/{id} fetches one. Both are owner-scoped like the form
# target itself — a token or id you don't own answers 404.
# ?format=csv (or Accept: text/csv) streams every submission as CSV instead:
# 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 (a leading =/+/-/@/tab/CR gets a "'" prefix).
Best Practices
| # | Practice |
|---|---|
| 1 | Use the HTTP API — simpler than SMTP, better error reporting, JSON native |
| 2 | Check response status — 2xx = queued; 4xx = retry later; 5xx = do not retry |
| 3 | Include both body_text and body_html for maximum mail client compatibility. Text-only API sends get an auto-generated HTML alternative; SMTP submissions too when smtp.auto_multipart is enabled |
| 4 | Handle webhook retries — return 2xx promptly; rail retries on failure |
| 5 | Use request_id for idempotent retries on the send side |
| 6 | Add unsubscribe_url for user-facing mail (required by Gmail/Yahoo) |
| 7 | Don't send bulk marketing — rail is for transactional mail only |
API Discovery
| Endpoint | Format | Purpose |
|---|---|---|
/openapi.yaml | OpenAPI 3.1 | Machine-readable API spec |
/.well-known/ai-plugin.json | JSON | AI agent plugin manifest |
/llms.txt | Plain text | LLM-readable service summary |