Start from the mismatch, because every bug in this integration comes out of it. Email hands you a MIME tree, an SMTP envelope, and a Message-ID chain that defines the conversation. Slack hands you a channel, a message of at most 50 blocks, and a ts that defines the conversation. The whole job is mapping one onto the other without dropping information — the reply chain, the authentication verdicts, the attachments — on the floor.
Here's the whole inbound half, as a Cloudflare Worker. It runs as pasted with one KV namespace bound as SEEN and one dependency (npm install mailkite):
// worker.js — inbound email → Slack. wrangler secret put SLACK_BOT_TOKEN / MAILKITE_WEBHOOK_SECRET
import { MailKite } from "mailkite";
const clamp = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
function blocksFor(email) {
const subject = email.subject || "(no subject)";
const trusted = email.auth.dmarc === "pass";
const sender = trusted && email.from.name ? `${email.from.name} <${email.from.address}>` : email.from.address;
return [
{ type: "header", text: { type: "plain_text", text: clamp(`📧 ${subject}`, 150) } },
{ type: "section", fields: [
{ type: "mrkdwn", text: `*From:*\n${clamp(sender, 2000)}${trusted ? "" : " ⚠️"}` },
{ type: "mrkdwn", text: `*To:*\n${email.to[0].address}` },
] },
{ type: "section", text: { type: "mrkdwn", text: clamp(email.text || "_no text part_", 3000) } },
{ type: "context", elements: [
{ type: "mrkdwn", text: `spf \`${email.auth.spf ?? "unknown"}\` · dkim \`${email.auth.dkim ?? "unknown"}\` · dmarc \`${email.auth.dmarc ?? "unknown"}\`` },
] },
];
}
export default {
async fetch(req, env) {
const raw = await req.text();
const sig = req.headers.get("x-mailkite-signature");
// HMAC recompute, constant-time compare, ±5-minute replay window: one call
if (!MailKite.verifyWebhook(sig, raw, env.MAILKITE_WEBHOOK_SECRET)) {
return new Response("bad signature", { status: 401 });
}
const email = JSON.parse(raw); // parse only AFTER verifying
if (email.type !== "email.received") return new Response(MailKite.replyOk());
// A retry re-sends the identical body, so the message id is the idempotency key.
if (await env.SEEN.get(email.id)) return new Response(MailKite.replyOk());
const ts = email.threadId ? await env.SEEN.get(`thread:${email.threadId}`) : null;
const res = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8",
authorization: `Bearer ${env.SLACK_BOT_TOKEN}`,
},
body: JSON.stringify({
channel: env.SLACK_CHANNEL, // e.g. C0123456789
thread_ts: ts ?? undefined, // in-thread when we've seen this conversation
text: `${email.subject || "(no subject)"} — ${email.from.address}`, // notification fallback
blocks: blocksFor(email),
}),
});
const posted = await res.json();
// Slack answers 200 with { ok: false, error } — a non-2xx here makes MailKite retry the delivery.
if (!posted.ok) return new Response(`slack: ${posted.error}`, { status: 502 });
await env.SEEN.put(email.id, "1", { expirationTtl: 172_800 });
if (email.threadId && !ts) {
const month = { expirationTtl: 2_592_000 };
await env.SEEN.put(`thread:${email.threadId}`, posted.ts, month); // next email → this Slack thread
await env.SEEN.put(`slack:${posted.ts}`, email.threadId, month); // Slack reply → this mail thread
await env.SEEN.put(`from:${posted.ts}`, email.from.address, month);
await env.SEEN.put(`subj:${posted.ts}`, email.subject || "your message", month);
}
return new Response(MailKite.replyOk());
},
};
The receiving side is MailKite, which we build — the email.received event, the x-mailkite-signature header, and replyOk() are ours. The Slack half is plain Web API and transfers to any inbound provider that gives you parsed JSON.
Three ways to get email into Slack, and when each is right
Slack has had a native email-to-channel feature for years, and for a lot of teams it's the correct answer — don't write code you don't need:
| Slack email-to-channel | No-code (Zapier / n8n) | Your own handler | |
|---|---|---|---|
| Cost | Paid Slack plan only | Task/execution quota | Compute you already have |
| Setup | Minutes, in the Slack UI | ~15 minutes | ~50 lines |
| Route by recipient | No — one address per channel | Yes, with a filter step | Yes, in code or a route |
| Threading | No | No | Yes (thread_ts) |
| Reply to the customer | No | Extra action + step cost | Yes, same handler |
| Strip signatures/quoted text | No | Awkward | Yes |
| Attachments | Rendered by Slack | Passed as URLs | Your choice |
The native feature is free of code but not free of money: channel email addresses are a paid-plan feature and unavailable on Slack's free plan, per Slack's own help docs. It also gives you exactly one behaviour — this address dumps into that channel — which is fine for bugs@ and wrong for support@ the moment you want tickets grouped by conversation.
Write the handler when you need routing (billing@ → #finance, support@ → #support), threading, or a reply path. Otherwise stop here and use the built-in.
Block Kit's limits are a contract, and long subjects break it
Slack's block limits are hard validation, not truncation. Exceed one and the API returns 200 OK with {"ok": false, "error": "invalid_blocks"} — your message silently never appears, which is a miserable thing to debug at 2am. The ones inbound email actually hits, from the Block Kit reference:
| Field | Limit | What overruns it |
|---|---|---|
header block text
|
150 chars | Forwarded subjects: Re: Fwd: Re: … chains blow past this routinely |
section block text
|
3,000 chars | Any real email body |
section block fields
|
10 items, 2,000 chars each | Long From: with a display name |
| Blocks per message | 50 | Only if you build a block per paragraph |
chat.postMessage |
~1 message/sec/channel | A mailing list burst, an autoresponder loop |
That's why every string in blocksFor() goes through clamp(). The 150-character header is the one that bites first and hardest, because a subject line has no length limit in SMTP and forwarding prepends to it forever.
The rate limit is the second one. chat.postMessage sits in Slack's special tier — roughly one message per second per channel, short bursts tolerated — and returns 429 with a Retry-After header when you exceed it. Don't build a retry loop for that: return a non-2xx from your handler and let the webhook delivery retry, so the backpressure lives in the queue that was designed for it instead of in a Worker holding a request open.
Retries will double-post unless you dedupe
Any webhook worth using retries, and a retry that reaches Slack twice puts the same customer email in the channel twice. The fix is an idempotency key, and it has to be one that's stable across attempts — not a timestamp, not a hash of the body with a re-serialized field order.
MailKite re-sends the identical body on an automatic retry or a manual replay, and id is stable across all of them, so id is the key. Two lines in the Worker above:
if (await env.SEEN.get(email.id)) return new Response(MailKite.replyOk());
// … after Slack accepts it:
await env.SEEN.put(email.id, "1", { expirationTtl: 172_800 });
Write the key after Slack accepts, never before. If you mark it seen first and the Slack call fails, the retry is deduped away and the email is lost silently — the failure mode nobody notices until a customer asks why they were ignored.
The display name in that Slack message is a claim, not a fact
This one is specific to piping email into a chat tool, and it's the part I'd push back on in review. A Slack message rendered as Ada Lovelace with a friendly avatar reads as identity to everyone in the channel. But the display name comes from the MIME From: header, which the sender wrote. Nothing verifies it.
So blocksFor() only shows the display name when DMARC passed, and appends a ⚠️ when it didn't:
const trusted = email.auth.dmarc === "pass";
const sender = trusted && email.from.name ? `${email.from.name} <${email.from.address}>` : email.from.address;
Two details matter here. from.name is omitted, not null, when the message carried no display name or when the From: header names a different address than the SMTP envelope did — which is exactly what spoofed mail, forwarders, and mailing lists look like. And a null in auth means the check didn't run, so null is unknown, never pass. The context block prints the three raw verdicts for that reason: the person triaging in Slack gets to see dmarc fail instead of inferring trust from a name.
Email thread → Slack thread → email thread
This is where the Incoming Webhook path dead-ends. An Incoming Webhook accepts a thread_ts you already have, but its success response is the plain string ok — Slack's docs state outright that the message ts "is not returned when sending a request to an incoming webhook." No ts means no key to thread the next email onto, and you'd have to go fish it back out of conversations.history. Use chat.postMessage with a bot token (chat:write); it returns ts in the response body.
The mapping in both directions is what makes the loop work:
threadId is the conversation root — the message's In-Reply-To/References root, falling back to its own Message-ID — so every message in one exchange resolves to the same key without you parsing headers. It's null when the message carried no usable id, which is why the Worker guards on email.threadId before touching KV.
Closing the loop: reply from the Slack thread
Add a second route for Slack's Events API, subscribe to message.channels, and any threaded reply becomes an outbound email in the original conversation. Verify Slack's signature the same way you verified MailKite's — different algorithm, same rule:
// slack-events.js — Slack thread reply → email. Route: POST /hooks/slack
import { MailKite } from "mailkite";
async function slackSigned(req, raw, secret) {
const ts = req.headers.get("x-slack-request-timestamp");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`v0:${ts}:${raw}`));
const mine = "v0=" + [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
return mine === req.headers.get("x-slack-signature");
}
export default {
async fetch(req, env) {
const raw = await req.text();
if (!(await slackSigned(req, raw, env.SLACK_SIGNING_SECRET))) {
return new Response("bad signature", { status: 401 });
}
const body = JSON.parse(raw);
if (body.type === "url_verification") return new Response(body.challenge); // one-time handshake
const ev = body.event;
// Only threaded human replies. bot_id guard is what stops an infinite loop.
if (ev?.type === "message" && ev.thread_ts && !ev.bot_id && !ev.subtype) {
const threadId = await env.SEEN.get(`slack:${ev.thread_ts}`);
const to = await env.SEEN.get(`from:${ev.thread_ts}`);
if (threadId && to) {
const mk = new MailKite(env.MAILKITE_API_KEY);
await mk.send({
from: "support@myapp.ai", // an address on a verified domain
to,
subject: `Re: ${await env.SEEN.get(`subj:${ev.thread_ts}`) || "your message"}`,
text: ev.text,
inReplyTo: threadId, // sets In-Reply-To + References
});
}
}
return new Response("", { status: 200 }); // ack fast; Slack retries on timeout
},
};
inReplyTo is the whole trick: pass threadId straight back and the reply threads correctly in Gmail, Outlook, and anything else that honours References. The from: and subj: keys it reads are the two the inbound Worker wrote alongside the thread mapping — Slack's event tells you which thread a reply belongs to, but not who the original email came from.
Two guards are load-bearing. The bot_id check stops your own posted message from being read as a reply and emailed back out; without it, one autoresponder on the other end is an infinite loop with your domain's reputation on the line. And Slack retries any event you don't acknowledge within 3 seconds, so acknowledge first and do slow work after — in a Worker, ctx.waitUntil().
Attachments, and keeping spam out of the channel
Attachments arrive as either a signed url (a credential-free GET link, valid 7 days, then 410 Gone) or base64 content on zero-retention domains — handle both. Slack won't render a link it can't fetch, so the simple version is one context element joining every filename (size) as <url|filename> links — one element, not one per file, since a context block caps at 10; the full version uploads the bytes with files.getUploadURLExternal + files.completeUploadExternal — files.upload stopped accepting new apps in May 2024 and was sunset on 12 November 2025, so don't copy an older tutorial here. Either way, download anything you need to keep before the 7 days are up.
For spam, you don't need to post it at all. When your route is in control mode, your 2xx body is an instruction, so the SDK's reply helpers keep junk out of the channel entirely:
if (email.auth.spam === "spam") return new Response(MailKite.replyDrop()); // discard, don't post
replySpam() marks the stored message instead of deleting it, and replyBlockSender() blocks the sender for good — future mail from them is dropped before it's ever delivered to you. That's a MailKite-specific shape (we built it because "the webhook already knows enough to make this decision" kept coming up), but the general point holds anywhere: filter before you post, not in the channel.
When to use something else
If you're already on Postmark or SES, their inbound products get email to a webhook perfectly well and the Slack half of this post is unchanged — the only thing that moves is the signature check and the payload field names. Cloudflare Email Routing is free and fine as a pure pipe, with the caveat that it can't send, so the reply path in this post isn't available. Self-hosting Haraka or Postfix and parsing MIME yourself is the right call if mail volume is your product or the messages can't leave your infrastructure — the tradeoff is that you now own MX uptime, TLS, spam filtering, and the MIME edge cases.
What MailKite (which we build) does here is the parsing, the auth verdicts, the retry ledger, the signature, and the thread root, so the Slack integration is the ~50 lines above rather than a mail stack. The inbound payload is documented in full on the receiving docs, and a stripped-down Incoming Webhook version — no bot token, no threading — is in the Slack integration guide if you just want mail in a channel. Free tier includes inbound and webhooks: app.mailkite.dev.
FAQ
Can I do this on Slack's free plan?
Yes — this approach uses chat.postMessage with a bot token, which is available on every plan. It's Slack's built-in email-to-channel addresses that require a paid plan.
Can the bot post to a private channel?
Only if it's a member. Invite it with /invite @yourapp in the channel; chat:write alone isn't enough, and you'll get not_in_channel otherwise.
How do I route different addresses to different channels?
Either branch on email.to[0].address in the handler, or create a route per address (match, action, destination) pointing at separate endpoints. Branching in code is simpler until the endpoints genuinely differ.
What about Discord or Microsoft Teams?
Same shape, different message format: Discord takes an embeds array on its webhook URL, Teams takes an Adaptive Card. The verify-dedupe-thread logic is identical; only blocksFor() changes.
Related: How to verify inbound email webhooks — the signature check in Node, Python, and Go · Email to webhook: the complete guide — the full payload reference · Turn inbound email into Zapier workflows — the no-code version of this post
Originally published at mailkite.dev.
Top comments (0)