Guide

How to Forward Email to Slack (Without Zapier)

Getting important email into Slack is one of those things that sounds trivial until you actually try it. You want customer replies, form submissions, vendor alerts, or monitoring notices to land in a channel — cleanly formatted, and without the noise of everything else in the inbox.

Slack has a built-in way to do this, and Zapier is the usual paid fallback. Both work, but both have real limits. In this guide we will look at those options honestly, then build a better one: a small serverless function that receives email as JSON from InboxBridge and posts a formatted message to a Slack incoming webhook, with filtering and attachment handling included.

The whole build works on the InboxBridge free plan. You do not need Zapier, and you do not need to run a mail server.

In this guide

  • Slack's built-in email-to-channel address and its limits
  • Zapier's email parser and what it costs
  • Creating a Slack incoming webhook
  • Creating an InboxBridge bridge
  • Writing a serverless function that posts to Slack
  • Formatting sender, subject, and body into Slack blocks
  • Handling attachment metadata
  • Filtering by sender and subject
  • Verifying the request signature

The common approaches (and where they fall short)

Slack's built-in email-to-channel address

Slack can generate an email address for a channel. On paid Slack plans, you can add the “Send emails to Slack” app to a channel, get an address like your-channel-xyz@yourworkspace.slack.com, and forward mail to it. Every message sent there shows up in the channel.

It is fine for a quick, low-volume feed, but the limits show up fast:

  • It is a paid-plan feature. On the free Slack plan the channel email address is not available.
  • You get no control over formatting. Slack renders the raw email more or less as-is, collapsed behind a preview. You cannot pick out the fields that matter.
  • There is no filtering. Everything forwarded to the address lands in the channel, including signatures, footers, and quoted reply chains.
  • There is no routing logic. You cannot send billing emails to one channel and support emails to another based on sender or subject.
  • You cannot post structured content, buttons, or links built from the email.

Zapier's email parser

The usual next step is Zapier. Its Email Parser gives you a mailbox, you highlight the parts of a sample email you care about, and a Zap posts them to Slack. It works, and for a non-technical team it can be the right call.

The tradeoffs are cost and brittleness:

  • Zapier bills per task, and every email is a task. Add a filter step or a formatter step and a single email can burn multiple tasks. A busy address eats into your monthly quota quickly, and higher volumes push you onto paid tiers.
  • Template-based parsing is fragile. When a sender changes their email layout, the highlighted fields drift and your Zap starts posting empty or wrong values.
  • You are formatting inside a Zap editor, not in code. Anything beyond simple field mapping — conditional formatting, Block Kit layouts, attachment logic — gets awkward.
  • Debugging lives in Zapier's task history rather than your own logs.

The webhook approach

The alternative is to treat inbound email like any other event in your stack. You give out an email address, and when a message arrives it is delivered to your code as a clean JSON webhook. You format and post to Slack in a few lines, filtering happens in code you control, and there is no per-task meter running.

That is what InboxBridge does. It gives you an inbound address, parses each message, and sends the result to your endpoint as structured JSON — signed and retried. The rest of this guide builds exactly that.

What we are building

The flow is short:

  1. Someone sends (or forwards) an email to your InboxBridge address
  2. InboxBridge parses it and POSTs JSON to your serverless function
  3. Your function decides whether the email should be forwarded
  4. If it should, your function formats it and posts to a Slack incoming webhook
  5. The message appears in your channel, cleanly formatted

We will deploy the function on Vercel because it pairs naturally with a Next.js or Node project, but the same code runs on any platform that can receive an HTTP POST — Cloudflare Workers, AWS Lambda, Netlify, or your own server.

Step 1: Create a Slack incoming webhook

An incoming webhook is a URL that posts a message to one specific channel. To create one:

  1. Go to api.slack.com/apps and create a new app (choose “From scratch”) in your workspace.
  2. Open “Incoming Webhooks” and toggle it on.
  3. Click “Add New Webhook to Workspace” and pick the target channel.
  4. Copy the generated URL. It looks like https://hooks.slack.com/services/T000/B000/XXXX.

Treat that URL like a password — anyone who has it can post to your channel. Store it as an environment variable, not in your code:

SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/XXXX

Step 2: Create an InboxBridge bridge

A bridge connects an inbound email address to your webhook URL. Create one, and InboxBridge generates a unique address for it, in the form abc123@webhook.inboxbridge.io. Everything sent to that address is parsed and delivered to the endpoint you configure.

When you create the bridge you set:

  • Webhook URL — where deliveries are POSTed, for example https://your-app.vercel.app/api/email-to-slack.
  • MethodPOST by default.
  • Signing secret — generated for you, and used to sign every delivery so you can verify it came from InboxBridge.

