Give Your OpenAI Assistant Safe Email Access

Register MultiMail as a function tool in any Assistant. Reads, sends, and replies route through configurable oversight modes before delivery.


The OpenAI Assistants API manages the orchestration layer for you — threads, run lifecycle, tool dispatch, and file context — so you can focus on what the assistant actually does. When that includes email, you need more than a raw SMTP credential attached to a function.

MultiMail exposes its full surface as JSON-schema function tools that register directly in an Assistant definition. The assistant calls check_inbox, send_email, or reply_email; MultiMail handles authentication, rate limiting, oversight gating, and delivery receipts before anything leaves your infrastructure.

Oversight mode is set per mailbox, not per call. An assistant operating in gated_send mode composes messages freely but every outbound send pauses for human approval via the MultiMail dashboard or webhook. The assistant never needs to know whether a given run required human review — the control plane is invisible to the model.

Because Assistants run server-side with persistent threads, inbound email is handled via webhooks that post to your server and optionally resume a Run with a tool output. MultiMail's inbound webhook delivers a structured payload — sender, subject, body, attachments — ready to pass directly into a thread as a tool result.

Built for OpenAI Assistants API developers

Function schemas that match the Assistants tool format

MultiMail provides pre-built JSON schemas for send_email, check_inbox, read_email, reply_email, and tag_email. Drop them into your Assistant's tools array and submit_tool_outputs works without any translation layer.

Oversight modes enforced outside the model

The Assistants runtime controls when tools are called, but not what happens after. MultiMail's gated_send mode intercepts every outbound message at the API layer regardless of what the model decided, ensuring approvals can't be bypassed by prompt injection or unexpected tool chaining.

Inbound email as first-class tool output

MultiMail webhooks deliver inbound messages as structured JSON. Your server can call submit_tool_outputs to feed the email into an active Run, continuing the thread without polling or background jobs.

Provenance across thread turns

Every email sent or received is logged with the thread ID, run ID, and assistant ID that triggered it. Audit trails map back to the exact conversation state that produced each outbound message.

Isolation between assistants and mailboxes

Each assistant gets its own MultiMail API key scoped to specific mailboxes. A customer-support assistant cannot access the mailboxes provisioned for a billing assistant, even if both run under the same OpenAI organization.


Get started in minutes

Register MultiMail tools in an Assistant definition
python
from openai import OpenAI

client = OpenAI()

assistant = client.beta.assistants.create(
    name="Email Assistant",
    model="gpt-4o",
    instructions="You help users manage their email. Always confirm before sending.",
    tools=[
        {
            "type": "function",
            "function": {
                "name": "send_email",
                "description": "Send an email from the agent mailbox",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "to": {"type": "string", "description": "Recipient email address"},
                        "subject": {"type": "string"},
                        "body": {"type": "string"},
                        "mailbox_id": {"type": "string", "description": "MultiMail mailbox ID"}
                    },
                    "required": ["to", "subject", "body", "mailbox_id"]
                }
            }
        },
        {
            "type": "function",
            "function": {
                "name": "check_inbox",
                "description": "List recent emails in a mailbox",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "mailbox_id": {"type": "string"},
                        "limit": {"type": "integer", "default": 20}
                    },
                    "required": ["mailbox_id"]
                }
            }
        }
    ]
)

print(f"Assistant created: {assistant.id}")

Define send_email and check_inbox as function tools when creating or updating an Assistant. The schemas match what MultiMail expects when you forward tool calls.

Handle tool calls and forward to MultiMail
python
import time
import requests
from openai import OpenAI

client = OpenAI()
MULTIMAIL_API_KEY = "mm_live_your_key_here"
MULTIMAIL_BASE = "https://api.multimail.dev"

def call_multimail(tool_name: str, args: dict) -> dict:
    endpoint_map = {
        "send_email": "/v1/send",
        "check_inbox": "/v1/inbox",
        "read_email": "/v1/email",
        "reply_email": "/v1/reply",
    }
    url = MULTIMAIL_BASE + endpoint_map[tool_name]
    resp = requests.post(
        url,
        json=args,
        headers={"Authorization": f"Bearer {MULTIMAIL_API_KEY}"}
    )
    resp.raise_for_status()
    return resp.json()

