agent-pages

Gmail Read-Only Agent Gateway — v1 Implementation Plan (Python)

Download

Gmail Read-Only Agent Gateway — v1 Implementation Plan (Python)

Audience: an implementing agent (and its sub-agents). This document is self-contained: it defines the architecture, exact contracts, module boundaries, a non-overlapping task breakdown, and machine-verifiable success criteria for each task so a main agent can verify completion without human judgment.


1. Project summary

A personal, self-hosted service that exposes a Gmail mailbox to AI agents in a strictly read-only fashion. The service syncs new mail from Gmail into a local SQLite store using an OAuth token with only the gmail.readonly scope, and serves it to a single polling agent over a small authenticated HTTP API. No write capability of any kind exists anywhere in the codebase — no send, no delete, no modify, no label changes. Attachments are never stored or served.

Security invariants (must hold in every task)

  1. The only Google OAuth scope requested anywhere is https://www.googleapis.com/auth/gmail.readonly.
  2. Google credentials (client secret, refresh token) are never readable via any HTTP endpoint and never logged.
  3. The HTTP API registers only GET routes. No POST/PUT/PATCH/DELETE handlers exist (they must return 405).
  4. Email bodies are stored and served as sanitized plain text only (see §7). Attachments and raw MIME are discarded at ingest.
  5. API auth is a single static bearer token compared in constant time (secrets.compare_digest). The comparison lives in one function (app/auth.py:verify_token) so it can later be swapped for multi-key lookup.

2. Stack and constraints

Concern Decision
Language Python 3.12+
Web framework FastAPI + uvicorn
Storage SQLite via stdlib sqlite3 (WAL mode). No ORM.
Gmail client google-api-python-client, google-auth, google-auth-oauthlib
HTML→text beautifulsoup4
Config Environment variables via a single app/config.py (may use pydantic-settings)
Testing pytest, httpx (ASGI test client). No network access in tests.
Packaging pyproject.toml, uv for dependency management
Container Single Dockerfile; one volume mounted at /data for DB + token
Lint/format ruff (lint + format), config in pyproject.toml

The sync worker runs as an asyncio background task inside the same FastAPI process (started via lifespan). No queue, no Postgres, no Redis, no rate limiting, no multi-key management — explicitly out of scope for v1.


3. Repository layout (create exactly this)

.
├── pyproject.toml
├── README.md
├── Dockerfile
├── .env.example
├── app/
│   ├── __init__.py
│   ├── config.py          # Settings: env parsing, paths, validation
│   ├── db.py              # connection factory, schema DDL, migrations
│   ├── models.py          # dataclasses: Message, HealthStatus, UpdatesPage
│   ├── auth.py            # verify_token(header_value) -> None | raises 401
│   ├── sanitize.py        # html_to_text, strip_dangerous_unicode, normalize
│   ├── cursor.py          # encode_cursor / decode_cursor (opaque, urlsafe)
│   ├── store.py           # insert_message, get_updates(cursor), get_since(ts), sync metadata
│   ├── gmail/
│   │   ├── __init__.py
│   │   ├── client.py      # GmailClient protocol (interface)
│   │   ├── real.py        # RealGmailClient: OAuth + history.list + messages.get
│   │   └── fake.py        # FakeGmailClient: reads JSON fixtures from a directory
│   ├── sync.py            # sync loop: cursor mgmt, fetch, normalize, persist
│   ├── api.py             # FastAPI app factory, routes, lifespan
│   └── main.py            # uvicorn entrypoint
├── scripts/
│   └── authorize.py       # one-time OAuth consent flow, writes token file
├── tests/
│   ├── conftest.py        # tmp DB fixture, app fixture with FakeGmailClient
│   ├── fixtures/emails/   # JSON fixture emails for fake mode
│   ├── test_sanitize.py
│   ├── test_cursor.py
│   ├── test_store.py
│   ├── test_sync.py
│   ├── test_api_auth.py
│   ├── test_api_updates.py
│   └── test_api_readonly.py
└── .github/ (none needed for v1)

4. Configuration contract (app/config.py)

All config from environment variables. .env.example must list every one with a comment.

Variable Required Default Meaning
GATEWAY_API_KEY yes Static bearer token agents present. Startup fails with a clear error if unset or < 32 chars.
GATEWAY_DB_PATH no /data/gateway.db SQLite file path.
GATEWAY_GMAIL_MODE no real real or fake.
GATEWAY_FAKE_MAILDIR if fake Directory of JSON fixture emails for fake mode.
GATEWAY_GOOGLE_CREDENTIALS if real /data/credentials.json OAuth client file from Google Cloud console.
GATEWAY_GOOGLE_TOKEN if real /data/token.json Refresh-token file written by scripts/authorize.py.
GATEWAY_SYNC_INTERVAL_SECONDS no 300 Sync loop period.
GATEWAY_HOST / GATEWAY_PORT no 127.0.0.1 / 48291 Bind address. Port is deliberately uncommon.

