Build1 publisher3 min readPublished
Email and Slack disagree on what a conversation is, and the join key is the envelope
A Cloudflare Worker walkthrough puts the whole bridge in one file, and the load-bearing part is four key-value writes, not the API calls.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened
- 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. Every bug in the integration comes out of that mismatch.
- The published inbound handler is a single Cloudflare Worker that runs as pasted with one KV namespace bound as SEEN and one dependency (npm install mailkite).
- The handler treats the inbound message id as the idempotency key: if env.SEEN.get(email.id) returns a value it returns replyOk() and does nothing, and it only writes env.SEEN.put(email.id, "1") after Slack returns ok: true. A retry re-sends the identical body.
- The message-id dedupe key is written with expirationTtl: 172_800 seconds.
- When a thread is first seen the handler writes thread:{threadId} -> posted.ts and slack:{posted.ts} -> email.threadId, both with expirationTtl: 2_592_000 seconds.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A walkthrough on dev.to, written by the team that builds the inbound-email service it uses, ships a complete email-to-Slack handler as a single Cloudflare Worker and spends its first paragraph on the thing most integration posts skip [1][9]. Email defines a conversation with a MIME tree, an SMTP envelope and a Message-ID chain; Slack defines one with a channel, a message of at most 50 blocks, and a `ts` [1]. Neither API is hard. The mapping between those two definitions is where the duplicates live.
The handler's actual logic is four writes to a single KV namespace bound as `SEEN` [2]. On a successful post it stores the inbound message id with a 48-hour TTL, then, if this is the first email in a conversation, writes `thread:{threadId}` to the Slack `ts` and the reverse `slack:{ts}` back to the mail thread, plus the sender address and subject, all at 30 days [3][4][5][6]. The next email in that conversation reads the stored `ts` and passes it as `thread_ts`, so it lands in the existing Slack thread instead of starting a new one [7].
Note the asymmetry: the thread map outlives the dedupe key by a factor of 15 [12]. That is the right way round. The dedupe key only needs to survive a provider's retry window, while the conversation map needs to survive a customer going quiet for three weeks. It also means a reply arriving after the 30-day expiry posts as a fresh top-level message, because the lookup returns null and `thread_ts` goes undefined [13].
The retry contract is the part worth copying. The handler verifies an HMAC signature with a constant-time compare and a plus-or-minus five-minute replay window before it parses the body, and returns 401 on failure [8][14]. Slack answers a rejected post with HTTP 200 and `{ok: false, error}`, so the handler inspects the body and returns 502 upstream, which makes the email provider retry delivery [10]. Because the `SEEN.put` happens only after `ok: true`, a failed post does not burn the idempotency key, and the retry, which re-sends an identical body, is either absorbed or re-attempted rather than double-posted [3][11].
Two details that will bite anyone building this from scratch. Slack's block limits are hard validation, not truncation: exceed one and you get 200 OK with `ok: false`, which is why the sample clamps the header to 150 characters, section fields to 2,000 and the body text to 3,000 [15][16]. And the display name is gated on authentication, not on presence: the sender's name is only rendered when DMARC passes, otherwise the raw address goes up with a warning marker [17].
The honest caveat is in the post itself. Slack has had native email-to-channel for years, and per Slack's own help docs channel email addresses are a paid-plan feature unavailable on the free plan [18][19]. Native gives exactly one behaviour, one address into one channel, which is correct for `bugs@` and wrong for `support@` the moment you want grouping by conversation [20]. Write the handler when you need routing, threading or a reply path [21].
Watch the reverse maps. The post writes `slack:{ts}`, `from:{ts}` and `subj:{ts}` but the published code is the inbound half only [5][6][2]. The outbound direction is where a mis-keyed thread stops being a cosmetic duplicate and starts sending a customer someone else's reply.