fmaiily
by instax-dutta
README.md
# Fmaiily
[](https://github.com/instax-dutta/gmail-automator/actions/workflows/ci.yml)
[](LICENSE)
[](pyproject.toml)
**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 `/v1` share 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](docs/google-cloud-setup.md) · [Operations runbook](docs/operations.md) ·
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
> The GitHub repository is `gmail-automator`; the Python distribution and import package are
> `fmaiily`, and the CLI is `fmaiily`. They are the same thing.
## Contents
- [The problem](#the-problem)
- [Requirements](#requirements)
- [Quickstart](#quickstart)
- [Give your agent a key](#give-your-agent-a-key)
- [REST API](#rest-api)
- [MCP](#mcp)
- [How it works](#how-it-works)
- [How limits are enforced](#how-limits-are-enforced)
- [Configuration](#configuration)
- [CLI](#cli)
- [Operator status page](#operator-status-page)
- [Workspace: unattended sending](#workspace-unattended-sending)
- [When not to use this](#when-not-to-use-this)
- [Development](#development)
- [Security](#security)
- [Your responsibilities](#your-responsibilities)
- [Contributing](#contributing)
- [License](#license)
---
## 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+ |
| Google | 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)
```bash
cp .env.example .env
$EDITOR .env # set FMAIILY_TOKEN_ENCRYPTION_KEY and the Google OAuth client id/secret
docker compose up -d
```
The 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
```bash
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-stdio
```
### Connect an account and send
```bash
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.com
```
---
## Give your agent a key
```bash
uv run fmaiily keys create my-agent --scopes send,read
# fmg_1a2b3c4d_... <- shown once
```
The secret goes to stdout and the warning to stderr, so this is safe:
```bash
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:
```bash
curl -s http://localhost:8000/v1/quota -H "authorization: Bearer fmg_..." | jq
```
---
## REST API
Base path `/v1`. Every route requires `Authorization: Bearer <key>` except `/health`.
| Method | Path | Purpose |
|----------|-----------------------------|----------------------------------------------------|
| `GET` | `/health` | Liveness, version, account count, queue depth |
| `GET` | `/v1/accounts` | Connected accounts with status and scopes |
| `GET` | `/v1/accounts/{email}` | One account |
| `DELETE` | `/v1/accounts/{email}` | 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/{email}` | Disconnect |
| `GET` | `/v1/quota` | Remaining 24h capacity for every account |
| `GET` | `/v1/quota/{email}` | Remaining 24h capacity for 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 |
Interactive docs are at `/docs`.
### Send
```bash
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
}'
```
```json
{
"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
```bash
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:
```json
{"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:
```json
{
"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](docs/operations.md).
### stdio
For agent clients that spawn a subprocess:
```json
{
"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](#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_email` | Send or queue one message; returns a message id or a job id |
| `send_batch` | Queue up to 50 messages, one result each |
| `get_quota_status` | Messages and recipients left in the 24h window, queue depth |
| `list_accounts` | Connected accounts, status, granted scopes |
| `start_account_connect` | Google consent URL for connecting an account |
| `disconnect_account` | Disconnect an account and delete its tokens |
| `get_send_history` | Recent sends with status and error codes |
| `get_send_status` | One job in detail, including the Gmail message id |
| `create_draft` | 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 written
```
The 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
1. **Soft limits, not hard ones.** The gateway caps each account at
`FMAIILY_DEFAULT_DAILY_MESSAGE_LIMIT x FMAIILY_SOFT_LIMIT_RATIO` (default `500 x 0.85 = 425`).
2. **Rolling window.** Counted from `send_jobs` over the last 24 hours - completed sends plus jobs
already queued. No counter table to drift out of sync.
3. **Refuse before calling Google.** A send that would cross a soft limit is rejected with
`quota_exceeded` and the numbers involved. Google is never asked.
4. **Pacing.** Jobs are *scheduled* `FMAIILY_DEFAULT_SEND_INTERVAL_SECONDS` apart per account, not
slept on, so a worker thread never blocks and the pacing survives restarts.
5. **Backoff, and knowing when to stop.** `rateLimitExceeded` and 5xx retry with
`2^(attempt-1) + jitter`, capped, honouring `Retry-After`, up to `FMAIILY_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.send` and 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`](.env.example) for the annotated list. The ones that matter most:
| Variable | Default | Meaning |
|-----------------------------------------|----------------------|----------------------------------------------------|
| `FMAIILY_TOKEN_ENCRYPTION_KEY` | *required* | 32 bytes, base64. Losing it makes stored tokens unreadable |
| `FMAIILY_DATABASE_URL` | `sqlite:///./data/…` | SQLite or `postgresql+psycopg://…` |
| `FMAIILY_AUTH_MODE` | `api_key` | `none` for loopback-only local use |
| `FMAIILY_SOFT_LIMIT_RATIO` | `0.85` | Fraction of the hard limit the gateway will use |
| `FMAIILY_DEFAULT_SEND_INTERVAL_SECONDS` | `2.0` | Per-account pacing interval |
| `FMAIILY_HOST` / `FMAIILY_PORT` | `127.0.0.1` / `8000` | Bind address |
| `FMAIILY_WORKER_ENABLED` | `true` | Run the send worker in this process |
| `FMAIILY_ATTACHMENTS_ENABLED` | `false` | Allow attachments |
| `FMAIILY_ATTACHMENT_ALLOWED_DIRS` | *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:
```dotenv
FMAIILY_SERVICE_ACCOUNT_KEY_FILE=/run/secrets/fmaiily-sa.json
FMAIILY_SERVICE_ACCOUNT_SUBJECT=agent@acme.co
```
See [`docs/google-cloud-setup.md`](docs/google-cloud-setup.md#service-accounts-workspace-only) for
the admin-side steps. Everything else - least-privilege scopes, encrypted storage, quota
enforcement - is unchanged.
---
## Development
```bash
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 `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.send` plus `openid` and `email`. 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`](CONTRIBUTING.md); the short version is `make check` plus
`uv run pytest -m e2e` must pass, and commits follow
[Conventional Commits](https://www.conventionalcommits.org/).
Please read [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) before participating.
## License
MIT - see [`LICENSE`](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues