Send email from a Mastra agent

Connect a Mastra agent to the Unitpost MCP server with MCPClient, or give it one tool that calls the REST API. Tool approval pauses each send until you approve or decline it.

  1. Create a send-only key

    Open https://www.unitpost.com → Settings → API keys and create a key with Sending only permissions. On a paid plan, open Advanced and keep only emails:send, so the key can send email and do nothing else. Copy the pk_live_… value (shown once) into the environment your agent runs in.

    Shell
    export UNITPOST_API_KEY=pk_live_YOUR_KEY

    A Sending only key can also send campaigns and read contacts, segments, templates and domains. Keep the key in your server code: the model never needs to see it.

  2. Connect MCPClient

    Install @mastra/mcp and @mastra/core. MCPClient sends the key in requestInit.headers. listTools() names each tool serverName_toolName, so the send tool is unitpost_email_send.

    TypeScript
    import { Agent } from "@mastra/core/agent";
    import { MCPClient } from "@mastra/mcp";
    
    export const mcp = new MCPClient({
      id: "unitpost",
      servers: {
        unitpost: {
          url: new URL("https://mcp.unitpost.com/mcp?toolsets=sending"),
          requestInit: {
            headers: { Authorization: `Bearer ${process.env.UNITPOST_API_KEY}` },
          },
        },
      },
    });
    
    const tools = await mcp.listTools();
    
    export const mailer = new Agent({
      id: "mailer",
      name: "Mailer",
      instructions: "Send email only when asked. Pass a new idempotency_key per message.",
      model: "openai/gpt-6.1-sol", // any model Mastra supports
      tools: { unitpost_email_send: tools.unitpost_email_send },
    });

    email_send takes an idempotency_key beside body. Tell the agent to pass a new one per message and the same one on a retry: the server's instructions say so too.

  3. Or add one REST send tool

    No MCP needed: one createTool that posts to /api/v1/email. requireApproval: true pauses every call to it before execute runs.

    TypeScript
    import { createTool } from "@mastra/core/tools";
    import { z } from "zod";
    
    const ALLOWED = new Set(["ada@example.com"]); // who this agent may email
    
    export const sendEmail = createTool({
      id: "send-email",
      description: "Send one email through Unitpost.",
      inputSchema: z.object({
        from: z.string().describe("Sender on a verified domain, e.g. Acme <hi@acme.com>"),
        to: z.string().describe("One recipient address"),
        subject: z.string(),
        text: z.string().describe("Plain-text body"),
        idempotency_key: z.string().describe("A new value for each message; the same value when retrying this one"),
      }),
      requireApproval: true,
      execute: async (email) => {
        if (!ALLOWED.has(email.to)) return { refused: `${email.to} is not on the allowlist` };
        const { idempotency_key, ...message } = email;
        const body = JSON.stringify(message);
        const res = await fetch("https://www.unitpost.com/api/v1/email", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.UNITPOST_API_KEY}`,
            "Content-Type": "application/json",
            "User-Agent": "my-agent/1.0",
            "Idempotency-Key": idempotency_key, // the model's: a retry of this message reuses it
          },
          body,
        });
        return res.json(); // { id, status, scheduled_at } or { error: { code, message, details } }
      },
    });

    The model passes idempotency_key: a new one per message, and the same one when it retries that message, so a lost response never becomes a second email. A key made inside the tool would change on every retry. Never derive it from the content, or two separate identical notifications become one. User-Agent is required: a request without one is refused with missing_user_agent (403).

  4. Gate each send

    Every send is real mail to a real person. Send only transactional mail or mail people opted in to: the Acceptable Use Policy rules out cold outreach. Check each recipient against an allowlist, or have a person approve the send, before the call goes out. Pass an idempotency key, and the same key on a retry: for 24 hours a retry returns the first result instead of sending twice. In Mastra, requireApproval on the tool, or requireToolApproval on the MCP server, pauses each send. The stream emits a tool-call-approval chunk; approve or decline it with the stream's runId.

    TypeScript
    // On the MCP server instead: requireToolApproval: ({ toolName }) => toolName === "email_send"
    const stream = await mailer.stream("Email ada@example.com: the report is ready.");
    
    for await (const chunk of stream.fullStream) {
      if (chunk.type === "tool-call-approval") {
        const ok = await askPerson(chunk.payload.toolName, chunk.payload.args); // your own UI
        const next = ok
          ? await mailer.approveToolCall({ runId: stream.runId })
          : await mailer.declineToolCall({ runId: stream.runId });
        for await (const text of next.textStream) process.stdout.write(text);
      }
    }

    Approval keeps a snapshot of the paused run, so configure a storage provider on your Mastra instance. Errors come back as { "error": { "code", "message", "details" } }. Branch on error.code and fix the call instead of resending it: domain_not_verified means the from domain isn't verified, validation_error names the bad fields in details, and rate_limit_exceeded comes with a Retry-After wait. Over MCP the same error arrives under data, with a hint for the next move.

  5. Verify the send

    Make the first send to an address you own, from an address on a verified domain. A success returns the email's id and a status of queued (scheduled when you set scheduled_at); over MCP the same object comes back under data. Open Emails in the dashboard to see the message, its recipient and its delivery status, or read it back with email_get (MCP) or GET /api/v1/email/{id}.

    JSON
    {
      "id": "email_…",
      "status": "queued",
      "scheduled_at": null
    }

    A retry with the same idempotency key returns this same body (REST adds Idempotent-Replayed: true) and sends nothing new.

Search docs and guides

Search the docs and product guides.