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.
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']})")
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 per-recipient outcomes:
# delivered, bounced, or deferred — with timestamps and error details
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 |
| 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 |