Reply-by-email: two plus-signs and a token
- #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:
| Address | Who sends it | Effect |
|---|---|---|
[email protected] | Anyone — it's the public "email us" address | Opens or threads a conversation by subject |
support+<token>@acme.acornreply.com | The customer, replying in-thread | Routes into the exact conversation |
reply+<token>@acme.acornreply.com | The agent, replying to a notification from their own inbox | Posts 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 theReply-Totosupport+<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'sReply-Toisreply+<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.
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, dropOne 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:
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 nullThe 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:
token = base64url(random_bytes(16)) # ~22 chars, unguessableThe agent token is the one with a lifecycle. It's stored with the conversation, the triggering message, the bound agent, and an expiry:
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:
row = select from reply_tokens where token = ?
and consumed_at is null
and superseded_at is null
and expires_at > nowWhen 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:
# 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 nullThe 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 = 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:
# 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 siblingsThe 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:
provided = secret_from_basic_auth(request)
if not constant_time_equal(provided, INBOUND_SECRET): return 401One 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
Fromdomain. - 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.