AcornReply
← All posts

Reply-by-email: two plus-signs and a token

10 min read
  • #email
  • #security
  • #postmark

A support inbox lives and dies by one boring-sounding capability: an email comes in, and you have to put it in the right conversation. Not "a" conversation — the one, the thread this message is a reply to, in the right workspace, attributed to the right person. Get it wrong and you've either leaked one customer's thread into another's, or spammed a stranger, or dropped a reply on the floor.

Acorn Reply does this with plus-addressing and tokens. There are three inbound address shapes and two different kinds of token, and the whole design turns on one rule that email forces on you the hard way: you can never trust the From line. This post is about how the routing actually works, and why the security model is what it is.

Three addresses, two audiences

Every workspace has a slug — say acme — and a shared inbound domain suffix, so its addresses live under acme.acornreply.com. Three local parts route three different ways:

AddressWho sends itEffect
[email protected]Anyone — it's the public "email us" addressOpens or threads a conversation by subject
support+<token>@acme.acornreply.comThe customer, replying in-threadRoutes into the exact conversation
reply+<token>@acme.acornreply.comThe agent, replying to a notification from their own inboxPosts the agent's reply into the conversation

The two plus-tagged forms look almost identical and route very differently, so the distinction is worth pinning down:

  • support+ is the customer thread address. When Acorn emails a customer, it sets the Reply-To to support+<thread-token>@…. The customer hits reply in Gmail, and their message comes back tagged with the conversation's token.
  • reply+ is the agent address. When a customer writes in, Acorn notifies the human agents. That notification's Reply-To is reply+<single-use-token>@…, so an agent can answer straight from their phone's mail app and have it posted as the reply — no app, no login.

Because reply+ and support+ share the same slug domain but mean completely different things, the parser checks reply+ (agent) first.

Parse an inbound local part (pseudocode)
strip any "Display Name <addr>" wrapper down to the bare address
lowercase a copy for matching (email addresses are case-insensitive)
 
if matches  reply+<token>@<slug>.acornreply.com    -> { agent_reply,    slug, token }
if matches  support+<token>@<slug>.acornreply.com  -> { customer_reply, slug, token }
if matches  support@<slug>.acornreply.com          -> { general,        slug }
otherwise -> null   # unroutable, drop

One subtlety hides in "lowercase a copy." The tokens are base64url, which is case-sensitive, so we match on the lowercased address but pull the token out of the original text to preserve its casing. Lowercase the token and you've quietly invalidated one in a few thousand of them.

Which address did they actually send to?

The To header is a suggestion. On a reply-all, or a forward, or a mailing-list bounce, our address might be the third recipient, wrapped in a display name, or rewritten entirely. So routing doesn't read one field — it tries three sources in order of how much they can be forged:

Pick the routing address (pseudocode)
for candidate in [
    envelope_recipient,     # SMTP RCPT TO — a bare address, hardest to forge
    parsed_to_list,         # the provider's pre-split To: entries, walk them all
    raw_to_header,          # last resort
]:
    parsed = parse_local_part(candidate)
    if parsed and parsed.slug is one of our workspaces:
        return parsed
return null

The SMTP envelope recipient wins when present because it's what the mail server was actually told to deliver to — immune to the cosmetic games a From or To header can play.

Two tokens, on purpose

Both plus-addresses carry a token, but they are not the same token, and conflating them is the easiest way to get this wrong.

  • The customer thread token is stable. It's minted once per conversation and reused for the life of the thread — every email to that customer carries the same support+<token>, because a thread is a long-lived thing.
  • The agent reply token is single-use and expiring. A fresh one is minted for each notification recipient every time there's something to reply to, and it's meant to be spent exactly once.

Both are generated the same humble way — 16 random bytes, base64url encoded, stored as-is with a uniqueness constraint:

Mint a token (pseudocode)
token = base64url(random_bytes(16))    # ~22 chars, unguessable

The agent token is the one with a lifecycle. It's stored with the conversation, the triggering message, the bound agent, and an expiry:

Mint a single-use agent reply token (pseudocode)
token = base64url(random_bytes(16))
expires_at = now + REPLY_TOKEN_TTL_DAYS * 24h    # default 7 days
insert into reply_tokens {
    token, workspace_id, conversation_id,
    message_id,          # the inbound message this reply answers
    user_id,             # the specific agent this link belongs to
    expires_at, consumed_at = null, superseded_at = null,
}

A token is only minted when it makes sense to: the workspace's plan allows reply-by-email, there's a draft to act on, and there's a specific inbound message being answered. And it's minted per recipient — if three teammates get notified, three tokens go out, all pointing at the same message. That fan-out is what makes the next part necessary.

First agent to reply wins

Three people got the "new message" email; each can reply from their inbox. The first reply should win, and the other two links should quietly die — you don't want two agents unknowingly both answering the customer.

A token is "live" only if it hasn't been consumed, hasn't been superseded by a sibling, and hasn't expired:

Load a live token (pseudocode)
row = select from reply_tokens where token = ?
      and consumed_at   is null
      and superseded_at is null
      and expires_at    > now

When an agent's reply actually sends, we consume their token and supersede its siblings in one transaction — but only after the outbound send has committed, never before:

Consume the winner, kill the siblings (pseudocode)
# only after the reply has actually been sent
in one transaction:
    update reply_tokens set consumed_at = now
        where token = winner and consumed_at is null      # idempotent guard
    update reply_tokens set superseded_at = now
        where message_id = winner.message_id
          and token != winner
          and consumed_at is null and superseded_at is null

The consumed_at is null guard in the where clause is what makes it race-safe: two concurrent replies can't both consume the same token, and the loser simply updates zero rows.

