All posts
Patterns··11 min read·Mails.ai Team

Sending Transactional Email from AI Agents: OTPs, Links, Notifications

TL;DR

AI agents sending transactional email — OTPs, magic links, account alerts — need more than a simple API call. They require idempotency guards, short TTLs, proper authentication headers, and sending infrastructure separated from bulk traffic to protect deliverability.

Sending Transactional Email from AI Agents: OTPs, Links, Notifications

An AI agent that can authenticate users, trigger account actions, or notify stakeholders is genuinely useful. But the moment an agent fires an email on behalf of a user — a one-time passcode, a magic login link, a billing alert — it inherits all the operational constraints of a transactional email system: sub-second latency expectations, strict security properties, deliverability isolation, and exactly-once delivery semantics.

Most guides treat these as an afterthought. This one treats them as the main problem.

Why transactional email from agents is different

Transactional email triggered by an AI agent differs from a human-initiated send in two critical ways: the trigger is non-deterministic, and retries are dangerous.

A traditional transactional system fires a magic link when a user clicks "Sign in." The trigger is a discrete HTTP request. An agent might decide to send that link based on parsed conversation context, a tool call result, or a chain-of-thought conclusion — any of which can produce duplicates if the agent retries on timeout or if the orchestration layer re-runs a step. A duplicate OTP email is confusing. A duplicate magic link that burns the original token is a support ticket.

Agents also often run in environments with looser rate-limit awareness than conventional web backends. A tight loop — say, an agent checking whether a user authenticated yet and re-sending the link on each poll iteration — can generate dozens of sends in seconds.

Get the fundamentals right and these problems become mechanical to solve.

Anatomy of a transactional email send

Regardless of email provider, every transactional send needs the same set of properties configured correctly.

Required headers

From: noreply@mail.yourdomain.com
Reply-To: support@yourdomain.com
Message-ID: <uuid@mail.yourdomain.com>
X-Entity-Ref-ID: <idempotency-key>
Precedence: bulk          # suppresses OOO auto-replies
Auto-Submitted: auto-generated
Content-Type: text/html; charset=UTF-8

Message-ID must be globally unique and scoped to your sending domain — <uuid@mail.yourdomain.com>, not <uuid@localhost>. Providers that generate Message-ID for you will do this correctly, but if you're constructing raw MIME, you must set it. Mail clients and spam filters use Message-ID for deduplication and threading.

Precedence: bulk tells receiving mail servers and auto-responders not to generate out-of-office replies. Without it, your agent's OTP sends can trigger a cascade of auto-reply loops — especially in enterprise environments with aggressive vacation responders.

X-Entity-Ref-ID is your idempotency key. Most major providers (SendGrid, Postmark, AWS SES) read a custom header like this to deduplicate sends at the API layer. Use a deterministic key: sha256(user_id + email_type + floor(unix_ts / 300)) gives you a 5-minute dedup window without storing state.

Authentication stack

For transactional sends to land in the inbox, the sending domain needs all three:

  • SPF: v=spf1 include:_spf.yourprovider.com ~all — authorizes your ESP to send on your domain's behalf
  • DKIM: 2048-bit key, provider-managed or self-hosted; signs the message headers and body
  • DMARC: v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc@yourdomain.com — enforces alignment between From: header domain and DKIM/SPF

The From: domain in your agent's sends must DKIM-align with the signing domain. Misalignment — sending From: noreply@yourdomain.com but signing with a subdomain the provider controls — breaks DMARC and can put your messages in spam or quarantine.

For high-volume automated senders, a dedicated sending IP isolates your transactional reputation from any other traffic. Shared IPs mean your OTP deliverability is coupled to every other tenant on that IP.

OTP emails: security properties that matter

OTP emails have stricter requirements than other transactional types because they're a direct authentication factor.

Token design

  • Entropy: Use secrets.token_hex(3) (Python) or crypto.randomBytes(3).toString('hex') (Node) for a 6-digit hex code — 24 bits of entropy, brute-force resistant for a 10-minute window with 5 attempts allowed
  • TTL: 10 minutes maximum. 5 minutes is better for security-sensitive operations
  • Single use: Invalidate the token on first use, not on expiry
  • Rate limit: No more than 3 OTP sends per user per 10-minute window, enforced server-side before the agent makes the API call

The agent-specific problem: re-sends on timeout

If your agent waits for OTP confirmation and times out, it may re-run the send step. You need two separate guards:

  1. Idempotency key at the API layer — prevents the email provider from sending twice even if you call twice
  2. Token dedup at the application layer — check whether an unexpired token already exists for this user before generating a new one