5. Database schema (app/db.py)

CREATE TABLE IF NOT EXISTS messages (
  id            TEXT PRIMARY KEY,        -- Gmail message id
  thread_id     TEXT NOT NULL,
  sender_name   TEXT NOT NULL DEFAULT '',
  sender_email  TEXT NOT NULL,
  subject       TEXT NOT NULL DEFAULT '',
  body_text     TEXT NOT NULL,           -- sanitized plain text only
  internal_date_ms INTEGER NOT NULL,     -- Gmail internalDate (epoch ms)
  synced_at_ms  INTEGER NOT NULL         -- when THIS service ingested it (epoch ms, monotonic per §6)
);
CREATE INDEX IF NOT EXISTS idx_messages_synced ON messages (synced_at_ms, id);

CREATE TABLE IF NOT EXISTS sync_state (
  key   TEXT PRIMARY KEY,   -- 'history_id', 'last_sync_ok_ms', 'last_sync_error'
  value TEXT NOT NULL
);

Rules:


6. Cursor format (app/cursor.py)

Opaque, URL-safe token encoding (synced_at_ms: int, last_id: str):


7. Sanitization spec (app/sanitize.py)

Applied at ingest, before storage:

  1. Prefer the text/plain MIME part; if absent, convert text/html via BeautifulSoup get_text(separator="\n").
  2. Strip zero-width characters: U+200B, U+200C, U+200D, U+2060, U+FEFF.
  3. Strip bidirectional override/embedding controls: U+202A–U+202E, U+2066–U+2069.
  4. Normalize to NFC. Collapse runs of 3+ newlines to 2. Strip leading/trailing whitespace.
  5. Truncate stored body at 100,000 characters (append \n[truncated] when cut).

No attachments: at MIME walk time, skip any part with filename set or Content-Disposition: attachment; never store or return part bytes other than the chosen text body.


8. HTTP API contract (app/api.py)

All routes require Authorization: Bearer <GATEWAY_API_KEY>; failures return 401 {"error":"unauthorized"} (same response for missing and wrong key). Only GET routes exist.

GET /v1/updates?cursor=<opaque>&limit=<1..500, default 100>

{
  "messages": [{
    "id": "...", "thread_id": "...",
    "sender_name": "...", "sender_email": "...",
    "subject": "...",
    "body_untrusted": "...",
    "received_at": "2026-08-28T11:00:00Z",
    "synced_at": "2026-08-28T11:02:11Z"
  }],
  "next_cursor": "...",
  "has_more": false
}

GET /v1/messages?since=<ISO8601>&limit=<1..500, default 100>

GET /v1/health


9. Gmail client boundary (app/gmail/)

client.py defines the protocol both implementations satisfy:

class GmailClient(Protocol):
    def list_new_message_ids(self, history_id: str | None) -> tuple[list[str], str]:
        """Returns (new_message_ids, new_history_id). history_id None => bootstrap:
        return ([], current_history_id) — v1 starts from 'now', no backfill.
        Raises HistoryExpired if the stored history_id is too old."""
    def fetch_message(self, message_id: str) -> RawMessage:
        """RawMessage: dataclass with headers dict, mime parts (text/plain, text/html), internal_date_ms."""

10. Sync loop (app/sync.py)

async def run_sync_loop(client, store, interval): every interval seconds → read history_id from sync_statelist_new_message_ids → for each id not already in DB: fetch_message, extract sender via email.utils.parseaddr(headers['From']), sanitize body per §7, insert → persist new history_id and last_sync_ok_ms. On any exception: record last_sync_error, log, sleep, continue (loop never dies). Also expose async def sync_once(...) used by tests and by lifespan for an immediate first sync.


11. Task breakdown for sub-agents

Tasks are designed to be non-overlapping by file: no two tasks in the same wave touch the same file. Interfaces above are the contract; implement to them exactly.

Wave 1 (parallel, no dependencies)

T1 — Skeleton & config. Files: pyproject.toml, .env.example, README.md, Dockerfile, app/__init__.py, app/config.py, app/models.py, app/main.py (stub that imports app factory lazily). Success criteria: uv sync succeeds; uv run python -c "from app.config import Settings" works; instantiating settings without GATEWAY_API_KEY raises a validation error whose message names the variable; ruff check . clean.

