Implementation
The security model, at the level of code
The security page says what the system refuses to do. This one says where each refusal lives, so that a claim can be checked rather than believed. It describes the code as written and tested; it does not describe anything as operated, because nothing here has been operated.
The order of checks
Every request that touches mail runs the same five steps in the same order, and the side effect is the last one:
Authentication
Who is calling — a browser session, or an OAuth token bound to this resource.
Active workspace membership
Not membership recorded at login. Membership now.
Mailbox visibility
Ownership for an owner or admin; an explicit per-mailbox grant for an operator or viewer. Absent a grant, the mailbox does not exist as far as the request is concerned.
Role permission
Whether this role may perform this particular operation.
Only then a side effect
A database write, an object-storage read, a Durable Object call, mail, or a model invocation.
Two consequences worth stating plainly. Revocation takes effect on the next action rather than the next login: a session that is open when a grant is removed fails on its next request, including mid-conversation with the agent. And there is no code path that opens two mailboxes to answer one question, so a cross-mailbox read is not a check somebody forgot — it is a shape the code does not have.
The agent inherits this exactly. It runs as you, with your grants, re-checked on every turn and every tool call. It has no service identity of its own that could read a mailbox you cannot.
Inbound mail is the one entry point that is not a user. That handler resolves the authoritative envelope recipient to an active mailbox before it reads the MIME body, so an attacker cannot steer delivery with a forged header.
What the card is drawn from
The agent has ten tools. Four of them — listing, reading, threading and searching — are read-only and run immediately. The other six stop and render an approval card, and the rule is written as a default rather than a list: a tool is allowed to run unattended only if it is a known read tool, so a tool added tomorrow and a tool nobody classified both fail safe to human approval.
Among the ten there is no send tool and no tool that deletes a message you have received. The agent cannot send mail and cannot delete mail somebody sent you, whatever it decides it wants.
It is not, however, incapable of losing you anything. Two of its tools destroy something already stored — revising a draft and discarding a draft — and each stops at the same card as the rest. What they can reach is a message you wrote and have not sent; the card names it, and nothing that arrived in your mailbox is reachable by either.
The card itself is rendered by the server from the stored message — the record, not the model’s account of the record. That is the whole point: a confused or manipulated agent can still request a wrong action, but it cannot show you a card that misdescribes the action it is requesting. The card carries the message’s revision, and approving re-reads the record and re-runs the permission check at that moment. If the message moved, was read, or was deleted between the card being drawn and you clicking it, the change does not apply to a message you were not looking at.
Denying does nothing at all — it is not a soft delete or a queued rejection, it is the absence of an action. There is no configuration that disables approval and no per-sender allow-list that skips it.
The automatic drafting path that runs on incoming mail is narrower still: one capability, drafting a reply. There is no send in that path, so no failure of judgement inside it can deliver a message.
The card is drawn on the security page, with real field names and fabricated sample data.
The seven tools that wait
The MCP endpoint exposes fourteen tools over OAuth 2.1 with PKCE, gated by six scopes, and a token can never exceed the mailbox grants of the account that authorised it. That is the outer boundary. Inside it there is a second one.
The seven tools that would change something — send_email,
send_reply, delete_email, discard_draft,
update_draft, move_email and mark_email_read — do not
run when they are called. A scope proves which client is calling. It does not prove
that a person saw the change, and the promise the in-app card makes is about a person, not
about a client. So the tool answers with a URL-mode elicitation — the mechanism the
2026-07-28 revision of the protocol defines for interactions that must not pass through the
client — and the client repeats the call once the mailbox owner has answered it.
The link opens the approval card above, on an authenticated page, rendered from the same
module. Three properties are what make it an approval rather than a formality. The
accept a client returns means only that the user consented to open the link;
the protocol says the outcome happens out of band and the client is not told it, so the only
thing consulted is the record the browser wrote. Nothing correlating travels through the
client: the waiting row is found again by re-deriving a digest over the authenticated
subject, the client, the mailbox, the tool and its exact arguments, so changing one argument
is a different, unapproved change. And the row names the subject it was minted for, so a
link opened by a different signed-in person renders exactly what a made-up identifier
would.
The approval is spent by one conditional update, so a repeated retry finds it consumed
rather than approved. A client that has not declared elicitation.url in its
per-request capabilities is refused outright, with the link in the message; the server never
falls back to executing, and the SDK independently refuses to emit an elicitation for a
capability the request did not declare.
No mailbox setting or allow-list bypasses this approval. A retired settings key is ignored when old records are read and cannot be written through the current API.
The seven that work are the ones a client can be trusted with: listing mailboxes, listing messages, reading a message, reading a thread, searching, drafting a reply and creating a draft. Overwriting an existing draft is not among them — replacing a draft can destroy text a person wrote, and that is a change like any other. A draft is inert. It sits in the mailbox until a person opens it and sends it.
Dynamic client registration is deliberately off, so no client can register itself. Clients are created by an owner or admin, who can disable, rotate, delete and revoke consent, and the default scope set for a managed client is read-only.
Untrusted input
Inbound mail is hostile input by construction: anyone can write to your address, and anyone writing to your address can try to write instructions to your agent.
Before the drafting model sees a message it is scanned for prompt injection. A flagged message blocks automatic drafting for that message. A scanner that errors is treated exactly as a scanner that says no, so the failure direction is closed rather than open. The bytes handed to the scanner are the same bytes handed to the drafting model, capped at the same fixed budget — there is no larger or differently-assembled text downstream that could slip past the check.
Be exact about the scope of that. It gates automatic drafting and nothing else. A flagged message is not quarantined; it stays in the mailbox and stays readable, so if you ask the agent to summarise it, its content reaches the model like any other message. The scan removes the case where hostile mail steers the agent with nobody in the room. It does not remove the case where you point the agent at hostile mail yourself, and no scanner removes that.
What is handed to the model is a bounded projection of a message rather than the message: raw headers stripped, size capped. That is a cost control and a blast-radius control at the same time.
What crosses a queue
Background work is where systems leak, because a queue is a copy of your data in a place nobody is looking at. The envelopes this application publishes carry opaque operation and job identifiers — not message bodies, not credentials, not attachments, not mailbox addresses. A random key resolves to a mailbox only while that mailbox’s lifecycle still allows the work, so a message replayed after a deletion resolves to nothing.
There is one exception and it is not ours to remove. Cloudflare’s delivery events publish sender, recipient, subject and status directly into the lifecycle queue. That means the retention setting on that queue and its dead-letter queue is part of the privacy posture of a deployment, not an operational detail, and it is named as required evidence before production.
Erasure
Deletion is hard deletion by default rather than a flag on a row. A mailbox erasure destroys the mailbox’s own database and its agent’s state natively, verifies that the object-storage cleanup actually happened, anonymises addresses in the control plane, fences any in-flight send so a job cannot resurrect what was deleted, and writes an immutable completion receipt. Legal hold and timed retention exist as explicit, audited overrides — the override is the thing that gets recorded, not the deletion.
This website
keelpost.com — including these documentation pages — is a separate Worker from the product. It holds no session, no mailbox binding and no product secret. Its only state is a table of waitlist addresses and a hashed form of the submitting IP address, kept so the form can be rate-limited.
It serves default-src 'self' with base-uri 'none',
object-src 'none' and frame-ancestors 'none', alongside
nosniff, a deny on framing, a strict referrer policy and a permissions policy
that turns off camera, microphone, geolocation, payment and interest-cohort. There is no
third-party script, analytics service, font host or image origin on it at all — which is why
the policy can be that narrow and stay that narrow.
Documentation on the apex rather than a documentation subdomain is part of that. Why, in full — the short version is that a subdomain is not a security boundary a browser respects, and it would force a second copy of a stylesheet the content security policy will not let two hosts share.
None of this is a certification and none of it has been reviewed by anyone outside the project. The list of things we do not claim is deliberately longer than this page.
Join the waitlist
Sign-ups are closed while we build. If this is the shape of thing you want between an agent and your mail, leave an address and we will write once, when they open.
We store the address you type, plus a hashed form of your IP address so the form can be rate-limited. One email when sign-ups open, and nothing after that. We do not share the list, and there is no analytics or third-party script on this site.
Next: what has actually been exercised, and what running it costs.