def send_otp(user_id: str, email: str) -> dict:
    # Check for existing unexpired token first
    existing = token_store.get(user_id)
    if existing and existing["expires_at"] > time.time():
        return {"status": "already_sent", "expires_at": existing["expires_at"]}

    token = secrets.token_hex(3).upper()  # e.g. "A3F9C1"
    idempotency_key = hashlib.sha256(
        f"{user_id}:otp:{token}".encode()
    ).hexdigest()

    token_store.set(user_id, {
        "token": hashlib.sha256(token.encode()).hexdigest(),  # store hash, not plaintext
        "expires_at": time.time() + 600
    })

    return email_client.send(
        to=email,
        subject="Your verification code",
        body=render_otp_template(token),
        headers={"X-Entity-Ref-ID": idempotency_key}
    )

Store the hash of the token, not the plaintext. If your token store is compromised, attackers shouldn't get live OTPs.

Magic links are stateless tokens embedded in a URL. The email is just a delivery vehicle — the security lives in the token.

Token construction

import hmac, hashlib, base64, time, json

def generate_magic_link(user_id: str, secret_key: bytes, ttl: int = 900) -> str:
    payload = json.dumps({
        "uid": user_id,
        "exp": int(time.time()) + ttl,
        "jti": secrets.token_hex(8)  # prevents replay if link used before expiry
    })
    payload_b64 = base64.urlsafe_b64encode(payload.encode()).decode()
    sig = hmac.new(secret_key, payload_b64.encode(), hashlib.sha256).hexdigest()
    return f"https://app.yourdomain.com/auth/verify?token={payload_b64}.{sig}"

HMAC-SHA256 lets you verify the token without a database lookup — important for low-latency auth flows. You still need a used-token store (a Redis SET with TTL matching the link TTL) to enforce single-use.

Email rendering for magic links

Mail clients aggressively rewrite and pre-fetch URLs. Gmail's link protection, Outlook's SafeLinks, and corporate proxies will all GET your magic link before the user clicks it. This burns the token.

Solutions:

  • Redirect pattern: The link points to an intermediate URL that shows a "Click to sign in" button. The token is only consumed on button click, not on page load.
  • POST on click: The frontend POSTs the token rather than including it in a GET parameter that gets pre-fetched.
  • Longer single-use window with device fingerprint: Validate device+IP matches the one that opened the email (available via email client tracking pixels, though privacy blockers limit this).

The redirect pattern is the most reliable. Your magic link URL delivers an HTML page with a confirmation button; the actual authentication happens on form submit.

Notification emails: batching and rate control

Notification emails — "Your report is ready," "Action required on case #4821," "Your agent completed the task" — have less strict latency requirements than OTPs but more complex batching needs.

Digest batching

An agent that sends one email per event will quickly generate inbox fatigue and unsubscribes. Implement digest batching at the agent level:

class NotificationBuffer:
    def __init__(self, user_id: str, flush_interval: int = 300):
        self.user_id = user_id
        self.events = []
        self.flush_interval = flush_interval
        self.last_flush = time.time()

    def add(self, event: dict):
        self.events.append(event)
        if time.time() - self.last_flush >= self.flush_interval or len(self.events) >= 10:
            self.flush()

    def flush(self):
        if not self.events:
            return
        send_digest_email(self.user_id, self.events)
        self.events = []
        self.last_flush = time.time()

Flush on time (5 minutes) or count (10 events), whichever comes first. This collapses bursts of agent activity into a single readable summary.

Unsubscribe headers

All notification emails — even triggered ones — need proper unsubscribe mechanics. CAN-SPAM and GDPR both apply. Required headers:

List-Unsubscribe: <https://app.yourdomain.com/unsubscribe?uid=USER_ID&type=notifications>, <mailto:unsub@mail.yourdomain.com?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The List-Unsubscribe-Post header enables Gmail and Apple Mail's one-click unsubscribe UI. Without it, Google may add its own unsubscribe link pointing somewhere you don't control.

Sending architecture for agent-triggered email

sequenceDiagram
    participant Agent as AI Agent
    participant Guard as Send Guard
    participant Queue as Send Queue
    participant ESP as Email Provider
    participant Track as Event Webhook

    Agent->>Guard: send_otp request with user_id
    Guard->>Guard: check rate limit and idempotency
    Guard->>Queue: enqueue with idempotency key
    Queue->>ESP: POST send with X-Entity-Ref-ID
    ESP-->>Queue: 202 Accepted with message_id
    Queue-->>Agent: message_id returned
    ESP->>Track: delivered event
    ESP->>Track: opened event
    Track-->>Agent: webhook callback

The key architectural choice: the agent never calls the email API directly. It goes through a send guard that enforces rate limits and idempotency before the message reaches the queue. Agent retries are safe by default.

Deliverability isolation

Mix your OTP sends with your marketing emails on the same IP and domain, and a single spam complaint on a campaign can suppress your authentication emails. Keep them separated:

Email Type Sending Domain IP Pool Priority
OTPs, magic links mail.yourdomain.com Dedicated Immediate
Account notifications notify.yourdomain.com Dedicated <5 min
Product updates updates.yourdomain.com Shared or warmed Batched
Marketing mkt.yourdomain.com Shared Scheduled