def run_until_complete(thread_id: str, assistant_id: str):
    run = client.beta.threads.runs.create(
        thread_id=thread_id,
        assistant_id=assistant_id
    )

    while run.status in ("queued", "in_progress", "requires_action"):
        time.sleep(1)
        run = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run.id)

        if run.status == "requires_action":
            tool_outputs = []
            for call in run.required_action.submit_tool_outputs.tool_calls:
                import json
                args = json.loads(call.function.arguments)
                result = call_multimail(call.function.name, args)
                tool_outputs.append({
                    "tool_call_id": call.id,
                    "output": json.dumps(result)
                })

            run = client.beta.threads.runs.submit_tool_outputs(
                thread_id=thread_id,
                run_id=run.id,
                tool_outputs=tool_outputs
            )

    return run

Poll for required_action on a Run, dispatch each tool call to the MultiMail API, then submit all outputs to continue the thread.

Resume a thread when inbound email arrives
python
from flask import Flask, request, jsonify
from openai import OpenAI
import json

app = Flask(__name__)
client = OpenAI()

"cm"># Map mailbox -> active (thread_id, run_id) when awaiting a reply
active_runs: dict[str, tuple[str, str]] = {}

@app.route("/webhooks/multimail/inbound", methods=["POST"])
def handle_inbound():
    payload = request.json
    mailbox_id = payload["mailbox_id"]
    email_data = {
        "from": payload["from"],
        "subject": payload["subject"],
        "body": payload["body"],
        "email_id": payload["id"]
    }

    if mailbox_id in active_runs:
        thread_id, run_id = active_runs[mailbox_id]
        "cm"># Feed the inbound email back into the waiting run
        client.beta.threads.runs.submit_tool_outputs(
            thread_id=thread_id,
            run_id=run_id,
            tool_outputs=[{
                "tool_call_id": payload.get("tool_call_id", "inbound"),
                "output": json.dumps(email_data)
            }]
        )
    else:
        "cm"># Start a new thread for this inbound message
        thread = client.beta.threads.create(
            messages=[{
                "role": "user",
                "content": f"New email received: {json.dumps(email_data)}"
            }]
        )
        "cm"># Trigger a run with your assistant
        client.beta.threads.runs.create(
            thread_id=thread.id,
            assistant_id="asst_your_assistant_id"
        )

    return jsonify({"status": "ok"})

MultiMail's inbound webhook posts structured email data to your server. Forward it into the thread as a tool output to continue an active Run, or start a new thread if none is active.

Gated send: check approval status before the run continues
python
import time
import requests
from openai import OpenAI

client = OpenAI()
MULTIMAIL_API_KEY = "mm_live_your_key_here"

def send_with_approval_check(thread_id: str, run_id: str, tool_call_id: str, send_args: dict):
    """Forward a send_email tool call and wait for human approval."""
    resp = requests.post(
        "https://api.multimail.dev/v1/send",
        json=send_args,
        headers={"Authorization": f"Bearer {MULTIMAIL_API_KEY}"}
    ).json()

    if resp.get("status") == "pending_approval":
        message_id = resp["id"]
        print(f"Message {message_id} awaiting approval in MultiMail dashboard")

        "cm"># Poll until approved or rejected (max 10 minutes)
        for _ in range(120):
            time.sleep(5)
            status_resp = requests.get(
                f"https://api.multimail.dev/v1/messages/{message_id}",
                headers={"Authorization": f"Bearer {MULTIMAIL_API_KEY}"}
            ).json()

            if status_resp["status"] in ("sent", "rejected"):
                result = {"status": status_resp["status"], "id": message_id}
                break
        else:
            result = {"status": "timeout", "id": message_id}
    else:
        result = resp

    "cm"># Return the outcome to the Assistant
    client.beta.threads.runs.submit_tool_outputs(
        thread_id=thread_id,
        run_id=run_id,
        tool_outputs=[{"tool_call_id": tool_call_id, "output": str(result)}]
    )

