fmaiily
Provides a self-hosted gateway for sending email via Gmail and Google Workspace accounts, enforcing sending limits and offering MCP tools for sending, drafting, and checking quota.
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., "@fmaiilysend a follow-up email to Sarah about the meeting notes"
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.
Fmaiily
Status: alpha. The API may still change; pin a version if you depend on it.
Self-hosted Gmail gateway for AI agents. Your agent sends email as you, through your own Gmail or Google Workspace account, over MCP or a REST API, while the gateway quietly enforces Gmail's official sending limits so the account is never locked.
MIT licensed, no vendor lock-in, no paid dependency. SQLite by default; Postgres optional.
Two interfaces, one policy. MCP tools (stdio and Streamable HTTP) and REST
/v1share the same services, so validation, quota, and errors are identical either way.Limits first. Rolling 24-hour counters with configurable soft limits, per-account pacing, and exponential backoff. A send that would exceed a limit is refused before Google is called.
Tokens encrypted at rest (AES-256-GCM) and never returned over the API or written to a log.
Drains on your schedule. A durable SQLite-backed queue with leases, so a restart mid-send resumes instead of losing the job.
Documentation: Google Cloud setup · Operations runbook · Contributing · Changelog
The GitHub repository is
gmail-automator; the Python distribution and import package arefmaiily, and the CLI isfmaiily. They are the same thing.
Contents
Related MCP server: Gmail MCP Server
The problem
Handing an agent a Gmail credential means handing it a live sending quota. One careless retry loop and the account is throttled or locked, and the failure looks like a bug in the agent rather than a limit. Most existing options make this worse: they ask for full mailbox read access, they hide their quota assumptions, or they run someone else's infrastructure over your mail.
Fmaiily sits in between. It is a small service you run yourself that holds one narrow credential
(gmail.send), accounts for every send before it happens, and refuses the risky one with a clear
error your agent can reason about.
Requirements
Python | 3.12 or newer (3.12 and 3.13 tested in CI) |
Database | SQLite (bundled) or PostgreSQL 14+ |
A Cloud project with the Gmail API enabled and an OAuth client | |
Access | A Gmail account you own, or a Workspace mailbox with domain-wide delegation |
Nothing else. No Redis, no message broker, no external database to operate.
Quickstart
Docker (one command)
cp .env.example .env
$EDITOR .env # set FMAIILY_TOKEN_ENCRYPTION_KEY and the Google OAuth client id/secret
docker compose up -dThe gateway listens on 127.0.0.1:8000. Nothing is exposed to your network until you change the
published address in docker-compose.yml.
From a checkout
uv sync --extra dev
uv run fmaiily gen-key # -> FMAIILY_TOKEN_ENCRYPTION_KEY
cp .env.example .env # fill in the key + Google OAuth credentials
uv run fmaiily migrate
uv run fmaiily serve # or: uv run fmaiily mcp-stdioConnect an account and send
uv run fmaiily accounts connect # prints the Google consent URL; open it in a browser
uv run fmaiily status # accounts, remaining capacity, queue depth
uv run fmaiily keys create agent # prints an API key, once
uv run fmaiily send-test someone@example.comGive your agent a key
uv run fmaiily keys create my-agent --scopes send,read
# fmg_1a2b3c4d_... <- shown onceThe secret goes to stdout and the warning to stderr, so this is safe:
KEY=$(fmaiily keys create my-agent --scopes send,read | tail -1)Give the key only the scopes its consumer needs - send to send, read for status and quota. A key
with send alone is refused by /v1/quota with 403, which is the point.
Then either header works:
curl -s http://localhost:8000/v1/quota -H "authorization: Bearer fmg_..." | jqREST API
Base path /v1. Every route requires Authorization: Bearer <key> except /health.
Method | Path | Purpose |
|
| Liveness, version, account count, queue depth |
|
| Connected accounts with status and scopes |
|
| One account |
|
| Disconnect and delete its tokens |
|
| Google consent URL |
|
| OAuth redirect target |
|
| Disconnect |
|
| Remaining 24h capacity for every account |
|
| Remaining 24h capacity for one account |
|
| Send one email, or queue it |
|
| Queue up to 50 emails |
|
| The limits this gateway will actually enforce |
|
| Status of one send |
|
| Recent sends, newest first |
|
| Create a draft instead of sending |
Interactive docs are 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"
}wait: true (the default) blocks until the worker has sent it, so you get the Gmail message id
directly. wait: false returns immediately with a job id - use it for bulk work.
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 FMAIILY_DEFAULT_SEND_INTERVAL_SECONDS apart.
Errors
Every error, on every endpoint, has the same shape:
{"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": "..."}}}Stable codes: 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.
MCP
Streamable HTTP
Served by the same process at /mcp, guarded by the same API key:
{
"mcpServers": {
"fmaiily": {
"url": "http://localhost:8000/mcp",
"headers": { "Authorization": "Bearer fmg_YOUR_KEY_HERE" }
}
}
}Clients that read mcpServers from a JSON file (Claude Desktop, Cursor, Windsurf, and most others)
want the same shape.
Use /mcp without a trailing slash. /mcp/ answers with a 307, which MCP clients do not follow
for POST, so the request fails with an opaque error. This is not a quirk of the URL you type - it is
why the server uses a custom mount instead of a conventional one, noted in
docs/operations.md.
stdio
For agent clients that spawn a subprocess:
{
"mcpServers": {
"fmaiily": { "command": "fmaiily", "args": ["mcp-stdio"] }
}
}The subprocess inherits the environment, so FMAIILY_DATABASE_URL and
FMAIILY_TOKEN_ENCRYPTION_KEY must be visible to the client - see
Configuration. If the client does not pass the environment through, wrap the
command: "command": "sh", "args": ["-c", "FMAIILY_DATABASE_URL=... fmaiily mcp-stdio"].
Tool | What it does |
| Send or queue one message; returns a message id or a job id |
| Queue up to 50 messages, one result each |
| Messages and recipients left in the 24h window, queue depth |
| Connected accounts, status, granted scopes |
| Google consent URL for connecting an account |
| Disconnect an account and delete its tokens |
| Recent sends with status and error codes |
| One job in detail, including the Gmail message id |
| Create a draft instead of sending, for human review |
Tools return structured results. A refused operation comes back as a readable error result whose
text begins with the error code, e.g. quota_exceeded: daily message soft limit reached..., so a
model can react rather than seeing a stack trace.
How it works
One send, end to end:
agent ──MCP tool / POST /v1/send──▶ SendService
│ validate, build MIME, count recipients
│ check quota: message + recipient budget, 24h window
▼
QueueService encrypted body, job id as AAD
▼
Worker (separate process or thread)
│ lease the job, refresh the token if needed
│ pacing cursor says "not before 18:42:07"
▼
GmailTransport ──▶ Gmail API
│
▼
event recorded ──▶ body wiped, history row writtenThe rules that matter:
The queue is the database. A job is a row. Restart the gateway and the next worker picks up where the last one stopped, with a lease so two workers never send the same job.
Refuse before the network. Quota, recipient count, and body size are all checked before Google is called, so a refusal costs nothing and cannot half-happen.
Pacing is scheduled, not slept. Jobs carry a not-before time, so a worker thread never blocks and the schedule survives a restart.
One policy, two front doors. The MCP tools and the REST routes are thin adapters over the same services. There is no way to reach a send that skips the quota check.
Failures are typed. Every refusal carries a stable error code, so an agent can react (
quota_exceeded, wait) instead of retrying blindly (auth_expired, do not).
How limits are enforced
Soft limits, not hard ones. The gateway caps each account at
FMAIILY_DEFAULT_DAILY_MESSAGE_LIMIT x FMAIILY_SOFT_LIMIT_RATIO(default500 x 0.85 = 425).Rolling window. Counted from
send_jobsover the last 24 hours - completed sends plus jobs already queued. No counter table to drift out of sync.Refuse before calling Google. A send that would cross a soft limit is rejected with
quota_exceededand the numbers involved. Google is never asked.Pacing. Jobs are scheduled
FMAIILY_DEFAULT_SEND_INTERVAL_SECONDSapart per account, not slept on, so a worker thread never blocks and the pacing survives restarts.Backoff, and knowing when to stop.
rateLimitExceededand 5xx retry with2^(attempt-1) + jitter, capped, honouringRetry-After, up toFMAIILY_MAX_ATTEMPTS. A daily quota rejection is terminal for the job: Google documents that it can stay in force for hours, so retrying would only spend more of the remaining budget.
Every limit is configuration, not a constant. Gmail changes its published numbers; this file and
docs/google-cloud-setup.md record the values the defaults were chosen from.
When not to use this
You need to read mail. Fmaiily requests
gmail.sendand nothing else. It will not become a mail client, and it deliberately cannot read your inbox.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. Fmaiily stays well inside Gmail's limits as a safety margin. It is not permission, and Gmail's Terms of Service still apply.
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. A queued job is retried with backoff, but there is no delivery receipt beyond Gmail's own message id.
Configuration
Every setting is an environment variable prefixed FMAIILY_; see
.env.example for the annotated list. The ones that matter most:
Variable | Default | Meaning |
| required | 32 bytes, base64. Losing it makes stored tokens unreadable |
|
| SQLite or |
|
|
|
|
| Fraction of the hard limit the gateway will use |
|
| Per-account pacing interval |
|
| Bind address |
|
| Run the send worker in this process |
|
| Allow attachments |
| empty | Directories path attachments may be read from |
CLI
fmaiily serve # HTTP gateway: REST + MCP
fmaiily mcp-stdio # MCP over stdio
fmaiily migrate # apply database migrations
fmaiily gen-key # generate a token encryption key
fmaiily status [--json] # accounts, remaining capacity, queue depth
fmaiily send-test <recipient> # send a real message end to end
fmaiily rotate-keys [--dry-run] # re-encrypt stored tokens under a new key
fmaiily accounts list|connect|disconnect
fmaiily keys create|list|revoke
fmaiily 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.
Operator status page
GET /status renders the same numbers as fmaiily status for a browser: accounts, quota used
against the soft limit, queue depth, and the next send time. Server-rendered, no JavaScript, and 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, Fmaiily can send as a Workspace mailbox unattended - no interactive consent, and no refresh token stored at all:
FMAIILY_SERVICE_ACCOUNT_KEY_FILE=/run/secrets/fmaiily-sa.json
FMAIILY_SERVICE_ACCOUNT_SUBJECT=agent@acme.coSee docs/google-cloud-setup.md for
the admin-side steps. Everything else - least-privilege scopes, encrypted storage, quota
enforcement - is unchanged.
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 behaviourThe architecture is enforced by tests rather than convention: tests/unit/test_import_boundaries.py
fails the build if anything outside fmaiily/gmail/client.py imports googleapiclient, if anything
outside fmaiily/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, and tests/support/sync_asgi.py adapts it for both httpx and httplib2
without binding a port.
Security
OAuth tokens are AES-256-GCM encrypted at rest, bound to the account address as AAD. They are never returned by the API and never written to a log.
Scopes are least-privilege:
gmail.sendplusopenidandemail. The gateway never asks for mailbox read access.Email bodies are held only while a job is in flight and are wiped on a terminal state.
API keys are stored as a prefix plus a SHA-256 hash and compared with
hmac.compare_digest.The log pipeline redacts secret-looking keys recursively, and the container runs as uid 10001 with a single writable volume.
Your responsibilities
You remain responsible for the Gmail Terms of Service, recipient consent, and anti-spam rules. Fmaiily 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.
Contributing
Bug reports and pull requests are welcome. Start with
CONTRIBUTING.md; the short version is make check plus
uv run pytest -m e2e must pass, and commits follow
Conventional Commits.
Please read CODE_OF_CONDUCT.md before participating.
License
MIT - see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Email infrastructure for AI agents — send, receive, search, and reply to email over MCP.
Let AI agents send email from your own Gmail, Microsoft 365 or SMTP inbox, with guardrails.
1Email inboxes for AI agents: send, receive, reply, search, and manage threaded email over MCP.
Your agent needs a mailbox of its own — to receive, thread, draft and send, with attachments, without borrowing your personal inbox or your company's SMTP. **What you can ask for** • "Create an inbox for this agent and tell me its address." • "Read the new messages in this thread and draft a reply." • "Send this message with the attachment and wait for the response." • "Search this inbox for everything from that domain." • "Show delivery metrics and the events on this inbox." **How to use it** Point any MCP client at https://mcp.aisa.one/mail/mcp and sign in with OAuth — there is no key to create or paste. 49 tools: create and delete inboxes, list and read messages, raw message bodies, attachments, threads, drafts and draft attachments, send and reply, message search, inbox events, metrics, and list entries — reads and writes. **Why this rather than the source** A real inbox an agent owns, rather than an SMTP credential it borrows from a human. **It is also a door to the rest** The same login reaches 26 sources and 580+ operations. Find the contact elsewhere in the catalogue, then write to them from here — without adding a second server. **What it costs** Finding and inspecting an operation is free. Running one is billed per call at API prices, with no seat and no monthly minimum, and every call takes max_price_usd so an agent cannot overspend by accident. **Where else it reaches** https://mcp.aisa.one/sales/mcp finds the person to write to.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables AI applications to send emails, manage drafts, and compose professional emails through Gmail API with secure multi-user OAuth2 authentication and encrypted token storage.-
- AlicenseAqualityDmaintenanceEnables AI agents to search, read, send, and organize Gmail emails via MCP protocol.22111 npm2MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to send Gmail emails, create drafts, and append content to Google Docs through MCP tools. Provides secure OAuth-based integration with Google Workspace.360 npmMIT
- AlicenseNot gradedqualityBmaintenanceEnables MCP-compatible AI agents to create Gmail drafts, send emails, and append content to Google Docs with OAuth-secured authentication.28 npmMIT