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:

  1. Authentication

    Who is calling — a browser session, or an OAuth token bound to this resource.

  2. Active workspace membership

    Not membership recorded at login. Membership now.

  3. 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.

  4. Role permission

    Whether this role may perform this particular operation.

  5. 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.

There is no send tool and no delete tool among the ten at all. The agent cannot send mail and cannot delete mail, whatever it decides it wants; the strongest thing it can do to your mailbox is propose a move, a read-mark or a draft.

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, writing a draft. 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 six tools that refuse

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.

Six tools — send_email, send_reply, move_email, mark_email_read, delete_email and discard_draft — refuse on every mailbox. 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. Until MCP has a confirmation round-trip we can render a card into, granting mail:send to a client does not let it send.

The refusal is the default and the failure mode, not a flag someone remembered to set. A mailbox is mutable over MCP only if its settings record explicitly says so; an absent settings document, an unreadable one, a missing block and a missing flag all refuse, and nothing in the product writes that record.

The eight that work are the ones a client can be trusted with: listing mailboxes, reading a message or a thread, searching, and the three that write drafts. 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.