# Sending email from a LangChain agent with VaEmail

VaEmail is email infrastructure for applications and autonomous agents: REST API, MCP server, CLI, SMTP and a Brevo-compatible API, with servers in Germany and sending in Europe. A LangChain agent can use it two ways: bind the MCP server's eighteen tools through `langchain-mcp-adapters`, or define one narrow tool of your own over the REST API at `https://app.vaemail.fr/api/v1`. Whichever you pick, the sequence is the same: dry run, send with an `Idempotency-Key`, read the delivery status before the agent reports anything. A send returns 202, which means accepted, not delivered.

Facts checked against the VaEmail documentation on 6 September 2026.

## The MCP route

This is the reference example, `examples/langchain/agent.py` in https://github.com/vaemail/examples:

```python
"""A LangChain agent that sends email through the VaEmail MCP server.

pip install langchain langchain-mcp-adapters langchain-anthropic
VAEMAIL_API_KEY=swm_... ANTHROPIC_API_KEY=... python agent.py
"""
import asyncio
import os

from langchain.agents import create_agent
from langchain_mcp_adapters.client import MultiServerMCPClient

MODEL = os.environ.get("MODEL", "anthropic:claude-sonnet-5")

SYSTEM = """You send email through VaEmail. Rules:
- Before the first real send, call vaemail_validate_email and stop if sendable is false.
- A send returns an id and status "queued": that means accepted, not delivered.
  Call vaemail_get_message before you report a delivery.
- Publishing DNS records or paying are human steps: name them, never declare them done."""


async def main() -> None:
    client = MultiServerMCPClient(
        {
            "vaemail": {
                "transport": "stdio",
                "command": "npx",
                "args": ["-y", "vaemail", "mcp"],
                "env": {"VAEMAIL_API_KEY": os.environ["VAEMAIL_API_KEY"]},
            }
        }
    )
    tools = await client.get_tools()
    agent = create_agent(MODEL, tools, system_prompt=SYSTEM)
    result = await agent.ainvoke(
        {"messages": [{"role": "user", "content": (
            "Send an email to client@example.com, subject 'Your invoice', "
            "body '<p>Hello, your invoice is attached.</p>', then tell me its delivery status."
        )}]}
    )
    print(result["messages"][-1].content)


asyncio.run(main())
```