We do not have the function deployed yet, so leave the URL as a placeholder for now and come back to it. A useful pattern is one bridge per purpose: a dedicated address for, say, monitoring alerts keeps that feed cleanly separated from customer mail — a coarse form of filtering before a single line of code runs.

Step 3: Receive the webhook

When an email arrives, InboxBridge sends your function a JSON body that looks like this:

{
  "id": "9b2c1f7a4e",
  "receivedAt": "2026-07-07T15:04:22.000Z",
  "from": { "email": "jordan@acme.com", "name": "Jordan Lee" },
  "to": [{ "email": "abc123@webhook.inboxbridge.io", "name": "" }],
  "cc": [],
  "bcc": [],
  "replyTo": null,
  "subject": "Question about invoice #1234",
  "date": "2026-07-07T15:04:00.000Z",
  "textBody": "Hi, can someone look into invoice #1234?",
  "htmlBody": "<p>Hi, can someone look into invoice #1234?</p>",
  "attachments": []
}

Notice the shape: from, to, cc, and bcc are objects with an email and a name, not raw header strings, and the body is split into textBody and htmlBody. That makes formatting straightforward. Here is a first version of the function that just receives and logs:

// app/api/email-to-slack/route.ts (Next.js App Router on Vercel)

import { NextResponse } from "next/server";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const email = await request.json();

  console.log("Inbound email:", {
    from: email.from.email,
    subject: email.subject,
  });

  return NextResponse.json({ ok: true });
}

Return a 2xx quickly. InboxBridge treats a non-2xx or a timeout as a failed delivery and will retry it, which is exactly what you want when something is genuinely broken — but not something to trigger by doing slow work in the handler.

Step 4: Format the email into Slack blocks

Slack's Block Kit lets you build a structured message instead of a wall of text. We will build a header with the subject, a context line with the sender, and a section with the body. Two details matter: a Slack section's text is capped at 3000 characters, so truncate long bodies, and prefer textBody over the HTML version for a clean read.

// lib/format-slack-message.ts

type InboundEmail = {
  id: string;
  from: { email: string; name: string };
  subject: string;
  textBody: string | null;
  htmlBody: string | null;
  attachments: Array<{
    name: string;
    contentType: string;
    contentLength: number;
    downloadUrl?: string;
  }>;
};

function truncate(text: string, max = 2900) {
  return text.length > max ? text.slice(0, max) + "\n\n… (truncated)" : text;
}

function humanSize(bytes: number) {
  if (bytes < 1024) return bytes + " B";
  if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB";
  return (bytes / 1024 / 1024).toFixed(1) + " MB";
}

export function formatSlackMessage(email: InboundEmail) {
  const senderName = email.from.name || email.from.email;
  const body = truncate(email.textBody || "(no text body)");

  const blocks: any[] = [
    {
      type: "header",
      text: { type: "plain_text", text: email.subject || "(no subject)" },
    },
    {
      type: "context",
      elements: [
        {
          type: "mrkdwn",
          text: `*From:* ${senderName} <${email.from.email}>`,
        },
      ],
    },
    {
      type: "section",
      text: { type: "mrkdwn", text: body },
    },
  ];

  return { blocks };
}

The header block only accepts plain text, so the raw subject is safe there. Body text goes in an mrkdwn section. If you expect senders to include Slack-sensitive characters, escape &, <, and > in user-supplied strings before adding them to mrkdwn fields.

Step 5: Handle attachment metadata

InboxBridge always includes an attachments array describing each file — its name, contentType, and contentLength. On paid plans, each entry also carries a secure, time-limited downloadUrl, because the file bytes are stored for you and fetched on demand. On the free plan you get the metadata but no download link, which is all Slack needs to show what came in.

Add a block that lists attachments, linking the file name when a downloadUrl is present:

// add inside formatSlackMessage, before "return { blocks }"

if (email.attachments.length > 0) {
  const lines = email.attachments.map((a) => {
    const size = humanSize(a.contentLength);
    const label = `${a.name} (${size})`;
    // Link the name when the plan includes a signed downloadUrl.
    return a.downloadUrl ? `• <${a.downloadUrl}|${label}>` : `• ${label}`;
  });

  blocks.push({
    type: "section",
    text: {
      type: "mrkdwn",
      text: `*Attachments*\n${lines.join("\n")}`,
    },
  });
}

Signed download links expire after your plan's retention window, so they are meant for prompt access from the channel, not as permanent storage. If you need to keep a file, fetch it from the downloadUrl and re-store it somewhere durable.

Step 6: Filter which emails get forwarded

InboxBridge forwards every message that hits a bridge, which is the right default — it keeps routing rules in your code, where they are versioned and testable, rather than buried in a dashboard. So filtering lives in your function: check the sender and subject, and return early for anything you do not want in Slack.