Subdomain separation means a DMARC failure or deliverability hit on your marketing sends doesn't affect authentication email reputation. Most ESPs support multiple sending domains on a single account.

Sender reputation is domain and IP-specific — keeping these pools isolated is the single highest-leverage deliverability decision you can make for agent-triggered email.

Integrating with agent frameworks

If your agent runs on an MCP-compatible orchestration layer, email sending becomes a typed tool call rather than a raw HTTP request. You get structured inputs the agent can reason about — TTL, email type, idempotency scope — rather than free-form API parameters.

Mails.ai's MCP-native email infrastructure is built specifically for this pattern: agents call a typed send_email tool, the platform handles idempotency and deliverability separation, and send events come back as structured webhook payloads the agent can observe.

For teams building their own tool wrappers, the interface should look something like:

@tool
def send_transactional_email(
    to: str,
    email_type: Literal["otp", "magic_link", "notification"],
    user_id: str,
    payload: dict,
    idempotency_scope_minutes: int = 5
) -> SendResult:
    """
    Send a transactional email. Returns message_id and send status.
    Idempotent within idempotency_scope_minutes for the same user_id + email_type.
    """
    ...

The agent gets back a message_id it can reference in logs and downstream tool calls. The guard logic is handled transparently by the implementation.

Testing agent email flows end-to-end

Transactional email is notoriously hard to test because the failure modes are operational, not just functional. A unit test that mocks the email API won't catch header misconfiguration or idempotency bugs under concurrent load.

Test matrix:

  • Duplicate send test: Fire the same OTP request twice within 60 seconds. The user should receive exactly one email.
  • Token replay test: Use a magic link twice. The second attempt should fail with an appropriate error.
  • Rate limit test: Send 5 OTP requests for the same user inside 2 minutes. Only 3 should result in sends.
  • DMARC alignment test: Use mail-tester.com or MXToolbox to verify DKIM signatures and DMARC alignment on a real send.
  • Pre-fetch test: Send a magic link to a Gmail or Outlook address and check your token store — if the token is consumed before you click the link, you need the redirect pattern.

For the email API integration layer, capture and assert on the exact headers your provider receives. Most providers expose a send log or webhook that includes the raw headers — use that to verify Message-ID, X-Entity-Ref-ID, and List-Unsubscribe are present and correctly formed.

Frequently Asked Questions

What's the right TTL for OTP emails sent by AI agents?

10 minutes is the standard upper bound. For high-security contexts (password resets, payment confirmations), use 5 minutes. The shorter the window, the less time an attacker has if the email is intercepted or forwarded. Pair the TTL with a maximum attempt count (3-5 attempts) enforced server-side — time expiry alone isn't sufficient because it allows brute force within the window.

How do I prevent an agent from sending the same magic link twice?

Two guards work together. First, set an idempotency key (X-Entity-Ref-ID or provider equivalent) on the API call — this deduplicates at the ESP layer if you call twice within seconds. Second, check application state before generating a new token: if an unexpired token already exists for this user, return the existing send result rather than generating a new one. The agent should treat the already_sent response as a success.

Should OTPs and notifications share the same sending domain?

No. Use separate subdomains at minimum — mail.yourdomain.com for authentication email, notify.yourdomain.com for notifications. A spam complaint on a notification digest can damage the subdomain's sending reputation. Since DMARC and SPF are domain-scoped, a problem on one subdomain doesn't propagate to others on the same root domain.

How do I handle bounce and complaint events in agent workflows?

Subscribe to your ESP's webhook for bounce, complaint, and unsubscribe events. On hard bounce, immediately suppress future sends to that address — store it in a suppression list the agent checks before every send. On spam complaint, suppress and flag the user record. Never retry a hard-bounced address: repeated sends to invalid addresses increase your bounce rate and damage IP reputation.

What headers prevent my OTP from triggering out-of-office auto-replies?

Set Precedence: bulk and Auto-Submitted: auto-generated. These are RFC 3834-defined headers that compliant mail clients and servers use to identify automated mail and suppress vacation auto-replies. Without them, an OTP send to a user with an active out-of-office can trigger a reply to your sending address, which may then confuse an inbound-processing agent.

Can an AI agent safely handle the email send inside a retry loop?

Yes, if the send is idempotent. The agent must pass a deterministic idempotency key (derived from stable inputs like user_id + email_type + time_bucket) and the API layer must honor it. With that in place, retrying a failed or timed-out send step is safe — the provider deduplicates and the user receives exactly one email regardless of how many times the agent calls the API.

Closed beta

Built for agents.
Self-serve in minutes.

Public API opens Q3 2026. Drop ~6 lines into your agent and ship.

npmpnpmbunpip
$ npm install @mailsai/sdk
Packages publish with cohort 1 · Q3 2026