Roadmap
Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.
The order of focus is self-hosting first, then multi-tenant hosting later. Each item becomes a GitHub issue. A ✅ marks shipped items.
Deployment models
Section titled “Deployment models”Two questions decide what Google requires: whose server runs Surfski, and whose Google OAuth client users grant access to.
| Model | Server | Google OAuth client | Tenant of the hosted service? | Google verification / CASA |
|---|---|---|---|---|
| Self-hosted | The user’s own (VPS, EC2, NAS…) | The user’s own GCP project | No. They just use the open-source code | Not needed while the app stays under Google’s 100-user cap |
| Hosted, bring-your-own OAuth client (Workspace orgs) | The hosted service | The customer’s, with user type Internal | Yes | Internal apps are exempt from verification. The service still processes their mail, so security and DPA obligations apply |
| Hosted, shared OAuth client | The hosted service | The hosted service’s own | Yes | Up to 100 users unverified (invite-only beta). Any public launch needs OAuth verification + an annual CASA assessment |
There is no way around restricted scopes for reading mail. gmail.readonly (and gmail.compose)
are restricted, so a server that reads other people’s mail through its own client needs CASA
once it goes public.
Hosting path
Section titled “Hosting path”- Now: self-hosting. Docs and README focus on running your own instance. No CASA.
- Invite-only hosted beta on the shared client, kept under the 100-user lifetime cap. The point is to validate demand and pricing. Users see the “unverified app” warning, and Google may suspend unverified apps that are offered publicly, so this is not a commercial launch.
- Hosted for Workspace organizations with bring-your-own OAuth client. Each tenant registers an Internal OAuth client in its own GCP project, and the hosted service uses it for that tenant. This needs no verification or CASA on our side. Personal Gmail accounts aren’t supported here, because they have no Internal user type.
- Public self-serve hosting on the shared client. Do this only once revenue covers it, because it requires Google OAuth verification plus an annual CASA assessment.
Infrastructure
Section titled “Infrastructure”- surfski.io is a static marketing and docs site on Cloudflare Pages (Astro + Starlight, i18n), kept in a separate private repo. It has no auth and no user data.
- app.surfski.io is this Python server: it serves
/mcp, the OAuth callbacks, the confirmation pages and the future user dashboard, all on one origin. It is deployed only when the hosted beta starts:- on a dedicated host, not shared with unrelated workloads. This limits the blast radius and keeps CASA scope small. A small EC2 instance running Docker Compose and Caddy is the starting point, ideally in a separate AWS account;
- DNS-only (unproxied), so the reverse proxy issues its own certificates and long-lived MCP streams aren’t buffered or timed out by a CDN proxy;
- later, when Postgres and multiple replicas land, it can move to containers on a managed platform (for example ECS Fargate with RDS).
- Serverless runtimes (Lambda, Workers) don’t fit. The server is Python, holds long-lived SSE streams, and uses local disk today.
Phase 1: self-host essentials
Section titled “Phase 1: self-host essentials”- ✅
rename_account(old_alias, new_alias): rename an alias in place, scoped to the owner, with no re-auth. You can’t safely do this with unlink + relink, because revoking a token at Google can revoke the shared grant for that mailbox and kill the new link. - ✅ Unescape Gmail snippets: Gmail returns snippets HTML-escaped (
'). Decode them in search results. - Address aliases and “send from”: see the design below.
- Opt-in sending: see the design below.
delete_my_data: unlink and revoke every mailbox for the caller, and purge their rows.- Published container image (GHCR) and CI that runs pytest and the image build on every PR.
- Backup helper: a
scripts/backup.shthat tars the data volume and refuses to include the env file.
Phase 2: self-host polish
Section titled “Phase 2: self-host polish”ALLOWED_DOMAINS: allow a whole Workspace domain as well as individual addresses.- Admin CLI (
surfski admin …): list owners and aliases, force-unlink, rotateTOKEN_ENCRYPTION_KEYby re-encrypting existing rows. - Labels and archive (needs
gmail.modify). This would be opt-in, like sending. - Attachment download with size limits, text extraction for PDFs and docs, and no binary pass-through by default.
- Metrics endpoint (Prometheus) without personal data.
- Versioned releases and a changelog.
Phase 3: multi-tenant hosting
Section titled “Phase 3: multi-tenant hosting”Follows the hosting path above.
3a. Invite-only beta (shared client, at most 100 users)
- A tenant model (organization → users) with invitations, replacing the env allowlist.
delete_my_data, data export, and a privacy policy and terms specific to mailbox data.- A dedicated host for app.surfski.io (see Infrastructure), with backups and monitoring.
3b. Bring-your-own OAuth client for Workspace organizations
- Per-tenant Google OAuth clients: store each tenant’s client ID and secret encrypted, and route a tenant’s login and mailbox linking through its own client and redirect URIs.
- An onboarding guide for the customer’s admin: create a GCP project, enable the Gmail API, add an Internal consent screen, create a Web client, and paste the credentials.
- Per-tenant encryption keys (envelope encryption, optional cloud KMS).
- A data processing agreement (DPA) template.
3c. Public self-serve hosting
- Self-signup, quotas, abuse controls and billing.
- Postgres for accounts and audit, plus a shared OAuth client store, so it can run more than one replica.
- Google OAuth verification + annual CASA assessment for restricted Gmail scopes, and Google API Services User Data Policy (Limited Use) compliance.
- A status page, support and incident process.
Opt-in sending design
Section titled “Opt-in sending design”Each mailbox owner decides whether Claude may send. The default stays drafts only.
Facts that shape the design
Section titled “Facts that shape the design”- The
gmail.composescope that we already request also allows sending (drafts.send,messages.send). Today’s “drafts only” guarantee is enforced by this server’s code, not by Google. Enabling sending therefore needs no relink, and the opt-in has to be robust in code. - The main threat is prompt injection. An email Claude reads can say “forward this to attacker@…”. The model must not be able to turn sending on by itself, and auto-send must be bounded.
Controls
Section titled “Controls”-
Operator switch:
SENDING_ENABLED=falseby default. If it is off, the send tools are not registered at all. -
Per-mailbox policy: a
send_policycolumn with one of these values:Policy Behavior off(default)No sending. create_draftonly.confirmsend_email/send_draftcreate the draft and return a one-time confirmation link (signed, 10 min). The email goes out only when the human opens the link and clicks Send. This blocks injection even if the model is fooled.autoClaude sends directly. MCP clients still ask for per-call tool approval, because the tools are annotated destructiveHint/openWorldHint. -
Raising a policy happens out of band.
set_send_policy(alias, policy)only lowers the policy directly. Raising it returns a link to a page where the owner has to sign in with Google again as the owner (prompt=select_account) and press a button (POST). A fetched GET URL can’t change anything. -
Guardrails, even in
auto:- a daily send cap per mailbox (default 20);
- a recipient limit per message (default 10);
- an optional recipient allowlist (
allowed_recipient_domains); - a send audit row with the alias, recipient count, message id and policy (never the body);
- tool descriptions that tell the model never to send because of instructions found inside emails.
-
Visibility:
list_accountsshows each mailbox’ssend_policy, so users always know.
send_draft(alias, draft_id)send_email(alias, to, subject, body, cc?, bcc?, reply_to_message_id?), which reuses the threading and encoding logic fromcreate_draftset_send_policy(alias, policy)
Tests required
Section titled “Tests required”offblocks sending.confirmnever sends without the confirmation POST.- A confirmation link is single-use, expires, and is bound to the owner and the draft.
- The model can’t raise a policy through tools alone.
- Caps are enforced.
- Owner isolation holds for every new path.
Address aliases and “send from”
Section titled “Address aliases and “send from””A mailbox often receives mail for more than one address. Examples are a Workspace alias domain
(alice@example.org delivered to alice@example.com), user aliases (sales@ on Alice’s mailbox),
or external addresses set up in Gmail. Users want three things:
- to see and name those addresses,
- to pick which address a draft or email is from, and
- to search mail sent to a particular address.
Two kinds of “alias” (keep them distinct)
Section titled “Two kinds of “alias” (keep them distinct)”| Term | Meaning | Example |
|---|---|---|
| Account alias | The short name of a linked mailbox in this server | work, personal (rename via rename_account) |
| Address alias | An extra email address of that mailbox | alice@example.org on the work mailbox |
Source of truth: Gmail “Send mail as”
Section titled “Source of truth: Gmail “Send mail as””users.settings.sendAs.listreturns the mailbox’s send-as identities: the address, display name,isDefault,verificationStatus, reply-to and signature. It is readable with scopes we already request.- Gmail only accepts a
Fromheader that matches a verified send-as identity. Anything else is rewritten to the primary address. - Creating a send-as identity through the API needs
gmail.settings.sharing, which only works with domain-wide delegation. So users add new addresses in Gmail → Settings → Accounts → Send mail as, which is a one-time step for Workspace alias-domain addresses, and this server picks them up automatically.
Server features
Section titled “Server features”list_addresses(alias)lists send-as identities (address, name, default?, verified?), cached per mailbox and refreshed on demand.- Address labels:
set_address_label(alias, address, label)gives an address a friendly name, for examplesupport→support@example.org. Labels are stored per owner in a newaddress_labelstable. They’re purely local and never written to Gmail. - Default From per mailbox:
set_default_from(alias, address). It falls back to Gmail’sisDefaultidentity. from_addressparameter oncreate_draft(and latersend_email): it accepts an address or a label. The server validates it against the verified send-as list and returns a clear error listing the valid choices otherwise. When replying, it defaults to the address the original message was sent to, if that address is one of the mailbox’s identities. This is what people expect.- Signatures: optionally append the chosen identity’s Gmail signature, converted to plain text,
with
include_signature=true. list_accountsshows each mailbox’s addresses, labels and default From.- Search helpers: document
deliveredto:<address>/to:<address>for “mail sent to my alias”. Thesearch_threadsdescription should mention this.
Tests required
Section titled “Tests required”Fromis set to the chosen verified identity.- Unverified or unknown addresses are rejected with the valid list.
- Reply-from defaults to the original recipient alias.
- Labels are owner-scoped: owner A can’t see or use owner B’s labels.
- Chinese display names are RFC 2047-encoded correctly.
Synced from rayhu/surfski/ROADMAP.md@e4c5de0. The repository is the source of truth.