What does a losing agent see? Not a silent failure. If they tap their (now superseded) link, the token won't load, we look up which sibling won, and we email them back a friendly "already answered by Dana" notice. A token that simply expired unused gets a "your reply link has expired" notice instead. The dead-link case is ordinary, so it gets an ordinary, human-readable response.

There's a nice bit of bookkeeping here: once an agent answers by email, the speculative auto-draft Acorn had queued for that same message is discarded — you already replied, so the robot shouldn't also be holding a draft. That cleanup runs outside the consume transaction on purpose: if discarding the draft failed, it must not roll back the token consumption and let the reply be sent twice.

The routing decision, end to end

Putting it together, here's what happens when a message hits the inbound webhook:

Route one inbound email (pseudocode)
route = pick_routing_address(message)
if route is null: drop("unroutable")
 
if route.kind == agent_reply:
    handle_agent_reply(message, route)      # authenticated path, returns here
    return
 
workspace = find_workspace_by_slug(route.slug)   # unknown slug -> drop
if route.kind == customer_reply:
    conv = find_conversation_by_thread_token(workspace, route.token)
    if conv is null: drop("token doesn't belong to this workspace — spoof")
else:  # general "support@"
    conv = find_by_message_headers(message)      # In-Reply-To / References
           or open_or_thread_by_subject(message)
 
post_customer_message(conv, message)

Two details in there matter. First, a support+ token that doesn't resolve to a conversation in that workspace is dropped as a spoofing attempt, not retried loosely — a valid-looking token for the wrong slug is a red flag, not a near-miss. Second, the header-based threading fallback (In-Reply-To / References) is trusted less than a token match, because Message-IDs leak into every quoted reply and forward, while the token is a secret that only appears in the address.

Why the From line is never trusted

Here's the load-bearing idea. Email From is trivially forgeable. If Acorn posted an agent's reply just because the From said so, anyone who learned a reply+ address could impersonate that agent. So the token is necessary but deliberately not sufficient. The agent reply-by-email path clears a stack of checks before it posts anything:

Authenticate an agent reply (pseudocode)
# post the reply only if EVERY check passes:
#   - the token is still live        (unused, not superseded, not expired)
#   - the real sender is the user the token was minted for
#   - that sender still belongs to the workspace
#   - the workspace plan still allows reply-by-email
#   - sender authentication shows no sign of spoofing
# then, and only then: send, consume the token, and supersede its siblings

The single-use token — which lives only in that one agent's inbox — and the requirement that the sender match the user it was minted for are the primary anti-spoof controls. Standard sender authentication (SPF/DKIM/DMARC) is a supplementary signal layered on top, not the thing the path leans on.

Note what happens on a From mismatch: we drop silently. If someone is spoofing an agent, the reply address is probably the victim's — bouncing a "that didn't work" notice there is just backscatter to an innocent party. The friendly notices are reserved for the cases that are plausibly the real agent (expired link, plan downgrade, empty reply), never for the cases that smell like an attack.

And the webhook itself is authenticated before any of this runs. The inbound provider is configured with a shared secret, and the handler compares it in constant time so a timing side-channel can't be used to guess it byte by byte:

Authenticate the webhook (pseudocode)
provided = secret_from_basic_auth(request)
if not constant_time_equal(provided, INBOUND_SECRET): return 401

One From, many identities

A last wrinkle that shapes the whole design: outbound mail uses a single shared From address, not one per workspace. The reason is operational — the email provider requires every distinct From to be a confirmed sender signature, and minting one per tenant doesn't scale. So per-tenant identity rides entirely in two places the provider doesn't gatekeep: the display name (Dana from Acme) and the Reply-To (support+<token>@acme…). The From says who's sending on the infrastructure; the Reply-To says where the human conversation continues. Keeping those two jobs separate is what lets one sending domain speak for thousands of workspaces without asking any of them to verify a thing.

Where this is heading

Email routing is a game of trust signals, and the signals keep improving:

  • Stronger inbound authentication. DMARC adoption is still climbing, and the arrival of enforced alignment at the big mailbox providers means the supplementary SPF/DKIM/DMARC check gets more reliable over time — enough that it could graduate from "supplementary" toward "load-bearing" for senders we can prove are aligned.
  • ARC for forwarded mail. Authenticated Received Chain lets a forwarder vouch for the auth results it saw upstream, which is exactly the case (mailing lists, corporate forwarders) where naïve SPF checks fall apart today. Leaning on ARC would let us trust more forwarded replies without loosening the token requirement.
  • Shorter-lived, scope-tighter tokens. A single-use token that expires in a week is already conservative; as reply latency drops, there's room to shrink the window further and bind tokens even more tightly to a single intended action.

The throughline doesn't change: the token is the secret that proves which conversation, and the sender checks prove which person. Email will keep handing you forgeable envelopes; the trick is never to route on the parts anyone can write.

Further reading

  • IETF RFC 5233 — the "subaddress" (plus-addressing) extension that the support+/reply+ local parts build on.
  • IETF RFC 7489 — DMARC, the policy layer that ties SPF and DKIM results to the visible From domain.
  • IETF RFC 8617 — the Authenticated Received Chain (ARC) protocol for preserving auth results across forwarders.
  • IETF RFC 4648 — Base64/base64url encoding, including the URL- and filename-safe alphabet the tokens use.
  • Node.js, crypto.timingSafeEqual — the constant-time comparison used to check the webhook secret without leaking it through timing.
  • Postmark, What is inbound processing? — how a hosted provider parses and posts inbound mail to a webhook.