All posts
Email to Slack: threading, Block Kit limits, and the duplicate-post trap
Gabe 15 min read

Email to Slack: threading, Block Kit limits, and the duplicate-post trap

Slack's own email-to-channel needs a paid plan, pins every message to one fixed channel, and can't thread. Wiring inbound email to Slack yourself is about 50 lines — but three things bite once real mail arrives: Slack rejects any header block over 150 characters, webhook retries post the same email twice, and Incoming Webhooks never return a message ts, so an email thread can never become a Slack thread. Here's the handler that survives all three, and the return path that lets your team reply to the customer from inside the Slack thread.

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.

customer ada@example.com MX edge parse + SPF/DKIM signed POST email.received your handler 1 · verify signature 2 · dedupe on message id 3 · threadId ⇄ ts lookup chat.postMessage thread_ts when known #support Block Kit message thread reply Events API mk.send({ inReplyTo: threadId }) lands in the customer's mail thread — inbound — outbound one handler, both directions
The handler is the only stateful piece: it holds the mapping between an email conversation (threadId) and a Slack conversation (ts). Everything else is a stateless hop.

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-channelNo-code (Zapier / n8n)Your own handler
CostPaid Slack plan onlyTask/execution quotaCompute you already have
SetupMinutes, in the Slack UI~15 minutes~50 lines
Route by recipientNo — one address per channelYes, with a filter stepYes, in code or a route
ThreadingNoNoYes (thread_ts)
Reply to the customerNoExtra action + step costYes, same handler
Strip signatures/quoted textNoAwkwardYes
AttachmentsRendered by SlackPassed as URLsYour 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:

FieldLimitWhat overruns it
header block text150 charsForwarded subjects: Re: Fwd: Re: … chains blow past this routinely
section block text3,000 charsAny real email body
section block fields10 items, 2,000 chars eachLong From: with a display name
Blocks per message50Only if you build a block per paragraph
chat.postMessage~1 message/sec/channelA 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:

EVENT KV STATE 1 · ada@example.com → support@myapp.ai subject "Invoice #1042" · threadId <a1b2@mail.example.com> get thread:<a1b2…> miss chat.postMessage new message in #support · no thread_ts → ts 1785196800.001 put thread:<a1b2…> = …001 put slack:…001 = <a1b2…> 2 · ada replies to the same mail thread References root → same threadId post with thread_ts …001 → inside the Slack thread get thread:<a1b2…> hit …001 3 · teammate replies inside the Slack thread Events API message · thread_ts …001 reverse lookup → threadId get slack:…001 hit <a1b2…> mk.send({ inReplyTo: threadId }) → ada's mail thread
Written once per conversation, in both directions. thread:<messageId> turns the next email into a threaded Slack reply; slack:<ts> turns a Slack reply back into an email in the right conversation.

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.completeUploadExternalfiles.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

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts