You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

294 lines
8.5 KiB
Markdown

# 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_RESOURCE_SERVER_URL`, `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.
### Suggested meeting intervals for AI agents
```bash
curl -X POST "http://127.0.0.1:8000/meeting-intervals" \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret" \
-d '{
"start": "2026-03-09T08:00:00+01:00",
"end": "2026-03-10T18:00:00+01:00",
"calendar_ids": ["primary"]
}'
```
### 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"]
}
}'
```
### A2A SendMessage meeting intervals
```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-2",
"method": "SendMessage",
"params": {
"action": "available_meeting_intervals",
"start": "2026-03-09T08:00:00+01:00",
"end": "2026-03-10T18:00:00+01:00",
"calendar_ids": ["primary"]
}
}'
```
### MCP server (availability and scheduling tools)
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 `check_availability` and `available_meeting_intervals`.
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_RESOURCE_SERVER_URL=https://mcp.example.com/mcp
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`
- `available_meeting_intervals`: `available_meeting_intervals:read`
- `scan_mailbox`: `mail:scan`
- `list_unsubscribe_candidates`: `unsubscribe:read`
- `execute_unsubscribe`: `unsubscribe:execute`
With `MCP_AUTH_MODE=oauth`, the MCP server also exposes RFC 9728 protected-resource metadata:
```text
https://<host>/.well-known/oauth-protected-resource/mcp
```
### 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).