Skip to main content
Glama
instax-dutta

fmaiily

by instax-dutta

gmail-automator

CI License: MIT Python 3.12+ M8ven Score

Let Claude Code, Cursor, OpenCode, or any AI agent send, read, and reply to Gmail as you - without it ever getting your account locked.

gmail-automator is a self-hosted MCP server for Gmail that exposes 14 tools to AI agents and coding agents over the Model Context Protocol, with a REST API alongside it. It holds the Google OAuth credential so your agent never does, and it accounts for every send against Gmail's real sending quota before contacting Google - refusing a send that would breach the limit instead of discovering the limit by hitting it.

Use it when you want an agent to send email from Claude Code or Cursor, read and triage a Gmail inbox with an LLM, reply to email on your behalf, or automate outreach - and you want the mailbox credential to stay encrypted on your own host.

Protocol

MCP (Model Context Protocol) over Streamable HTTP or stdio; REST API at /v1

Works with

Claude Code, Claude Desktop, Cursor, Windsurf, OpenCode, Cline, Continue, any MCP client

License

MIT. Self-hosted. No vendor lock-in, no paid tier, no signup

Language

Python 3.12+, FastAPI, SQLAlchemy. SQLite by default, PostgreSQL optional

Credentials

AES-256-GCM encrypted at rest, never returned by the API, never logged

Default scope

gmail.send only. Read, reply, and labels refuse until you widen it

Quota

Enforces Gmail's rolling 24-hour limit at 85%, pacing and backoff included

Alpha. Pre-1.0: the API can still change. Pin a version if you depend on it. Nothing here is load-bearing for your mail until you point an agent at it.

A restart is not a lost email: the queue is a table with leases, so the next worker resumes.

Docs: Google Cloud setup · Operations runbook · Contributing · Changelog · Security policy · llms.txt


The failure it prevents

Give an agent a Gmail credential and you have also given it a live sending quota. Agents retry. A retry loop looks like a rate limit, then like a lockout, and Gmail's recovery is measured in hours or days. From inside the agent it just looks like the tool is broken.

The usual ways out are worse than the disease: hand the agent full mailbox read scope "just in case", or route mail through someone else's infrastructure. So gmail-automator takes the narrow credential, keeps it, and makes the dangerous send impossible rather than merely discouraged.


Related MCP server: Gmail MCP Server

What it is, and what it is not

Not a mail client, and not a hosted service. It is a quota-aware send proxy with a durable queue in front of the Gmail API. Every path into it - MCP tool or REST route - goes through the same validation, the same quota check, and the same errors, so there is no side door around the policy.


How it compares

Most "let my agent use Gmail" answers point at one of these. Each solves part of the problem.

Approach

Credential lives

Quota enforced

Survives restart

Cost

What it misses

gmail-automator

on your host, encrypted

before the call, with numbers

durable queue

free, MIT

you run it

Gmail API called directly by the agent

in the agent

discovered by hitting it

in-flight sends lost

free

the agent can lock you out

Handing the agent a refresh token

in the agent's context

not enforced

n/a

free

worst option - a leak is a mailbox

Composio, Zapier, Make, n8n

a SaaS vendor

vendor-side

vendor-side

paid tier

your mail routes through a third party

Hosted or community Gmail MCP servers

the operator's SaaS

unknown

unknown

paid

same, plus you cannot audit the quota path

Purpose-built mail API (SendGrid, Postmark, SES)

a vendor key

vendor-side

vendor-side

per send

not Gmail; no inbox, no threads, no labels

The distinction that matters: everyone else either gives the agent the credential or gives a vendor your mail. This keeps the credential and makes the dangerous send impossible rather than merely discouraged - a send that would cross Gmail's limit is rejected before Google is contacted, with the numbers in the error, so the agent gets a decision to make instead of a lockout to recover from.

If you only need to fire off the occasional message from a script, call the Gmail API directly. This is for the case where the agent is autonomous enough that "the agent overshot the quota" is a real possibility.


What people build with it

  • Email from a coding agent. A Claude Code session opens a PR, the CI run goes green, and the agent emails the result to the reviewer - without the agent ever holding a token.

  • Inbox triage. An agent reads unread mail with Gmail's own search syntax (is:unread, newer_than:7d, from:), labels what it can categorise, and leaves the rest for you.

  • Replies that thread. reply derives the recipient, subject, In-Reply-To, the whole References chain, and Gmail's threadId from the original, so a reply lands in the same conversation instead of a new one.

  • Outreach that respects the limit. Queue a batch, let pacing spread the sends, and get messages_remaining back on every response.

  • Human review before anything sends. draft: true, or create_draft, leaves the message in Gmail for a person to approve.


