Email control plane for Slack-based agents

Turn slash commands and workflow automations into governed email actions. MultiMail's approval queue means a chat message never becomes an unreviewed outbound email.


Slack is where agents and humans already collaborate — so it's a natural place to trigger email actions. A sales bot summarizes an inbox thread on demand. A support workflow routes inbound emails to the right channel. A scheduled bot drafts and queues outbound messages for human review. These patterns all work, but they require a layer between the chat command and the SMTP relay.

MultiMail sits at that layer. When a Slack bot calls `send_email`, the request enters MultiMail's oversight pipeline rather than going directly to delivery. Depending on the oversight mode you configure per mailbox, the email is either queued for approval, sent with a notification, or delivered autonomously. The Slack user who typed the command sees an immediate confirmation; the email policy is enforced regardless of how casual the command felt.

The Bolt SDK makes it straightforward to wire up slash commands and workflow steps to MultiMail's REST API. You get typed request/response shapes, webhook delivery for approval events, and an audit trail that satisfies compliance requirements — without building any of that infrastructure yourself.

Built for Slack developers

Approval queue for chat-triggered sends

Any email triggered by a slash command or workflow step enters MultiMail's pending queue when the mailbox is in `gated_send` mode. Approvers see the full message before delivery. A casual `/send-proposal` command cannot bypass review.

Per-mailbox oversight modes

Set `gated_all` on executive mailboxes, `monitored` on internal digest bots, and `autonomous` on transactional notification senders — all within the same Slack app. Oversight is a property of the mailbox, not the bot.

Webhook callbacks to Slack

MultiMail fires `message.approved`, `message.rejected`, and `message.delivered` webhooks that your Bolt app can relay back to the originating channel. The agent loop closes: Slack → MultiMail → approved → Slack notification.

Inbound email surfaced to channels

Subscribe a Slack channel to inbound webhook events on a MultiMail mailbox. Replies, tag changes, and thread updates stream to Slack in real time, making email activity visible without building a polling loop.

Identity-verified sends

MultiMail enforces that outbound email matches the authenticated mailbox identity. A Slack bot cannot spoof a From address it does not own, even if the slash command attempts to override it.

Full audit trail

Every action — send, approve, reject, tag, decide — is recorded with the originating API key, timestamp, and message ID. CAN-SPAM and GDPR audit requests are answerable without scraping Slack history.


Get started in minutes

Slash command: send email with approval
javascript
import { App, AwsLambdaReceiver } from '@slack/bolt';

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: new AwsLambdaReceiver({ signingSecret: process.env.SLACK_SIGNING_SECRET }),
});

"cm">// Step 1: open a modal to collect email fields
app.command('/send-email', async ({ ack, client, body }) => {
  await ack();
  await client.views.open({
    trigger_id: body.trigger_id,
    view: {
      type: 'modal',
      callback_id: 'compose_email',
      title: { type: 'plain_text', text: 'Send Email' },
      submit: { type: 'plain_text', text: 'Send' },
      blocks: [
        { type: 'input', block_id: 'to', label: { type: 'plain_text', text: 'To' },
          element: { type: 'plain_text_input', action_id: 'to_input' } },
        { type: 'input', block_id: 'subject', label: { type: 'plain_text', text: 'Subject' },
          element: { type: 'plain_text_input', action_id: 'subject_input' } },
        { type: 'input', block_id: 'body', label: { type: 'plain_text', text: 'Message' },
          element: { type: 'plain_text_input', action_id: 'body_input', multiline: true } },
      ],
    },
  });
});

"cm">// Step 2: submit modal → call MultiMail
app.view('compose_email', async ({ ack, view, client, body }) => {
  await ack();
  const vals = view.state.values;
  const to = vals.to.to_input.value;
  const subject = vals.subject.subject_input.value;
  const text = vals.body.body_input.value;

  const res = await fetch('https://api.multimail.dev/v1/email/send', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.MULTIMAIL_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: '[email protected]',
      to,
      subject,
      text,
    }),
  });

  const result = await res.json();
  const isQueued = result.status === 'pending_approval';

  await client.chat.postMessage({
    channel: body.user.id,
    text: isQueued
      ? `Email queued for approval. Message ID: \`${result.message_id}\`. A reviewer will approve or reject it before delivery.`
      : `Email sent. Message ID: \`${result.message_id}\``,
  });
});

A `/send-email` slash command collects recipient, subject, and body from a Slack modal, then calls MultiMail. With `gated_send` oversight, the API returns a pending message ID rather than delivering immediately.

Inbound email → Slack channel via webhook
javascript
import express from 'express';
import { App, ExpressReceiver } from '@slack/bolt';

const receiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});

const app = new App({ token: process.env.SLACK_BOT_TOKEN, receiver });

