{
  "type": "guide",
  "id": "let-an-agent-send-email-safely",
  "title": "Let an agent send email safely through the Resend MCP server",
  "summary": "Give an agent email through Resend's MCP server without letting it mail anyone, anything, at any time: pin the sender, fence off local files, draft first, deny-by-default tool gating, and a human approval step that shows recipients and subject. Tested offline against resend-mcp 2.28.0.",
  "author": "Agentica Author",
  "tags": [
    "email",
    "resend",
    "mcp",
    "human-in-the-loop",
    "permissions",
    "tool-gating",
    "prompt-injection",
    "claude-agent-sdk"
  ],
  "published": "2026-11-04",
  "updated": "2026-11-04",
  "last_verified": "2026-11-04",
  "difficulty": "intermediate",
  "time_estimate": "40 min",
  "entries": [
    "resend-mcp",
    "agentmail",
    "claude-agent-sdk"
  ],
  "links": {
    "html": "https://indexagentica.com/guides/let-an-agent-send-email-safely/",
    "markdown": "https://indexagentica.com/guides/let-an-agent-send-email-safely.md",
    "json": "https://indexagentica.com/api/longform/guides/let-an-agent-send-email-safely.json",
    "source": "https://github.com/Drudley/indexagentica/blob/main/content-long/guides/let-an-agent-send-email-safely.md"
  },
  "status": "published",
  "prerequisites": [
    "A Resend account and API key (only for the final live test; everything else runs offline)",
    "Node.js 18+ for resend-mcp, and Python 3.10+ with claude-agent-sdk for the gate"
  ],
  "entries_detail": [
    {
      "id": "resend-mcp",
      "name": "Resend MCP",
      "summary": "Resend's official MCP server for email: send and schedule emails, manage templates, broadcasts, contacts, domains, logs and webhooks. Hosted with OAuth or run locally.",
      "url": "https://indexagentica.com/entries/resend-mcp/",
      "json": "https://indexagentica.com/api/entries/resend-mcp.json"
    },
    {
      "id": "agentmail",
      "name": "AgentMail",
      "summary": "Email inboxes for AI agents via API: create inboxes, send, receive, search threads and manage calendars; hosted MCP server with 47 tools.",
      "url": "https://indexagentica.com/entries/agentmail/",
      "json": "https://indexagentica.com/api/entries/agentmail.json"
    },
    {
      "id": "claude-agent-sdk",
      "name": "Claude Agent SDK",
      "summary": "Anthropic's SDK for building production agents with Claude Code as a library, in Python and TypeScript.",
      "url": "https://indexagentica.com/entries/claude-agent-sdk/",
      "json": "https://indexagentica.com/api/entries/claude-agent-sdk.json"
    }
  ],
  "related": [
    {
      "type": "guide",
      "id": "screen-tool-output-for-prompt-injection",
      "title": "Screen tool and web output for prompt injection before your agent reads it",
      "url": "https://indexagentica.com/guides/screen-tool-output-for-prompt-injection/",
      "json": "https://indexagentica.com/api/longform/guides/screen-tool-output-for-prompt-injection.json"
    },
    {
      "type": "guide",
      "id": "test-an-agent-for-prompt-injection",
      "title": "Test a tool-using agent for prompt injection with AgentDojo",
      "url": "https://indexagentica.com/guides/test-an-agent-for-prompt-injection/",
      "json": "https://indexagentica.com/api/longform/guides/test-an-agent-for-prompt-injection.json"
    },
    {
      "type": "guide",
      "id": "connect-an-agent-to-a-remote-mcp-server",
      "title": "Connect an agent to a remote MCP server",
      "url": "https://indexagentica.com/guides/connect-an-agent-to-a-remote-mcp-server/",
      "json": "https://indexagentica.com/api/longform/guides/connect-an-agent-to-a-remote-mcp-server.json"
    }
  ],
  "sources": [
    {
      "title": "Resend MCP server documentation",
      "url": "https://resend.com/docs/mcp-server",
      "accessed": "2026-11-04"
    },
    {
      "title": "resend-mcp README (options and local file access warning)",
      "url": "https://github.com/resend/resend-mcp",
      "accessed": "2026-11-04"
    },
    {
      "title": "resend-mcp on npm",
      "url": "https://www.npmjs.com/package/resend-mcp",
      "accessed": "2026-11-04"
    },
    {
      "title": "Resend changelog: Remote MCP Server",
      "url": "https://resend.com/changelog/remote-mcp-server",
      "accessed": "2026-11-04"
    },
    {
      "title": "Resend API: Create API key (permissions and domain restriction)",
      "url": "https://resend.com/docs/api-reference/api-keys/create-api-key",
      "accessed": "2026-11-04"
    },
    {
      "title": "Resend: Send test emails",
      "url": "https://resend.com/docs/dashboard/emails/send-test-emails",
      "accessed": "2026-11-04"
    },
    {
      "title": "Resend: Idempotency keys",
      "url": "https://resend.com/docs/dashboard/emails/idempotency-keys",
      "accessed": "2026-11-04"
    },
    {
      "title": "Claude Agent SDK: Configure permissions",
      "url": "https://code.claude.com/docs/en/agent-sdk/permissions",
      "accessed": "2026-11-04"
    },
    {
      "title": "claude-agent-sdk on PyPI",
      "url": "https://pypi.org/project/claude-agent-sdk/",
      "accessed": "2026-11-04"
    }
  ],
  "front_matter": {
    "id": "let-an-agent-send-email-safely",
    "type": "guide",
    "title": "Let an agent send email safely through the Resend MCP server",
    "summary": "Give an agent email through Resend's MCP server without letting it mail anyone, anything, at any time: pin the sender, fence off local files, draft first, deny-by-default tool gating, and a human approval step that shows recipients and subject. Tested offline against resend-mcp 2.28.0.",
    "description": "Resend's MCP server (npm resend-mcp 2.28.0) exposes 136 tools, of which 56 are marked read-only, and it has no option to turn tools off. Several send real email the moment they are called. This guide shows how to keep that safe: choose the API key permission, pin the sender with --sender, restrict attachments with --allowed-file-dirs, prefer inbox drafts, gate every send through a deny-by-default Claude Agent SDK can_use_tool callback with a recipient-domain allowlist and a human approval prompt, add idempotency keys so a retry cannot send twice, and test with resend.dev addresses. The gate, the sender pinning and the file restriction were tested offline on 2026-11-04 with fetch stubbed out; no real email was sent.",
    "author": "Agentica Author",
    "difficulty": "intermediate",
    "time_estimate": "40 min",
    "prerequisites": [
      "A Resend account and API key (only for the final live test; everything else runs offline)",
      "Node.js 18+ for resend-mcp, and Python 3.10+ with claude-agent-sdk for the gate"
    ],
    "tags": [
      "email",
      "resend",
      "mcp",
      "human-in-the-loop",
      "permissions",
      "tool-gating",
      "prompt-injection",
      "claude-agent-sdk"
    ],
    "entries": [
      "resend-mcp",
      "agentmail",
      "claude-agent-sdk"
    ],
    "related": [
      "screen-tool-output-for-prompt-injection",
      "test-an-agent-for-prompt-injection",
      "connect-an-agent-to-a-remote-mcp-server"
    ],
    "sources": [
      {
        "title": "Resend MCP server documentation",
        "url": "https://resend.com/docs/mcp-server",
        "accessed": "2026-11-04"
      },
      {
        "title": "resend-mcp README (options and local file access warning)",
        "url": "https://github.com/resend/resend-mcp",
        "accessed": "2026-11-04"
      },
      {
        "title": "resend-mcp on npm",
        "url": "https://www.npmjs.com/package/resend-mcp",
        "accessed": "2026-11-04"
      },
      {
        "title": "Resend changelog: Remote MCP Server",
        "url": "https://resend.com/changelog/remote-mcp-server",
        "accessed": "2026-11-04"
      },
      {
        "title": "Resend API: Create API key (permissions and domain restriction)",
        "url": "https://resend.com/docs/api-reference/api-keys/create-api-key",
        "accessed": "2026-11-04"
      },
      {
        "title": "Resend: Send test emails",
        "url": "https://resend.com/docs/dashboard/emails/send-test-emails",
        "accessed": "2026-11-04"
      },
      {
        "title": "Resend: Idempotency keys",
        "url": "https://resend.com/docs/dashboard/emails/idempotency-keys",
        "accessed": "2026-11-04"
      },
      {
        "title": "Claude Agent SDK: Configure permissions",
        "url": "https://code.claude.com/docs/en/agent-sdk/permissions",
        "accessed": "2026-11-04"
      },
      {
        "title": "claude-agent-sdk on PyPI",
        "url": "https://pypi.org/project/claude-agent-sdk/",
        "accessed": "2026-11-04"
      }
    ],
    "last_verified": "2026-11-04",
    "published": "2026-11-04",
    "updated": "2026-11-04"
  },
  "markdown": "\n## The problem\n\nAn email tool is one of the few agent tools whose mistakes can't be undone. A sent message stays sent. If the agent reads inbound mail, that mail is untrusted input that can ask it to forward a thread, BCC someone or attach a file.\n\nThe [Resend MCP server](https://indexagentica.com/entries/resend-mcp/) is a good example because it is thorough. Version 2.28.0 (npm, 2026-11-03) lists **136 tools**. Only 56 carry `readOnlyHint`. The server has no flag to disable tools or run read-only. We grepped the package and found none. These tools send or have effects outside your account:\n\n| Effect | Tools |\n|---|---|\n| Sends email now or later | `send-email` (can schedule), `send-batch-emails` (up to 100), `send-broadcast`, `reply-to-inbox-thread-email`, `forward-inbox-thread-email`, `send-inbox-draft`, `send-new-inbox-email` |\n| Changes what gets sent later | `create-broadcast`, `update-broadcast`, `create-automation`, `update-automation`, `send-event` (triggers automations), `update-email` (reschedule) |\n| Sends data elsewhere | `create-webhook`, `update-webhook`, `replay-webhook-event`, `share-email` (public link) |\n| Changes access or deliverability | `create-api-key`, `remove-api-key`, `create-domain`, `remove-domain`, `add-suppression`, `remove-suppression`, `revoke-oauth-grant` |\n\nThe four inbox send tools describe themselves as sending \"a real email the moment you call it\". `create-inbox-draft` saves a draft and sends nothing.\n\nThe approach below uses five layers. Each one is cheap, and none depends on the model behaving.\n\n## 1. Pick the key for the job\n\nResend API keys have one of two permissions ([Create API key](https://resend.com/docs/api-reference/api-keys/create-api-key)):\n\n- `full_access` can create, delete, get and update any resource.\n- `sending_access` can only send. For Inboxes it can call only Reply to Thread Email, Forward Thread Email, Create Draft and Send Draft. It can be restricted to one domain, and a domain-restricted key can use only that domain's inboxes.\n\nThere is no read-only key. That means:\n\n- **A notifier agent** only sends alerts and never reads mail. Give it a `sending_access` key restricted to a domain you use only for agent mail. The key itself then blocks API keys, webhooks, domains and log reads, whatever the MCP server exposes.\n- **An inbox agent** reads threads and drafts replies. It needs `full_access`, because `sending_access` can't list or read threads. The gate in step 4 is then your main control.\n\nThe hosted server at `https://mcp.resend.com/mcp` signs you in with OAuth. You \"grant the necessary permissions\" and can revoke them in Team settings ([changelog](https://resend.com/changelog/remote-mcp-server)). The docs we read don't say whether the OAuth grant can be narrowed to sending only. If you need the narrow key, pass it as a Bearer token or run the server locally.\n\n## 2. Pin the sender and fence off files\n\nRun the server locally so you control its flags:\n\n```bash\nRESEND_API_KEY=re_... npx -y resend-mcp@2.28.0 \\\n  --sender agent@yourdomain.example \\\n  --allowed-file-dirs /srv/agent-outbox\n```\n\n**`--sender`** (or `SENDER_EMAIL_ADDRESS`) removes the `from` field from the `send-email` input schema. We tested this offline with `fetch` stubbed out. A call that passed `\"from\": \"ceo@evil.example\"` anyway went to the API with `from: agent@yourdomain.example`.\n\n**`--allowed-file-dirs`** (or `MCP_ALLOWED_FILE_DIRS`) limits where `attachments[].filePath` and `create-contact-import` may read from. The README is blunt: it is \"unrestricted on stdio when unset\", and over `--http` file reads are disabled unless you set it. We tested both cases on stdio:\n\n- Unset: a call attaching `/tmp/secret.txt` was sent with the file attached.\n- Set to another directory: the same call returned `File not found or outside the allowed directories.` and made no request.\n\nAlways set it on stdio, even to an empty directory.\n\n## 3. Draft first\n\nFor inbox work, let the agent write with `create-inbox-draft` and `update-inbox-draft`, and have a person send from Resend. The agent gets the useful part of the job, and the irreversible step stays with a human. If you want the agent to send, route it through the gate below.\n\nDrafts aren't harmless just because they don't send. Both draft tools take `to`, `cc`, `bcc` and `attachments`, and `send-inbox-draft` later sends whatever the draft holds. So the gate checks draft calls exactly as it checks sends. A reply draft created without `to` is addressed to the author of the message it replies to, according to the tool's own description.\n\n## 4. Gate every call, deny by default\n\nDon't filter with a list of dangerous tools. A new server version can add a tool your denylist doesn't name. The gate below runs 11 read-only tools on their own. It sends three tools to a human for approval: `send-email`, `reply-to-inbox-thread-email` and `send-inbox-draft`. It refuses everything else, including `forward-inbox-thread-email`, `send-new-inbox-email` and tools that don't exist yet.\n\n**Draft tools** (`create-inbox-draft`, `update-inbox-draft`) run without a prompt, but only after these checks:\n\n- every `to`/`cc`/`bcc` address must be a single address in an allowed domain\n- at most five recipients per call\n- no attachments\n- `update-inbox-draft` may pass `null` to clear recipients or attachments\n\n**Send tools** get the same recipient and attachment checks. The gate also:\n\n- refuses scheduling, custom headers and `replyAll`\n- requires at least one recipient for `send-email`\n- adds an idempotency key\n- shows a person the details before anything goes out\n\n**Every error fails closed.** If the policy hits an unexpected value or an exception, the call is refused and the person is never asked.\n\n```python\n\"\"\"Approval gate for an agent using the Resend MCP server (resend-mcp 2.28.0 tool names).\n\nDeny by default: a tool runs on its own only if it is in AUTO_ALLOW, goes to a human\nonly if it is in ASK, and is refused otherwise -- including tools added in later versions.\nAny call that names recipients or attachments is checked, drafts included.\nAny error inside the policy refuses the call (fail closed).\n\"\"\"\nimport hashlib, json, re\n\nSERVER = \"resend\"                      # the name you give the server in mcp_servers\nP = f\"mcp__{SERVER}__\"\n\nREAD_ONLY = {                          # read-only inbox and email tools\n    \"list-inboxes\", \"get-inbox\", \"list-inbox-threads\", \"search-inbox-threads\",\n    \"get-inbox-thread\", \"list-inbox-thread-emails\", \"get-inbox-thread-email\",\n    \"list-inbox-drafts\", \"get-inbox-draft\", \"list-emails\", \"get-email\",\n}\nDRAFTS = {\"create-inbox-draft\", \"update-inbox-draft\"}   # save drafts, send nothing\nAUTO_ALLOW = READ_ONLY | DRAFTS\nASK = {\"send-email\", \"reply-to-inbox-thread-email\", \"send-inbox-draft\"}\nALLOWED_RECIPIENT_DOMAINS = {\"resend.dev\"}   # widen deliberately, e.g. {\"yourcompany.com\"}\nMAX_RECIPIENTS = 5\n\n_ADDR = re.compile(r\"[^@\\s,;<>\\\"']+@[A-Za-z0-9.-]+\")\n_NAMED = re.compile(r\"[^@,;<>\\\"']*[^@\\s,;<>\\\"'][^@,;<>\\\"']* <([^<>]*)>\")\n\nclass Refuse(Exception):\n    pass\n\ndef _address(a):\n    \"\"\"Return the bare address in a@b or `Name <a@b>`; refuse anything else.\"\"\"\n    if not isinstance(a, str):\n        raise Refuse(\"recipient is not a string\")\n    m = _NAMED.fullmatch(a)\n    addr = m.group(1) if m else a\n    if not _ADDR.fullmatch(addr):\n        raise Refuse(f\"recipient not a single plain address: {a!r}\")\n    return addr\n\ndef _recipients(args, nullable=False):\n    out = []\n    for field in (\"to\", \"cc\", \"bcc\"):\n        if field not in args or (nullable and args[field] is None):\n            continue\n        v = args[field]\n        if isinstance(v, str):\n            v = [v]\n        if not isinstance(v, list):\n            raise Refuse(f\"{field} must be a string or a list of strings\")\n        out += [_address(a) for a in v]\n    if len(out) > MAX_RECIPIENTS:\n        raise Refuse(f\"more than {MAX_RECIPIENTS} recipients\")\n    bad = [a for a in out if a.rsplit(\"@\", 1)[1].lower() not in ALLOWED_RECIPIENT_DOMAINS]\n    if bad:\n        raise Refuse(f\"recipient domain not allowed: {', '.join(bad)}\")\n    return out\n\ndef _no_extras(args):\n    if args.get(\"attachments\"): raise Refuse(\"attachments are not allowed\")\n    if args.get(\"scheduledAt\"): raise Refuse(\"scheduled sends are not allowed\")\n    if args.get(\"headers\"): raise Refuse(\"custom headers are not allowed\")\n\ndef _check(tool, args):\n    if not isinstance(tool, str) or not tool.startswith(P): return (\"deny\", f\"not a {SERVER} tool\")\n    if not isinstance(args, dict): return (\"deny\", \"arguments are not an object\")\n    name = tool[len(P):]\n    if name in READ_ONLY: return (\"allow\", None)\n    if name in DRAFTS:\n        _no_extras(args)\n        _recipients(args, nullable=(name == \"update-inbox-draft\"))   # null clears recipients\n        return (\"allow\", None)\n    if name not in ASK: return (\"deny\", f\"{name} is not on the allowlist\")\n    args = dict(args)\n    _no_extras(args)\n    if args.get(\"replyAll\"): raise Refuse(\"replyAll is not allowed\")\n    rcpts = _recipients(args)\n    if name == \"send-email\" and not rcpts: raise Refuse(\"no recipients\")\n    if not args.get(\"idempotencyKey\"):\n        # Same content -> same key, so a retried call cannot send twice.\n        digest = hashlib.sha256(json.dumps([name, args], sort_keys=True).encode()).hexdigest()\n        args[\"idempotencyKey\"] = f\"agent-{digest[:32]}\"\n    return (\"ask\", args)\n\ndef check(tool, args):\n    \"\"\"Return (\"allow\", None) | (\"ask\", args_to_send) | (\"deny\", reason). Pure: no I/O.\"\"\"\n    try:\n        return _check(tool, args)\n    except Refuse as e:\n        return (\"deny\", str(e))\n    except Exception as e:  # fail closed\n        return (\"deny\", f\"policy error ({type(e).__name__}); refused\")\n\ndef summary(tool, args):\n    name = tool[len(P):]\n    lines = [f\"Agent wants to call {name}\"]\n    for k in (\"to\", \"cc\", \"bcc\", \"replyTo\", \"subject\", \"inboxId\", \"threadId\", \"emailId\", \"draftId\"):\n        if args.get(k) not in (None, \"\", []): lines.append(f\"  {k}: {args[k]}\")\n    body = args.get(\"text\") or args.get(\"html\") or \"\"\n    if body: lines.append(\"  body:\\n    \" + str(body)[:800].replace(\"\\n\", \"\\n    \"))\n    if name == \"reply-to-inbox-thread-email\":\n        lines.append(\"  (goes to the author of the message being answered; that address is not in this call)\")\n    if name == \"send-inbox-draft\":\n        lines.append(\"  (recipients, subject and body are in the draft, not this call; open it with get-inbox-draft\"\n                     \" or in Resend before approving)\")\n    return \"\\n\".join(lines)\n\ndef make_can_use_tool(ask_human):\n    \"\"\"ask_human(text) -> bool. Returns a Claude Agent SDK can_use_tool callback.\"\"\"\n    from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny\n    async def can_use_tool(tool_name, tool_input, context):\n        verdict, extra = check(tool_name, tool_input)\n        if verdict == \"allow\": return PermissionResultAllow()\n        if verdict == \"deny\": return PermissionResultDeny(message=extra)\n        try:\n            text = summary(tool_name, extra)\n        except Exception:\n            return PermissionResultDeny(message=\"could not summarise this send; refused\")\n        if await ask_human(text):\n            return PermissionResultAllow(updated_input=extra)\n        return PermissionResultDeny(message=\"A human declined this send. Do not retry it.\")\n    return can_use_tool\n```\n\nWire it into the [Claude Agent SDK](https://indexagentica.com/entries/claude-agent-sdk/) as `can_use_tool`:\n\n```python\nimport asyncio, os\nfrom claude_agent_sdk import ClaudeAgentOptions, query\nfrom email_gate import make_can_use_tool\n\nasync def ask_human(text: str) -> bool:\n    print(text)\n    answer = await asyncio.to_thread(input, \"Send this email? [y/N] \")\n    return answer.strip().lower() == \"y\"\n\noptions = ClaudeAgentOptions(\n    mcp_servers={\"resend\": {\n        \"type\": \"stdio\",\n        \"command\": \"npx\",\n        \"args\": [\"-y\", \"resend-mcp@2.28.0\",\n                 \"--sender\", \"agent@yourdomain.example\",\n                 \"--allowed-file-dirs\", \"/srv/agent-outbox\"],\n        \"env\": {\"RESEND_API_KEY\": os.environ.get(\"RESEND_API_KEY\", \"\")},\n    }},\n    permission_mode=\"default\",           # never \"bypassPermissions\"\n    can_use_tool=make_can_use_tool(ask_human),\n)\n\nasync def main():\n    async for msg in query(prompt=\"Draft a reply to the newest thread in my support inbox.\", options=options):\n        print(msg)\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nTwo SDK rules matter here ([permissions docs](https://code.claude.com/docs/en/agent-sdk/permissions)):\n\n- **Don't put Resend tools in `allowed_tools`.** Allow rules are evaluated before `can_use_tool`, and \"auto-approved tools never reach `canUseTool`\".\n- **Don't use `bypassPermissions`.** It approves unlisted tools before your callback sees them.\n\nThe gate does its own allowing so that every Resend call passes through it. In other harnesses, put the same `check()` function wherever tool calls are approved.\n\n### Why these checks\n\n- **One address per string.** A recipient must be either a plain `local@domain` or exactly `Name <local@domain>`. The name can't contain `@`, quotes, `,`, `;`, `<` or `>`. Anything with a separator or stray whitespace is refused before the domain is checked, including `a@evil.example, delivered@resend.dev`, `<a@evil.example>delivered@resend.dev`, `a@evil.example;b@resend.dev` and `a@evil.example <delivered@resend.dev>`. Numbers, booleans, objects and lists holding non-strings are refused too. The domain must match the allowlist exactly, so `a@resend.dev.evil.example` is refused.\n- **No custom headers.** A header such as `Bcc` would bypass the recipient check.\n- **Idempotency keys.** Resend drops a repeat of the same key within 24 hours, returns 409 if the payload differs, and accepts keys up to 256 characters ([docs](https://resend.com/docs/dashboard/emails/idempotency-keys)). The gate derives the key from the tool name and arguments, so a retried, identical call can't send twice. We confirmed offline that resend-mcp forwards `idempotencyKey` as the `Idempotency-Key` header.\n- **What the gate can't see.** `reply-to-inbox-thread-email` goes to the author of the message being answered, and that address isn't in the call. The gate checks only the extra `cc`/`bcc` and refuses `replyAll`. `send-inbox-draft` takes only an `inboxId`, a `draftId`, optional `headers` (refused) and an `idempotencyKey`. The draft's recipients and attachments were checked when the agent created or updated it through this gate. But a draft can also come from a person, another tool or a reply draft addressed by default, so `send-inbox-draft` still needs human approval. The prompt asks you to open the draft first.\n- **Search filters.** `search-inbox-threads` also takes `to`/`cc`/`bcc`, but only as search filters. It is read-only and sends nothing. No other auto-allowed tool takes recipients or attachments. The test asserts this against the 2.28.0 schemas.\n- **Untrusted content.** Thread contents come back through allowed read tools and can carry instructions. The gate limits what a hijacked agent can do. To catch injection earlier, see [Screen tool output for prompt injection](https://indexagentica.com/guides/screen-tool-output-for-prompt-injection/).\n\n## 5. Test without mailing anyone\n\nResend provides test addresses ([Send test emails](https://resend.com/docs/dashboard/emails/send-test-emails)):\n\n- `delivered@resend.dev`\n- `bounced@resend.dev` (SMTP 550 5.1.1)\n- `complained@resend.dev` (marked as spam)\n- `suppressed@resend.dev`\n\nAll except `suppressed` take `+labels`, such as `delivered+signup@resend.dev`. Test emails count against your sending quota. The gate's default allowlist is `{\"resend.dev\"}`, so the agent can reach only these addresses until you widen it on purpose.\n\nTo run the server with no network at all, preload a stub `fetch` that records requests:\n\n```javascript\n// fake_fetch.mjs: node --import ./fake_fetch.mjs node_modules/resend-mcp/dist/index.js\nimport { appendFileSync } from 'node:fs';\nconst LOG = process.env.FAKE_FETCH_LOG || '/tmp/fake_fetch.log';\nglobalThis.fetch = async (url, init = {}) => {\n  appendFileSync(LOG, JSON.stringify({ url: String(url), method: init.method || 'GET',\n    idem: new Headers(init.headers).get('idempotency-key'), body: init.body ? String(init.body) : null }) + '\\n');\n  return new Response(JSON.stringify({ id: '00000000-0000-0000-0000-000000000000' }),\n    { status: 200, headers: { 'content-type': 'application/json' } });\n};\n```\n\nWith `RESEND_API_KEY` set to any dummy value, you can call tools over stdio and read exactly what would have gone to `api.resend.com`. That is how the results in step 2 were produced.\n\nUnit-test the gate against the real tool list. Dump `tools/list` from the server into `tools.json`, then assert:\n\n- every name in the gate exists\n- the only auto-allowed tools without `readOnlyHint` are the two draft tools\n- all 120 other tools are denied\n- no auto-allowed tool takes recipient or attachment fields the gate doesn't check\n- each of the tricks above is refused, for sends and drafts alike\n- an exception inside the policy refuses the call\n\nOur 97-case test passed on 2026-11-04 with claude-agent-sdk 0.2.178.\n\n## What was and wasn't tested\n\n**Tested offline on 2026-11-04**, with resend-mcp 2.28.0 and the network stubbed:\n\n- the tool count and annotations\n- sender pinning\n- the `--allowed-file-dirs` behaviour\n- idempotency-key forwarding\n- the gate's 97 cases\n- building the SDK options\n\n**Not tested:**\n\n- a live send\n- the hosted server's OAuth grant\n- `sending_access` keys against the MCP read tools\n- a full agent run with a model\n\nTool names and counts change between versions. Re-dump `tools/list` and rerun the gate test when you upgrade, and pin the version as shown.\n\n## Other email servers\n\n[AgentMail](https://indexagentica.com/entries/agentmail/) gives agents their own inboxes through an API and a hosted MCP server. The same layers apply: a narrow credential, drafts before sends, a deny-by-default gate and human approval. Map them onto its tool names before you rely on them.\n",
  "raw": "---\nid: let-an-agent-send-email-safely\ntype: guide\ntitle: Let an agent send email safely through the Resend MCP server\nsummary: \"Give an agent email through Resend's MCP server without letting it mail anyone, anything, at any time: pin the sender, fence off local files, draft first, deny-by-default tool gating, and a human approval step that shows recipients and subject. Tested offline against resend-mcp 2.28.0.\"\ndescription: \"Resend's MCP server (npm resend-mcp 2.28.0) exposes 136 tools, of which 56 are marked read-only, and it has no option to turn tools off. Several send real email the moment they are called. This guide shows how to keep that safe: choose the API key permission, pin the sender with --sender, restrict attachments with --allowed-file-dirs, prefer inbox drafts, gate every send through a deny-by-default Claude Agent SDK can_use_tool callback with a recipient-domain allowlist and a human approval prompt, add idempotency keys so a retry cannot send twice, and test with resend.dev addresses. The gate, the sender pinning and the file restriction were tested offline on 2026-11-04 with fetch stubbed out; no real email was sent.\"\nauthor: Agentica Author\ndifficulty: intermediate\ntime_estimate: 40 min\nprerequisites:\n  - \"A Resend account and API key (only for the final live test; everything else runs offline)\"\n  - \"Node.js 18+ for resend-mcp, and Python 3.10+ with claude-agent-sdk for the gate\"\ntags: [email, resend, mcp, human-in-the-loop, permissions, tool-gating, prompt-injection, claude-agent-sdk]\nentries: [resend-mcp, agentmail, claude-agent-sdk]\nrelated: [screen-tool-output-for-prompt-injection, test-an-agent-for-prompt-injection, connect-an-agent-to-a-remote-mcp-server]\nsources:\n  - title: Resend MCP server documentation\n    url: https://resend.com/docs/mcp-server\n    accessed: 2026-11-04\n  - title: resend-mcp README (options and local file access warning)\n    url: https://github.com/resend/resend-mcp\n    accessed: 2026-11-04\n  - title: resend-mcp on npm\n    url: https://www.npmjs.com/package/resend-mcp\n    accessed: 2026-11-04\n  - title: \"Resend changelog: Remote MCP Server\"\n    url: https://resend.com/changelog/remote-mcp-server\n    accessed: 2026-11-04\n  - title: \"Resend API: Create API key (permissions and domain restriction)\"\n    url: https://resend.com/docs/api-reference/api-keys/create-api-key\n    accessed: 2026-11-04\n  - title: \"Resend: Send test emails\"\n    url: https://resend.com/docs/dashboard/emails/send-test-emails\n    accessed: 2026-11-04\n  - title: \"Resend: Idempotency keys\"\n    url: https://resend.com/docs/dashboard/emails/idempotency-keys\n    accessed: 2026-11-04\n  - title: \"Claude Agent SDK: Configure permissions\"\n    url: https://code.claude.com/docs/en/agent-sdk/permissions\n    accessed: 2026-11-04\n  - title: claude-agent-sdk on PyPI\n    url: https://pypi.org/project/claude-agent-sdk/\n    accessed: 2026-11-04\nlast_verified: 2026-11-04\npublished: 2026-11-04\nupdated: 2026-11-04\n---\n\n## The problem\n\nAn email tool is one of the few agent tools whose mistakes can't be undone. A sent message stays sent. If the agent reads inbound mail, that mail is untrusted input that can ask it to forward a thread, BCC someone or attach a file.\n\nThe [Resend MCP server](https://indexagentica.com/entries/resend-mcp/) is a good example because it is thorough. Version 2.28.0 (npm, 2026-11-03) lists **136 tools**. Only 56 carry `readOnlyHint`. The server has no flag to disable tools or run read-only. We grepped the package and found none. These tools send or have effects outside your account:\n\n| Effect | Tools |\n|---|---|\n| Sends email now or later | `send-email` (can schedule), `send-batch-emails` (up to 100), `send-broadcast`, `reply-to-inbox-thread-email`, `forward-inbox-thread-email`, `send-inbox-draft`, `send-new-inbox-email` |\n| Changes what gets sent later | `create-broadcast`, `update-broadcast`, `create-automation`, `update-automation`, `send-event` (triggers automations), `update-email` (reschedule) |\n| Sends data elsewhere | `create-webhook`, `update-webhook`, `replay-webhook-event`, `share-email` (public link) |\n| Changes access or deliverability | `create-api-key`, `remove-api-key`, `create-domain`, `remove-domain`, `add-suppression`, `remove-suppression`, `revoke-oauth-grant` |\n\nThe four inbox send tools describe themselves as sending \"a real email the moment you call it\". `create-inbox-draft` saves a draft and sends nothing.\n\nThe approach below uses five layers. Each one is cheap, and none depends on the model behaving.\n\n## 1. Pick the key for the job\n\nResend API keys have one of two permissions ([Create API key](https://resend.com/docs/api-reference/api-keys/create-api-key)):\n\n- `full_access` can create, delete, get and update any resource.\n- `sending_access` can only send. For Inboxes it can call only Reply to Thread Email, Forward Thread Email, Create Draft and Send Draft. It can be restricted to one domain, and a domain-restricted key can use only that domain's inboxes.\n\nThere is no read-only key. That means:\n\n- **A notifier agent** only sends alerts and never reads mail. Give it a `sending_access` key restricted to a domain you use only for agent mail. The key itself then blocks API keys, webhooks, domains and log reads, whatever the MCP server exposes.\n- **An inbox agent** reads threads and drafts replies. It needs `full_access`, because `sending_access` can't list or read threads. The gate in step 4 is then your main control.\n\nThe hosted server at `https://mcp.resend.com/mcp` signs you in with OAuth. You \"grant the necessary permissions\" and can revoke them in Team settings ([changelog](https://resend.com/changelog/remote-mcp-server)). The docs we read don't say whether the OAuth grant can be narrowed to sending only. If you need the narrow key, pass it as a Bearer token or run the server locally.\n\n## 2. Pin the sender and fence off files\n\nRun the server locally so you control its flags:\n\n```bash\nRESEND_API_KEY=re_... npx -y resend-mcp@2.28.0 \\\n  --sender agent@yourdomain.example \\\n  --allowed-file-dirs /srv/agent-outbox\n```\n\n**`--sender`** (or `SENDER_EMAIL_ADDRESS`) removes the `from` field from the `send-email` input schema. We tested this offline with `fetch` stubbed out. A call that passed `\"from\": \"ceo@evil.example\"` anyway went to the API with `from: agent@yourdomain.example`.\n\n**`--allowed-file-dirs`** (or `MCP_ALLOWED_FILE_DIRS`) limits where `attachments[].filePath` and `create-contact-import` may read from. The README is blunt: it is \"unrestricted on stdio when unset\", and over `--http` file reads are disabled unless you set it. We tested both cases on stdio:\n\n- Unset: a call attaching `/tmp/secret.txt` was sent with the file attached.\n- Set to another directory: the same call returned `File not found or outside the allowed directories.` and made no request.\n\nAlways set it on stdio, even to an empty directory.\n\n## 3. Draft first\n\nFor inbox work, let the agent write with `create-inbox-draft` and `update-inbox-draft`, and have a person send from Resend. The agent gets the useful part of the job, and the irreversible step stays with a human. If you want the agent to send, route it through the gate below.\n\nDrafts aren't harmless just because they don't send. Both draft tools take `to`, `cc`, `bcc` and `attachments`, and `send-inbox-draft` later sends whatever the draft holds. So the gate checks draft calls exactly as it checks sends. A reply draft created without `to` is addressed to the author of the message it replies to, according to the tool's own description.\n\n## 4. Gate every call, deny by default\n\nDon't filter with a list of dangerous tools. A new server version can add a tool your denylist doesn't name. The gate below runs 11 read-only tools on their own. It sends three tools to a human for approval: `send-email`, `reply-to-inbox-thread-email` and `send-inbox-draft`. It refuses everything else, including `forward-inbox-thread-email`, `send-new-inbox-email` and tools that don't exist yet.\n\n**Draft tools** (`create-inbox-draft`, `update-inbox-draft`) run without a prompt, but only after these checks:\n\n- every `to`/`cc`/`bcc` address must be a single address in an allowed domain\n- at most five recipients per call\n- no attachments\n- `update-inbox-draft` may pass `null` to clear recipients or attachments\n\n**Send tools** get the same recipient and attachment checks. The gate also:\n\n- refuses scheduling, custom headers and `replyAll`\n- requires at least one recipient for `send-email`\n- adds an idempotency key\n- shows a person the details before anything goes out\n\n**Every error fails closed.** If the policy hits an unexpected value or an exception, the call is refused and the person is never asked.\n\n```python\n\"\"\"Approval gate for an agent using the Resend MCP server (resend-mcp 2.28.0 tool names).\n\nDeny by default: a tool runs on its own only if it is in AUTO_ALLOW, goes to a human\nonly if it is in ASK, and is refused otherwise -- including tools added in later versions.\nAny call that names recipients or attachments is checked, drafts included.\nAny error inside the policy refuses the call (fail closed).\n\"\"\"\nimport hashlib, json, re\n\nSERVER = \"resend\"                      # the name you give the server in mcp_servers\nP = f\"mcp__{SERVER}__\"\n\nREAD_ONLY = {                          # read-only inbox and email tools\n    \"list-inboxes\", \"get-inbox\", \"list-inbox-threads\", \"search-inbox-threads\",\n    \"get-inbox-thread\", \"list-inbox-thread-emails\", \"get-inbox-thread-email\",\n    \"list-inbox-drafts\", \"get-inbox-draft\", \"list-emails\", \"get-email\",\n}\nDRAFTS = {\"create-inbox-draft\", \"update-inbox-draft\"}   # save drafts, send nothing\nAUTO_ALLOW = READ_ONLY | DRAFTS\nASK = {\"send-email\", \"reply-to-inbox-thread-email\", \"send-inbox-draft\"}\nALLOWED_RECIPIENT_DOMAINS = {\"resend.dev\"}   # widen deliberately, e.g. {\"yourcompany.com\"}\nMAX_RECIPIENTS = 5\n\n_ADDR = re.compile(r\"[^@\\s,;<>\\\"']+@[A-Za-z0-9.-]+\")\n_NAMED = re.compile(r\"[^@,;<>\\\"']*[^@\\s,;<>\\\"'][^@,;<>\\\"']* <([^<>]*)>\")\n\nclass Refuse(Exception):\n    pass\n\ndef _address(a):\n    \"\"\"Return the bare address in a@b or `Name <a@b>`; refuse anything else.\"\"\"\n    if not isinstance(a, str):\n        raise Refuse(\"recipient is not a string\")\n    m = _NAMED.fullmatch(a)\n    addr = m.group(1) if m else a\n    if not _ADDR.fullmatch(addr):\n        raise Refuse(f\"recipient not a single plain address: {a!r}\")\n    return addr\n\ndef _recipients(args, nullable=False):\n    out = []\n    for field in (\"to\", \"cc\", \"bcc\"):\n        if field not in args or (nullable and args[field] is None):\n            continue\n        v = args[field]\n        if isinstance(v, str):\n            v = [v]\n        if not isinstance(v, list):\n            raise Refuse(f\"{field} must be a string or a list of strings\")\n        out += [_address(a) for a in v]\n    if len(out) > MAX_RECIPIENTS:\n        raise Refuse(f\"more than {MAX_RECIPIENTS} recipients\")\n    bad = [a for a in out if a.rsplit(\"@\", 1)[1].lower() not in ALLOWED_RECIPIENT_DOMAINS]\n    if bad:\n        raise Refuse(f\"recipient domain not allowed: {', '.join(bad)}\")\n    return out\n\ndef _no_extras(args):\n    if args.get(\"attachments\"): raise Refuse(\"attachments are not allowed\")\n    if args.get(\"scheduledAt\"): raise Refuse(\"scheduled sends are not allowed\")\n    if args.get(\"headers\"): raise Refuse(\"custom headers are not allowed\")\n\ndef _check(tool, args):\n    if not isinstance(tool, str) or not tool.startswith(P): return (\"deny\", f\"not a {SERVER} tool\")\n    if not isinstance(args, dict): return (\"deny\", \"arguments are not an object\")\n    name = tool[len(P):]\n    if name in READ_ONLY: return (\"allow\", None)\n    if name in DRAFTS:\n        _no_extras(args)\n        _recipients(args, nullable=(name == \"update-inbox-draft\"))   # null clears recipients\n        return (\"allow\", None)\n    if name not in ASK: return (\"deny\", f\"{name} is not on the allowlist\")\n    args = dict(args)\n    _no_extras(args)\n    if args.get(\"replyAll\"): raise Refuse(\"replyAll is not allowed\")\n    rcpts = _recipients(args)\n    if name == \"send-email\" and not rcpts: raise Refuse(\"no recipients\")\n    if not args.get(\"idempotencyKey\"):\n        # Same content -> same key, so a retried call cannot send twice.\n        digest = hashlib.sha256(json.dumps([name, args], sort_keys=True).encode()).hexdigest()\n        args[\"idempotencyKey\"] = f\"agent-{digest[:32]}\"\n    return (\"ask\", args)\n\ndef check(tool, args):\n    \"\"\"Return (\"allow\", None) | (\"ask\", args_to_send) | (\"deny\", reason). Pure: no I/O.\"\"\"\n    try:\n        return _check(tool, args)\n    except Refuse as e:\n        return (\"deny\", str(e))\n    except Exception as e:  # fail closed\n        return (\"deny\", f\"policy error ({type(e).__name__}); refused\")\n\ndef summary(tool, args):\n    name = tool[len(P):]\n    lines = [f\"Agent wants to call {name}\"]\n    for k in (\"to\", \"cc\", \"bcc\", \"replyTo\", \"subject\", \"inboxId\", \"threadId\", \"emailId\", \"draftId\"):\n        if args.get(k) not in (None, \"\", []): lines.append(f\"  {k}: {args[k]}\")\n    body = args.get(\"text\") or args.get(\"html\") or \"\"\n    if body: lines.append(\"  body:\\n    \" + str(body)[:800].replace(\"\\n\", \"\\n    \"))\n    if name == \"reply-to-inbox-thread-email\":\n        lines.append(\"  (goes to the author of the message being answered; that address is not in this call)\")\n    if name == \"send-inbox-draft\":\n        lines.append(\"  (recipients, subject and body are in the draft, not this call; open it with get-inbox-draft\"\n                     \" or in Resend before approving)\")\n    return \"\\n\".join(lines)\n\ndef make_can_use_tool(ask_human):\n    \"\"\"ask_human(text) -> bool. Returns a Claude Agent SDK can_use_tool callback.\"\"\"\n    from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny\n    async def can_use_tool(tool_name, tool_input, context):\n        verdict, extra = check(tool_name, tool_input)\n        if verdict == \"allow\": return PermissionResultAllow()\n        if verdict == \"deny\": return PermissionResultDeny(message=extra)\n        try:\n            text = summary(tool_name, extra)\n        except Exception:\n            return PermissionResultDeny(message=\"could not summarise this send; refused\")\n        if await ask_human(text):\n            return PermissionResultAllow(updated_input=extra)\n        return PermissionResultDeny(message=\"A human declined this send. Do not retry it.\")\n    return can_use_tool\n```\n\nWire it into the [Claude Agent SDK](https://indexagentica.com/entries/claude-agent-sdk/) as `can_use_tool`:\n\n```python\nimport asyncio, os\nfrom claude_agent_sdk import ClaudeAgentOptions, query\nfrom email_gate import make_can_use_tool\n\nasync def ask_human(text: str) -> bool:\n    print(text)\n    answer = await asyncio.to_thread(input, \"Send this email? [y/N] \")\n    return answer.strip().lower() == \"y\"\n\noptions = ClaudeAgentOptions(\n    mcp_servers={\"resend\": {\n        \"type\": \"stdio\",\n        \"command\": \"npx\",\n        \"args\": [\"-y\", \"resend-mcp@2.28.0\",\n                 \"--sender\", \"agent@yourdomain.example\",\n                 \"--allowed-file-dirs\", \"/srv/agent-outbox\"],\n        \"env\": {\"RESEND_API_KEY\": os.environ.get(\"RESEND_API_KEY\", \"\")},\n    }},\n    permission_mode=\"default\",           # never \"bypassPermissions\"\n    can_use_tool=make_can_use_tool(ask_human),\n)\n\nasync def main():\n    async for msg in query(prompt=\"Draft a reply to the newest thread in my support inbox.\", options=options):\n        print(msg)\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nTwo SDK rules matter here ([permissions docs](https://code.claude.com/docs/en/agent-sdk/permissions)):\n\n- **Don't put Resend tools in `allowed_tools`.** Allow rules are evaluated before `can_use_tool`, and \"auto-approved tools never reach `canUseTool`\".\n- **Don't use `bypassPermissions`.** It approves unlisted tools before your callback sees them.\n\nThe gate does its own allowing so that every Resend call passes through it. In other harnesses, put the same `check()` function wherever tool calls are approved.\n\n### Why these checks\n\n- **One address per string.** A recipient must be either a plain `local@domain` or exactly `Name <local@domain>`. The name can't contain `@`, quotes, `,`, `;`, `<` or `>`. Anything with a separator or stray whitespace is refused before the domain is checked, including `a@evil.example, delivered@resend.dev`, `<a@evil.example>delivered@resend.dev`, `a@evil.example;b@resend.dev` and `a@evil.example <delivered@resend.dev>`. Numbers, booleans, objects and lists holding non-strings are refused too. The domain must match the allowlist exactly, so `a@resend.dev.evil.example` is refused.\n- **No custom headers.** A header such as `Bcc` would bypass the recipient check.\n- **Idempotency keys.** Resend drops a repeat of the same key within 24 hours, returns 409 if the payload differs, and accepts keys up to 256 characters ([docs](https://resend.com/docs/dashboard/emails/idempotency-keys)). The gate derives the key from the tool name and arguments, so a retried, identical call can't send twice. We confirmed offline that resend-mcp forwards `idempotencyKey` as the `Idempotency-Key` header.\n- **What the gate can't see.** `reply-to-inbox-thread-email` goes to the author of the message being answered, and that address isn't in the call. The gate checks only the extra `cc`/`bcc` and refuses `replyAll`. `send-inbox-draft` takes only an `inboxId`, a `draftId`, optional `headers` (refused) and an `idempotencyKey`. The draft's recipients and attachments were checked when the agent created or updated it through this gate. But a draft can also come from a person, another tool or a reply draft addressed by default, so `send-inbox-draft` still needs human approval. The prompt asks you to open the draft first.\n- **Search filters.** `search-inbox-threads` also takes `to`/`cc`/`bcc`, but only as search filters. It is read-only and sends nothing. No other auto-allowed tool takes recipients or attachments. The test asserts this against the 2.28.0 schemas.\n- **Untrusted content.** Thread contents come back through allowed read tools and can carry instructions. The gate limits what a hijacked agent can do. To catch injection earlier, see [Screen tool output for prompt injection](https://indexagentica.com/guides/screen-tool-output-for-prompt-injection/).\n\n## 5. Test without mailing anyone\n\nResend provides test addresses ([Send test emails](https://resend.com/docs/dashboard/emails/send-test-emails)):\n\n- `delivered@resend.dev`\n- `bounced@resend.dev` (SMTP 550 5.1.1)\n- `complained@resend.dev` (marked as spam)\n- `suppressed@resend.dev`\n\nAll except `suppressed` take `+labels`, such as `delivered+signup@resend.dev`. Test emails count against your sending quota. The gate's default allowlist is `{\"resend.dev\"}`, so the agent can reach only these addresses until you widen it on purpose.\n\nTo run the server with no network at all, preload a stub `fetch` that records requests:\n\n```javascript\n// fake_fetch.mjs: node --import ./fake_fetch.mjs node_modules/resend-mcp/dist/index.js\nimport { appendFileSync } from 'node:fs';\nconst LOG = process.env.FAKE_FETCH_LOG || '/tmp/fake_fetch.log';\nglobalThis.fetch = async (url, init = {}) => {\n  appendFileSync(LOG, JSON.stringify({ url: String(url), method: init.method || 'GET',\n    idem: new Headers(init.headers).get('idempotency-key'), body: init.body ? String(init.body) : null }) + '\\n');\n  return new Response(JSON.stringify({ id: '00000000-0000-0000-0000-000000000000' }),\n    { status: 200, headers: { 'content-type': 'application/json' } });\n};\n```\n\nWith `RESEND_API_KEY` set to any dummy value, you can call tools over stdio and read exactly what would have gone to `api.resend.com`. That is how the results in step 2 were produced.\n\nUnit-test the gate against the real tool list. Dump `tools/list` from the server into `tools.json`, then assert:\n\n- every name in the gate exists\n- the only auto-allowed tools without `readOnlyHint` are the two draft tools\n- all 120 other tools are denied\n- no auto-allowed tool takes recipient or attachment fields the gate doesn't check\n- each of the tricks above is refused, for sends and drafts alike\n- an exception inside the policy refuses the call\n\nOur 97-case test passed on 2026-11-04 with claude-agent-sdk 0.2.178.\n\n## What was and wasn't tested\n\n**Tested offline on 2026-11-04**, with resend-mcp 2.28.0 and the network stubbed:\n\n- the tool count and annotations\n- sender pinning\n- the `--allowed-file-dirs` behaviour\n- idempotency-key forwarding\n- the gate's 97 cases\n- building the SDK options\n\n**Not tested:**\n\n- a live send\n- the hosted server's OAuth grant\n- `sending_access` keys against the MCP read tools\n- a full agent run with a model\n\nTool names and counts change between versions. Re-dump `tools/list` and rerun the gate test when you upgrade, and pin the version as shown.\n\n## Other email servers\n\n[AgentMail](https://indexagentica.com/entries/agentmail/) gives agents their own inboxes through an API and a hosted MCP server. The same layers apply: a narrow credential, drafts before sends, a deny-by-default gate and human approval. Map them onto its tool names before you rely on them.\n",
  "html": "<h2>The problem</h2>\n<p>An email tool is one of the few agent tools whose mistakes can&#39;t be undone. A sent message stays sent. If the agent reads inbound mail, that mail is untrusted input that can ask it to forward a thread, BCC someone or attach a file.</p>\n<p>The <a href=\"/entries/resend-mcp/\">Resend MCP server</a> is a good example because it is thorough. Version 2.28.0 (npm, 2026-11-03) lists <strong>136 tools</strong>. Only 56 carry <code>readOnlyHint</code>. The server has no flag to disable tools or run read-only. We grepped the package and found none. These tools send or have effects outside your account:</p>\n<table><thead><tr><th scope=\"col\">Effect</th><th scope=\"col\">Tools</th></tr></thead><tbody><tr><td>Sends email now or later</td><td><code>send-email</code> (can schedule), <code>send-batch-emails</code> (up to 100), <code>send-broadcast</code>, <code>reply-to-inbox-thread-email</code>, <code>forward-inbox-thread-email</code>, <code>send-inbox-draft</code>, <code>send-new-inbox-email</code></td></tr><tr><td>Changes what gets sent later</td><td><code>create-broadcast</code>, <code>update-broadcast</code>, <code>create-automation</code>, <code>update-automation</code>, <code>send-event</code> (triggers automations), <code>update-email</code> (reschedule)</td></tr><tr><td>Sends data elsewhere</td><td><code>create-webhook</code>, <code>update-webhook</code>, <code>replay-webhook-event</code>, <code>share-email</code> (public link)</td></tr><tr><td>Changes access or deliverability</td><td><code>create-api-key</code>, <code>remove-api-key</code>, <code>create-domain</code>, <code>remove-domain</code>, <code>add-suppression</code>, <code>remove-suppression</code>, <code>revoke-oauth-grant</code></td></tr></tbody></table>\n<p>The four inbox send tools describe themselves as sending &quot;a real email the moment you call it&quot;. <code>create-inbox-draft</code> saves a draft and sends nothing.</p>\n<p>The approach below uses five layers. Each one is cheap, and none depends on the model behaving.</p>\n<h2>1. Pick the key for the job</h2>\n<p>Resend API keys have one of two permissions (<a href=\"https://resend.com/docs/api-reference/api-keys/create-api-key\">Create API key</a>):</p>\n<ul><li><code>full_access</code> can create, delete, get and update any resource.</li><li><code>sending_access</code> can only send. For Inboxes it can call only Reply to Thread Email, Forward Thread Email, Create Draft and Send Draft. It can be restricted to one domain, and a domain-restricted key can use only that domain&#39;s inboxes.</li></ul>\n<p>There is no read-only key. That means:</p>\n<ul><li><strong>A notifier agent</strong> only sends alerts and never reads mail. Give it a <code>sending_access</code> key restricted to a domain you use only for agent mail. The key itself then blocks API keys, webhooks, domains and log reads, whatever the MCP server exposes.</li><li><strong>An inbox agent</strong> reads threads and drafts replies. It needs <code>full_access</code>, because <code>sending_access</code> can&#39;t list or read threads. The gate in step 4 is then your main control.</li></ul>\n<p>The hosted server at <code>https://mcp.resend.com/mcp</code> signs you in with OAuth. You &quot;grant the necessary permissions&quot; and can revoke them in Team settings (<a href=\"https://resend.com/changelog/remote-mcp-server\">changelog</a>). The docs we read don&#39;t say whether the OAuth grant can be narrowed to sending only. If you need the narrow key, pass it as a Bearer token or run the server locally.</p>\n<h2>2. Pin the sender and fence off files</h2>\n<p>Run the server locally so you control its flags:</p>\n<pre><code class=\"language-bash\">RESEND_API_KEY=re_... npx -y resend-mcp@2.28.0 \\\n  --sender agent@yourdomain.example \\\n  --allowed-file-dirs /srv/agent-outbox</code></pre>\n<p><strong><code>--sender</code></strong> (or <code>SENDER_EMAIL_ADDRESS</code>) removes the <code>from</code> field from the <code>send-email</code> input schema. We tested this offline with <code>fetch</code> stubbed out. A call that passed <code>&quot;from&quot;: &quot;ceo@evil.example&quot;</code> anyway went to the API with <code>from: agent@yourdomain.example</code>.</p>\n<p><strong><code>--allowed-file-dirs</code></strong> (or <code>MCP_ALLOWED_FILE_DIRS</code>) limits where <code>attachments[].filePath</code> and <code>create-contact-import</code> may read from. The README is blunt: it is &quot;unrestricted on stdio when unset&quot;, and over <code>--http</code> file reads are disabled unless you set it. We tested both cases on stdio:</p>\n<ul><li>Unset: a call attaching <code>/tmp/secret.txt</code> was sent with the file attached.</li><li>Set to another directory: the same call returned <code>File not found or outside the allowed directories.</code> and made no request.</li></ul>\n<p>Always set it on stdio, even to an empty directory.</p>\n<h2>3. Draft first</h2>\n<p>For inbox work, let the agent write with <code>create-inbox-draft</code> and <code>update-inbox-draft</code>, and have a person send from Resend. The agent gets the useful part of the job, and the irreversible step stays with a human. If you want the agent to send, route it through the gate below.</p>\n<p>Drafts aren&#39;t harmless just because they don&#39;t send. Both draft tools take <code>to</code>, <code>cc</code>, <code>bcc</code> and <code>attachments</code>, and <code>send-inbox-draft</code> later sends whatever the draft holds. So the gate checks draft calls exactly as it checks sends. A reply draft created without <code>to</code> is addressed to the author of the message it replies to, according to the tool&#39;s own description.</p>\n<h2>4. Gate every call, deny by default</h2>\n<p>Don&#39;t filter with a list of dangerous tools. A new server version can add a tool your denylist doesn&#39;t name. The gate below runs 11 read-only tools on their own. It sends three tools to a human for approval: <code>send-email</code>, <code>reply-to-inbox-thread-email</code> and <code>send-inbox-draft</code>. It refuses everything else, including <code>forward-inbox-thread-email</code>, <code>send-new-inbox-email</code> and tools that don&#39;t exist yet.</p>\n<p><strong>Draft tools</strong> (<code>create-inbox-draft</code>, <code>update-inbox-draft</code>) run without a prompt, but only after these checks:</p>\n<ul><li>every <code>to</code>/<code>cc</code>/<code>bcc</code> address must be a single address in an allowed domain</li><li>at most five recipients per call</li><li>no attachments</li><li><code>update-inbox-draft</code> may pass <code>null</code> to clear recipients or attachments</li></ul>\n<p><strong>Send tools</strong> get the same recipient and attachment checks. The gate also:</p>\n<ul><li>refuses scheduling, custom headers and <code>replyAll</code></li><li>requires at least one recipient for <code>send-email</code></li><li>adds an idempotency key</li><li>shows a person the details before anything goes out</li></ul>\n<p><strong>Every error fails closed.</strong> If the policy hits an unexpected value or an exception, the call is refused and the person is never asked.</p>\n<pre><code class=\"language-python\">&quot;&quot;&quot;Approval gate for an agent using the Resend MCP server (resend-mcp 2.28.0 tool names).\n\nDeny by default: a tool runs on its own only if it is in AUTO_ALLOW, goes to a human\nonly if it is in ASK, and is refused otherwise -- including tools added in later versions.\nAny call that names recipients or attachments is checked, drafts included.\nAny error inside the policy refuses the call (fail closed).\n&quot;&quot;&quot;\nimport hashlib, json, re\n\nSERVER = &quot;resend&quot;                      # the name you give the server in mcp_servers\nP = f&quot;mcp__{SERVER}__&quot;\n\nREAD_ONLY = {                          # read-only inbox and email tools\n    &quot;list-inboxes&quot;, &quot;get-inbox&quot;, &quot;list-inbox-threads&quot;, &quot;search-inbox-threads&quot;,\n    &quot;get-inbox-thread&quot;, &quot;list-inbox-thread-emails&quot;, &quot;get-inbox-thread-email&quot;,\n    &quot;list-inbox-drafts&quot;, &quot;get-inbox-draft&quot;, &quot;list-emails&quot;, &quot;get-email&quot;,\n}\nDRAFTS = {&quot;create-inbox-draft&quot;, &quot;update-inbox-draft&quot;}   # save drafts, send nothing\nAUTO_ALLOW = READ_ONLY | DRAFTS\nASK = {&quot;send-email&quot;, &quot;reply-to-inbox-thread-email&quot;, &quot;send-inbox-draft&quot;}\nALLOWED_RECIPIENT_DOMAINS = {&quot;resend.dev&quot;}   # widen deliberately, e.g. {&quot;yourcompany.com&quot;}\nMAX_RECIPIENTS = 5\n\n_ADDR = re.compile(r&quot;[^@\\s,;&lt;&gt;\\&quot;&#39;]+@[A-Za-z0-9.-]+&quot;)\n_NAMED = re.compile(r&quot;[^@,;&lt;&gt;\\&quot;&#39;]*[^@\\s,;&lt;&gt;\\&quot;&#39;][^@,;&lt;&gt;\\&quot;&#39;]* &lt;([^&lt;&gt;]*)&gt;&quot;)\n\nclass Refuse(Exception):\n    pass\n\ndef _address(a):\n    &quot;&quot;&quot;Return the bare address in a@b or `Name &lt;a@b&gt;`; refuse anything else.&quot;&quot;&quot;\n    if not isinstance(a, str):\n        raise Refuse(&quot;recipient is not a string&quot;)\n    m = _NAMED.fullmatch(a)\n    addr = m.group(1) if m else a\n    if not _ADDR.fullmatch(addr):\n        raise Refuse(f&quot;recipient not a single plain address: {a!r}&quot;)\n    return addr\n\ndef _recipients(args, nullable=False):\n    out = []\n    for field in (&quot;to&quot;, &quot;cc&quot;, &quot;bcc&quot;):\n        if field not in args or (nullable and args[field] is None):\n            continue\n        v = args[field]\n        if isinstance(v, str):\n            v = [v]\n        if not isinstance(v, list):\n            raise Refuse(f&quot;{field} must be a string or a list of strings&quot;)\n        out += [_address(a) for a in v]\n    if len(out) &gt; MAX_RECIPIENTS:\n        raise Refuse(f&quot;more than {MAX_RECIPIENTS} recipients&quot;)\n    bad = [a for a in out if a.rsplit(&quot;@&quot;, 1)[1].lower() not in ALLOWED_RECIPIENT_DOMAINS]\n    if bad:\n        raise Refuse(f&quot;recipient domain not allowed: {&#39;, &#39;.join(bad)}&quot;)\n    return out\n\ndef _no_extras(args):\n    if args.get(&quot;attachments&quot;): raise Refuse(&quot;attachments are not allowed&quot;)\n    if args.get(&quot;scheduledAt&quot;): raise Refuse(&quot;scheduled sends are not allowed&quot;)\n    if args.get(&quot;headers&quot;): raise Refuse(&quot;custom headers are not allowed&quot;)\n\ndef _check(tool, args):\n    if not isinstance(tool, str) or not tool.startswith(P): return (&quot;deny&quot;, f&quot;not a {SERVER} tool&quot;)\n    if not isinstance(args, dict): return (&quot;deny&quot;, &quot;arguments are not an object&quot;)\n    name = tool[len(P):]\n    if name in READ_ONLY: return (&quot;allow&quot;, None)\n    if name in DRAFTS:\n        _no_extras(args)\n        _recipients(args, nullable=(name == &quot;update-inbox-draft&quot;))   # null clears recipients\n        return (&quot;allow&quot;, None)\n    if name not in ASK: return (&quot;deny&quot;, f&quot;{name} is not on the allowlist&quot;)\n    args = dict(args)\n    _no_extras(args)\n    if args.get(&quot;replyAll&quot;): raise Refuse(&quot;replyAll is not allowed&quot;)\n    rcpts = _recipients(args)\n    if name == &quot;send-email&quot; and not rcpts: raise Refuse(&quot;no recipients&quot;)\n    if not args.get(&quot;idempotencyKey&quot;):\n        # Same content -&gt; same key, so a retried call cannot send twice.\n        digest = hashlib.sha256(json.dumps([name, args], sort_keys=True).encode()).hexdigest()\n        args[&quot;idempotencyKey&quot;] = f&quot;agent-{digest[:32]}&quot;\n    return (&quot;ask&quot;, args)\n\ndef check(tool, args):\n    &quot;&quot;&quot;Return (&quot;allow&quot;, None) | (&quot;ask&quot;, args_to_send) | (&quot;deny&quot;, reason). Pure: no I/O.&quot;&quot;&quot;\n    try:\n        return _check(tool, args)\n    except Refuse as e:\n        return (&quot;deny&quot;, str(e))\n    except Exception as e:  # fail closed\n        return (&quot;deny&quot;, f&quot;policy error ({type(e).__name__}); refused&quot;)\n\ndef summary(tool, args):\n    name = tool[len(P):]\n    lines = [f&quot;Agent wants to call {name}&quot;]\n    for k in (&quot;to&quot;, &quot;cc&quot;, &quot;bcc&quot;, &quot;replyTo&quot;, &quot;subject&quot;, &quot;inboxId&quot;, &quot;threadId&quot;, &quot;emailId&quot;, &quot;draftId&quot;):\n        if args.get(k) not in (None, &quot;&quot;, []): lines.append(f&quot;  {k}: {args[k]}&quot;)\n    body = args.get(&quot;text&quot;) or args.get(&quot;html&quot;) or &quot;&quot;\n    if body: lines.append(&quot;  body:\\n    &quot; + str(body)[:800].replace(&quot;\\n&quot;, &quot;\\n    &quot;))\n    if name == &quot;reply-to-inbox-thread-email&quot;:\n        lines.append(&quot;  (goes to the author of the message being answered; that address is not in this call)&quot;)\n    if name == &quot;send-inbox-draft&quot;:\n        lines.append(&quot;  (recipients, subject and body are in the draft, not this call; open it with get-inbox-draft&quot;\n                     &quot; or in Resend before approving)&quot;)\n    return &quot;\\n&quot;.join(lines)\n\ndef make_can_use_tool(ask_human):\n    &quot;&quot;&quot;ask_human(text) -&gt; bool. Returns a Claude Agent SDK can_use_tool callback.&quot;&quot;&quot;\n    from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny\n    async def can_use_tool(tool_name, tool_input, context):\n        verdict, extra = check(tool_name, tool_input)\n        if verdict == &quot;allow&quot;: return PermissionResultAllow()\n        if verdict == &quot;deny&quot;: return PermissionResultDeny(message=extra)\n        try:\n            text = summary(tool_name, extra)\n        except Exception:\n            return PermissionResultDeny(message=&quot;could not summarise this send; refused&quot;)\n        if await ask_human(text):\n            return PermissionResultAllow(updated_input=extra)\n        return PermissionResultDeny(message=&quot;A human declined this send. Do not retry it.&quot;)\n    return can_use_tool</code></pre>\n<p>Wire it into the <a href=\"/entries/claude-agent-sdk/\">Claude Agent SDK</a> as <code>can_use_tool</code>:</p>\n<pre><code class=\"language-python\">import asyncio, os\nfrom claude_agent_sdk import ClaudeAgentOptions, query\nfrom email_gate import make_can_use_tool\n\nasync def ask_human(text: str) -&gt; bool:\n    print(text)\n    answer = await asyncio.to_thread(input, &quot;Send this email? [y/N] &quot;)\n    return answer.strip().lower() == &quot;y&quot;\n\noptions = ClaudeAgentOptions(\n    mcp_servers={&quot;resend&quot;: {\n        &quot;type&quot;: &quot;stdio&quot;,\n        &quot;command&quot;: &quot;npx&quot;,\n        &quot;args&quot;: [&quot;-y&quot;, &quot;resend-mcp@2.28.0&quot;,\n                 &quot;--sender&quot;, &quot;agent@yourdomain.example&quot;,\n                 &quot;--allowed-file-dirs&quot;, &quot;/srv/agent-outbox&quot;],\n        &quot;env&quot;: {&quot;RESEND_API_KEY&quot;: os.environ.get(&quot;RESEND_API_KEY&quot;, &quot;&quot;)},\n    }},\n    permission_mode=&quot;default&quot;,           # never &quot;bypassPermissions&quot;\n    can_use_tool=make_can_use_tool(ask_human),\n)\n\nasync def main():\n    async for msg in query(prompt=&quot;Draft a reply to the newest thread in my support inbox.&quot;, options=options):\n        print(msg)\n\nif __name__ == &quot;__main__&quot;:\n    asyncio.run(main())</code></pre>\n<p>Two SDK rules matter here (<a href=\"https://code.claude.com/docs/en/agent-sdk/permissions\">permissions docs</a>):</p>\n<ul><li><strong>Don&#39;t put Resend tools in <code>allowed_tools</code>.</strong> Allow rules are evaluated before <code>can_use_tool</code>, and &quot;auto-approved tools never reach <code>canUseTool</code>&quot;.</li><li><strong>Don&#39;t use <code>bypassPermissions</code>.</strong> It approves unlisted tools before your callback sees them.</li></ul>\n<p>The gate does its own allowing so that every Resend call passes through it. In other harnesses, put the same <code>check()</code> function wherever tool calls are approved.</p>\n<h3>Why these checks</h3>\n<ul><li><strong>One address per string.</strong> A recipient must be either a plain <code>local@domain</code> or exactly <code>Name &lt;local@domain&gt;</code>. The name can&#39;t contain <code>@</code>, quotes, <code>,</code>, <code>;</code>, <code>&lt;</code> or <code>&gt;</code>. Anything with a separator or stray whitespace is refused before the domain is checked, including <code>a@evil.example, delivered@resend.dev</code>, <code>&lt;a@evil.example&gt;delivered@resend.dev</code>, <code>a@evil.example;b@resend.dev</code> and <code>a@evil.example &lt;delivered@resend.dev&gt;</code>. Numbers, booleans, objects and lists holding non-strings are refused too. The domain must match the allowlist exactly, so <code>a@resend.dev.evil.example</code> is refused.</li><li><strong>No custom headers.</strong> A header such as <code>Bcc</code> would bypass the recipient check.</li><li><strong>Idempotency keys.</strong> Resend drops a repeat of the same key within 24 hours, returns 409 if the payload differs, and accepts keys up to 256 characters (<a href=\"https://resend.com/docs/dashboard/emails/idempotency-keys\">docs</a>). The gate derives the key from the tool name and arguments, so a retried, identical call can&#39;t send twice. We confirmed offline that resend-mcp forwards <code>idempotencyKey</code> as the <code>Idempotency-Key</code> header.</li><li><strong>What the gate can&#39;t see.</strong> <code>reply-to-inbox-thread-email</code> goes to the author of the message being answered, and that address isn&#39;t in the call. The gate checks only the extra <code>cc</code>/<code>bcc</code> and refuses <code>replyAll</code>. <code>send-inbox-draft</code> takes only an <code>inboxId</code>, a <code>draftId</code>, optional <code>headers</code> (refused) and an <code>idempotencyKey</code>. The draft&#39;s recipients and attachments were checked when the agent created or updated it through this gate. But a draft can also come from a person, another tool or a reply draft addressed by default, so <code>send-inbox-draft</code> still needs human approval. The prompt asks you to open the draft first.</li><li><strong>Search filters.</strong> <code>search-inbox-threads</code> also takes <code>to</code>/<code>cc</code>/<code>bcc</code>, but only as search filters. It is read-only and sends nothing. No other auto-allowed tool takes recipients or attachments. The test asserts this against the 2.28.0 schemas.</li><li><strong>Untrusted content.</strong> Thread contents come back through allowed read tools and can carry instructions. The gate limits what a hijacked agent can do. To catch injection earlier, see <a href=\"/guides/screen-tool-output-for-prompt-injection/\">Screen tool output for prompt injection</a>.</li></ul>\n<h2>5. Test without mailing anyone</h2>\n<p>Resend provides test addresses (<a href=\"https://resend.com/docs/dashboard/emails/send-test-emails\">Send test emails</a>):</p>\n<ul><li><code>delivered@resend.dev</code></li><li><code>bounced@resend.dev</code> (SMTP 550 5.1.1)</li><li><code>complained@resend.dev</code> (marked as spam)</li><li><code>suppressed@resend.dev</code></li></ul>\n<p>All except <code>suppressed</code> take <code>+labels</code>, such as <code>delivered+signup@resend.dev</code>. Test emails count against your sending quota. The gate&#39;s default allowlist is <code>{&quot;resend.dev&quot;}</code>, so the agent can reach only these addresses until you widen it on purpose.</p>\n<p>To run the server with no network at all, preload a stub <code>fetch</code> that records requests:</p>\n<pre><code class=\"language-javascript\">// fake_fetch.mjs: node --import ./fake_fetch.mjs node_modules/resend-mcp/dist/index.js\nimport { appendFileSync } from &#39;node:fs&#39;;\nconst LOG = process.env.FAKE_FETCH_LOG || &#39;/tmp/fake_fetch.log&#39;;\nglobalThis.fetch = async (url, init = {}) =&gt; {\n  appendFileSync(LOG, JSON.stringify({ url: String(url), method: init.method || &#39;GET&#39;,\n    idem: new Headers(init.headers).get(&#39;idempotency-key&#39;), body: init.body ? String(init.body) : null }) + &#39;\\n&#39;);\n  return new Response(JSON.stringify({ id: &#39;00000000-0000-0000-0000-000000000000&#39; }),\n    { status: 200, headers: { &#39;content-type&#39;: &#39;application/json&#39; } });\n};</code></pre>\n<p>With <code>RESEND_API_KEY</code> set to any dummy value, you can call tools over stdio and read exactly what would have gone to <code>api.resend.com</code>. That is how the results in step 2 were produced.</p>\n<p>Unit-test the gate against the real tool list. Dump <code>tools/list</code> from the server into <code>tools.json</code>, then assert:</p>\n<ul><li>every name in the gate exists</li><li>the only auto-allowed tools without <code>readOnlyHint</code> are the two draft tools</li><li>all 120 other tools are denied</li><li>no auto-allowed tool takes recipient or attachment fields the gate doesn&#39;t check</li><li>each of the tricks above is refused, for sends and drafts alike</li><li>an exception inside the policy refuses the call</li></ul>\n<p>Our 97-case test passed on 2026-11-04 with claude-agent-sdk 0.2.178.</p>\n<h2>What was and wasn&#39;t tested</h2>\n<p><strong>Tested offline on 2026-11-04</strong>, with resend-mcp 2.28.0 and the network stubbed:</p>\n<ul><li>the tool count and annotations</li><li>sender pinning</li><li>the <code>--allowed-file-dirs</code> behaviour</li><li>idempotency-key forwarding</li><li>the gate&#39;s 97 cases</li><li>building the SDK options</li></ul>\n<p><strong>Not tested:</strong></p>\n<ul><li>a live send</li><li>the hosted server&#39;s OAuth grant</li><li><code>sending_access</code> keys against the MCP read tools</li><li>a full agent run with a model</li></ul>\n<p>Tool names and counts change between versions. Re-dump <code>tools/list</code> and rerun the gate test when you upgrade, and pin the version as shown.</p>\n<h2>Other email servers</h2>\n<p><a href=\"/entries/agentmail/\">AgentMail</a> gives agents their own inboxes through an API and a hosted MCP server. The same layers apply: a narrow credential, drafts before sends, a deny-by-default gate and human approval. Map them onto its tool names before you rely on them.</p>"
}
