agent integration
Operational
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
rail certs issue \
  --name my-agent \
  --email agent@smtp.ataca.io \
  --webhook https://my-agent.example.com/hooks/email
FieldMeaning
CNAgent identity — appears in logs, rate-limit buckets
Email SANsAddresses the agent is allowed to send as
URI SANsWebhook URLs where inbound mail is POSTed
Sending Email — HTTP API (recommended)
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"}
FieldPurpose
request_idIdempotency key, echoed in response
ccCC recipients (array)
reply_toOverride Reply-To (disables VERP tracking)
unsubscribe_urlInjects List-Unsubscribe per RFC 8058
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"}
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());
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)
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
Sender internet MTA rail :25 (STARTTLS) local domain check HTTP POST to your webhook
{
  "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"
}
{
  "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"]
}
{
  "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.
{
  "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.
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
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)
# 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…
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.
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.
<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 fieldEffect
_replytoReply-To (falls back to a field named email)
_subjectSubject override (line breaks rejected)
_redirectRedirect override — origin-allowlisted only
_gotchaHoneypot — a filled value drops the submission
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
1Use the HTTP API — simpler than SMTP, better error reporting, JSON native
2Check response status — 2xx = queued; 4xx = retry later; 5xx = do not retry
3Include 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
4Handle webhook retries — return 2xx promptly; rail retries on failure
5Use request_id for idempotent retries on the send side
6Add unsubscribe_url for user-facing mail (required by Gmail/Yahoo)
7Don't send bulk marketing — rail is for transactional mail only
API Discovery
EndpointFormatPurpose
/openapi.yamlOpenAPI 3.1Machine-readable API spec
/.well-known/ai-plugin.jsonJSONAI agent plugin manifest
/llms.txtPlain textLLM-readable service summary