Hey everyone. Quick one today, not a release note, more of a "here's a decision we made and here's why it's still bugging me at 1am sometimes" post.
We build mailboxes for AI agents. Somewhere in that process we had to pick a protocol for how those agents actually talk to a mailbox, and we picked JMAP over IMAP. That sounds like a small technical footnote. It was not a small technical footnote. It was a whole thing.
So let's talk about it.
Why does the protocol even matter?
Here's the thing nobody tells you until you're debugging it at midnight: an LLM writing code against IMAP is basically improvising jazz. It knows roughly what SELECT and FETCH and UIDVALIDITY are supposed to do, in the way you roughly remember your high school chemistry, and then it just starts making stuff up. Wrong flags, invented response formats, confident nonsense.
JMAP is a JSON batch over HTTP. The model has seen ten thousand APIs shaped exactly like that. It doesn't improvise, it just writes the thing, because it already knows the shape.
That's the whole pitch, honestly. Pick the protocol that's already in the training data, save yourself a mountain of retries and half-hallucinated support tickets.
Okay but here's the actual burden part
Picking JMAP meant we couldn't just point an agent at our existing Cyrus IMAP store and call it a day. Cyrus's own login check is, and I promise I'm not exaggerating, sasl_pwcheck_method alwaystrue. Type any non-empty password. Any of them. It will let you in.
That's not an oversight we're quietly hoping nobody notices. It's intentional. Auth was never supposed to live inside the mail store. It was supposed to live at the edge, in something we control completely, which meant we had to go build that something ourselves.
So we built a proxy. And once you accept "the proxy is the entire security boundary," every decision downstream gets heavier. This is the burden. We chose it on purpose, we're not complaining, I'm just telling you it was not the fun Friday afternoon project it sounds like on a roadmap doc.
What did we actually build to carry it?
A chain of tokens instead of a password, each one shorter-lived than the last:
- A proof-of-work challenge, annoying to fake at scale, cheap for us to check
- A session token good for about an hour
- A capability token good for about two minutes, the one that actually touches every mail request
That capability token gets checked locally against a public key. No database call, no phoning home. Microseconds. The tradeoff nobody escapes here: you cannot un-leak a token. If one gets out, it's live until it expires, full stop. We didn't pretend that problem goes away. The two-minute window is the actual answer, not a placeholder for a better answer we haven't shipped yet.
Why is that specific detail keeping me up at night?
Because the capability token doesn't carry a scope. Sending mail versus only reading it comes down to one boolean, readOnly. Small surface, sure, right up until you realize anyone who could reach the endpoint minting these tokens could just ask for a send-capable one.
We didn't fix that with more fields in the token. We fixed it by making the minting endpoint unreachable from anywhere it shouldn't be reachable from:
location ~ ^/auth/mint/user(?:/|$) {
return 404;
}
See that little (?:/|$) bit? Without it, someone adds a trailing slash to the URL and the exact-match block doesn't catch it, so the request slides past the 404 and lands on the real endpoint anyway. One character group. That's the whole gap between "secure" and "the kind of headline nobody wants." We caught it before it shipped, which I'm choosing to feel good about instead of anxious about, most days.
So was it worth it?
The proxy itself ended up almost boring, in the good way. It forwards requests byte for byte and only touches four things: stamps an empty From:, rejects a From: it shouldn't allow, adds a signature, logs for analytics. That's it. We didn't build custom endpoints for send or reply or list. New JMAP methods just work, no proxy changes needed, because we're not reinventing the method surface, we're just guarding the door to it.
One rule almost got us anyway: any change to the message body has to happen before DKIM signs it, never after, because DKIM signs the exact bytes it sees. Get that order backwards and every signature you send silently stops meaning anything. You don't find out from an error. You find out three weeks later when your deliverability graph quietly falls off a cliff and someone has to go figure out why.
And the payoff is real. JMAP over WebSocket means an agent isn't sitting in a loop polling NOOP hoping something changed, it just gets pushed the update. And separately, we learned Cyrus's crash recovery depends entirely on which database format you're using underneath, twoskip versus the older skiplist. Same crash, same store. One recovers in seconds. The other takes about half an hour. We're on the fast one, but that number still makes me a little uneasy every time I think about it, because half an hour was apparently the default for a long time.
What if it doesn't fully solve the problem?
It doesn't. We're not going to pretend it does.
There's no way to kill a leaked capability token early, it's stateless by design, so the two-minute expiry is doing real load-bearing work with nothing behind it as a backup. And readOnly being a single boolean instead of a real scope system means the thing actually stopping someone from minting a bad token is a network rule sitting in nginx, not something baked into the token itself. It works today. It's not where we want it to live forever.
If you're picking a JMAP backend for your own agent project, don't just ask if they use JMAP. Ask what's actually stopping someone from minting a token they shouldn't have. If the answer is "a regex in an nginx config," at least make sure someone checked the regex.
Speaking of protocols nobody asked to think about, IMAP is over three decades old at this point. Still running most of the world's mailboxes. Still making everyone's life a little harder than it needs to be. Respect to it, honestly, but we picked the other burden.
Questions or comments? Curious how other people are solving agent-side auth without turning it into a second full-time job.
Top comments (0)