"cm">// MultiMail posts inbound email events to this route
receiver.router.post('/multimail/inbound', express.json(), async (req, res) => {
  "cm">// Verify MultiMail webhook signature (compare X-MultiMail-Signature header)
  const sig = req.headers['x-multimail-signature'];
  if (sig !== process.env.MULTIMAIL_WEBHOOK_SECRET) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const { from, subject, text, message_id } = req.body;

  await app.client.chat.postMessage({
    channel: process.env.EMAIL_ALERTS_CHANNEL_ID,
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*New email* in agent inbox\n*From:* ${from}\n*Subject:* ${subject}`,
        },
      },
      {
        type: 'section',
        text: { type: 'plain_text', text: text.slice(0, 300) },
      },
      {
        type: 'actions',
        elements: [
          {
            type: 'button',
            text: { type: 'plain_text', text: 'Read full email' },
            action_id: 'read_email',
            value: message_id,
          },
          {
            type: 'button',
            text: { type: 'plain_text', text: 'Reply' },
            action_id: 'reply_email',
            value: message_id,
            style: 'primary',
          },
        ],
      },
    ],
  });

  res.json({ ok: true });
});

"cm">// Handle read full email button
app.action('read_email', async ({ ack, body, client, action }) => {
  await ack();
  const message_id = action.value;

  const res = await fetch(`https:"cm">//api.multimail.dev/v1/email/${message_id}`, {
    headers: { Authorization: `Bearer ${process.env.MULTIMAIL_API_KEY}` },
  });
  const email = await res.json();

  await client.views.open({
    trigger_id: body.trigger_id,
    view: {
      type: 'modal',
      title: { type: 'plain_text', text: email.subject },
      blocks: [
        { type: 'section', text: { type: 'plain_text', text: email.text } },
      ],
    },
  });
});

(async () => { await app.start(3000); })();

Register a MultiMail inbound webhook and relay new emails to a Slack channel. Uses Bolt's HTTP receiver and formats a clean notification block.

Approval event webhook → notify Slack
javascript
import express from 'express';
import { App, ExpressReceiver } from '@slack/bolt';

const receiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});
const app = new App({ token: process.env.SLACK_BOT_TOKEN, receiver });

"cm">// Store message_id → slack_user_id during send (in KV or Redis)
const pendingMap = new Map();

receiver.router.post('/multimail/approval', express.json(), async (req, res) => {
  const sig = req.headers['x-multimail-signature'];
  if (sig !== process.env.MULTIMAIL_WEBHOOK_SECRET) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const { event, message_id, subject, to, approved_by } = req.body;
  "cm">// event is 'message.approved' or 'message.rejected'

  const slackUserId = pendingMap.get(message_id);
  if (!slackUserId) return res.json({ ok: true }); "cm">// unknown origin

  if (event === 'message.approved') {
    await app.client.chat.postMessage({
      channel: slackUserId,
      text: `:white_check_mark: Email approved and sent.\n*To:* ${to}\n*Subject:* ${subject}\n*Approved by:* ${approved_by}`,
    });
  } else if (event === 'message.rejected') {
    await app.client.chat.postMessage({
      channel: slackUserId,
      text: `:x: Email rejected and cancelled.\n*To:* ${to}\n*Subject:* ${subject}\n*Rejected by:* ${approved_by}`,
    });
  }

  pendingMap.delete(message_id);
  res.json({ ok: true });
});

"cm">// Usage: after calling /v1/email/send and getting status=pending_approval:
"cm">// pendingMap.set(result.message_id, slackUserId);

(async () => { await app.start(3000); })();

When a reviewer approves or rejects a pending email in MultiMail, post the outcome back to the Slack user who triggered the send. Closes the agent feedback loop without polling.

Slash command: inbox summary
javascript
import { App } from '@slack/bolt';

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});

app.command('/inbox-summary', async ({ ack, respond }) => {
  await ack();

  const res = await fetch(
    'https:"cm">//api.multimail.dev/v1/[email protected]&unread=true&limit=5',
    { headers: { Authorization: `Bearer ${process.env.MULTIMAIL_API_KEY}` } }
  );
  const { emails, total_unread } = await res.json();

  if (!emails.length) {
    return respond({ text: 'No unread emails in the agent mailbox.' });
  }

  const blocks = [
    {
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: `*Inbox summary* — ${total_unread} unread email${total_unread !== 1 ? 's' : ''}`,
      },
    },
    { type: 'divider' },
    ...emails.map((email) => ({
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: `*${email.subject}*\nFrom: ${email.from}\n${email.snippet}`,
      },
      accessory: {
        type: 'button',
        text: { type: 'plain_text', text: 'Read' },
        action_id: 'read_email',
        value: email.message_id,
      },
    })),
  ];

  await respond({ blocks });
});

(async () => { await app.start(3000); })();

Pull recent unread emails from a MultiMail mailbox and post a digest to the channel. Useful for agent briefing workflows triggered by a morning standup command.


Step by step

1

Create a Slack app and install Bolt

Create a new Slack app at api.slack.com/apps with `commands`, `chat:write`, and `views:open` OAuth scopes. Install the Bolt SDK.

bash
npm install @slack/bolt

"cm"># Set environment variables
export SLACK_BOT_TOKEN=xoxb-...
export SLACK_SIGNING_SECRET=...
2

Create a MultiMail mailbox and API key

Log into MultiMail and create a mailbox for your Slack agent (e.g., `[email protected]`). Set oversight mode to `gated_send` so slash-command-triggered emails require approval. Generate an API key with `email:send` and `inbox:read` scopes.

bash
curl -X POST https://api.multimail.dev/v1/mailboxes \
  -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"}'
3

Register MultiMail webhooks

Register your Bolt app's public endpoint to receive inbound email events and approval notifications from MultiMail.

bash
curl -X POST https://api.multimail.dev/v1/webhooks \
  -H &"cm">#039;Authorization: Bearer $MULTIMAIL_API_KEY...' \
  -H &"cm">#039;Content-Type: application/json' \
  -d &"cm">#039;{
    "url": "https://your-bolt-app.acme.com/multimail/inbound",
    "events": ["email.received", "message.approved", "message.rejected"],
    "mailbox": "[email protected]",
    "secret": "your-webhook-signing-secret"
  }&"cm">#039;
4

Implement a slash command handler

Add a `/send-email` command in your Slack app settings pointing to your Bolt server. Implement the handler to open a modal and submit to MultiMail on confirm. See the code sample above.

bash
"cm"># In Slack App settings:
"cm"># Slash Commands → /send-email → Request URL: https://your-bolt-app.acme.com/slack/events

export MULTIMAIL_API_KEY=mm_live_...
5

Test the approval loop end-to-end

Use a MultiMail test API key (`mm_test_...`) to trigger the slash command without sending real email. Verify that the pending message appears in the MultiMail dashboard and that your approval webhook fires correctly.

bash
"cm"># Send a test email via API with test key
curl -X POST https://api.multimail.dev/v1/email/send \
  -H &"cm">#039;Authorization: Bearer mm_test_...' \
  -H &"cm">#039;Content-Type: application/json' \
  -d &"cm">#039;{
    "from": "[email protected]",
    "to": "[email protected]",
    "subject": "Test from Slack agent",
    "text": "This is a test message from the Slack integration."
  }&"cm">#039;

"cm"># Check pending messages
curl https://api.multimail.dev/v1/messages/pending \
  -H &"cm">#039;Authorization: Bearer mm_test_...'

Common questions

Does the Slack user need a MultiMail account to send email?
No. The Bolt app holds a single MultiMail API key with permissions scoped to specific mailboxes. Slack users trigger actions through the bot — they never authenticate directly with MultiMail. Authorization is enforced at the Slack level (who can invoke the slash command) and at the MultiMail level (which mailbox the API key may access).
What happens if a slash command is invoked but the email is rejected?
When a reviewer rejects a pending message, MultiMail fires a `message.rejected` webhook to your Bolt app. Your app can relay that event back to the Slack user who initiated the command — telling them which message was rejected and by whom. The email is cancelled and never delivered.
Can I set different oversight levels for different Slack channels or commands?
Oversight mode is configured per mailbox, not per command. If you want different oversight for different workflows, create separate MultiMail mailboxes (e.g., `[email protected]` with `gated_all`, `[email protected]` with `autonomous`) and route each Slack command to the appropriate mailbox.
How do I prevent a Slack user from spoofing the From address?
The `from` field in the MultiMail API must match a mailbox that the API key is authorized to send from. If the bot's API key only has access to `[email protected]`, any request specifying a different From address is rejected with a 403. You should hardcode the From address in your Bolt handler rather than accepting it from user input.
Is there a rate limit on slash commands triggering email sends?
MultiMail enforces rate limits per API key based on your plan tier. For burst Slack usage (e.g., a team of 50 users all hitting a command simultaneously), use a `gated_send` mailbox — queued messages are rate-limited on delivery, not on enqueueing, so users get immediate feedback while delivery is paced.
Can I use Slack Workflow Builder instead of a custom Bolt app?
Slack Workflow Builder supports custom webhook steps. You can POST to MultiMail's REST API directly from a workflow step using the webhook step type, without writing a Bolt app. However, handling approval callbacks and relaying outcomes back to Slack requires a custom listener endpoint, so a lightweight Bolt app is recommended for production use cases.

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.