Surfski
Provides tools for working with Gmail / Google Workspace mailboxes: links one or more mailboxes per user under short aliases, lists, renames and unlinks accounts, searches threads within a single mailbox or across all linked accounts, retrieves full threads and individual messages, lists labels, and creates drafts. Drafts only — Claude saves drafts that the user sends from Gmail.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@SurfskiSearch my work inbox for Acme's latest invoice and draft a reply"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Surfski
Many Gmail inboxes, one Claude connector. · surfski.io
Surfski is a small, self-hosted remote MCP server. You add it to Claude once as a custom connector. After that, Claude can search, read and draft email across several Gmail / Google Workspace mailboxes for each user, and users never see each other's mailboxes.
Works from claude.ai on the web, desktop and mobile. Any client that speaks MCP Streamable HTTP with OAuth works too.
Each Claude user signs in with Google, and only allowlisted addresses get in.
Each user links any number of mailboxes under short aliases such as
work,personalorsupport.Drafts only by default. Claude saves drafts and you send them from Gmail. An operator can turn on opt-in sending, and each mailbox then picks
off,confirm(you click a link to send) orauto.
Tools: list_accounts, link_account, relink_account, rename_account, unlink_account, search_threads,
search_all_accounts, find_contact, get_thread, get_message, list_labels, create_draft, list_drafts, get_draft,
update_draft, delete_draft, list_addresses, set_address_label, set_default_from, forward_message,
get_attachment, unsubscribe, export_my_data, delete_my_data.
With SENDING_ENABLED=true also: send_draft, send_email, set_send_policy (and forward_message(send=true)).
With TRIAGE_ENABLED=true also: enable_triage, disable_triage, mark_read, mark_unread, star, unstar,
archive, move_to_inbox, apply_label, remove_label, create_label, trash, untrash (see
Triage).
With FEATURES=mail,calendar also: enable_calendar, disable_calendar, set_calendar_policy,
list_calendars, list_events, get_event, find_free_time, create_event, update_event,
respond_to_event, delete_event (see Calendar).
Find an address. find_contact("Alice") (or part of an address, or a name in any script, e.g.
王伟) looks through the From/To/Cc/Reply-To headers of your own recent mail in all your mailboxes (or
the aliases you pass) and returns likely addresses with their display names, message count, last
seen date, mailboxes and whether you've written to them, ranked by frequency, recency and that
"sent to" boost. It needs no extra Google scope. Each mailbox scans at most 50 threads / 200
messages. Your own addresses and no-reply senders are left out unless you pass include_self /
include_noreply. The audit log records only counts, never the name or addresses.
Unsubscribe. unsubscribe(alias, message_id) reads only the message's List-Unsubscribe and
List-Unsubscribe-Post headers (never links in the body) and reports what it would do; nothing
happens until it's called again with confirm=true, which re-reads the headers. It then uses, in
order: the RFC 8058 one-click POST to the sender's https endpoint; a mailto unsubscribe,
saved as a draft and sent only if the mailbox's send policy allows it (confirm
goes through the usual approval; with sending off the draft is kept for you to send); or, for a
plain https link, it returns the URL for you to open (it never loads web pages). Unsubscribing
tells the sender the address is live, so for spam, report it instead. See
Unsubscribe safety.
Send from an alias. create_draft and update_draft take from_address (a verified "Send mail as"
address or a label set with set_address_label) and include_signature. Replies default to the address
the original was sent to; new drafts use set_default_from, then Gmail's default. New addresses are added in
Gmail → Settings → Accounts → Send mail as.
Attach files from your mail. create_draft, send_email and forward_message take attachments, and
update_draft takes add_attachments. Each entry is {message_id, part_id}, using the part_id that
get_message / get_thread list. Files can only come from messages in the same mailbox; a message id
from another alias fails with a clear error. There are no local or arbitrary uploads. Duplicate entries
are attached once. The total is limited to 20 MB, checked from the declared sizes before anything is
downloaded, and large drafts go up as media uploads. update_draft keeps the draft's current
attachments. Inline images are kept as regular attachments, as in forward_message. Pass
remove_attachments (a filename as get_draft lists it, which removes every attachment with that
name, or a part_id) to drop some. The audit log records only the attachment count and total bytes.
Why self-host?
Gmail's read scopes are restricted scopes. To offer them to the public, an app needs Google OAuth verification plus a third-party CASA security assessment that is renewed every year. A self-hosted instance avoids all of that:
you use your own Google Cloud project and OAuth client;
an unverified app works for up to 100 users over the app's lifetime, which is plenty for a person, family or small team;
refresh tokens never leave your server.
The only cost is an "unverified app" warning on the Google consent screen. Your users click Advanced → continue once per mailbox.
Related MCP server: gmail-mcp
How it works
Claude ─HTTPS─► your reverse proxy :443 ─► container :8000 (published on 127.0.0.1:38000)
/mcp MCP endpoint (bearer auth)
/auth/callback Google login for Claude users (layer A)
/gmail/callback Gmail mailbox linking (layer B)
/healthz returns "ok"
volume → /data
data.db accounts (Fernet-encrypted refresh tokens), audit_log
oauth/ FastMCP OAuth client storage (Fernet-encrypted)There are two separate OAuth layers, and both use the same Google OAuth client:
Layer | Proves | Scopes | Redirect URI |
A. MCP login | which Claude user is calling, checked against |
|
|
B. Mailbox link | access to one mailbox |
|
|
Quickstart (self-host)
You need a Linux host with Docker and Compose, a domain name, and a TLS reverse proxy such as Caddy, nginx or Traefik.
1. Google Cloud (one time)
Create a project and enable the Gmail API (and the Google Calendar API if you set
FEATURES=mail,calendar).Google Auth Platform → Branding: set the app name, support email, home page, privacy policy link and authorized domain.
Audience: choose External, then click Publish app to move it to In production. Don't submit it for verification. In Testing mode Google expires refresh tokens after 7 days, so you would have to relink weekly.
Data Access: add
openid,.../auth/userinfo.email,.../auth/gmail.readonlyand.../auth/gmail.compose. If you turn on triage, also add.../auth/gmail.modify; for the calendar module,.../auth/calendar.readonlyand.../auth/calendar.events.Clients → create a Web application client with these redirect URIs:
https://<host>/auth/callbackhttps://<host>/gmail/callbackhttps://<host>/send/auth/callback(only needed withSENDING_ENABLED=trueorFEATURES=mail,calendar)
If you use Google Workspace, the admin should go to Admin console → Security → API controls → Manage third-party app access, add the app by client ID and mark it Trusted. Otherwise Workspace policy may block mailbox linking.
2. Configure
git clone <this repo> /opt/surfski && cd /opt/surfskisudo install -d -m 700 /etc/surfski && sudo install -m 600 deploy/env.example /etc/surfski/envpython3 scripts/gen_secrets.pyPaste the four generated secrets into /etc/surfski/env, then fill in BASE_URL,
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and ALLOWED_USERS (and/or ALLOWED_DOMAINS to allow
a whole Workspace domain). The server refuses to start if any secret is missing or malformed, or
if both allowlists are empty.
Aliases: Google sign-in reports a user's primary address. If your Workspace uses alias domains, put the primary addresses in
ALLOWED_USERS, or the Workspace primary domain inALLOWED_DOMAINS(an alias domain never matches; Google'shdclaim must equal the primary domain). Linking an alias address links the primary mailbox behind it.Secondary domains: if a user's address is on a secondary domain of a Workspace org, Google's
hdclaim may still be the org's primary domain, so list the primary domain inALLOWED_DOMAINS(or list the user inALLOWED_USERS). A mismatch fails closed: the user is refused.
3. Run
sudo docker compose -p surfski up -d --buildcurl -s 127.0.0.1:38000/healthzPrebuilt image (optional). Instead of building locally, you can use the multi-arch
(amd64/arm64) image published on each release. In compose.yaml, replace build: . and
image: surfski:latest with:
image: ghcr.io/rayhu/surfski:latest # or pin a release, e.g. ghcr.io/rayhu/surfski:v0.1.0Then start it without --build:
sudo docker compose -p surfski up -dPoint your reverse proxy at 127.0.0.1:38000. deploy/Caddyfile.snippet is a Caddy example.
Don't enable response compression on this route, because the MCP stream uses SSE.
4. Connect Claude
In Claude, go to Settings → Connectors → Add custom connector and enter
https://<host>/mcp. Click Connect and sign in with an allowlisted Google account. Then say:
"link my work Gmail as work" and open the link Claude returns.
When you link a mailbox, sign in as that mailbox and tick both permission checkboxes.
Upgrade
What changed in each version is in CHANGELOG.md; surfski --version (or
docker compose -p surfski exec app surfski --version) shows what you run.
git pull && sudo docker compose -p surfski up -d --buildWith the prebuilt image, run git pull && sudo docker compose -p surfski pull && sudo docker compose -p surfski up -d instead. The git pull picks up changes to compose.yaml and the env reference.
Backups
scripts/backup.sh backs up the surfski_surfski-data volume to
/var/backups/surfski/surfski-data-YYYYmmdd-HHMMSS.tgz (mode 600) and deletes archives older than
14 days. It takes a consistent SQLite snapshot with Python's online backup API inside the Surfski
image, so the server can keep running, and adds oauth/ as files. It refuses to run if anything
named env/*.env is in the volume. --dry-run shows what it would do; --help lists options
(--dest, --keep, --volume, --image).
Nightly at 03:15 from root's crontab (it uses the local surfski:latest image that docker compose build creates):
15 3 * * * /opt/surfski/scripts/backup.sh --keep 14 >> /var/log/surfski-backup.log 2>&1Restore:
docker compose stop app
sudo scripts/backup.sh --restore /var/backups/surfski/surfski-data-20260101-031500.tgz --yes
# add --force if the volume isn't empty; its contents are replaced
docker compose start appThe backup is useless without TOKEN_ENCRYPTION_KEY and STORAGE_ENCRYPTION_KEY, and it never
contains them. Keep the env file in a separate place, such as a password manager, never in the same
backup. Restore with the same keys that were in use when the backup was made. If you lose the
keys, users simply relink their mailboxes.
Administration (local CLI)
surfski with no arguments (or surfski serve) runs the server; that is what the container's
CMD does. The admin subcommands work on the database directly and run only from a shell on the
host or inside the container. There is no network admin endpoint. They never print refresh
tokens, ciphertext or keys, and they are safe to run while the server is up.
sudo docker compose -p surfski exec app surfski admin listCommand | What it does |
| Every owner with their mailboxes: alias, address, created / last used, send policy, triage |
| Remove one mailbox (as |
| Erase all of one owner's data, exactly as their |
| Re-encrypt every refresh token with the current key (see below) |
| Audit row counts per tool (ok / failed), never details |
Exit codes: 0 success; 1 refused or failed (unknown owner or alias, missing --yes, a failed
revocation at Google, undecryptable rows during rotation); 2 usage or configuration error. When a
revocation fails, unlink and purge-owner have still removed the data. An unexpected error (for example
database is locked) prints one line and exits 1; surfski admin --debug … shows the traceback.
Rotating encryption keys
TOKEN_ENCRYPTION_KEY (Gmail refresh tokens in SQLite). While TOKEN_ENCRYPTION_KEY_OLD is set,
the server decrypts with the new key, then the old one, and always encrypts with the new one, so
it keeps working during the whole rotation:
Generate a key:
python3 scripts/gen_secrets.pyand take itsTOKEN_ENCRYPTION_KEYline.In
/etc/surfski/env, move the current value toTOKEN_ENCRYPTION_KEY_OLDand put the new key inTOKEN_ENCRYPTION_KEY.Restart the server on the new key before rotating:
sudo docker compose -p surfski up -d(a plainrestartdoesn't reread the env file). This order matters: a server still running on the old key keeps writing old-key rows (every link or relink), and any it writes after step 4 stay on the old key.docker compose execalso takes the container's env, so the rotate command only sees the new key once the container was recreated. If you got the order wrong, restart and run step 4 again; it is idempotent and only touches rows that aren't on the current key yet.Rotate:
sudo docker compose -p surfski exec app surfski admin rotate-token-key. It re-encrypts every row in one SQLite write transaction and reports the counts. If any row can't be decrypted with the new or the old key, it changes nothing, lists the affected owner / alias pairs and exits 1 (relink oradmin unlinkthose, or supply the right old key, then run it again).Remove
TOKEN_ENCRYPTION_KEY_OLDand restart again.
Per-owner keys work the same way with TOKEN_ENCRYPTION_KEYS_OLD=owner=oldkey,…. Giving an existing
owner their own TOKEN_ENCRYPTION_KEYS entry is a rotation too: set
TOKEN_ENCRYPTION_KEYS_OLD=owner=<the default key> for it (an owner with a per-owner key never falls
back to the default key on its own). Removing an owner's entry: set TOKEN_ENCRYPTION_KEYS_OLD to
their former key.
Pending confirmation links (send, calendar and policy confirmations, which live 10 minutes) are encrypted
with the owner's token key too. They stay valid during a rotation while the old key is configured, and
only break if you remove TOKEN_ENCRYPTION_KEY_OLD (or the owner's TOKEN_ENCRYPTION_KEYS_OLD entry)
within 10 minutes of the link being issued; the owner then asks for a new link.
Backups after a rotation: older backups still need the old key. Keep the old key with the backups it protects until they expire, or take a fresh backup right after rotating.
STORAGE_ENCRYPTION_KEY (FastMCP's OAuth store under DATA_DIR/oauth: Claude's client
registrations and session tokens). Set STORAGE_ENCRYPTION_KEY_OLD to the previous key alongside the
new one and restart: existing entries stay readable and new ones are written with the new key. There
is no rewrite command, because that file store has no locking and a rewrite could race the running
server. When you finally remove STORAGE_ENCRYPTION_KEY_OLD, entries still under the old key read as
missing, so Claude users whose connector registration predates the rotation must reconnect the
connector (Settings → Connectors). Nothing else is lost: linked mailboxes are in SQLite.
Configuration reference
Variable | Required | Meaning |
| yes | Public URL, e.g. |
| yes | Web OAuth client |
| one of these two | Comma-separated primary Google addresses allowed to use the server |
| one of these two | Comma-separated Google Workspace domains (e.g. |
| yes | Signs FastMCP session tokens (≥32 chars) |
| yes | Fernet key for the OAuth client store |
| yes | Fernet key for stored Gmail refresh tokens |
| yes | HMAC key for single-use linking state (≥32 chars) |
| no | Default |
| no | Per-owner Fernet keys |
| no | Previous token key(s), accepted for decryption only during a rotation |
| no | Previous OAuth store key, accepted for reading only during a rotation |
| no | MCP client redirect allowlist. Defaults to Claude's callbacks, plus localhost when |
| no | Tool calls per owner per minute (default 60) |
| no |
|
| no | Sends per mailbox in a rolling 24 h, in |
| no | Unique to+cc+bcc recipients per message (default 10) |
| no | How |
| no | Comma-separated domains; if set, every recipient's domain must match one exactly |
| no | Largest attachment |
| no |
|
| no | Wall-clock limit per extraction; the child is killed after it (default 20, 1–120) |
| no | Address-space limit of the extraction child (default 512, 128–8192; Linux) |
| no |
|
| no | What |
| no |
|
| no | Triage changes (ids touched) per mailbox in a rolling 24 h (default 1000) |
| no | Modules to serve: |
| no | Calendar writes per mailbox in a rolling 24 h that involve attendees outside the mailbox's domain (default 20) |
| no |
|
| with metrics | Bearer token required on every |
| no | Extra https ports the one-click unsubscribe POST may use (comma-separated; 443 is always allowed, default none) |
| no | Logging and bind address |
Development
The runtime is Python 3.14: the Docker image and .python-version both use it. Python 3.12 and newer is supported for pip and uv installs, and CI tests 3.14 and 3.12.
uv syncuv run pytestThe tests never contact Google. Gmail and the token endpoints are mocked, and the caller's identity is injected.
For a local run, set BASE_URL=http://localhost:8000 and add http://localhost:8000/auth/callback
and /gmail/callback to your OAuth client:
set -a; source ./local.env; set +a; uv run surfskiLibrary notes
Built on fastmcp 4.x (
fastmcp>=4.1,<5) usingGoogleProvider. Google doesn't support Dynamic Client Registration, so FastMCP runs an OAuth proxy that Claude registers against.OAuth client storage is
key_value.aio.stores.filetree.FileTreeStorewrapped inFernetEncryptionWrapper. It's single-node, with no Redis.The MCP client redirect allowlist defaults to
https://claude.ai/api/mcp/auth_callbackandhttps://claude.com/api/mcp/auth_callback.Google reports
email_verifiedeither as the string"true"(tokeninfo) or as a boolean (userinfo), and both forms are handled.Mailbox linking talks to Google's token, revoke and profile endpoints directly with httpx, and uses PKCE (S256) with signed, single-use, 10-minute state.
Security
The container publishes only on
127.0.0.1, runs as non-root uid 10001 with a read-only root filesystem,cap_drop: ALLandno-new-privileges, and uses its own network.Docker-published ports bypass host firewalls like
ufw. Rely on your cloud firewall or security group: open 80/443 only.Every
accountsquery a tool makes is scoped to the caller; only the localsurfski adminCLI reads across owners. Refresh tokens are Fernet-encrypted at rest. Access tokens live only in memory.Logs never contain tokens, auth codes or message bodies. Audit rows record the tool, alias and a short detail only.
get_attachmentdownloads at mostATTACHMENT_MAX_MB(checked before download) and returns text only: text/CSV/JSON/XML/HTML, PDF (pypdf, first 200 pages, 15 s budget), DOCX/XLSX/PPTX (parsed from the zip with defusedxml; DTDs and entities refused; zip-bomb limits on entries, sizes and compression ratio). Other types return metadata only; binary is never passed through. Audit rows record message id, size and method, not the filename.Extraction runs in a sandboxed subprocess (
EXTRACT_SANDBOX=subprocess, the default): each call starts a freshpython -I -B -m surfski.extract_worker(never a fork of the threaded server), sends the bytes on stdin and reads JSON from stdout. The child drops its own limits before touching the input: address spaceEXTRACT_MEMORY_MB(RLIMIT_AS), CPU seconds (RLIMIT_CPU), no file data writes (RLIMIT_FSIZE=0), no new processes (RLIMIT_NPROC=0), 16 open files, no core dumps, and the kernel confinement below. Its environment holds onlyPATH,HOMEandLANG, and its stdout is read incrementally and cut off (child killed) pastmax_chars × 8 + 4 KB; results are type-checked before use. The parent kills the child's process group afterEXTRACT_TIMEOUT_Sand reaps it. At most 2 extractions run at once. A timeout, OOM or crash becomes "the file was too complex to read safely (timeout)" or similar; the child's stderr is discarded and only a short class name is reported. The in-process zip, XML and PDF caps stay as defense in depth. Cost: a process start per call, measured at about 140 ms (240 ms for PDFs, which load pypdf) in the hardened container under Docker Desktop, and 40 ms / 200 ms natively.Protecting the server's secrets: on Linux the server marks itself non-dumpable at startup (
prctl(PR_SET_DUMPABLE, 0)), so another process of the same uid, such as a compromised worker, can't read its/proc/<pid>/environ(where the keys arrived) or its memory, or ptrace it. It also removes the secret settings fromos.environonce the config is loaded. A side effect: profilers and debuggers that attach to a running process, such aspy-spyorgdb, can't attach to the server either (run them as root, or start the server under them).Kernel confinement (#43): before parsing, the worker restricts itself with Landlock (Linux 5.13+, unprivileged, works under
cap_drop: ALLandno-new-privileges). From then on it can only open files beneath the Python installation, the venv /sys.path, the system library directories,/usr/share/zoneinfo,/etc/ld.so.cache,/dev/nulland/dev/urandom, read-only: nothing underDATA_DIR(so not the SQLite database or the OAuth store), nothing else in/etc, and no writing, creating, deleting or truncating anywhere. With Landlock ABI 4+ (Linux 6.7+) it also can't bind or connect TCP sockets, and with ABI 6+ it can't signal processes or reach abstract unix sockets outside its sandbox. A small seccomp filter makessocket(),io_uring_setup()and pathtruncate()fail with EPERM (x86_64 and aarch64; foreign-ABI syscalls fail too). Both need onlyPR_SET_NO_NEW_PRIVS, which any process may set, and are applied with ctypes (no new dependency).Protection level: at startup the server runs one trivial extraction and logs the result, for example
extraction sandbox protection: full (Landlock ABI 6, filesystem restricted, TCP blocked, seccomp on), and exports it assurfski_extract_sandbox_info{level=…,landlock_abi=…,seccomp=…}. Levels:full(Landlock filesystem plus a kernel network block),filesystem,network,basic(rlimits only: macOS, kernels before 5.13, Landlock not enabled, or the syscalls blocked by a seccomp profile),none(EXTRACT_SANDBOX=inprocess). Anything belowfullon Linux is logged as a warning. SetEXTRACT_SANDBOX_STRICT=trueto refuse to read attachments (a clear tool error) unless the level isfull: filesystem confinement alone is not enough. Kernels with Landlock enabled (Ubuntu's default) reachfull; Ubuntu 24.04's 6.8 kernel gives Landlock ABI 4, which blocks TCP on its own, and the seccomp filter covers older ABIs on x86_64 and aarch64. The server also warns ifDATA_DIRsits beneath a path the worker may read. CI checks in the hardened container that the worker reachesfull, gets EACCES on/data/data.dband EPERM onsocket().Limits: the worker still runs as the same uid (a separate uid needs
CAP_SETUIDor a root entry point, whichcap_drop: ALLandno-new-privilegesrule out). It can read the code and libraries it is allowed to load (nothing secret). Landlock doesn't cover already-open file descriptors (only its stdin/stdout pipes), UDP needssocket()which seccomp blocks, but on architectures other than x86_64 and aarch64 the seccomp filter is skipped. A kernel exploit escapes all of this.Enforced on Linux (the container): all the rlimits above, plus Landlock and seccomp where the kernel supports them. On macOS (development),
RLIMIT_ASand some others aren't supported; they are skipped with a log line, and the timeout still applies.EXTRACT_SANDBOX=inprocessturns the sandbox off (not recommended).
forward_message, and drafts withattachments, hold the attachments in memory while the draft is built (up to the 20 MB limit, roughly 3× that at peak with the MIME encoding). Give the container ~150 MB of headroom per concurrent call.See SECURITY.md to report a vulnerability.
Sending (opt-in)
The gmail.compose scope already lets an app send mail, so "drafts only" is enforced by this
server's code. Sending is off unless you opt in, and is built to resist prompt injection (an email
that tells Claude to forward something to an attacker):
Operator switch: without
SENDING_ENABLED=truethe send tools and/send/*routes don't exist.Per-mailbox policy, shown by
list_accounts:off(default),confirm(Claude prepares the draft and returns a one-time link; nothing is sent until you open it, sign in, review From/To/Cc/Bcc, subject, attachments and the start of the body, and press Send), orauto(sent directly, within the caps). MCP clients still ask you to approve each send tool call.Confirming in the client (elicitation). In
confirmmode, if your MCP client supports elicitation (form mode), the server asks you directly in the client: From, To, Cc, Bcc, subject, attachment names and the first 500 characters of the body, with a single send/don't-send answer. There's no link for the model to leak or open. On accept the server rechecks the policy, the caps, the mailbox and the exact draft version (just like pressing Send on the link page) before sending; on decline or cancel it keeps the draft and returnssent: false. If the client doesn't support elicitation, or the request fails or times out (5 minutes), it falls back to the confirm link below, never to sending. Client support varies and changes: check your client's MCP documentation. Trust model: the server can't verify that a human answered; it trusts the client to show the prompt to you and not let the model answer it. A client or agent SDK that auto-answers elicitations (or lets the model answer them) bypasses the human entirely. If you don't trust every client your users connect with, setCONFIRM_CHANNEL=link. The prompt flattens header values to one line, strips bidi and zero-width characters, fences the body excerpt between markers and repeats the server's recipient counts after it, but the subject and body are still written by whoever wrote the email: read the recipient rows, not the text.2026-07-28 connections (input required). That protocol has no channel for server-initiated requests, so the same form goes back as the tool's result instead (an
InputRequiredResult, SEP-2322): the client asks you and calls the tool again with your answer. Sending happens on that second call, after the same recheck, and only for an explicit accept whose answer is a JSON booleantrue. The second call carries a continuation that is signed, single-use, expires in 10 minutes and is bound to the owner, the tool and its exact arguments (a continuation for another draft or a replayed one is refused, nothing sent); the server never re-runs the tool body, sosend_emaildoesn't create a second draft. Decline keeps the draft; a malformed answer gets the confirm link. Coverssend_draft,send_email,forward_messagewithsend=trueand the calendar writes;unsubscribe(mailto) uses the link. Same trust model as above. Continuations live in the server's database and the SDK's per-process request-state key, so a restart between the two calls makes the second fail cleanly (nothing sent; ask again).Owner sign-in before any confirm or approval page. Opening a link shows nothing about the email until you sign in with Google as the mailbox owner (
/send/auth/callback, which you must register on your OAuth client). That sets a session cookie for/sendpages only (HttpOnly, SameSite=Strict, Secure on https, 12 h, stored server-side). Send and Apply require that session for the same owner as the link, so a leaked link alone can't send. You sign in once per 12-hour session, not on every link.Tool calls alone can't raise a policy.
set_send_policylowers it immediately but, to raise it, returns a link that needs a Google sign-in from the last 10 minutes as the owner, then an Apply click.Links are HMAC-signed (key derived from
STATE_SECRET, separate from linking), single-use, expire in 10 minutes, and are bound to the owner, alias, mailbox address and the exact draft version: if the draft changes after the link was made, it won't send. Opening a page (GET) never sends or changes anything; pages can't be framed and don't leak the link via Referer.What this does not stop: an AI agent that can drive your own signed-in browser could open a link and press Send while your session is valid (and Google re-auth may be a single click if you're already signed in to Google). Keep
confirmfor mailboxes where that matters and don't give browser-control tools to the same agent.Caps apply in
confirm(re-checked when you press Send or accept in the client) andauto: daily sends per mailbox, recipients per message, optional recipient-domain allowlist.Audit: each send records alias, policy, recipient count and Gmail message id, never addresses or bodies.
Relinking an alias to a different mailbox resets its policy to
offand voids open links.
Triage (opt-in)
The triage tools (mark read or unread, star, archive, move to Inbox, user labels, Trash) need Google's
gmail.modify scope, which normal linking never requests. Two switches have to be on:
Operator switch: without
TRIAGE_ENABLED=truethe tools don't exist. The server then never asks Google forgmail.modify, even for a mailbox that granted it before.Per mailbox:
enable_triage(alias)returns a Google link for that mailbox only. It is a full re-consent (prompt=consent,include_granted_scopes=false) asking for read + compose + modify, so the new token carries exactly those scopes. The human has to sign in to Google as that mailbox and approve. That out-of-band consent is the owner confirmation; a tool call alone can't turn triage on. The server checks that Google really grantedgmail.modifyand that the account that signed in is the linked mailbox. If either check fails, nothing changes. Likelink_account, the consent link is only as safe as the browser it's opened in: an agent that drives a browser signed in to that Google account could click through the consent forenable_triageitself, the same exposure aslink_account.list_accountsshowstriage: on|off, and the scope and switch are rechecked before every triage call.Turning it off:
disable_triage(alias)takes effect at once: the server stops using, and stops requesting,gmail.modifyfor that mailbox. The stored Google grant still includes it until yourelink_account(alias), which re-consents with read + compose only, or until you remove the app at https://myaccount.google.com/permissions. A relink without modify also turns triage off.
Guardrails:
Caps: at most 50 ids per call (
messages.batchModifyfor messages; onethreads.modifyper thread) andTRIAGE_DAILY_CAPchanges per mailbox per 24 h. If a call fails partway through, the changes already made still count against the cap.No permanent delete.
trashmoves mail to Trash, where Gmail keeps it for 30 days, anduntrashrestores it. There is nodeleteorbatchDeletetool, by design.Labels:
apply_label/remove_labelwork only on your own labels, not system ones; reading, starring, archiving and trashing have their own tools. A label is created only withcreate=trueorcreate_label. Names are NFC-normalized and capped at 225 characters, and refused if they contain control or invisible characters, have empty or space-padded/parts, or collide with system names (INBOX,TRASH,CATEGORY_*, …).Tool descriptions tell the model never to act on instructions found inside an email, and to confirm bulk changes with you first.
Audit: each call records the action and a count (
messages=12), never message or thread ids or label names.
Calendar (opt-in)
Google Calendar lives in the same server as an optional module (FEATURES=mail,calendar). It uses the
same linked mailboxes, aliases, owner isolation and encrypted tokens.
Per mailbox:
enable_calendar(alias)returns a Google link for that mailbox only, asking forcalendar.readonly+calendar.events. Likeenable_triageit is a full re-consent (include_granted_scopes=false) with the exact scope set, and keepsgmail.modifyif triage is on (andenable_triagekeeps calendar). The callback checks that both calendar scopes were granted and that the account that signed in is the linked mailbox (getProfile); otherwise nothing changes.link_accountandrelink_accountnever ask for calendar scopes, so a relink turns calendar off.disable_calendar(alias)applies at once.list_accountsshowscalendar: on|offand thecalendar_policy. Calendar access tokens are requested with the calendar scopes only and cached separately from Gmail's.Reads:
list_calendars,list_events(recurring events expanded, up to 100 per page),get_event. Times come back as ISO 8601 with an offset in the calendar's IANA zone, plus that zone (time_zone); all-day events have dates andall_day: true. Inputs need an explicit offset (Z,-07:00) or atime_zone; a bare local time is rejected.find_free_time: onefreebusy.queryper mailbox (the caller's calendar-enabled mailboxes, or thealiasesgiven), busy blocks merged across accounts, free blocks of at leastduration_minutesreturned, optionally only insideworking_hours(computed per local day, so DST is handled) and without weekends. Window up to 31 days. If a mailbox can't be read, the result sayscomplete: falseinstead of treating its time as free.Writes (
create_event,update_event,respond_to_event,delete_event) follow the mailbox'scalendar_policy:draft(default): nothing is written by the tool. The user approves in the MCP client (elicitation, if it supports it) or opens a one-time/send/calendar/confirmlink, signs in with Google as the owner and presses Confirm. The link is single-use, expires in 10 minutes and is bound to a hash of the exact change. At commit the server rechecks the allowlist, that the mailbox is still linked to the same address with calendar on, that the event wasn't edited since (etag), and the caps; patches and deletes also sendIf-Match: <etag>, so an edit that lands in between makes Google refuse the write. The pending change (title, attendees, description) is stored encrypted with the owner's token key and dropped as soon as the link is used or expires.auto: written directly, within the caps.set_calendar_policy(alias, "auto")only returns a link where the owner re-authenticates and presses Apply; lowering todraftapplies at once. Calendar links have their own purposes, so they can never be used on the mail send pages.
Attendees are treated like recipients: at most
SEND_MAX_RECIPIENTSattendees; ifALLOWED_RECIPIENT_DOMAINSis set, every attendee the write can email (including removed attendees who get a cancellation) must match it, as for mail; and writes that involve attendees outside the mailbox's domain (everyone but the mailbox on gmail.com and other public domains), including cancellations to them, count againstCALENDAR_INVITE_DAILY_CAP. Google only emails attendees (sendUpdates=all) whennotify_attendees/notify_organizeris true; otherwisesendUpdates=none.delete_eventemails nobody unlessnotify_attendees=true.Audit and metrics record the action and counts (
action=create attendees=2 external=1 notify=all), never titles, attendees, locations or calendar ids.surfski_calendar_writes_totalcounts writes by action and outcome.export_my_dataincludes the capability flags, anddelete_my_datapurges the calendar rows.Not yet: a calendar-only
/calendar/mcpendpoint and calendar-only deployments (FEATURESmust includemail).
Metrics
With METRICS_ENABLED=true, GET /metrics returns the Prometheus text format. It is served on the
same port as everything else, which your reverse proxy makes public, so every request must carry
Authorization: Bearer <METRICS_TOKEN>: no header gets 401, a wrong one 403 (compared in constant
time). Generate the token with python3 -c "import secrets; print(secrets.token_urlsafe(32))". You
can also block /metrics at the proxy and scrape 127.0.0.1:38000 from the host.
# prometheus.yml
scrape_configs:
- job_name: surfski
scheme: https
metrics_path: /metrics
authorization:
type: Bearer
credentials_file: /etc/prometheus/surfski_token # contains METRICS_TOKEN
static_configs:
- targets: ["mcp.example.com"]Metric | Labels |
|
|
|
|
|
|
|
|
| none (messages/threads changed) |
|
|
|
|
| none |
| none (counted from SQLite at scrape time) |
|
|
No personal data: label values come from fixed sets (the server's own tool names and the values
above); anything else is recorded as other. No owner, alias, address, query, message id, label name
or URL ever appears, and cardinality is bounded.
Unsubscribe safety
The one-click POST (RFC 8058) is the only request this server makes to a host named in an email,
so it is locked down: https only, port 443 (unless UNSUBSCRIBE_ALLOWED_PORTS adds one), and every
address the host resolves to must be public. Loopback, private, link-local (including the
169.254.169.254 metadata address), CGNAT, multicast and reserved ranges are refused, also after
each redirect. The connection is pinned to the address that was checked, so DNS can't change
between the check and the request, while TLS is still verified against the hostname. At most 3
redirects, 10 s per operation and 20 s overall, the first 64 KB of the response only, no cookies,
auth, proxies or environment settings, and a fixed User-Agent. The body is always exactly
List-Unsubscribe=One-Click, so the sender learns nothing beyond "this unsubscribe link was
used". The audit log records the method and outcome only, never the URL (it often carries a token)
or the address.
Your data
What the server stores per Claude user (the "owner"): linked mailboxes (alias, address, encrypted
refresh token, scopes, timestamps), address labels, default From, send policy, pending link and
confirmation nonces, send counts for the daily cap, /send browser sessions, and audit rows (tool,
alias, short detail, never bodies or email addresses). Message content is never stored. With the
calendar module also: each mailbox's calendar flag and policy, counts of calendar
writes with external attendees (for the daily cap), and pending calendar confirmations. A pending
confirmation holds the proposed change (title, time, attendees, location, description) encrypted with
your token key, and only until the link is used or expires (10 minutes); like every confirmation
link's data, it is then erased, and the empty row is deleted a day later.
export_my_datareturns all of it except tokens.delete_my_data(confirm="DELETE")erases every row above for the caller (including pending calendar confirmations and calendar write counts) in one transaction, clears their in-memory caches, then revokes each mailbox's token at Google (best effort; the result is reported per alias, and a failed one can be removed at https://myaccount.google.com/permissions). Audit rows are deleted by default; setAUDIT_ON_DELETE=pseudonymizeto keep them instead, with the owner replaced by a keyed hash, the alias blanked and any email address in the detail replaced by<email>(details are written without addresses in the first place). Other users' data is untouched.The operator can do the same for a user from the host with
surfski admin purge-owner --owner <address> --yes(see Administration).Not covered: Claude's MCP session tokens for this server (in the encrypted OAuth store under
DATA_DIR/oauth) expire on their own; remove the connector in Claude to end them at once. Backups you made earlier still contain the old data.On the sending pages, Sign out ends the browser session; sessions of users removed from
ALLOWED_USERS(or whose domain leavesALLOWED_DOMAINS) stop working at once and are deleted at startup.
Hosting this for the public
If you want to run this as a service for people outside your own team:
you need Google OAuth app verification for restricted Gmail scopes, plus a CASA security assessment by a Google-approved lab, renewed every year;
you need your own privacy policy and terms covering mailbox data, and you must comply with Google's API Services User Data Policy (Limited Use);
you need multi-tenant features such as self-signup, data deletion and billing. These are tracked in ROADMAP.md.
One exception: Google Workspace organizations can register their own Internal OAuth client and use it with a hosted instance. Internal apps don't need verification. See Deployment models.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
Your mailboxes in ChatGPT and Claude: Gmail, iCloud, Fastmail, any IMAP. Passwords stay yours.
Connect any mailbox to Claude, ChatGPT & AI: read, send, reply, schedule & search emails.
Email inboxes for AI agents: send, receive, search, drafts with owner approval, and webhooks.
- MailowsOAuthcom.mailows
Give AI agents their own email inboxes: list, read and send mail via scoped, revocable OAuth grants.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceEnables Claude AI to directly interact with Gmail through natural language for comprehensive email management including sending, reading, searching, organizing, and managing drafts and labels. Uses secure OAuth authentication and runs locally without third-party servers.MIT
- AlicenseAqualityAmaintenanceEnables Claude.ai and Claude Code to read and write a single Gmail mailbox, including search, drafts, sending, and label management.21MIT
- AlicenseAqualityBmaintenanceYour mailboxes in ChatGPT and Claude: Gmail, iCloud, Fastmail or any IMAP/SMTP account, as many as you like. Search, read, threaded replies, drafts, allowlisted sending and attachments; passwords are encrypted in the browser and the server stores nothing.3319 npm1MIT
- AlicenseNot gradedqualityCmaintenanceConnects Gmail to Claude and other MCP clients so an assistant can search and read mail, send, reply-all with quoted history, forward, handle attachments and inline images, and manage drafts, labels, and threads. Runs as a remote server on your own Cloudflare Worker, serving several Google accounts simultaneously to desktop, browser, and phone clients while you retain the refresh tokens.MIT