// lib/should-forward.ts

const ALLOWED_SENDER_DOMAINS = ["acme.com", "billing.acme.com"];
const SUBJECT_KEYWORDS = ["invoice", "urgent", "outage"];

export function shouldForward(email: {
  from: { email: string };
  subject: string;
}) {
  const senderDomain = email.from.email.split("@")[1]?.toLowerCase() ?? "";
  const domainOk = ALLOWED_SENDER_DOMAINS.includes(senderDomain);

  const subject = (email.subject || "").toLowerCase();
  const subjectOk = SUBJECT_KEYWORDS.some((k) => subject.includes(k));

  // Forward only mail from a trusted domain that also matches a keyword.
  return domainOk && subjectOk;
}

Tune the logic to your needs — an allowlist of exact senders, a subject regex, or a rule that only forwards mail addressed to a particular alias. The point is that it is plain code: easy to read, easy to test, and free to run. When an email does not match, return 200 so InboxBridge marks the delivery successful and does not retry it.

Step 7: Verify the request came from InboxBridge

Your endpoint is public, so confirm each request is genuine before posting to Slack. InboxBridge signs every delivery. Each request carries an X-InboxBridge-Signature header in the form sha256=… and an X-InboxBridge-Timestamp header. The signature is an HMAC-SHA256 of the raw request body, keyed with your bridge's signing secret.

Read the body as text and verify before you call JSON.parse, because the HMAC is computed over the exact bytes that were sent:

// lib/verify-signature.ts

import crypto from "crypto";

export function verifySignature(rawBody: string, signatureHeader: string) {
  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", process.env.INBOXBRIDGE_SIGNING_SECRET!)
      .update(rawBody)
      .digest("hex");

  const a = Buffer.from(signatureHeader);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Always compare with a timing-safe function, never ===. This example uses Node's crypto module, so keep the route on the Node.js runtime; on an Edge runtime, use Web Crypto instead.

Full example

Putting it together: verify the signature, filter, format, and post to Slack.

// app/api/email-to-slack/route.ts

import { NextResponse } from "next/server";
import { verifySignature } from "@/lib/verify-signature";
import { shouldForward } from "@/lib/should-forward";
import { formatSlackMessage } from "@/lib/format-slack-message";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get("x-inboxbridge-signature") ?? "";

  if (!verifySignature(rawBody, signature)) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const email = JSON.parse(rawBody);

  // Filtering: only forward mail we care about.
  if (!shouldForward(email)) {
    return NextResponse.json({ ok: true, skipped: true });
  }

  const message = formatSlackMessage(email);

  const res = await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(message),
  });

  if (!res.ok) {
    // Return non-2xx so InboxBridge retries with backoff.
    return NextResponse.json({ error: "Slack post failed" }, { status: 502 });
  }

  return NextResponse.json({ ok: true });
}

A few things worth calling out in that handler:

  • It verifies the signature before doing anything else.
  • It filters in code, returning a successful, non-retried response for skips.
  • When Slack itself fails, it returns 502 so InboxBridge retries the delivery with exponential backoff instead of silently dropping the message.

Avoid duplicate messages

Because failed deliveries are retried, the same email can occasionally arrive twice — for instance if your function posts to Slack but times out before responding. Every payload includes a stable id, so if a double-post would be a problem you can record recently seen ids (in a small KV store or your database) and skip anything you have already handled. For a low-volume alert channel this is often unnecessary, but it is worth knowing the id is there.

Test it end to end

Once the function is deployed:

  1. Paste the deployed URL into your bridge, for example https://your-app.vercel.app/api/email-to-slack.
  2. Send a test email to your bridge address that matches your filter (a trusted sender and a keyword subject).
  3. Watch the formatted message appear in your Slack channel.
  4. Open the bridge's delivery log to confirm a 200 response, with the status code and response time recorded for that attempt.

Testing locally? InboxBridge cannot reach localhost, so use a tunnel like ngrok or Cloudflare Tunnel and point the bridge at the public forwarding URL while you iterate.

That delivery log is the quiet advantage over both alternatives. Every attempt is recorded with its response code and duration, failures are retried with backoff, and on paid plans you can replay any past message to Slack after fixing your function — none of which Slack's built-in address or a fire-and-forget Zap gives you.

Wrapping up

Slack's built-in email address is the fastest way to get mail into a channel, but it is paid-plan only, unformatted, and unfiltered. Zapier adds flexibility at a per-task cost that climbs with volume. Forwarding email to Slack through a webhook gives you full control over formatting and filtering, keeps the logic in code you own, adds signed and retried delivery, and runs comfortably on the free tier.

Create a bridge, deploy the function, and send yourself a test email. You will have clean, filtered email landing in Slack in a few minutes — no Zapier subscription and no mail server.

Related