MailManager is a multi-account email management platform with a FastAPI backend and a React frontend. It lets you group Gmail and Outlook accounts under mailbox entities, connect them with OAuth 2.0, fetch inbox messages across providers, and send emails from any connected account.
- Multi-mailbox model to isolate contexts (work, personal, clients).
- Multi-provider support: Gmail and Outlook are implemented.
- Unified inbox per mailbox across all connected accounts.
- Send email from a specific account in a mailbox.
- Draft creation that mirrors the draft at the provider (Gmail and Outlook).
- Draft update that replaces draft content at the provider (PATCH, Provider-First).
- Draft deletion at the provider with local cleanup (Provider-First Rule).
- Draft synchronization pulls the most recent drafts from every connected account into the local database (capped at 100 per account).
- Batch read/unread status management across accounts.
- Trash management: move emails to trash, permanently delete, or restore.
- Spam operations: move to spam and restore from spam with cross-provider support.
- OAuth 2.0 interactive connect flow plus silent re-authentication.
- PostgreSQL persistence for mailboxes, accounts, and tokens.
- Strict layered architecture with centralized API error mapping.
Request flow:
Routers (api/routers)
-> Routers helpers (api/routers/routers_helpers.py)
-> Services (api/services)
-> Auth (auth/)
-> Database (database/)
-> Core (core/email)
-> EmailManager
-> GmailClient / OutlookClient
Layer contracts:
Routers: HTTP interface only. No business logic.Services: orchestration, validation, and error translation.Auth: framework-agnostic authentication (Google OIDC, session management).Database: PostgreSQL persistence and token storage (independent layer).Core: provider-specific email behavior and client orchestration.
MailManager/
|-- backend/
| |-- api/
| | |-- routers/
| | |-- services/
| | |-- schemas/
| | `-- errors/
| |-- auth/
| |-- database/
| |-- core/
| | `-- email/
| |-- tests/
| | |-- unit/
| | |-- integration/
| | |-- e2e/
| | `-- shared/
| `-- main.py
|-- frontend/
| |-- src/
| `-- package.json
|-- requirements.txt
`-- README.md
- Python 3.12+
- Node.js 18+
- PostgreSQL
- Gmail OAuth app credentials JSON (Google Cloud)
- Outlook app credentials JSON (Azure app registration)
- Docker and Docker Compose (optional, for containerized deployment)
git clone <your-repo-url>
cd MailManager
python -m venv .venv
# Windows PowerShell
.venv\Scripts\Activate.ps1
# Linux/macOS
source .venv/bin/activate
pip install -r requirements.txtMailManager reads environment variables from the OS environment. The backend also supports a backend/.env file via python-dotenv (override=False, so OS-level variables take precedence). See backend/.env.example for a template.
Required:
DATABASE_URLMIA_GMAIL_CREDENTIALS_PATHMIA_OUTLOOK_CREDENTIALS_PATHTOKEN_ENCRYPTION_KEYGOOGLE_CLIENT_ID
Example (PowerShell):
$env:DATABASE_URL = "postgresql://user:pass@localhost:5432/mailmanager"
$env:MIA_GMAIL_CREDENTIALS_PATH = "C:\\secrets\\gmail_oauth.json"
$env:MIA_OUTLOOK_CREDENTIALS_PATH = "C:\\secrets\\outlook_oauth.json"
$env:TOKEN_ENCRYPTION_KEY = "<FERNET_KEY>"
$env:GOOGLE_CLIENT_ID = "<YOUR_GOOGLE_CLIENT_ID>"Generate a Fernet key (one-time):
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"python -m alembic -c backend/database/alembic.ini upgrade headFor existing databases initialized before Alembic:
python -m alembic -c backend/database/alembic.ini stamp 0001_initial_schema
python -m alembic -c backend/database/alembic.ini upgrade headcd backend
python main.pyBackend URL: http://localhost:8000
cd frontend
npm install
npm run devFrontend URL: http://localhost:5173
Instead of manual setup, run everything with Docker Compose:
# Configure the credentials volume in docker-compose.yml
# The volume mount path is developer-specific — edit the 'volumes' entry
# to point to your local OAuth credentials directory.
docker compose up --buildThis starts PostgreSQL and the backend (port 8000). The frontend service is currently commented out in docker-compose.yml. Run Alembic migrations before exposing the API.
docker compose down # stop all services
docker compose down -v # stop and delete database volume| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL DSN used by connection pool and Alembic migrations. |
DB_POOL_MIN_CONN |
No | Minimum pooled DB connections. Default: 1. |
DB_POOL_MAX_CONN |
No | Maximum pooled DB connections. Default: 10. |
DB_CONNECT_TIMEOUT_SECONDS |
No | Connection timeout for PostgreSQL. Default: 10. |
DB_APPLICATION_NAME |
No | PostgreSQL application_name. Default: mailmanager-api. |
DB_AUTO_MIGRATE |
No | If true, API startup runs alembic upgrade head. Default: false. |
DB_ALEMBIC_INI_PATH |
No | Custom Alembic config path. |
TOKEN_ENCRYPTION_KEY |
Yes | Fernet key for encrypted account tokens in DB. |
TOKEN_ENCRYPTION_KEY_ID |
No | Identifier for active encryption key. Default: v1. |
TOKEN_PLAINTEXT_FALLBACK_ENABLED |
No | Enables temporary legacy plaintext token reads. Default: true. |
MIA_GMAIL_CREDENTIALS_PATH |
Yes | Path to Gmail OAuth credentials JSON file. |
MIA_OUTLOOK_CREDENTIALS_PATH |
Yes | Path to Outlook app credentials JSON file. |
GOOGLE_CLIENT_ID |
Yes | Google OAuth client ID for OIDC authentication. |
GMAIL_BATCH_MAX_WORKERS |
No | Max parallel workers for Gmail batch operations. Default: 5. |
AUTH_SESSION_LIFETIME_DAYS |
No | Session duration in days. Default: 7. |
AUTH_COOKIE_SECURE |
No | HTTPS-only session cookies. Default: false. |
CORS_ALLOWED_ORIGINS |
No | Comma-separated CORS origins. Default: http://localhost:5173. |
The following variable is consumed only by the Vite dev server / frontend bundle. It is not read by the backend; configure it in frontend/.env.
| Variable | Required | Description |
|---|---|---|
VITE_API_BASE_URL |
No | Frontend override for the backend URL. Defaults to http://localhost:8000. |
Outlook credential file keys: client_id, client_secret, tenant, redirect_uri, scopes.
Health:
GET /health
Mailboxes:
POST /mailboxesGET /mailboxesGET /mailboxes/{mailbox_id}DELETE /mailboxes/{mailbox_id}
Accounts:
GET /mailboxes/{mailbox_id}/accountsPOST /mailboxes/{mailbox_id}/accountsGET /mailboxes/{mailbox_id}/accounts/{account_id}PATCH /mailboxes/{mailbox_id}/accounts/{account_id}DELETE /mailboxes/{mailbox_id}/accounts/{account_id}POST /mailboxes/{mailbox_id}/accounts/{account_id}/connect
Emails:
POST /mailboxes/{mailbox_id}/emails/sync-metadataPOST /mailboxes/{mailbox_id}/emails/sendPATCH /mailboxes/{mailbox_id}/emails/read-statusPOST /mailboxes/{mailbox_id}/emails/trashPOST /mailboxes/{mailbox_id}/emails/move-to-trashPOST /mailboxes/{mailbox_id}/emails/spamPOST /mailboxes/{mailbox_id}/emails/restore-from-spamGET /mailboxes/{mailbox_id}/emails— Required query param:box=ALL_MAIL|SENT|SPAM|TRASH. Optional:account_id,q(free-text search, 2-200 chars, accent/case-insensitive substring across subject + sender),limit(default 200, max 500),offset(default 0).GET /mailboxes/{mailbox_id}/emails/{provider_message_id}/content— Required query param:account_id.
Drafts:
POST /mailboxes/{mailbox_id}/accounts/{account_id}/drafts— Create a draft at the provider and persist it locally (Provider-First; Outlook usesPrefer: IdType="ImmutableId").PATCH /mailboxes/{mailbox_id}/accounts/{account_id}/drafts/{provider_draft_id}— Replace an existing draft's content at the provider (full-field replacement) and persist the new values locally. Provider-First with a pre-check: the draft must exist in the local DB (404draft_not_foundotherwise) before any provider call. Gmail usesusers().drafts().update(); Outlook usesPATCH /me/messages/{id}withPrefer: IdType="ImmutableId"repeated on every call.created_atis preserved;updated_atis refreshed.DELETE /mailboxes/{mailbox_id}/accounts/{account_id}/drafts/{draft_id}— Delete a draft at the provider and remove the local row (Provider-First). Returns{"status": "deleted"}.POST /mailboxes/{mailbox_id}/drafts/sync— Fetch the most recent drafts from the provider(s) into the local database (full replace per account, capped at 100 drafts per account most recent by date). Optional query paramaccount_id: when provided, syncs only that account; when omitted, syncs every account in the mailbox. Gmail uses parallel batcheddrafts.getcalls (workers configurable viaGMAIL_BATCH_MAX_WORKERS, default 5); Outlook uses$top=100+$orderby=lastModifiedDateTime descpaginated fetch with per-page retries.POST /mailboxes/{mailbox_id}/accounts/{account_id}/drafts/{provider_draft_id}/send— Send an existing draft at the provider and remove it from local storage. Provider-First with 3-attempt retry in the client layer. On success: deletes the localdraftsrow and persists the sent email metadata toemail_metadata(both best-effort). Gmail returns a newmessage_id; Outlook keeps the same ID (ImmutableId).GET /mailboxes/{mailbox_id}/drafts— List drafts for the mailbox (DB-only, no provider calls). Optional query paramaccount_id: when provided, returns drafts of that account; when omitted, returns the unified view across all accounts in the mailbox. Ordered bycreated_at DESC.
Auth:
POST /auth/googleGET /auth/mePOST /auth/logoutDELETE /auth/me
Detailed endpoint contracts: backend/api/api_guide.md
All API errors follow this schema:
{
"error": {
"code": "account_not_found",
"message": "Account '...' not found.",
"detail": {}
}
}Each API error code maps to a fixed HTTP status. The list below shows every code with its status. The full reference (with usage notes per class) lives in backend/api/api_guide.md under "Service-Layer Error Classes".
api_error— 500 (default for any unmapped class)mailbox_not_found— 404account_not_found— 404email_not_found— 404draft_not_found— 404user_not_found— 404account_misconfigured— 400recipients_missing— 400unauthorized— 401account_connect_auth_error— 401forbidden— 403account_not_connected— 409email_not_in_trash— 409app_credentials_invalid— 500app_credentials_missing— 500env_var_error— 500credential_file_error— 500database_connection_error— 503database_query_error— 503database_migration_error— 500token_decryption_error— 500token_encryption_error— 500token_integrity_error— 500trash_operation_error— 500email_list_error— 500draft_list_error— 500mailbox_operation_error— 500account_operation_error— 500session_operation_error— 500user_operation_error— 500email_fetch_error— 502email_send_error— 502external_api_error— 502read_status_update_error— 502move_to_trash_error— 502spam_move_error— 502spam_restore_error— 502email_content_fetch_error— 502draft_creation_error— 502draft_update_error— 502draft_delete_error— 502draft_sync_error— 502
# All tests
python -m pytest backend/tests
# Unit tests
python -m pytest backend/tests/unit -v
# Integration tests (requires DATABASE_URL)
python -m pytest backend/tests/integration -v
# E2E tests (requires DATABASE_URL and provider credentials)
python -m pytest backend/tests/e2e -v -sTesting docs:
backend/tests/unit/unit_guide.mdbackend/tests/integration/integration_guide.mdbackend/tests/e2e/e2e_guide.md
- API layer guide:
backend/api/api_guide.md - Auth layer guide:
backend/auth/auth_guide.md - Database guide:
backend/database/database_guide.md - Core (email) guide:
backend/core/core_guide.md
backend/api/CLAUDE.mdbackend/auth/CLAUDE.mdbackend/database/CLAUDE.mdbackend/core/CLAUDE.mdbackend/tests/unit/CLAUDE.mdbackend/tests/integration/CLAUDE.mdbackend/tests/e2e/CLAUDE.md
- Frontend setup:
frontend/README.md - Agent guidance:
CLAUDE.md