When a mailbox uses gated_send mode, MultiMail holds the message and returns a pending status. Poll list_pending to check approval state, then submit the result to the Assistant as a tool output.


Step by step

1

Create a MultiMail mailbox

Sign up at multimail.dev and provision a mailbox for your assistant. Set the oversight mode to gated_send to require approval on all outbound messages during development.

bash
curl -X POST https://api.multimail.dev/v1/mailboxes \
  -H "Authorization: Bearer $MULTIMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d &"cm">#039;{"address": "[email protected]", "oversight_mode": "gated_send"}'
2

Define MultiMail function tools in your Assistant

Add send_email and check_inbox to your Assistant's tools list. Use the JSON schemas that match MultiMail's request body format so tool arguments pass through without transformation.

bash
assistant = client.beta.assistants.create(
    name="Email Agent",
    model="gpt-4o",
    tools=[send_email_tool_schema, check_inbox_tool_schema]
)
3

Handle requires_action in your run loop

Poll run status and intercept requires_action states. For each tool call, forward arguments to the corresponding MultiMail endpoint and collect the JSON response.

bash
run = client.beta.threads.runs.retrieve(thread_id=tid, run_id=run.id)
if run.status == "requires_action":
    outputs = dispatch_tool_calls(run.required_action.submit_tool_outputs.tool_calls)
    client.beta.threads.runs.submit_tool_outputs(thread_id=tid, run_id=run.id, tool_outputs=outputs)
4

Register the inbound webhook

In the MultiMail dashboard, set an inbound webhook URL pointing to your server. MultiMail will POST structured email payloads on new delivery, which you can feed into active threads or use to trigger new runs.

bash
curl -X POST https://api.multimail.dev/v1/mailboxes/mbx_your_id/webhooks \
  -H "Authorization: Bearer $MULTIMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d &"cm">#039;{"url": "https://yourapp.com/webhooks/multimail/inbound", "events": ["email.received"]}'
5

Approve or reject sends from the dashboard

With gated_send active, outbound messages appear in the MultiMail approval queue. Review and approve from the dashboard or via the API. Once approved, MultiMail delivers the message and your run continues.


Common questions

Can the Assistant call MultiMail tools without a middleware server?
No. The Assistants API requires your server to handle the requires_action lifecycle — retrieve pending tool calls, execute them, and submit outputs. MultiMail is a REST API your server calls during that step. There is no direct connection between OpenAI's runtime and MultiMail.
Does MultiMail support streaming Runs?
Yes. The tool dispatch logic works identically with streaming Runs. When you receive a thread.run.requires_action event in the stream, call the same submit_tool_outputs endpoint with results from MultiMail. The stream resumes after submission.
What happens if a gated send is rejected?
MultiMail returns status: rejected in the message record. Your server should submit this as the tool output so the Assistant knows the send did not complete. The Assistant can then decide whether to retry, modify the message, or notify the user — the rejection is visible to the model as a tool result.
How are thread IDs linked to email audit logs?
Pass the OpenAI thread ID and run ID in the request metadata when calling MultiMail endpoints. These are stored on the email record and visible in the audit log, so you can trace every sent or received message back to the exact conversation turn that caused it.
Can one Assistant use multiple mailboxes?
Yes. Pass mailbox_id as a parameter in every tool call. The Assistant can manage multiple mailboxes — for example, a support mailbox and a billing mailbox — as long as your API key has access to both. Each mailbox has its own oversight mode configuration.
Does this work with the Responses API as well?
Yes. The Responses API uses the same function calling contract as Assistants. The tool schemas, dispatch logic, and MultiMail API calls are identical — only the run lifecycle management differs.

Explore more

The only agent email with a verifiable sender

Email infrastructure built for AI agents. Verifiable identity, graduated oversight, and a 38-tool MCP server. Formally verified in Lean 4.