# Personal Gmail + Calendar Agent This project runs a small local API service that: - scans unread emails in the root Gmail inbox - classifies emails with **Strands** (`LINKEDIN`, `ADVERTISING`, `VEILLE_TECHNO`, `OTHER`) - moves LinkedIn emails to a `LinkedIn` label/folder - moves advertising emails to an `Advertising` label/folder - moves veille techno emails to a `VeilleTechno` label/folder - scans the `Advertising` label and emails you new unsubscribe links (deduplicated) - discovers unsubscribe-ready mailing lists for human review, then auto-unsubscribes selected lists - exposes a secure availability endpoint powered by Google Calendar free/busy ## 1) Prerequisites - Python 3.11+ - `uv` ([installation guide](https://docs.astral.sh/uv/getting-started/installation/)) - A Google account - An OpenAI-compatible API key for Strands (`STRANDS_OPENAI_API_KEY`) - A Google Cloud project with: - Gmail API enabled - Google Calendar API enabled - OAuth consent configured - OAuth Client ID of type **Desktop app** ## 2) Google OAuth setup 1. In Google Cloud Console, create a desktop OAuth client. 2. Download the client JSON file. 3. Save it in this project as `credentials.json`. The first run opens a browser window for consent and creates `token.json`. If your existing token was created before `gmail.send` was added, you may be prompted again. ## 3) Install and configure ```bash uv sync cp .env.example .env ``` Edit `.env` and set: - `AGENT_API_KEY` to a strong secret for agent-to-agent calls - `AUTH_MODE` (`api_key`, `jwt`, or `hybrid`) - optional JWT settings (`AUTH_JWT_SECRET`, `AUTH_JWT_ISSUER`, `AUTH_JWT_AUDIENCE`) - `STRANDS_OPENAI_API_KEY` and optional `STRANDS_MODEL_ID` / `STRANDS_OPENAI_BASE_URL` - optional unsubscribe digest settings (`UNSUBSCRIBE_*`) - optional scan frequency and additional Gmail filters (`GMAIL_QUERY`) - optional MCP auth override (`MCP_AUTH_MODE=inherit|api_key|jwt|hybrid|oauth`) - for MCP OAuth mode: `MCP_OAUTH_INTROSPECTION_URL`, optional `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET`, and optional `MCP_OAUTH_ISSUER` / `MCP_OAUTH_AUDIENCE` ## 4) Run ### Local ```bash uv run uvicorn app.main:app --reload ``` ### Docker ```bash docker compose up --build -d personal-agent personal-agent-mcp docker compose logs -f personal-agent personal-agent-mcp ``` Docker endpoints: - REST + A2A API: `http://127.0.0.1:8000` - MCP streamable HTTP: `http://127.0.0.1:8001/mcp` Container mounts: - `./${GOOGLE_CLIENT_SECRETS_FILE}` -> `/app/${GOOGLE_CLIENT_SECRETS_FILE}` (read-only) - `./${GOOGLE_TOKEN_FILE}` -> `/app/${GOOGLE_TOKEN_FILE}` - `./data` -> `/app/data` At startup, the scheduler runs: - Gmail triage (`GMAIL_SCAN_INTERVAL_MINUTES`) - unsubscribe digest (`UNSUBSCRIBE_DIGEST_INTERVAL_MINUTES`) - auto-unsubscribe for approved lists (`UNSUBSCRIBE_AUTO_INTERVAL_MINUTES`) when `UNSUBSCRIBE_AUTO_ENABLED=true` ## 5) API usage ### Health check ```bash curl http://127.0.0.1:8000/health ``` ### Manual Gmail scan ```bash curl -X POST "http://127.0.0.1:8000/scan?max_results=100" \ -H "X-API-Key: your-secret" ``` ### Availability for other AI agents ```bash curl -X POST "http://127.0.0.1:8000/availability" \ -H "Content-Type: application/json" \ -H "X-API-Key: your-secret" \ -d '{ "start": "2026-03-09T09:00:00+01:00", "end": "2026-03-09T10:00:00+01:00", "calendar_ids": ["primary"] }' ``` If `available` is `true`, there are no busy slots in that range. ### A2A discovery ```bash curl http://127.0.0.1:8000/.well-known/agent-card.json ``` ### A2A SendMessage availability ```bash curl -X POST "http://127.0.0.1:8000/a2a/rpc" \ -H "Content-Type: application/json" \ -H "X-API-Key: your-secret" \ -d '{ "jsonrpc": "2.0", "id": "req-1", "method": "SendMessage", "params": { "start": "2026-03-09T09:00:00+01:00", "end": "2026-03-09T10:00:00+01:00", "calendar_ids": ["primary"] } }' ``` ### MCP server (availability tool) Run MCP on a dedicated port: ```bash uv run uvicorn app.mcp_main:app --host 0.0.0.0 --port 8001 ``` With Docker Compose, MCP is provided by the `personal-agent-mcp` service. MCP streamable HTTP endpoint: ```text http://127.0.0.1:8001/mcp ``` By default, MCP exposes only `check_availability`. To expose internal mutation tools (`scan_mailbox`, `list_unsubscribe_candidates`, `execute_unsubscribe`), set: ```bash MCP_ENABLE_MUTATION_TOOLS=true ``` MCP auth mode defaults to `inherit` (same mode as `AUTH_MODE`). To force OAuth only on MCP: ```bash MCP_AUTH_MODE=oauth MCP_OAUTH_INTROSPECTION_URL=https://issuer.example/oauth2/introspect MCP_OAUTH_CLIENT_ID=your-resource-server-client-id MCP_OAUTH_CLIENT_SECRET=your-resource-server-client-secret MCP_OAUTH_ISSUER=https://issuer.example MCP_OAUTH_AUDIENCE=personal-agent-mcp ``` Scopes required per MCP tool: - `check_availability`: `availability:read` - `scan_mailbox`: `mail:scan` - `list_unsubscribe_candidates`: `unsubscribe:read` - `execute_unsubscribe`: `unsubscribe:execute` ### Manual unsubscribe digest ```bash curl -X POST "http://127.0.0.1:8000/unsubscribe-digest?max_results=500" \ -H "X-API-Key: your-secret" ``` ### Human-in-the-loop unsubscribe candidates ```bash curl -X POST "http://127.0.0.1:8000/unsubscribe/candidates?max_results=500" \ -H "X-API-Key: your-secret" ``` ### Execute unsubscribe for selected mailing lists ```bash curl -X POST "http://127.0.0.1:8000/unsubscribe/execute" \ -H "Content-Type: application/json" \ -H "X-API-Key: your-secret" \ -d '{ "selected_candidate_ids": ["abc123def456", "987zyx654wvu"], "remember_selection": true }' ``` ### Trigger auto-unsubscribe run (approved lists only) ```bash curl -X POST "http://127.0.0.1:8000/unsubscribe/auto-run?max_results=500" \ -H "X-API-Key: your-secret" ``` ## Classification behavior - Scan scope is always forced to `in:inbox is:unread` (root inbox + unread). - `GMAIL_QUERY` is treated as additional filters (for example `-label:AgentProcessed`). - Strands classification is used for each email (`LINKEDIN`, `ADVERTISING`, `VEILLE_TECHNO`, `OTHER`). - LinkedIn has priority over advertising inside the classifier prompt. - Set `LLM_FALLBACK_TO_RULES=true` only if you want rules-based backup when LLM calls fail. - Every scanned message gets an `AgentProcessed` label to avoid reprocessing loops. ## Unsubscribe digest behavior - Reads emails from `UNSUBSCRIBE_QUERY` (default `label:Advertising`). - Extracts unsubscribe URLs from `List-Unsubscribe` headers and message content. - Removes duplicates within the run and across runs. - Persists already sent links in `UNSUBSCRIBE_STATE_FILE`. - Sends only new links by email, unless `UNSUBSCRIBE_SEND_EMPTY_DIGEST=true`. ## Human-In-The-Loop Unsubscribe - Candidate discovery groups advertising messages into mailing lists using headers (`List-Id`, `From`) and unsubscribe methods. - You pick the `candidate_id` values you want to unsubscribe from. - Only selected lists are executed. - Executed unsubscribe methods are persisted and never executed twice. - Selected lists can be remembered (`remember_selection=true`) for scheduled auto-unsubscribe. - State is saved in `UNSUBSCRIBE_HIL_STATE_FILE`. ## Runbooks - A2A protocol runbook: `docs/a2a.md` - MCP protocol runbook: `docs/mcp.md` - Security, rotation, and rollout checklist: `docs/security.md` ## Notes - Gmail "folders" are labels. This agent creates: - `LinkedIn` - `VeilleTechno` - `Advertising` - `AgentProcessed` - Messages classified as LinkedIn/Advertising/Veille_Techno are removed from `INBOX` (moved out of inbox).