The server is the npm package `vaemail`. It has zero dependencies and needs Node 18, nothing else. It runs over stdio. A separate remote connector at `mcp.vaemail.fr`, with OAuth, exposes its own list of 16 tools. Read tools carry the `readOnlyHint` annotation, so an agent can tell which ones it may call without asking permission. The full tool list is on [the MCP page](https://vaemail.fr/docs/mcp.md) and on [mcp-email-server](https://vaemail.fr/mcp-email-server).

## Defining your own tool

Eighteen tools is more surface than most agents need. When the agent only has to send and confirm, one tool over three REST calls is enough. The HTTP shape below is the one used by `examples/python/send.py`, standard library only:

```python
BASE = os.environ.get("VAEMAIL_BASE_URL", "https://app.vaemail.fr")
KEY = os.environ.get("VAEMAIL_API_KEY") or sys.exit("VAEMAIL_API_KEY is not set")
headers = {"api-key": KEY, "Content-Type": "application/json"}
```

Wrap it in a `@tool` whose docstring states the two things the model will otherwise get wrong: the send is only accepted, and the idempotency key comes from the business action rather than from a random value. Keep one recipient per call. Several recipients means several calls, each with its own key. For a whole list, campaigns exist, and `POST /v3/emailCampaigns/{id}/sendNow` is irreversible and requires an explicit confirmation in the request body.

## Validate before you send

`POST /api/v1/messages/validate` takes the same `to`, `subject` and `html` (or `template_id`) as a real send, sends nothing and consumes no quota:

```bash
curl -X POST https://app.vaemail.fr/api/v1/messages/validate \
  -H "api-key: $VAEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"client@exemple.fr","html":"<p>Bonjour</p>"}'
```

The response carries `sendable`, a `blocking` list using the same codes as real errors, plus `quota_remaining` and `daily_key_remaining`. If `sendable` is false, the agent fixes what blocks or stops. It does not send to find out. The MCP equivalent is `vaemail_validate_email`.

## Idempotency

An agent retries. That is the difference between an agent and a person, and it is why the header matters:

```bash
curl -X POST https://app.vaemail.fr/api/v1/transactional/send \
  -H "api-key: $VAEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: facture-2412" \
  -d '{"to":"client@exemple.fr","subject":"Votre facture","html":"<p>Bonjour</p>"}'
```

The key memorises the response for 24 hours and replays it identically, with `Idempotent-Replay: true`. Derive it from the business action, `order-8812-confirmation` rather than a UUID generated at call time, so a retried LangChain step lands on the same key. The same key with a different body is refused with `409 IDEMPOTENCY_KEY_REUSED`: that is a bug in your key derivation, not a replay. Server errors (5xx) are not memorised, so a retry with the same key after a 5xx is safe. The transactional send endpoint requires the `email.send` scope and is rate limited to 60 requests per minute.

## Reading delivery status

The send answers 202:

```json
{ "success": true, "data": { "id": 2841, "status": "queued" } }
```

That is the whole trap. The message is queued, not in anyone's inbox. Only `GET /api/v1/messages/{id}` (scope `email.read_status`) says what became of it. `status` is one of `queued`, `sent`, `delivered`, `failed`, `suppressed`, `held`, `cancelled`. On failure, `error` carries the transport's reason and `events` the sequence of what happened. Poll a few times with a pause of a few seconds; confirmation depends on the recipient's provider. Only `delivered` lets the agent say the email arrived. Put that rule in the system prompt, as the reference example does, because a model that has seen a 202 will otherwise report success.

## Error handling by stable code

Every error body carries an `error` block: `code`, `message`, `retryable`, `resolution` (an `action`, sometimes an `endpoint`) and `documentation`. Codes are stable: new ones get added, existing ones are not renamed. Branch on `code`, never on the message text, which is in French.

```json
{
  "code": "DOMAIN_NOT_VERIFIED",
  "retryable": false,
  "resolution": {
    "action": "add_dns_records",
    "endpoint": "/api/v1/domains"
  }
}
```

The codes an agent meets most: `UNAUTHORIZED` (401, key missing, invalid or account disabled), `INSUFFICIENT_SCOPE` (403, the response names the missing scope and the ones the key has), `SENDER_DOMAIN_NOT_ALLOWED` (403), `DOMAIN_NOT_VERIFIED`, `RECIPIENT_SUPPRESSED` (the address is on the suppression list, do not work around it), `CONTENT_REQUIRED` (422, neither `html` nor `template_id`), `VALIDATION_FAILED` (422), `TOO_MANY_RECIPIENTS`, `DAILY_LIMIT_REACHED` (429, `retryable: true`), `QUOTA_EXCEEDED` (429), `RATE_LIMITED` (429, back off and retry with the same key), `IDEMPOTENCY_KEY_REUSED` (409), `MESSAGE_NOT_FOUND` (404), `TEMPLATE_NOT_FOUND` (404). When `retryable` is false, an identical call will never succeed: change something or stop the run. Through MCP, an API error comes back as a tool result marked in error, with the code, the corrective action and whether a retry is worth it, not as an opaque transport failure.

When mail lands badly, `GET /api/v1/deliverability/diagnose?domain=example.com` returns SPF, DKIM and DMARC verdicts read live in public DNS, the issues found and recommended actions. More on that in [ai-agent-email-deliverability](https://vaemail.fr/ai-agent-email-deliverability).

## Environment variables

Two, and nothing else, read by the MCP server, the CLI and the examples:

| Variable | Meaning |
| --- | --- |
| `VAEMAIL_API_KEY` | Account API key, created by a human in the dashboard under API keys. Required for every tool except `vaemail_capabilities`. |
| `VAEMAIL_BASE_URL` | API base URL. Defaults to `https://app.vaemail.fr`. |

Keep the key in the environment or in a git-ignored `.env`. Never in source, never in a committed config, never in a prompt, never echoed back to the user. Keys are shown once at creation and stored hashed: there is no recovery endpoint, and a lost key is replaced by a human.

## A scoped key for the agent

Create a key dedicated to this agent and bound at creation:

- scopes: `email.send` and `email.read_status` are enough to send and track. Add `domain.read` and `domain.write` only if the agent configures the sending domain, `analytics.read` for diagnostics and the audit log, `billing.read` for usage. Eight scopes exist.
- `max_emails_per_day`: the volume the agent is supposed to produce, not more.
- `max_recipients_per_email`: 1 for transactional mail.
- `allowed_domains`: the single sender domain planned.

`GET /api/v1/usage` returns the monthly quota, today's sends and, for the current key, its scopes, daily cap and what remains. A LangChain run that batches sends should read that number and decide to stop from it, not from a counter of its own. `GET /api/v1/audit-logs` returns one line per call: key, operation, parameters, result, emails engaged, with message bodies and merge variables excluded. That is what lets the agent report exactly what it did.

## What VaEmail does not do

Said plainly, because a capability announced wrongly costs more than a missing one. There is no inbox created on the fly: an agent cannot get an address to receive a verification code or a human reply. Inbound exists only for prospecting sequences, attached to a declared IMAP mailbox. There is no signup endpoint, so the account and the first key are a human step, and publishing DNS records at the registrar is another. VaEmail ships a Python SDK (`pip install vaemail`), and existing Brevo clients work against `/v3`. The MCP server is local (stdio) only. Webhooks cover six events: sent, delivered, bounce, complaint, opened, clicked. Part of the documentation is in French. It is a young product, run by an agency. Prices are public on https://vaemail.fr/tarifs/.

## Copy this to your agent

```
Add VaEmail as the outbound email provider for this LangChain agent.
Documentation for machines: https://vaemail.fr/llms-full.txt
OpenAPI specification: https://app.vaemail.fr/openapi.json
What the service supports, no key needed: https://app.vaemail.fr/api/v1/capabilities

Rules:
- The API key is in the VAEMAIL_API_KEY environment variable. Do not ask for it, and
  never write it in a committed file.
- Bind the MCP tools with langchain-mcp-adapters (stdio, npx -y vaemail mcp), or define
  one tool over POST /api/v1/transactional/send.
- Before any real send, call POST /api/v1/messages/validate and stop if sendable is false.
- Put an Idempotency-Key header on every send, derived from the business action.
- A 202 means accepted, not delivered: read GET /api/v1/messages/{id} before reporting
  a delivery.
- Branch on error.code, never on the message text. retryable false means an identical
  retry will not succeed.
- Publishing DNS records, creating an account and paying are human steps: name them,
  never declare them done.
```

## Links

- Agent page: https://vaemail.fr/agents
- Full documentation in one file: https://vaemail.fr/llms-full.txt
- Index for language models: https://vaemail.fr/llms.txt
- Agent skill: https://vaemail.fr/skill
- MCP server and CLI: https://vaemail.fr/docs/mcp.md
- Error codes: https://vaemail.fr/docs/errors.md
- API reference: https://vaemail.fr/docs/api.md
- Security, scopes and caps: https://vaemail.fr/docs/security.md
- Domain authentication: https://vaemail.fr/docs/domains.md
- OpenAPI 3.1: https://app.vaemail.fr/openapi.json
- Capabilities, no key: https://app.vaemail.fr/api/v1/capabilities
- Runnable examples: https://github.com/vaemail/examples
- Comparison with Resend, Brevo, Postmark and AgentMail: https://vaemail.fr/compare/index.md
- Other platforms: [email-api-for-ai-agents](https://vaemail.fr/email-api-for-ai-agents), [email-for-crewai](https://vaemail.fr/email-for-crewai), [email-for-openai-agents](https://vaemail.fr/email-for-openai-agents)