Quickstart

Five steps, and step 5 is a real email in a real inbox. Budget about ten minutes; most of it is Google's consent screen.

1. Run it

git clone https://github.com/instax-dutta/gmail-automator
cd gmail-automator
cp .env.example .env
docker compose up -d

It listens on 127.0.0.1:8000 and nothing else. No Redis, no broker, no database to operate.

uv sync --extra dev
uv run gmail-automator gen-key        # -> GMAIL_AUTOMATOR_TOKEN_ENCRYPTION_KEY
cp .env.example .env                  # fill in the key and your Google OAuth client
uv run gmail-automator migrate
uv run gmail-automator serve          # or: gmail-automator mcp-stdio

2. Create a Google OAuth client

You need a Cloud project with the Gmail API enabled, then one OAuth client of type Web application. Register this redirect URI exactly:

http://127.0.0.1:8000/v1/oauth/google/callback

Put the client id and secret in .env. Step-by-step, including the part where Google says the app is unverified and you click through it: docs/google-cloud-setup.md.

3. Connect a mailbox

docker compose exec gmail-automator gmail-automator accounts connect

It prints a consent URL. Open it, approve the scope, and the tokens are stored encrypted. To connect a second mailbox, or to change the scopes later, run it again - Google adds the new permission to the existing grant rather than resetting it.

4. Mint a key for your agent

KEY=$(docker compose exec -T gmail-automator gmail-automator keys create my-agent --scopes send,read | tail -1)

Shown once. The secret goes to stdout and the warning to stderr, so the pipe above is safe. Give each consumer its own key and only the scopes it needs; a key with send alone is refused by /v1/quota with 403, which is the point.

5. Send a real message

docker compose exec gmail-automator gmail-automator send-test you@gmail.com

That is a genuine send through the whole path: quota check, queue, worker, Gmail. If it arrives, the hard part is done.


Point your agent at it

This is the part that makes it useful. MCP (the Model Context Protocol) is how agent tools are exposed over HTTP, so any MCP client can drive this - Claude Code, Claude Desktop, Cursor, Windsurf, OpenCode, Cline, Continue, Zed, or anything else that speaks MCP.

Streamable HTTP - same process as the API, guarded by the same key:

{
  "mcpServers": {
    "gmail": {
      "url": "http://localhost:8000/mcp",
      "headers": { "Authorization": "Bearer fmg_YOUR_KEY_HERE" }
    }
  }
}

Drop that into the MCP config your client reads. Claude Desktop uses claude_desktop_config.json; Cursor and Windsurf use ~/.cursor/mcp.json and ~/.codeium/windsurf/mcp_config.json; OpenCode and Cline take the same mcpServers shape in their own config files. Two things that will otherwise cost you an hour:

  • Use /mcp without a trailing slash. /mcp/ answers 307, which MCP clients do not follow for POST, so it fails with an opaque error.

  • If your client runs on another machine, change the host to wherever the gateway is bound.

stdio - for clients that spawn a subprocess instead:

{
  "mcpServers": {
    "gmail": { "command": "gmail-automator", "args": ["mcp-stdio"] }
  }
}

The subprocess inherits the environment, so GMAIL_AUTOMATOR_DATABASE_URL and GMAIL_AUTOMATOR_TOKEN_ENCRYPTION_KEY must be visible to it. If your client does not pass the environment through, wrap it: "command": "sh", "args": ["-c", "GMAIL_AUTOMATOR_DATABASE_URL=... gmail-automator mcp-stdio"].


What your agent can do

Fourteen tools. Each states its own preconditions, and a refusal comes back as a readable result rather than a stack trace - so a model can react instead of retrying blindly.

Send

Tool

Does

send_email

Send or queue one message; returns a Gmail message id or a job id

send_batch

Up to 50 messages, all-or-nothing

create_draft

Leave it in Gmail for a human to review instead of sending

Read and organise - need a scope you have to grant

Tool

Does

list_messages

Search with Gmail's own syntax: from:, newer_than:7d, is:unread

read_message

Decoded headers, body, and the threading headers. Reads HTML-only mail too

reply

Replies so it threads in Gmail; draft: true to review first

modify_message

Read/unread, star, archive, trash, custom labels

list_labels

Every label with counts, so nothing gets filed under a guess

Operate

Tool

Does

get_quota_status

Budget left, when it frees up, queue depth

list_accounts

Connected accounts with the scopes each actually holds

get_send_status

