Skip to main content
Glama

Surfski

CI

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, personal or support.

  • 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) or auto.

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 ALLOWED_USERS / ALLOWED_DOMAINS

openid, userinfo.email

https://<host>/auth/callback

B. Mailbox link

access to one mailbox

gmail.readonly, gmail.compose

https://<host>/gmail/callback

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)

  1. Create a project and enable the Gmail API (and the Google Calendar API if you set FEATURES=mail,calendar).

  2. Google Auth Platform → Branding: set the app name, support email, home page, privacy policy link and authorized domain.

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

  4. Data Access: add openid, .../auth/userinfo.email, .../auth/gmail.readonly and .../auth/gmail.compose. If you turn on triage, also add .../auth/gmail.modify; for the calendar module, .../auth/calendar.readonly and .../auth/calendar.events.

  5. Clients → create a Web application client with these redirect URIs:

    • https://<host>/auth/callback

    • https://<host>/gmail/callback

    • https://<host>/send/auth/callback (only needed with SENDING_ENABLED=true or FEATURES=mail,calendar)

  6. 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/surfski
sudo install -d -m 700 /etc/surfski && sudo install -m 600 deploy/env.example /etc/surfski/env
python3 scripts/gen_secrets.py

Paste 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 in ALLOWED_DOMAINS (an alias domain never matches; Google's hd claim 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 hd claim may still be the org's primary domain, so list the primary domain in ALLOWED_DOMAINS (or list the user in ALLOWED_USERS). A mismatch fails closed: the user is refused.

3. Run

sudo docker compose -p surfski up -d --build
curl -s 127.0.0.1:38000/healthz

Prebuilt 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.0

Then start it without --build:

sudo docker compose -p surfski up -d

Point 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 --build

With 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>&1

Restore:

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 app

The 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 list

Command

What it does

admin list [--json]

Every owner with their mailboxes: alias, address, created / last used, send policy, triage

admin unlink --owner X --alias Y [--revoke]

Remove one mailbox (as unlink_account). --revoke also revokes its token at Google

admin purge-owner --owner X --yes

Erase all of one owner's data, exactly as their delete_my_data would (revokes at Google; audit rows follow AUDIT_ON_DELETE). Without --yes nothing is deleted

admin rotate-token-key [--dry-run]

Re-encrypt every refresh token with the current key (see below)

admin audit --owner X [--since 7d|2026-10-01]

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:

  1. Generate a key: python3 scripts/gen_secrets.py and take its TOKEN_ENCRYPTION_KEY line.

  2. In /etc/surfski/env, move the current value to TOKEN_ENCRYPTION_KEY_OLD and put the new key in TOKEN_ENCRYPTION_KEY.

  3. Restart the server on the new key before rotating: sudo docker compose -p surfski up -d (a plain restart doesn'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 exec also 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.

  4. 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 or admin unlink those, or supply the right old key, then run it again).

  5. Remove TOKEN_ENCRYPTION_KEY_OLD and 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

BASE_URL

yes

Public URL, e.g. https://mcp.example.com. Must be https unless it is localhost.

GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET

yes

Web OAuth client

ALLOWED_USERS

one of these two

Comma-separated primary Google addresses allowed to use the server

ALLOWED_DOMAINS

one of these two

Comma-separated Google Workspace domains (e.g. example.com, no @) whose users may use the server. List the Workspace org's primary domain. A user matches only if the domain of their primary address equals it exactly (subdomains and alias domains never match) and Google's hosted-domain claim hd equals it too, with a verified email. So only accounts managed by that Workspace org get in: a personal Google account registered on a company address has no hd and is rejected by design (add it to ALLOWED_USERS if you really want it). Public mail providers (gmail.com, outlook.com, yahoo.com, icloud.com, …) are refused at startup: list those users in ALLOWED_USERS

JWT_SIGNING_KEY

yes

Signs FastMCP session tokens (≥32 chars)

STORAGE_ENCRYPTION_KEY

yes

Fernet key for the OAuth client store

TOKEN_ENCRYPTION_KEY

yes

Fernet key for stored Gmail refresh tokens

STATE_SECRET

yes

HMAC key for single-use linking state (≥32 chars)

DATA_DIR

no

Default /var/lib/surfski; /data in the container

TOKEN_ENCRYPTION_KEYS

no

Per-owner Fernet keys owner=key,owner=key

TOKEN_ENCRYPTION_KEY_OLD / TOKEN_ENCRYPTION_KEYS_OLD

no

Previous token key(s), accepted for decryption only during a rotation

STORAGE_ENCRYPTION_KEY_OLD

no

Previous OAuth store key, accepted for reading only during a rotation

ALLOWED_CLIENT_REDIRECT_URIS

no

MCP client redirect allowlist. Defaults to Claude's callbacks, plus localhost when BASE_URL is localhost

RATE_LIMIT_PER_MINUTE

no

Tool calls per owner per minute (default 60)

SENDING_ENABLED

no

true registers the send tools and /send/* pages (default false: drafts only)

SEND_DAILY_CAP

no

Sends per mailbox in a rolling 24 h, in confirm and auto (default 20)

SEND_MAX_RECIPIENTS

no

Unique to+cc+bcc recipients per message (default 10)

CONFIRM_CHANNEL

no

How confirm sends are approved: auto (default: in the MCP client via elicitation when it supports it, or an input-required result on 2026-07-28 connections, else the confirm link) or link (always the confirm link)

ALLOWED_RECIPIENT_DOMAINS

no

Comma-separated domains; if set, every recipient's domain must match one exactly

ATTACHMENT_MAX_MB

no

Largest attachment get_attachment will download (default 15, max 25)

EXTRACT_SANDBOX

no

subprocess (default): extract attachment text in a resource-limited child process; inprocess: in a server thread

EXTRACT_TIMEOUT_S

no

Wall-clock limit per extraction; the child is killed after it (default 20, 1–120)

EXTRACT_MEMORY_MB

no

Address-space limit of the extraction child (default 512, 128–8192; Linux)

EXTRACT_SANDBOX_STRICT

no

true: refuse to read attachments unless the extraction child reaches the full sandbox level, i.e. Landlock filesystem confinement and a kernel network block (default false: degrade and log a warning). Requires EXTRACT_SANDBOX=subprocess

AUDIT_ON_DELETE

no

What delete_my_data does with the caller's audit rows: delete (default) or pseudonymize (keep them under a keyed hash)

TRIAGE_ENABLED

no

true registers the triage tools (default false: no triage tools, and gmail.modify is never requested)

TRIAGE_DAILY_CAP

no

Triage changes (ids touched) per mailbox in a rolling 24 h (default 1000)

FEATURES

no

Modules to serve: mail (default) or mail,calendar. Without calendar no calendar tool or page exists and calendar scopes are never requested. See Calendar

CALENDAR_INVITE_DAILY_CAP

no

Calendar writes per mailbox in a rolling 24 h that involve attendees outside the mailbox's domain (default 20)

METRICS_ENABLED

no

true serves Prometheus metrics at /metrics (default false: the route doesn't exist). See Metrics

METRICS_TOKEN

with metrics

Bearer token required on every /metrics request (≥32 chars; the server refuses to start without it when metrics are on)

UNSUBSCRIBE_ALLOWED_PORTS

no

Extra https ports the one-click unsubscribe POST may use (comma-separated; 443 is always allowed, default none)

LOG_LEVEL, HOST, PORT

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 sync
uv run pytest

The 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 surfski

Library notes

  • Built on fastmcp 4.x (fastmcp>=4.1,<5) using GoogleProvider. 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.FileTreeStore wrapped in FernetEncryptionWrapper. It's single-node, with no Redis.

  • The MCP client redirect allowlist defaults to https://claude.ai/api/mcp/auth_callback and https://claude.com/api/mcp/auth_callback.

  • Google reports email_verified either 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: ALL and no-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 accounts query a tool makes is scoped to the caller; only the local surfski admin CLI 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_attachment downloads at most ATTACHMENT_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 fresh python -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 space EXTRACT_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 only PATH, HOME and LANG, and its stdout is read incrementally and cut off (child killed) past max_chars × 8 + 4 KB; results are type-checked before use. The parent kills the child's process group after EXTRACT_TIMEOUT_S and 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 from os.environ once the config is loaded. A side effect: profilers and debuggers that attach to a running process, such as py-spy or gdb, 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: ALL and no-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/null and /dev/urandom, read-only: nothing under DATA_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 makes socket(), io_uring_setup() and path truncate() fail with EPERM (x86_64 and aarch64; foreign-ABI syscalls fail too). Both need only PR_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 as surfski_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 below full on Linux is logged as a warning. Set EXTRACT_SANDBOX_STRICT=true to refuse to read attachments (a clear tool error) unless the level is full: filesystem confinement alone is not enough. Kernels with Landlock enabled (Ubuntu's default) reach full; 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 if DATA_DIR sits beneath a path the worker may read. CI checks in the hardened container that the worker reaches full, gets EACCES on /data/data.db and EPERM on socket().

    • Limits: the worker still runs as the same uid (a separate uid needs CAP_SETUID or a root entry point, which cap_drop: ALL and no-new-privileges rule 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 needs socket() 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_AS and some others aren't supported; they are skipped with a log line, and the timeout still applies. EXTRACT_SANDBOX=inprocess turns the sandbox off (not recommended).

  • forward_message, and drafts with attachments, 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=true the 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), or auto (sent directly, within the caps). MCP clients still ask you to approve each send tool call.

  • Confirming in the client (elicitation). In confirm mode, 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 returns sent: 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, set CONFIRM_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 boolean true. 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, so send_email doesn't create a second draft. Decline keeps the draft; a malformed answer gets the confirm link. Covers send_draft, send_email, forward_message with send=true and 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 /send pages 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_policy lowers 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 confirm for 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) and auto: 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 off and 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=true the tools don't exist. The server then never asks Google for gmail.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 granted gmail.modify and that the account that signed in is the linked mailbox. If either check fails, nothing changes. Like link_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 for enable_triage itself, the same exposure as link_account. list_accounts shows triage: 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.modify for that mailbox. The stored Google grant still includes it until you relink_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.batchModify for messages; one threads.modify per thread) and TRIAGE_DAILY_CAP changes per mailbox per 24 h. If a call fails partway through, the changes already made still count against the cap.

  • No permanent delete. trash moves mail to Trash, where Gmail keeps it for 30 days, and untrash restores it. There is no delete or batchDelete tool, by design.

  • Labels: apply_label / remove_label work only on your own labels, not system ones; reading, starring, archiving and trashing have their own tools. A label is created only with create=true or create_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 for calendar.readonly + calendar.events. Like enable_triage it is a full re-consent (include_granted_scopes=false) with the exact scope set, and keeps gmail.modify if triage is on (and enable_triage keeps 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_account and relink_account never ask for calendar scopes, so a relink turns calendar off. disable_calendar(alias) applies at once. list_accounts shows calendar: on|off and the calendar_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 and all_day: true. Inputs need an explicit offset (Z, -07:00) or a time_zone; a bare local time is rejected.

  • find_free_time: one freebusy.query per mailbox (the caller's calendar-enabled mailboxes, or the aliases given), busy blocks merged across accounts, free blocks of at least duration_minutes returned, optionally only inside working_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 says complete: false instead of treating its time as free.

  • Writes (create_event, update_event, respond_to_event, delete_event) follow the mailbox's calendar_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/confirm link, 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 send If-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 to draft applies 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_RECIPIENTS attendees; if ALLOWED_RECIPIENT_DOMAINS is 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 against CALENDAR_INVITE_DAILY_CAP. Google only emails attendees (sendUpdates=all) when notify_attendees / notify_organizer is true; otherwise sendUpdates=none. delete_event emails nobody unless notify_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_total counts writes by action and outcome. export_my_data includes the capability flags, and delete_my_data purges the calendar rows.

  • Not yet: a calendar-only /calendar/mcp endpoint and calendar-only deployments (FEATURES must include mail).

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

surfski_tool_calls_total

tool, outcome (ok, error, internal_error)

surfski_tool_duration_seconds (histogram)

tool

surfski_gmail_api_errors_total

status_class (400, 401, 403, 404, 429, 4xx, 5xx, refresh, storage, other)

surfski_sends_total

policy (auto, confirm), outcome (sent, failed, declined, link_issued)

surfski_triage_modifications_total

none (messages/threads changed)

surfski_attachment_extractions_total

method (text, html, pdf, docx, xlsx, pptx, none), outcome (ok, metadata_only, error)

surfski_unsubscribe_total

method (one_click, mailto, manual, none), outcome

surfski_rate_limit_rejections_total

none

surfski_linked_mailboxes, surfski_owners (gauges)

none (counted from SQLite at scrape time)

surfski_build_info, surfski_process_start_time_seconds, surfski_uptime_seconds

version on build info

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_data returns 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; set AUDIT_ON_DELETE=pseudonymize to 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 leaves ALLOWED_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

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables 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
  • A
    license
    A
    quality
    A
    maintenance
    Enables Claude.ai and Claude Code to read and write a single Gmail mailbox, including search, drafts, sending, and label management.
    21
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Your 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.
    33
    19 npm
    1
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Connects 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