Multi-tenancy Architecture
This document describes the multi-tenant architecture of genro-mail-proxy, including how to configure tenants and the bidirectional PUSH communication pattern between the proxy and tenant servers.
Overview
genro-mail-proxy supports multiple tenants, each with:
Dedicated SMTP accounts
Per-tenant delivery report routing
Independent authentication for sync callbacks
Isolated rate limiting and quotas
The proxy implements a PUSH-based architecture where:
Tenant servers submit messages to the proxy via
POST /commands/add-messagesThe proxy dispatches messages via SMTP
The proxy pushes delivery reports back to each tenant’s configured endpoint
Bidirectional Communication Flow
The following diagram illustrates the complete message lifecycle:
Proxy Tenant Server (Client)
│ │
│ POST {client_base_url + sync_path} │
│ {"delivery_report": [...]} │
│ ─────────────────────────────────► │
│ │
│ 1. Process delivery reports
│ 2. Update local message statuses
│ 3. Query pending messages
│ 4. POST /commands/add-messages ──────┐
│ ◄───────────────────────────────────────────────────────────┘
│ │
│ return report_summary │
│ {"sent": N, "error": M, ...} │
│ ◄───────────────────────────────── │
Step-by-step flow:
Proxy sends delivery reports: The proxy’s client report loop periodically collects completed message results (sent, error, deferred) and POSTs them to each tenant’s sync endpoint (
client_base_url+client_sync_path).Tenant processes reports: The tenant server receives the delivery reports, updates its local database with message statuses (delivered, failed, etc.).
Tenant submits new messages: Optionally, the tenant can query its pending outbox and submit new messages back to the proxy via
POST /commands/add-messages.Proxy acknowledges: The tenant returns a summary response; the proxy marks the reports as delivered (
reported_ts) and eventually cleans them up.
API Authentication Model
genro-mail-proxy implements a two-tier authentication model:
Global Admin Token (
GMP_API_TOKEN): Full access to all endpoints and all tenantsTenant-Specific Token: Limited access to the tenant’s own resources only
This separation ensures that:
Administrators can manage all tenants and system configuration
Each tenant can only access their own data (messages, accounts, reports)
A compromised tenant token cannot affect other tenants or system configuration
Token Types and Permissions
Endpoint Category |
Global Admin Token |
Tenant Token |
|---|---|---|
Admin-Only Endpoints |
✅ Full access |
❌ Rejected (HTTP 403) |
Tenant-Scoped Endpoints |
✅ Access to any tenant |
✅ Access to own tenant only |
Admin-Only Endpoints (require global token):
POST /tenant- Create new tenantsDELETE /tenant/{id}- Delete tenantsGET /tenants- List all tenantsPOST /tenant/{id}/api-key- Generate tenant API keyDELETE /tenant/{id}/api-key- Revoke tenant API keyGET /instance,PUT /instance- Instance configurationPOST /instance/reload-bounce- Reload bounce configurationGET /command-log,GET /command-log/export- Audit trail
Tenant-Scoped Endpoints (allow tenant or admin tokens):
GET /tenant/{id},PUT /tenant/{id}- View/update own tenantGET /messages,POST /commands/add-messages- Manage messagesGET /accounts,POST /account,DELETE /account/{id}- Manage SMTP accountsPOST /commands/suspend,POST /commands/activate- Suspend/activate sendingPOST /commands/delete-messages,POST /commands/cleanup-messages- Message cleanup
Authentication Flow
When a request arrives with an X-API-Token header:
Check global token first: If it matches
GMP_API_TOKEN, grant admin accessCheck tenant tokens: Look up the token hash in the tenants table
Verify scope: For tenant tokens, verify the request’s
tenant_idmatches the token ownerReject if neither: Return HTTP 401 Unauthorized
Request with X-API-Token header
│
▼
┌─────────────────────┐
│ Is token = global? │──Yes──► Admin access (all endpoints)
└─────────────────────┘
│ No
▼
┌─────────────────────┐
│ Is token in tenants │──Yes──► Tenant access
│ table? │ (own resources only)
└─────────────────────┘
│ No
▼
HTTP 401 Unauthorized
For tenant-scoped endpoints, an additional scope check ensures tenant tokens can only access their own data:
Tenant-scoped request (e.g., GET /messages?tenant_id=acme)
│
▼
┌─────────────────────┐
│ Is admin token? │──Yes──► Allow access to any tenant
└─────────────────────┘
│ No (tenant token)
▼
┌─────────────────────┐
│ Does tenant_id │──Yes──► Allow access
│ match token owner? │
└─────────────────────┘
│ No
▼
HTTP 401 "Token not authorized for this tenant"
Automatic API Key Generation
When creating a new tenant via POST /tenant, an API key is automatically
generated and returned in the response:
curl -X POST http://localhost:8000/tenant \
-H "Content-Type: application/json" \
-H "X-API-Token: $ADMIN_TOKEN" \
-d '{"id": "acme", "name": "ACME Corp"}'
Response for new tenant:
{
"ok": true,
"api_key": "k3Xp9qR7mNvL2sWtYhBjCfDgEaUiOp..."
}
Warning
The api_key is shown only once at creation time. Store it securely!
It cannot be retrieved later. If lost, use POST /tenant/{id}/api-key
to generate a new key (which invalidates the old one).
Response for existing tenant (update):
{
"ok": true
}
Note: When updating an existing tenant, the API key is not changed or returned.
Managing Tenant API Keys
Generate a new API key (invalidates the previous one):
curl -X POST http://localhost:8000/tenant/acme/api-key \
-H "X-API-Token: $ADMIN_TOKEN"
Response:
{
"ok": true,
"api_key": "newKeyHere..."
}
Revoke an API key (tenant must use admin token or get a new key):
curl -X DELETE http://localhost:8000/tenant/acme/api-key \
-H "X-API-Token: $ADMIN_TOKEN"
Response:
{
"ok": true
}
Token Properties
Tokens are stored as SHA-256 hashes (the raw token is never stored)
Optional expiration via
api_key_expires_at(Unix timestamp)One token per tenant (creating a new one replaces the old)
Tokens are 43 characters long (URL-safe base64)
Security Best Practices
Keep admin token secret: Only system administrators should have access
Use tenant tokens for applications: Each tenant application should use its own tenant token, not the admin token
Rotate keys periodically: Use
POST /tenant/{id}/api-keyto generate new keysUse HTTPS: Always use TLS in production to protect tokens in transit
Set expiration: For temporary access, use the
expires_atparameter
Tenant Configuration
Tenants are configured via the REST API. Each tenant has:
Field |
Type |
Required |
Description |
|---|---|---|---|
|
|
Yes |
Unique tenant identifier |
|
|
No |
Human-readable name |
|
|
No |
Base URL for tenant HTTP endpoints (e.g., |
|
|
No |
Path for delivery report callbacks (default: |
|
|
No |
Path for attachment fetcher endpoint (default: |
|
|
No |
Common authentication for all HTTP endpoints (sync and attachments) |
|
|
No |
Whether tenant is enabled (default: |
|
|
No |
SHA-256 hash of tenant’s dedicated API token (internal use) |
|
|
No |
Unix timestamp when the API token expires (optional) |
TenantAuth Configuration
The client_auth object supports multiple authentication methods
and is used for both delivery report sync and attachment fetching:
Bearer Token Authentication:
{
"client_auth": {
"method": "bearer",
"token": "your-secret-token"
}
}
The proxy will send: Authorization: Bearer your-secret-token
Basic Authentication:
{
"client_auth": {
"method": "basic",
"user": "username",
"password": "password"
}
}
The proxy will send: Authorization: Basic <base64(user:password)>
No Authentication:
{
"client_auth": {
"method": "none"
}
}
Or simply omit the client_auth field entirely.
Tenant Management API
POST /tenant(Admin only)Create or update a tenant configuration.
Authentication: Requires global admin token.
Request body:
{ "id": "tenant-acme", "name": "ACME Corporation", "client_base_url": "https://api.acme.com", "client_sync_path": "/proxy_sync", "client_attachment_path": "/attachments", "client_auth": { "method": "bearer", "token": "acme-secret-token" }, "active": true }
Response for new tenant (API key auto-generated):
{ "ok": true, "api_key": "k3Xp9qR7mNvL2sWtYhBjCfDg..." }
Response for existing tenant (update, no API key change):
{ "ok": true }
GET /tenants(Admin only)List all configured tenants.
Authentication: Requires global admin token.
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", "updated_at": "2024-01-20T10:00:00Z" } ] }
GET /tenant/{tenant_id}(Tenant-scoped)Get a specific tenant configuration.
Authentication: Admin token or matching tenant token.
Response: Single tenant object or
404if not found.PUT /tenant/{tenant_id}(Tenant-scoped)Update an existing tenant. All fields are optional in the request body.
Authentication: Admin token or matching tenant token.
Response:
{"ok": true}DELETE /tenant/{tenant_id}(Admin only)Remove a tenant configuration.
Authentication: Requires global admin token.
Response:
{"ok": true}POST /tenant/{tenant_id}/api-key(Admin only)Generate a new API key for a tenant. Invalidates any existing key.
Authentication: Requires global admin token.
Response:
{ "ok": true, "api_key": "newGeneratedKey..." }
DELETE /tenant/{tenant_id}/api-key(Admin only)Revoke the tenant’s API key.
Authentication: Requires global admin token.
Response:
{"ok": true}
Delivery Report Routing
When the proxy has delivery reports to send, it routes them based on the
tenant_id associated with each message:
Messages with tenant_id: Reports are grouped by tenant and sent to each tenant’s sync endpoint (
client_base_url+client_sync_path) with the appropriate authentication.Messages without tenant_id: Reports are sent to the global sync endpoint (configured via
GMP_CLIENT_SYNC_URLenvironment variable).Tenants without client_base_url: Falls back to the global URL.
The routing logic ensures tenant isolation - each tenant only receives reports for their own messages.
Delivery Report Payload
The proxy sends delivery reports as HTTP POST requests:
POST /proxy_sync HTTP/1.1
Host: api.tenant.com
Content-Type: application/json
Authorization: Bearer acme-secret-token
{
"delivery_report": [
{
"tenant_id": "acme",
"id": "MSG-001",
"pk": "550e8400-e29b-41d4-a716-446655440000",
"sent_ts": 1705750800
},
{
"tenant_id": "acme",
"id": "MSG-002",
"pk": "550e8400-e29b-41d4-a716-446655440001",
"error_ts": 1705750850,
"error": "550 User not found"
}
]
}
Report fields:
tenant_id: Tenant identifierid: Client-provided message identifierpk: Internal UUID primary key (useful for correlation)
Event-specific fields (only relevant field is present):
sent_ts: Unix timestamp when message was successfully deliverederror_ts+error: Timestamp and description when delivery failed permanentlydeferred_ts+deferred_reason: Timestamp when message was deferred for retrybounce_ts+bounce_type+bounce_code+bounce_reason: Bounce notification detailspec_event+pec_ts+pec_details: PEC receipt information (pec_acceptance, pec_delivery, pec_error)
Expected Response
The tenant should respond with a JSON object containing at minimum an ok field:
{
"ok": true,
"queued": 15,
"next_sync_after": null
}
Response fields:
Field |
Type |
Required |
Description |
|---|---|---|---|
|
bool |
Yes |
|
|
int |
No |
Number of messages ready to send. When > 0, triggers immediate resync. |
|
int |
No |
Unix timestamp for “Do Not Disturb”. Proxy won’t call until this time. |
|
list[str] |
No |
Message IDs that failed to process |
|
list[str] |
No |
Message IDs not found in tenant’s database |
The proxy uses this response to:
Mark reports as acknowledged (
reported_tstimestamp)Schedule next sync based on
next_sync_after(or default 5-minute interval)Trigger immediate resync if
queued > 0Eventually clean up old reports based on retention policy
If the tenant returns an error (HTTP 4xx/5xx), the reports remain unacknowledged and will be retried on the next sync cycle.
Do Not Disturb Feature
Tenants with serverless databases (Neon, PlanetScale) may want to avoid cold-start
costs during idle hours. By returning next_sync_after with a future timestamp,
the tenant tells the proxy: “don’t call me until this time”.
{
"ok": true,
"queued": 0,
"next_sync_after": 1706500800
}
Important: If the tenant has pending events to report (sent, error, bounce, etc.), the proxy will still call them regardless of the DND setting. DND only affects the periodic “heartbeat” sync calls when there are no events.
Overriding DND: If a tenant needs immediate sync while in DND mode, they can
call POST /commands/run-now with their tenant token. This resets their DND
and triggers an immediate sync cycle for that tenant only.
Implementing the Tenant Endpoint
Your tenant server must expose an endpoint to receive delivery reports. Example using FastAPI:
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/proxy_sync")
async def receive_delivery_reports(request: Request):
"""
Receive delivery reports from the mail proxy.
This endpoint is called by the proxy to notify us about
message delivery status.
"""
data = await request.json()
reports = data.get("delivery_report", [])
sent = error = deferred = 0
for report in reports:
msg_id = report["id"]
if report.get("sent_ts"):
sent += 1
# Update local message: mark as delivered
await update_message_status(msg_id, "delivered")
elif report.get("error_ts"):
error += 1
# Update local message: mark as failed
error_msg = report.get("error", "Unknown error")
await update_message_status(msg_id, "failed", error=error_msg)
elif report.get("deferred_ts"):
deferred += 1
# Message will be retried by proxy
await update_message_status(msg_id, "deferred")
# Optionally: submit new pending messages to the proxy
pending_messages = await get_pending_outbox_messages()
if pending_messages:
await submit_messages_to_proxy(pending_messages)
return {"sent": sent, "error": error, "deferred": deferred}
Configuration Example
Complete tenant setup example:
Create tenant (using admin token):
# Use the global admin token (GMP_API_TOKEN) curl -X POST http://localhost:8000/tenant \ -H "Content-Type: application/json" \ -H "X-API-Token: $ADMIN_TOKEN" \ -d '{ "id": "acme", "name": "ACME Corp", "client_base_url": "https://api.acme.com", "client_sync_path": "/proxy_sync", "client_attachment_path": "/attachments", "client_auth": { "method": "bearer", "token": "acme-secret" } }'
Response (save the api_key!):
{ "ok": true, "api_key": "ACME_TENANT_TOKEN_save_this_securely" }
Create SMTP account for tenant (can use tenant token now):
# Use either admin token or the tenant's own token curl -X POST http://localhost:8000/account \ -H "Content-Type: application/json" \ -H "X-API-Token: $ACME_TENANT_TOKEN" \ -d '{ "id": "smtp-acme", "tenant_id": "acme", "host": "smtp.acme.com", "port": 587, "user": "mailer@acme.com", "password": "smtp-password", "use_tls": true }'
Submit messages (using tenant token):
curl -X POST http://localhost:8000/commands/add-messages \ -H "Content-Type: application/json" \ -H "X-API-Token: $ACME_TENANT_TOKEN" \ -d '{ "messages": [{ "id": "acme-msg-001", "tenant_id": "acme", "account_id": "smtp-acme", "from": "noreply@acme.com", "to": ["customer@example.com"], "subject": "Welcome!", "body": "Welcome to ACME." }] }'
Proxy delivers message and sends report to
https://api.acme.com/proxy_sync
Note
In production, each tenant application should use its own tenant token (received at creation time) rather than the global admin token. This ensures proper isolation - a tenant cannot access or modify other tenants’ data.
Batch Suspension
Tenants can suspend message sending at different granularity levels:
Full suspension: Stop all message sending for the tenant
Batch-specific suspension: Stop only messages belonging to a specific batch/campaign
This feature is useful when you need to halt a mailing campaign due to content errors, while allowing other messages (transactional emails, other campaigns) to continue normally.
Use case example:
A tenant sends a newsletter to 5000 recipients and discovers an error in the content:
Suspend the batch: Stop sending for that specific campaign
Re-submit corrected messages: Messages with the same IDs overwrite unsent ones
Activate the batch: Resume sending with corrected content
Meanwhile, transactional emails and other campaigns continue uninterrupted.
Batch Code in Messages
Messages can include an optional batch_code field to group them into campaigns:
{
"messages": [{
"id": "newsletter-2026-01-001",
"account_id": "smtp-acme",
"batch_code": "NL-2026-01",
"from": "newsletter@acme.com",
"to": ["customer@example.com"],
"subject": "January Newsletter",
"body": "..."
}]
}
Messages without batch_code are only affected by full tenant suspension (*).
Suspend/Activate API
POST /commands/suspendSuspend message sending for a tenant.
Query parameters:
tenant_id(str, required): The tenant to suspendbatch_code(str, optional): Specific batch to suspend. If omitted, suspends all sending.
Examples:
# Suspend all sending for tenant curl -X POST "http://localhost:8000/commands/suspend?tenant_id=acme" \ -H "X-API-Token: your-api-token" # Suspend only a specific batch curl -X POST "http://localhost:8000/commands/suspend?tenant_id=acme&batch_code=NL-2026-01" \ -H "X-API-Token: your-api-token"
Response:
{ "ok": true, "tenant_id": "acme", "batch_code": "NL-2026-01", "suspended_batches": ["NL-2026-01"], "pending_messages": 4500 }
POST /commands/activateResume message sending for a tenant.
Query parameters:
tenant_id(str, required): The tenant to activatebatch_code(str, optional): Specific batch to activate. If omitted, clears all suspensions.
Examples:
# Activate all sending for tenant (clear all suspensions) curl -X POST "http://localhost:8000/commands/activate?tenant_id=acme" \ -H "X-API-Token: your-api-token" # Activate only a specific batch curl -X POST "http://localhost:8000/commands/activate?tenant_id=acme&batch_code=NL-2026-01" \ -H "X-API-Token: your-api-token"
Response:
{ "ok": true, "tenant_id": "acme", "batch_code": "NL-2026-01", "suspended_batches": [], "pending_messages": 0 }
Suspension Behavior
The suspended_batches field in the tenant record stores the suspension state:
Empty/NULL: No suspension, all messages are processed normally
“*”: Full suspension, no messages are sent for this tenant
“NL-01,NL-02”: Comma-separated list of suspended batch codes
Processing rules:
If
suspended_batches = "*": All messages for the tenant are skippedIf
suspended_batchescontains the message’sbatch_code: That message is skippedMessages without
batch_codeare only affected by full suspension (*)
Important notes:
Suspending multiple batches accumulates them in the list
Activating a single batch removes only that batch from the list
Activating without
batch_codeclears all suspensionsYou cannot activate a single batch when full suspension (
*) is active; you must first activate all (clear the*)
Complete Workflow Example
Submit newsletter campaign:
curl -X POST http://localhost:8000/commands/add-messages \ -H "Content-Type: application/json" \ -H "X-API-Token: your-api-token" \ -d '{ "messages": [ {"id": "nl-001", "account_id": "smtp-acme", "batch_code": "NL-2026-01", ...}, {"id": "nl-002", "account_id": "smtp-acme", "batch_code": "NL-2026-01", ...}, ... ] }'
Discover error, suspend the batch:
curl -X POST "http://localhost:8000/commands/suspend?tenant_id=acme&batch_code=NL-2026-01" \ -H "X-API-Token: your-api-token" # Response shows 4500 pending messages in that batch
Re-submit corrected messages (same IDs overwrite unsent ones):
curl -X POST http://localhost:8000/commands/add-messages \ -H "Content-Type: application/json" \ -H "X-API-Token: your-api-token" \ -d '{ "messages": [ {"id": "nl-001", "account_id": "smtp-acme", "batch_code": "NL-2026-01", "body": "Corrected content..."}, {"id": "nl-002", "account_id": "smtp-acme", "batch_code": "NL-2026-01", "body": "Corrected content..."}, ... ] }'
Resume sending:
curl -X POST "http://localhost:8000/commands/activate?tenant_id=acme&batch_code=NL-2026-01" \ -H "X-API-Token: your-api-token" # Messages with corrected content are now being sent