One job in detail, message id or error code

get_send_history

Recent sends, newest first

start_account_connect

Google consent URL for a new mailbox

disconnect_account

Revoke and delete that mailbox's tokens

The agent does not need to know your limits in advance. Every send response carries messages_remaining, every refusal arrives with the numbers, and get_quota_status is there for planning. Limits live in the gateway, so the numbers reach the model from one source and cannot go stale.

There is no permanent delete. modify_message can trash, and that is all.


REST API

Base path /v1. Everything requires Authorization: Bearer <key> except /health, /status, /metrics, and the Google OAuth callback - the last because a browser redirect cannot carry a header.

Method

Path

Purpose

GET

/health

Liveness, version, account count, queue depth

GET

/status

Server-rendered operator dashboard

GET

/metrics

Prometheus

GET

/v1/accounts

Connected accounts, status, granted scopes

GET

/v1/accounts/{account}

One account

DELETE

/v1/accounts/{account}

Disconnect and delete its tokens

GET

/v1/oauth/google/start

Google consent URL

GET

/v1/oauth/google/callback

OAuth redirect target

DELETE

/v1/oauth/google/{account}

Disconnect

GET

/v1/quota

Remaining 24h capacity, all accounts

GET

/v1/quota/{account}

Remaining 24h capacity, one account

POST

/v1/send

Send one email, or queue it

POST

/v1/send/batch

Queue up to 50 emails

GET

/v1/send/limits

The limits this gateway will actually enforce

GET

/v1/jobs/{id}

Status of one send

GET

/v1/history

Recent sends, newest first

POST

/v1/drafts

Create a draft instead of sending

GET

/v1/mailbox/messages

Search the mailbox

GET

/v1/mailbox/messages/{id}

Read one message

GET

/v1/mailbox/labels

Every label, system and custom

POST

/v1/mailbox/messages/{id}/labels

Change a message's labels

POST

/v1/reply

Reply, or draft the reply, threaded correctly

Interactive docs at /docs.

Send

curl -s http://localhost:8000/v1/send \
  -H "authorization: Bearer $KEY" \
  -H 'content-type: application/json' \
  -d '{
        "to": ["someone@example.com"],
        "cc": ["team@example.com"],
        "subject": "Build finished",
        "body": "All green. Logs attached.",
        "wait": true
      }'
{
  "job_id": 42,
  "status": "sent",
  "message_id": "18f0a1b2c3d4e5f6",
  "account": "you@gmail.com",
  "messages_remaining": 424
}

wait: true (the default) blocks until the worker has sent it, so you get the Gmail message id directly. wait: false returns a job id immediately - use it for bulk work, then poll /v1/jobs/{id} or ask the agent for get_send_status.

Batch

curl -s http://localhost:8000/v1/send/batch \
  -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"emails": [{"to": ["a@example.com"], "subject": "1", "body": "..."},
                  {"to": ["b@example.com"], "subject": "2", "body": "..."}],
       "wait": false}'

The whole batch is validated and quota-checked before anything is queued, so a refusal leaves the queue untouched. Pacing then spreads the sends GMAIL_AUTOMATOR_DEFAULT_SEND_INTERVAL_SECONDS apart.

Errors

Every error, on every endpoint, has the same shape - which is what lets an agent parse it:

{"error": {"code": "quota_exceeded",
           "message": "daily message soft limit reached for you@gmail.com; 425 of 425 used",
           "details": {"account": "you@gmail.com", "resource": "messages",
                       "used": 425, "soft_limit": 425, "reset_at": "..."}}}

invalid_request, unauthorized, forbidden, account_not_found, scope_missing, quota_exceeded, queue_full, duplicate_request, send_failed, daily_send_quota_exceeded, attachment_too_large, attachment_path_not_allowed, crypto_error, internal_error.


How the limit actually works

Gmail allows roughly 500 messages a day on a personal account, 2,000 on Workspace, counted over a rolling 24 hours. Those are Google's numbers, not constants here.

  1. It stops at 85%, not 100%. 500 x 0.85 = 425. The remaining 15% is headroom for sends you make by hand, and for the traffic a human generates without the gateway noticing.

  2. The window rolls. Counted from the job table over the last 24 hours - completed sends plus jobs already queued. There is no counter table to drift.

  3. The refusal is free. A send that would cross the line is rejected before Google is called, with the numbers in the error. Your agent gets a decision to make, not a lockout to recover from.

  4. Pacing is scheduled, not slept. Jobs carry a not-before time 2s apart per account, so a worker thread never blocks and the schedule survives a restart.

  5. Backoff, and knowing when to stop. rateLimitExceeded and 5xx retry with 2^(attempt-1) + jitter, capped, honouring Retry-After, up to GMAIL_AUTOMATOR_MAX_ATTEMPTS. A daily rejection is terminal on purpose: Google documents that it can stay in force for hours, so retrying would only spend more of what is left.

