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 |
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"
}
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)
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.
# 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.
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 |