Durable AI Email Workflows with Temporal and MultiMail

Pair Temporal's durable execution model with MultiMail's graduated oversight to build email workflows that pause for approval, survive process crashes, and resume exactly where they left off.


Temporal is a durable workflow engine that persists every step of a long-running process. When an AI agent needs to send an email that requires human approval, Temporal can pause the workflow indefinitely—surviving restarts, deploys, and network failures—then resume the moment approval arrives.

MultiMail's `gated_send` and `gated_all` oversight modes map directly onto Temporal's timer and signal primitives. A workflow activity calls `send_email` via the MultiMail API, receives a pending approval ID, then waits on a Temporal signal that fires when the human approves or rejects. No polling loops, no external state storage, no lost approvals.

This pattern is especially useful for high-stakes outbound email in regulated industries: loan offer notifications, patient communications, contract delivery. Temporal guarantees at-least-once execution of each activity; MultiMail guarantees the human stays in the loop before anything leaves the system.

Built for Temporal + AI Agents developers

Approvals as first-class workflow signals

MultiMail's approval queue exposes webhook events when a human approves or rejects a pending send. These events map cleanly to Temporal workflow signals, letting you pause a workflow at the send step and resume it without any external coordination logic.

Durable execution survives oversight latency

Human approval may take hours or days. Temporal workflows can pause for weeks without losing state. Combined with MultiMail's persistent approval queue, you never lose a pending send because a worker restarted.

Graduated trust over time

Start with `gated_all` while building confidence, then promote a mailbox to `monitored` or `autonomous` via the MultiMail API. Temporal workflow logic doesn't need to change—oversight behavior is a configuration property on the mailbox, not embedded in workflow code.

Retries without duplicate sends

Temporal retries failed activities automatically, but MultiMail's idempotency keys prevent duplicate email delivery. Pass a deterministic key derived from the workflow ID and activity attempt number to ensure exactly-once delivery semantics.

Full thread context across workflow steps

MultiMail's `get_thread` endpoint retrieves complete conversation history. Temporal activities can fetch this context at any step, letting an AI agent craft replies with full awareness of prior exchanges without re-fetching from upstream sources.


Get started in minutes

Durable send-with-approval workflow
typescript
import { proxyActivities, defineSignal, condition, setHandler } from '@temporalio/workflow';
import type { EmailActivities } from './activities';

const { sendEmailForApproval, recordApprovalOutcome } = proxyActivities<EmailActivities>({
  startToCloseTimeout: '30 seconds',
});

export const approvalSignal = defineSignal<[{ approved: boolean; approver: string }]>('approval');

export async function emailApprovalWorkflow(params: {
  to: string;
  subject: string;
  body: string;
  mailboxId: string;
}): Promise<{ sent: boolean; messageId: string | null }> {
  const { pendingId, messageId } = await sendEmailForApproval(params);

  let approved = false;
  let approver = '';
  let signalReceived = false;

  setHandler(approvalSignal, ({ approved: a, approver: ap }) => {
    approved = a;
    approver = ap;
    signalReceived = true;
  });

  "cm">// Wait up to 72 hours for human approval
  await condition(() => signalReceived, '72 hours');

  await recordApprovalOutcome({ pendingId, approved, approver, messageId });

  return { sent: approved, messageId: approved ? messageId : null };
}

A Temporal workflow that sends an email via MultiMail in `gated_send` mode, waits for the approval signal, and records the outcome. The workflow can pause for days awaiting human review without losing state.

Temporal activities calling MultiMail REST API
typescript
import { Context } from '@temporalio/activity';

const MULTIMAIL_API = 'https://api.multimail.dev';
const MM_TOKEN = process.env.MULTIMAIL_API_KEY!;

export interface EmailActivities {
  sendEmailForApproval(params: {
    to: string;
    subject: string;
    body: string;
    mailboxId: string;
  }): Promise<{ pendingId: string; messageId: string }>;

  recordApprovalOutcome(params: {
    pendingId: string;
    approved: boolean;
    approver: string;
    messageId: string;
  }): Promise<void>;
}