Every one of these is configuration, and docs/google-cloud-setup.md records the published figures the defaults came from.


FAQ

Can an AI agent send email without getting my Gmail account banned?

Yes, and that is the specific problem this exists to solve. Gmail allows roughly 500 messages a day on a personal account and 2,000 on Workspace, counted over a rolling 24 hours. Agents retry, and a retry loop looks like a rate limit, then like a lockout, and Gmail's recovery is measured in hours or days. gmail-automator stops at 85% of the hard limit (425 of 500 on a personal account) and rejects any send that would cross the line before Google is contacted, returning the remaining budget in the error.

Does my AI agent ever see my Google OAuth token?

No. The refresh token is AES-256-GCM encrypted at rest, bound to the account address as associated data so it cannot be moved between accounts, and is never returned by the API or written to a log. Your agent holds a scoped gateway API key instead (fmg_...), stored as a prefix plus a SHA-256 hash and shown exactly once.

Which AI coding tools can use this?

Any MCP client. There are ready-made configs for Claude Code, Claude Desktop, Cursor, Windsurf, OpenCode, Cline, Continue, and Zed - all of them read the same mcpServers JSON shown above. There is also a plain REST API at /v1 if you would rather call it from a script.

Can the agent read my Gmail, or is it send-only?

Send-only by default, on purpose. An account connected with gmail.send cannot be read: the read_message, list_messages, reply, and modify_message tools refuse with scope_missing rather than quietly doing nothing. To let an agent into the mailbox, widen GMAIL_AUTOMATOR_SOOGLE_OAUTH_SCOPES to gmail.modify (or gmail.readonly for reading alone) and reconnect. Widening it is a real change in what the gateway can reach, not a formality.

Is there a hosted version?

No, and that is the trade. The credential never leaves your host. It runs in Docker or a systemd unit, binds 127.0.0.1:8000 by default, and needs no Redis, no broker, and no database you have to operate.

What happens if the service restarts mid-send?

Nothing is lost. The queue is a table with leases, so a queued job is a row: the next worker picks it up and sends it. Pacing is stored as a not-before time per job rather than a sleep, so the schedule survives the restart too.

Does it send real email or simulate it?

Real email, through the whole path: quota check, queue, worker, Gmail. gmail-automator send-test you@gmail.com is a genuine send - if it arrives, the hard part is done.

Can an AI agent reply to an email without breaking the thread?

Yes. reply derives the recipient, the subject, In-Reply-To, the entire existing References chain, and Gmail's threadId from the original message, so the reply lands in the same conversation. Pass draft: true to leave it in Gmail for a human to review instead of sending.


Not for you if

Being explicit here saves you an afternoon:

  • You only want to send. That is the recommended setup, and the read, reply, and label tools will refuse. Widen GMAIL_AUTOMATOR_OAUTH_SCOPES only if you actually want an agent inside your mailbox.

  • You want someone else's infrastructure. There is no hosted version. That is the trade: the credential never leaves your host.

  • You are sending bulk or unsolicited mail. This stays well inside Gmail's limits as a safety margin. It is not permission.

  • You need multi-tenant isolation. The auth model assumes one operator issuing keys to their own agents. It is not a public SaaS backend.

  • You need guaranteed delivery or a receipt. A queued job is retried with backoff, and the confirmation is Gmail's own message id. Nothing more.


Configuration

Every setting is an environment variable prefixed GMAIL_AUTOMATOR_; .env.example is the annotated list. These are the ones that matter first:

Variable

Default

Meaning

GMAIL_AUTOMATOR_TOKEN_ENCRYPTION_KEY

required

32 bytes, base64. Lose it and stored tokens are unreadable

GMAIL_AUTOMATOR_DATABASE_URL

sqlite:///./data/…

SQLite, or postgresql+psycopg://…

GMAIL_AUTOMATOR_AUTH_MODE

api_key

none for loopback-only local use

GMAIL_AUTOMATOR_SOOGLE_OAUTH_SCOPES

gmail.send,openid,email

Widen to gmail.modify for read/reply/organise

GMAIL_AUTOMATOR_SOFT_LIMIT_RATIO

0.85

Fraction of the hard limit the gateway will use