T2 — Storage core. Files: app/db.py, app/cursor.py, app/store.py, tests/test_cursor.py, tests/test_store.py. Success criteria: uv run pytest tests/test_cursor.py tests/test_store.py green. Tests must cover: cursor round-trip; decode_cursor raising InvalidCursor on ≥4 malformed inputs; idempotent insert (same id twice → 1 row); monotonic synced_at_ms under two same-millisecond inserts; get_updates pagination returning correct pages and has_more across a 250-row dataset with limit 100.

T3 — Sanitization. Files: app/sanitize.py, tests/test_sanitize.py. Success criteria: uv run pytest tests/test_sanitize.py green. Tests must cover: HTML→text of a nested HTML email; removal of every codepoint listed in §7 items 2–3 (assert none remain in output); NFC normalization; newline collapsing; 100k truncation with marker; plain-text passthrough unchanged apart from whitespace rules.

T4 — Gmail clients. Files: app/gmail/__init__.py, app/gmail/client.py, app/gmail/real.py, app/gmail/fake.py, scripts/authorize.py, tests/fixtures/emails/ (≥5 varied fixtures: plain-text, HTML-only, both-parts, attachment-bearing, unicode-tricks). Success criteria: uv run python -c "import app.gmail.real" succeeds (imports must not require credentials); FakeGmailClient over the fixtures returns all ids once and then ([], hid) on the next call; grep gate: grep -r "gmail" app/ scripts/ | grep -i scope shows only gmail.readonly; no other Gmail scope string appears anywhere in the repo.

Wave 2 (parallel, depends on Wave 1)

T5 — Sync engine. Files: app/sync.py, tests/test_sync.py, tests/conftest.py (owns conftest). Success criteria: uv run pytest tests/test_sync.py green. Tests must cover: sync_once with FakeGmailClient ingests all fixtures with sanitized bodies (assert the unicode-tricks fixture is cleaned and the attachment fixture stored without attachment data); second sync_once inserts nothing; a client whose fetch_message raises records last_sync_error without raising out of sync_once's loop wrapper run_sync_loop iteration; history_id persisted.

T6 — API layer. Files: app/auth.py, app/api.py, tests/test_api_auth.py, tests/test_api_updates.py, tests/test_api_readonly.py; finalize app/main.py. Success criteria: uv run pytest tests/test_api_auth.py tests/test_api_updates.py tests/test_api_readonly.py green. Tests must cover: 401 on missing/wrong bearer for all three routes (identical body); verify_token uses secrets.compare_digest (assert via source inspection or monkeypatch); bootstrap /v1/updates without cursor returns 0 messages + usable next_cursor; full poll cycle: bootstrap → insert 3 rows → poll returns exactly 3 with correct field names including body_untrusted → next poll returns 0; invalid cursor → 400; /v1/messages?since= valid & invalid; /v1/health shape and degraded transition; read-only gate: parametrized test asserting POST/PUT/PATCH/DELETE on every route path returns 405, and a route-table introspection test asserting every registered route allows only GET (and HEAD).

Wave 3 (single agent, integration)

T7 — Integration & docs polish. May touch any file. Run the full suite, run the service end-to-end in fake mode: start server, bootstrap cursor via curl, drop a new JSON fixture into the maildir, wait one sync interval, poll and confirm the new message appears; verify Docker build; finalize README (setup incl. Google Cloud OAuth steps, fake-mode quickstart, API reference, security notes). Success criteria: uv run pytest fully green; ruff check . clean; the curl end-to-end transcript shows the dropped fixture returned by /v1/updates; docker build . succeeds; README contains a copy-pasteable fake-mode quickstart that works.

Main-agent verification checklist (after all waves)

  1. uv run pytest -q → exit 0, zero skipped security tests.
  2. ruff check . → exit 0.
  3. Grep gates: only gmail.readonly scope string in repo; no users().messages().send, modify, trash, or delete calls anywhere; body_untrusted present in API responses.
  4. Live fake-mode smoke test per T7 transcript.
  5. Confirm .env.example documents every variable in §4.

12. Explicitly out of scope for v1

Attachments, HTML bodies in responses, multiple API keys, rate limiting, MCP adapter, Gmail push (Pub/Sub), backfill of pre-existing mail, label filtering, TLS termination (run behind a reverse proxy or on localhost/tailnet).

13. Future-proofing notes for the implementer

Keep store.get_updates and sync_once free of FastAPI imports — they are the durable core. The HTTP layer and any future MCP adapter are skins over those functions. app/auth.py:verify_token is the single seam for a future multi-key upgrade.