export const emailActivities: EmailActivities = {
  async sendEmailForApproval({ to, subject, body, mailboxId }) {
    const info = Context.current().info;
    "cm">// Deterministic idempotency key: workflow + activity attempt
    const idempotencyKey = `${info.workflowExecution.workflowId}-${info.attempt}`;

    const res = await fetch(`${MULTIMAIL_API}/send_email`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${MM_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        mailbox_id: mailboxId,
        to,
        subject,
        body,
        idempotency_key: idempotencyKey,
      }),
    });

    if (!res.ok) throw new Error(`MultiMail send failed: ${res.status}`);
    const data = await res.json() as { pending_id: string; message_id: string };
    return { pendingId: data.pending_id, messageId: data.message_id };
  },

  async recordApprovalOutcome({ pendingId, approved, approver, messageId }) {
    "cm">// Fetch the final state for audit logging
    const res = await fetch(`${MULTIMAIL_API}/read_email?message_id=${messageId}`, {
      headers: { Authorization: `Bearer ${MM_TOKEN}` },
    });
    const email = await res.json();
    console.log({ pendingId, approved, approver, status: email.status });
  },
};

The activity implementations that call the MultiMail API. Activities are retried automatically by Temporal on failure; idempotency keys prevent duplicate sends on retry.

Webhook handler that signals Temporal on approval
typescript
import { Client, Connection } from '@temporalio/client';
import { approvalSignal } from './workflows';
import { createHmac } from 'crypto';

const connection = await Connection.connect({ address: process.env.TEMPORAL_ADDRESS });
const client = new Client({ connection, namespace: process.env.TEMPORAL_NAMESPACE });

export async function handleMultimailWebhook(req: Request): Promise<Response> {
  "cm">// Verify MultiMail webhook signature
  const sig = req.headers.get('x-multimail-signature') ?? '';
  const body = await req.text();
  const expected = createHmac('sha256', process.env.MULTIMAIL_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');
  if (sig !== expected) return new Response('Unauthorized', { status: 401 });

  const event = JSON.parse(body) as {
    type: 'approval.approved' | 'approval.rejected';
    workflow_id: string;
    approver_email: string;
    pending_id: string;
  };

  if (event.type !== 'approval.approved' && event.type !== 'approval.rejected') {
    return new Response('ignored', { status: 200 });
  }

  "cm">// workflow_id is set as a custom metadata field when the Temporal workflow starts
  const handle = client.workflow.getHandle(event.workflow_id);
  await handle.signal(approvalSignal, {
    approved: event.type === 'approval.approved',
    approver: event.approver_email,
  });

  return new Response('ok', { status: 200 });
}

An HTTP endpoint that receives MultiMail approval webhooks and delivers them as Temporal workflow signals. Deploy this as a separate worker or API route.

Polling inbox and processing inbound emails as workflow inputs
typescript
import { Client } from '@temporalio/client';
import { emailApprovalWorkflow } from './workflows';

const MM_TOKEN = process.env.MULTIMAIL_API_KEY!;

export async function pollInboxAndDispatch(
  client: Client,
  mailboxId: string,
): Promise<void> {
  const res = await fetch(
    `https:"cm">//api.multimail.dev/check_inbox?mailbox_id=${mailboxId}&unread=true`,
    { headers: { Authorization: `Bearer ${MM_TOKEN}` } },
  );
  const { emails } = await res.json() as { emails: Array<{ id: string; from: string; subject: string }> };

  for (const email of emails) {
    const workflowId = `reply-${email.id}`;

    "cm">// Idempotent start — skip if workflow already running
    await client.workflow.start(emailApprovalWorkflow, {
      taskQueue: 'email-agent',
      workflowId,
      args: [{
        to: email.from,
        subject: `Re: ${email.subject}`,
        body: await generateReply(email.id),
        mailboxId,
      }],
    });
  }
}

async function generateReply(emailId: string): Promise<string> {
  const res = await fetch(`https:"cm">//api.multimail.dev/read_email?message_id=${emailId}`, {
    headers: { Authorization: `Bearer ${MM_TOKEN}` },
  });
  const { body } = await res.json() as { body: string };
  "cm">// Replace with your LLM call
  return `Thank you for your message. We will respond shortly.\n\nOriginal: ${body.slice(0, 200)}`;
}

A Temporal activity that checks the MultiMail inbox and starts a new workflow for each unprocessed inbound email. Useful for intake pipelines where each arriving email kicks off a durable processing chain.


Step by step

1

Install Temporal SDK and configure your worker

Install the Temporal TypeScript SDK and set up a worker that runs on the `email-agent` task queue. The worker registers your workflow and activity functions.

bash
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity

"cm"># Start a local Temporal server for development
npx @temporalio/create@latest --sample hello-world temporal-dev
2

Create a MultiMail mailbox in gated_send mode

Create a mailbox via the MultiMail API with `gated_send` oversight so all outbound messages wait for human approval. Retrieve your API key from the MultiMail dashboard.

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

Register a webhook for approval events

Configure MultiMail to deliver `approval.approved` and `approval.rejected` events to your webhook endpoint. This endpoint will signal your Temporal workflows when humans act on pending sends.

bash
curl -X POST https://api.multimail.dev/webhooks \
  -H &"cm">#039;Authorization: Bearer $MULTIMAIL_API_KEY' \
  -H &"cm">#039;Content-Type: application/json' \
  -d &"cm">#039;{
    "url": "https://yourapp.com/webhooks/multimail",
    "events": ["approval.approved", "approval.rejected"],
    "secret": "your_webhook_signing_secret"
  }&"cm">#039;
