API Reference

FastAPI

The REST API is served by FastAPI. The automatically generated documentation is available at:

  • OpenAPI JSON: http://localhost:8000/openapi.json

  • Swagger UI: http://localhost:8000/docs

  • ReDoc: http://localhost:8000/redoc

All endpoints require the X-API-Token header when GMP_API_TOKEN is configured.

Core endpoints

POST /commands/run-now

Wake the dispatcher and reporting loops so they execute a cycle immediately instead of waiting for the next scheduled interval.

Behavior depends on authentication token:

  • Admin token: Processes all pending messages across all tenants. Does not override any tenant’s “Do Not Disturb” (DND) settings.

  • Tenant token: Only processes that specific tenant. Additionally, resets the tenant’s DND setting, forcing an immediate sync call even if the tenant had previously requested to not be disturbed until a future time.

Use cases:

  • Admin token: Maintenance scripts, test_mode instances where interval is infinite

  • Tenant token: Tenant has urgent messages and needs immediate sync, even if they previously set a DND period (e.g., during night hours)

Examples:

# Admin: wake all tenants (respects DND)
curl -X POST "http://localhost:8000/commands/run-now" \
  -H "X-API-Token: $ADMIN_TOKEN"

# Tenant: force immediate sync for this tenant only (overrides DND)
curl -X POST "http://localhost:8000/commands/run-now" \
  -H "X-API-Token: $TENANT_TOKEN"
POST /commands/suspend / POST /commands/activate

Toggle the scheduler.

POST /commands/add-messages

Validate and enqueue a batch of messages. Each payload entry matches mail_proxy.api.MessagePayload; the response contains the number of queued items plus a rejected list with {"id","reason"} entries describing invalid payloads (missing id, bad addresses, unknown account, duplicates, …). See core.mail_proxy.entities.message.endpoint.MessageEndpoint.

POST /account / GET /accounts / DELETE /account/{id}

Manage SMTP account credentials.

GET /messages

Inspect the SQLite-backed messages table. Each record includes:

  • pk: Internal UUID primary key

  • id: Client-provided message identifier

  • tenant_id: Tenant identifier

  • tenant_name: Tenant display name (from tenants table)

  • account_id: SMTP account used for sending

  • priority: Message priority (1-4)

  • payload: Original message content

  • Lifecycle fields: deferred_ts, smtp_ts, error_ts, error

GET /metrics

Expose Prometheus metrics generated by tools.prometheus.metrics.MailMetrics.

Example:

curl "http://localhost:8000/metrics" \
  -H "X-API-Token: your-token"

Tenant management

These endpoints manage tenant configurations for multi-tenant deployments. See Multi-tenancy Architecture for full architecture details.

POST /tenant

Create or update a tenant configuration.

Example:

curl -X POST "http://localhost:8000/tenant" \
  -H "X-API-Token: your-token" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "tenant-acme",
    "name": "ACME Corporation",
    "client_base_url": "https://api.acme.com",
    "client_sync_path": "/mail-proxy/sync",
    "active": true
  }'

Request body:

{
  "id": "tenant-acme",
  "name": "ACME Corporation",
  "client_base_url": "https://api.acme.com",
  "client_sync_path": "/mail-proxy/sync",
  "client_attachment_path": "/mail-proxy/attachments",
  "client_auth": {
    "method": "bearer",
    "token": "secret-token"
  },
  "rate_limits": {
    "hourly": 1000,
    "daily": 10000
  },
  "large_file_config": {
    "enabled": true,
    "max_size_mb": 10,
    "storage_url": "s3://bucket/mail-attachments",
    "file_ttl_days": 30,
    "action": "rewrite"
  },
  "active": true
}

large_file_config fields:

  • enabled (bool): Enable large file handling (default: false)

  • max_size_mb (float): Size threshold in MB (default: 10.0)

  • storage_url (string): fsspec URL for storage backend: - S3/MinIO: s3://bucket/path - Google Cloud Storage: gs://bucket/path - Azure Blob: az://container/path - Local filesystem: file:///var/www/downloads

  • public_base_url (string): Required for local filesystem storage

  • file_ttl_days (int): Days before files expire (default: 30)

  • lifespan_after_download_days (int): Days to keep after first download

  • action (string): Behavior when limit exceeded: - warn: Log warning, send normally (default) - reject: Reject message with error - rewrite: Upload to storage, replace with download link