GMAIL_AUTOMATOR_DEFAULT_SEND_INTERVAL_SECONDS

2.0

Per-account pacing interval

GMAIL_AUTOMATOR_HOST / GMAIL_AUTOMATOR_PORT

127.0.0.1 / 8000

Bind address

GMAIL_AUTOMATOR_WORKER_ENABLED

true

Run the send worker in this process

GMAIL_AUTOMATOR_ATTACHMENTS_ENABLED

false

Allow attachments

GMAIL_AUTOMATOR_ATTACHMENT_ALLOWED_DIRS

empty

Directories path attachments may be read from


CLI

gmail-automator serve                    # HTTP gateway: REST + MCP
gmail-automator mcp-stdio                # MCP over stdio
gmail-automator migrate                  # apply database migrations
gmail-automator gen-key                  # generate a token encryption key
gmail-automator status [--json]          # accounts, remaining capacity, queue depth
gmail-automator send-test <recipient>    # send a real message end to end
gmail-automator rotate-keys [--dry-run]  # re-encrypt stored tokens under a new key
gmail-automator accounts list|connect|disconnect
gmail-automator keys create|list|revoke
gmail-automator purge-history [--days N]

status --json and keys list write JSON to stdout; logs and human messages go to stderr, so both are safe to pipe.

rotate-keys moves stored tokens onto a new encryption key in three steps - set the new key plus GMAIL_AUTOMATOR_TOKEN_ENCRYPTION_KEY_OLD, run the command, drop the old key. The runbook has the order and the failure modes: docs/operations.md.


Operator status page

GET /status renders what gmail-automator status prints: accounts, quota used against the soft limit, queue depth, and the next send time. Server-rendered, no JavaScript, no external assets, so it works on a host with no internet access.


Workspace: unattended sending

With a Workspace admin's domain-wide delegation grant, it can send as a Workspace mailbox with no interactive consent - and no refresh token stored at all:

GMAIL_AUTOMATOR_SERVICE_ACCOUNT_KEY_FILE=/run/secrets/sa.json
GMAIL_AUTOMATOR_SERVICE_ACCOUNT_SUBJECT=agent@acme.co

The admin-side steps are in docs/google-cloud-setup.md. Limits, pacing, and encryption work exactly as they do for an OAuth account.


Security

  • OAuth tokens are AES-256-GCM encrypted at rest, bound to the account address as associated data, so a ciphertext cannot be moved to another account and still decrypt. Never returned by the API, never logged.

  • Scopes are least-privilege and yours to widen. The default is gmail.send plus openid and email: an account connected that way cannot be read, and the read, reply, and label tools refuse with scope_missing rather than degrading. Reading and replying need gmail.modify (or gmail.readonly for reading alone) added to GMAIL_AUTOMATOR_OAUTH_SCOPES, then a reconnect. gmail.modify also grants reading message bodies - widening it is a real change in what the gateway can reach, not a formality.

  • API keys are stored as a prefix plus a SHA-256 hash, compared with hmac.compare_digest, and shown exactly once.

  • Email bodies live only while a job is in flight and are wiped on a terminal state.

  • The log pipeline redacts secret-looking keys recursively, and a test fails the build if a credential-shaped literal ever reaches the repository.

  • The container runs as a non-root uid with a single writable volume, and there is no permanent delete on messages.


Your responsibilities

You remain responsible for the Gmail Terms of Service, recipient consent, and anti-spam rules. This does not hide or bypass Gmail's limits; it stays well inside them so your account is not at risk. Do not use it for bulk or unsolicited mail.


Development

uv sync --extra dev
uv run pytest -q                    # unit + integration, no sockets
uv run pytest -m e2e                # end-to-end over real localhost sockets
uv run ruff check . && uv run ruff format --check . && uv run mypy src
./scripts/docker-smoke.sh           # build the image and assert the deployed behaviour

The architecture is enforced by tests rather than convention. tests/unit/test_import_boundaries.py fails the build if anything outside gmail_automator/gmail/client.py imports googleapiclient, if anything outside gmail_automator/db.py creates an engine, or if a service calls datetime.now() instead of taking an injected Clock. Google is never contacted in the default test run: tests/support/fake_gmail_app.py is an in-process fake of the Gmail and OAuth endpoints.


Contributing

Bug reports and pull requests are welcome. Start with CONTRIBUTING.md; the short version is make check and uv run pytest -m e2e must pass, and commits follow Conventional Commits. Please read CODE_OF_CONDUCT.md first.

License

MIT - see LICENSE.

Related MCP Connectors

Related MCP Servers