4

Deploy workflow, activities, and webhook handler

Register your workflow and activities with the Temporal worker, deploy the webhook handler to your API, and start a Temporal workflow for each email that needs to be sent. Use the workflow ID as the correlation key between MultiMail events and Temporal executions.

bash
import { Worker } from &"cm">#039;@temporalio/worker';
import { emailActivities } from &"cm">#039;./activities';

const worker = await Worker.create({
  workflowsPath: require.resolve(&"cm">#039;./workflows'),
  activities: emailActivities,
  taskQueue: &"cm">#039;email-agent',
});

await worker.run();

Common questions

What happens if the Temporal worker restarts while waiting for approval?
Nothing. Temporal persists the full workflow event history in its database. When the worker restarts, the workflow resumes from exactly where it paused. The `condition()` call that blocks on `approvalSignal` re-evaluates against the in-memory state rebuilt from history—no data is lost.
How do I correlate a MultiMail webhook event back to the right Temporal workflow?
Set the Temporal workflow ID as a custom metadata field when calling the MultiMail `send_email` endpoint, or store the mapping in Temporal's memo or search attributes. When the webhook fires, read the workflow ID from the event payload and call `client.workflow.getHandle(workflowId).signal(...)` to resume the correct instance.
Can a Temporal workflow handle both sending and receiving emails for the same thread?
Yes. Combine `send_email` for outbound steps with `check_inbox` and `get_thread` for inbound polling activities. Use Temporal timers (`sleep`) to wait for replies, and cancel the timer early via a signal if an inbound message arrives before the timeout.
Does using Temporal affect MultiMail's idempotency guarantees?
Temporal retries failed activities, which could cause duplicate API calls. Prevent duplicate sends by passing an idempotency key derived from the workflow ID and activity attempt number: `${workflowId}-${attemptNumber}`. MultiMail deduplicates on this key, so retries are safe.
How do I escalate to a stricter oversight mode mid-workflow if something looks suspicious?
Call the MultiMail API from a Temporal activity to update the mailbox oversight mode: `PATCH https://api.multimail.dev/mailboxes/{id}` with `{ "oversight_mode": "gated_all" }`. Subsequent email activities in the same or future workflows will pick up the stricter mode automatically.
Can I use Temporal with the MultiMail Python SDK instead of the REST API?
Temporal has a Python SDK (`pip install temporalio`) and MultiMail has a Python SDK (`pip install multimail-sdk`). Activity functions are regular Python callables, so you can call `multimail.send_email(...)` directly inside an activity decorated with `@activity.defn`. The same idempotency and signal patterns apply.

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.