Response: {"ok": true}

GET /tenants

List all configured tenants.

Query parameters:

  • active_only (bool, optional): Filter to active tenants only

Response:

{
  "ok": true,
  "tenants": [
    {
      "id": "tenant-acme",
      "name": "ACME Corporation",
      "client_base_url": "https://api.acme.com",
      "active": true,
      "created_at": "2024-01-20T10:00:00Z"
    }
  ]
}
GET /tenants/sync-status

Retrieve synchronization status for all tenants (admin only).

Returns the last sync timestamp and Do Not Disturb status for each tenant. Useful for monitoring tenant synchronization health.

Response:

{
  "ok": true,
  "sync_interval_seconds": 300,
  "tenants": [
    {
      "id": "tenant-acme",
      "name": "ACME Corporation",
      "active": true,
      "client_base_url": "https://api.acme.com",
      "last_sync_ts": 1706450400,
      "next_sync_due": false,
      "in_dnd": false
    },
    {
      "id": "tenant-idle",
      "name": "Idle Corp",
      "active": true,
      "client_base_url": "https://idle.example.com",
      "last_sync_ts": 1706500800,
      "next_sync_due": false,
      "in_dnd": true
    }
  ]
}

Response fields per tenant:

  • last_sync_ts: Unix timestamp of last sync call (or future timestamp if DND)

  • next_sync_due: true if sync interval has expired and tenant should be called

  • in_dnd: true if tenant is in “Do Not Disturb” mode (future last_sync_ts)

GET /tenant/{tenant_id}

Get a specific tenant configuration.

Response: Single tenant object or 404 if not found.

PUT /tenant/{tenant_id}

Update an existing tenant. All fields are optional.

Response: {"ok": true}

DELETE /tenant/{tenant_id}

Remove a tenant configuration.

Response: {"ok": true}

Additional commands

POST /commands/delete-messages?tenant_id=<tenant_id>

Remove messages from the queue by their IDs.

Query parameters:

  • tenant_id (string, required): Tenant identifier for security isolation

Request body:

{
  "ids": ["MSG-001", "MSG-002"]
}

Example:

curl -X POST "http://localhost:8000/commands/delete-messages?tenant_id=acme" \
  -H "X-API-Token: your-token" \
  -H "Content-Type: application/json" \
  -d '{"ids": ["MSG-001", "MSG-002"]}'

Response:

{
  "ok": true,
  "removed": 2,
  "not_found": [],
  "unauthorized": []
}

The unauthorized field contains IDs of messages that belong to a different tenant.

POST /commands/cleanup-messages?tenant_id=<tenant_id>

Remove old reported messages based on retention policy.

Query parameters:

  • tenant_id (string, required): Tenant identifier for security isolation

Request body (optional):

{
  "older_than_seconds": 86400
}

If older_than_seconds is not provided, uses the configured report_retention_seconds value.

Example:

# With custom retention period
curl -X POST "http://localhost:8000/commands/cleanup-messages?tenant_id=acme" \
  -H "X-API-Token: your-token" \
  -H "Content-Type: application/json" \
  -d '{"older_than_seconds": 86400}'

# With default retention
curl -X POST "http://localhost:8000/commands/cleanup-messages?tenant_id=acme" \
  -H "X-API-Token: your-token" \
  -H "Content-Type: application/json" \
  -d '{}'

Response:

{
  "ok": true,
  "removed": 42
}

Prometheus metrics

The following metrics are exported (all prefixed with gmp_ for genro-mail-proxy):

  • gmp_sent_total{account_id} - Total successfully sent emails

  • gmp_errors_total{account_id} - Total permanent send failures

  • gmp_deferred_total{account_id} - Total temporarily deferred messages

  • gmp_rate_limited_total{account_id} - Total rate limit enforcement events

  • gmp_pending_messages - Current queue depth

Outbound proxy sync

Besides REST endpoints that clients call, the service also issues a POST request to the configured sync endpoint (per-tenant: client_base_url + client_sync_path, or global: GMP_CLIENT_SYNC_URL) whenever there are delivery results to share with your application. Example payload:

The payload contains the delivery results read from the messages table:

{
  "delivery_report": [
    {
      "tenant_id": "tenant-acme",
      "id": "MSG-101",
      "pk": "550e8400-e29b-41d4-a716-446655440000",
      "sent_ts": 1728458400
    },
    {
      "tenant_id": "tenant-acme",
      "id": "MSG-102",
      "pk": "550e8400-e29b-41d4-a716-446655440001",
      "error_ts": 1728458612,
      "error": "SMTP timeout"
    }
  ]
}

Each report includes tenant_id, id (client-facing), and pk (internal UUID). Event-specific fields depend on the event type: sent_ts for successful delivery, error_ts and error for failures, deferred_ts for retries.

Client Callback Endpoints

These endpoints are NOT exposed by the proxy. Your application must implement them to receive callbacks from the proxy.

Sync Endpoint (Required)

The proxy sends delivery reports to your sync endpoint (per-tenant: client_base_url + client_sync_path, or global: GMP_CLIENT_SYNC_URL). This is a bidirectional protocol that allows you to both receive reports AND signal pending messages.

Request (from proxy to your application):

{
  "delivery_report": [
    {"id": "MSG-001", "sent_ts": 1728458400},
    {"id": "MSG-002", "error_ts": 1728458612, "error": "SMTP timeout"},
    {"id": "MSG-003", "pec_event": "pec_acceptance", "pec_ts": 1728458700}
  ]
}

Response schema (from your application to proxy):

{
  "ok": true,
  "queued": 15,
  "error": [],
  "not_found": []
}

Response fields:

Field

Type

Required

Description

ok

bool

Yes

true if reports were processed successfully

queued

int

No (default: 0)

Number of messages your application has ready to send. Important: When queued > 0, the proxy immediately re-calls sync instead of waiting for the normal interval (5 minutes). This enables efficient batch submission.

next_sync_after

int

No

Unix timestamp. Proxy will not sync this tenant until this time (“Do Not Disturb”). Useful for serverless databases to avoid cold-start costs during idle hours. Tenant can override by calling /commands/run-now with their token.

error

list[str]

No

Message IDs that could not be processed

not_found

list[str]

No

Message IDs not found in your database

Accelerated sync loop:

When your application responds with queued > 0:

  1. Proxy receives response with queued: N

  2. Your application calls POST /commands/add-messages with next batch

  3. Proxy immediately calls sync endpoint again (no wait)

  4. Repeat until queued: 0

This design allows efficient batch submission without polling.

Example implementation:

@app.post("/proxy-sync")
async def sync_endpoint(request: Request):
    data = await request.json()
    reports = data.get("delivery_report", [])

    # 1. Process delivery reports
    for report in reports:
        msg_id = report["id"]
        if "sent_ts" in report:
            mark_sent(msg_id, report["sent_ts"])
        elif "error_ts" in report:
            mark_failed(msg_id, report["error"])
        elif "pec_event" in report:
            handle_pec(msg_id, report["pec_event"], report.get("pec_ts"))

    # 2. Check outbox and submit next batch
    pending = get_pending_messages(limit=100)
    total_pending = count_all_pending()

    if pending:
        # Submit batch to proxy (async, don't block this response)
        asyncio.create_task(submit_to_proxy(pending))

    # 3. Return queued count to trigger immediate resync if needed
    return {"ok": True, "queued": total_pending}

See Protocols and APIs for the complete specification.

Attachment Endpoint (optional)

If using fetch_mode: "endpoint" for attachments, the proxy fetches file content from your attachment endpoint (client_base_url + client_attachment_path). Your endpoint receives a POST with the storage_path value and must return the file content.

Example implementation:

@app.post("/attachments")
async def serve_attachment(request: Request):
    data = await request.json()
    storage_path = data.get("storage_path")  # e.g., "doc_id=123"

    # Parse storage_path and fetch file from your storage
    file_content = fetch_file_from_storage(storage_path)

    return Response(content=file_content, media_type="application/octet-stream")