mcp-mail runs in two shapes from the same code:
accounts.json, one /mcp endpoint. Nothing on
this page is active unless you turn it on, and behavior is identical to claude-mail-mcp v0.2.x
(enforced by the compatibility gate).All extension points below are generic: any self-hoster or product can use them.
Every MCP endpoint serves both protocol eras on the same URL:
| Client | How it’s served |
|---|---|
2026-07-28 (stateless, per-request _meta, server/discover) |
SDK v2 strict handler: header validation (-32020), version negotiation (-32022) |
2024-11-05 to 2025-11-25 (initialize handshake, e.g. existing Claude.ai connectors) |
Stateless, plain JSON responses, exactly as v0.2.x |
| Variable | Default | Meaning |
|---|---|---|
MULTI_TENANT |
false |
Serve POST /t/<tenantId>/mcp |
DATA_DIR |
/var/lib/mcp-mail |
Tenants, encrypted secrets, audit logs |
TENANT_BASE_URL |
PUBLIC_URL |
Public origin used in endpoint URLs, e.g. https://connect.example.com |
CONFIG_PROVIDER |
store |
store (admin API manages tenants) or http (an internal service owns tenant config) |
CONFIG_PROVIDER_URL, CONFIG_PROVIDER_TOKEN[_FILE] |
For http: GET <url>/tenants/<id> with Authorization: Bearer <token> |
|
CONFIG_CACHE_TTL_MS |
30000 |
Cache TTL for http config |
SECRETS_KEY_PROVIDER |
local |
local (key file) or oci (OCI Vault) |
SECRETS_KEY_FILE |
$DATA_DIR/keys/kek |
32-byte key-encryption key, mode 0600 |
ADMIN_API |
false |
Serve /admin/v1 (requires MULTI_TENANT=true) |
ADMIN_HOST / ADMIN_PORT |
127.0.0.1 / 3330 |
Must be loopback or a private address; public binds are refused |
ADMIN_TOKEN[_FILE] |
Service token, at least 32 characters | |
SECRETS_ENCRYPTION |
unset | Single-tenant only: local decrypts enc:v1: passwords in accounts.json |
A tenant record holds limits, mailboxes (hosts, ports, users, display names) and agent clients. It never holds secrets: mailbox passwords live in the secret store and client tokens are stored only as SHA-256 hashes.
Isolation.
401.Limits (pushed by the product, enforced by the engine): maxMailboxes, maxClients,
toolCallsPerDay, allowSend, maxRecipientsPerSend, auditRetentionDays. A suspended tenant’s
agents get “This account is suspended” on every tool call.
Client policy. Each client has a preset that grants capabilities; tools a client may not use
are hidden from tools/list and rejected if called anyway. Sending additionally needs the
tenant’s allowSend and the client’s own sendEnabled toggle, which is off by default.
| Preset | Capabilities |
|---|---|
read-only |
read, calendar-read |
read-draft |
+ draft |
organize |
+ organize (mark read, move) |
full-no-delete |
+ send, calendar-write |
full |
+ destructive (delete) |
Envelope encryption: each tenant has a random 256-bit data key; secrets are sealed with AES-256-GCM under it (bound to tenant and secret name), and the data key is stored only wrapped by a key-encryption key (KEK) from a key provider. Secrets can be set or replaced through the admin API but never read back through any API, log, error or tool output.
Key providers:
local: a KEK file. Create it with mcp-mail secrets init-key --key-file <path>. Keep it
on a different volume from DATA_DIR, readable only by the engine user.oci: OCI Vault master key via the crypto endpoint’s encrypt/decrypt with API-key
request signing. Set OCI_KMS_CRYPTO_ENDPOINT, OCI_KMS_KEY_ID, OCI_TENANCY_OCID,
OCI_USER_OCID, OCI_KEY_FINGERPRINT, OCI_PRIVATE_KEY_FILE. The signing is unit-tested
against OCI’s documented scheme; validate against your Vault before production use.Other KMSs implement the two-method KeyProvider interface in src/platform/secrets.ts.
Single-tenant encryption at rest (opt-in): mcp-mail secrets encrypt rewrites the password
fields of accounts.json as enc:v1:… values after writing a timestamped backup; then set
SECRETS_ENCRYPTION=local and SECRETS_KEY_FILE. mcp-mail secrets decrypt reverses it.
/admin/v1, specified in openapi/admin-v1.json. Every response in
the integration suite is validated against the spec, and CI fails if any operation is untested.
Breaking changes ship as /admin/v2, with v1 kept for at least one major version.
| Area | Operations |
|---|---|
| Tenants | create, get, suspend, unsuspend, delete (purge), set limits |
| Mailboxes | list, add, update (incl. password replacement), remove, test connectivity without saving |
| Clients | list, add (returns endpoint + token once), update, revoke, rotate |
| Activity | audit log (paginated, filter by client), usage per day and client |
| Meta | health, version |
A minimal product-side client is in examples/admin-client.mjs.
One JSONL line per tool call per tenant ($DATA_DIR/audit/<tenant>.jsonl, rotated at 5 MB):
timestamp, client, tool, account, mailbox, UID, recipient count for sends, result
(ok/denied/error), denial reason and duration. Message bodies, subjects, attachments,
passwords and tokens are never logged.
/versionPublic on every deployment: package version, commit SHA, build time and (for CI-built images) the container image digest, so anyone can match a running deployment to a public commit.
Clients that can only connect through a browser sign-in (Claude.ai, ChatGPT) use OAuth. With
TENANT_OAUTH=true each tenant gets its own issuer at <TENANT_BASE_URL>/t/<tenant>/. The
engine has no user accounts, so the login step is handed to the product:
/t/<tenant>/mcp, registers (DCR) and
opens /t/<tenant>/authorize.OAUTH_DELEGATE_URL with txn (signed, 10 minutes),
tenant, client_name, redirect_host and return_to.POST /tenants/<id>/clients), and redirects to
return_to?txn=<txn>&client=<client id>&sig=<hex HMAC-SHA256(OAUTH_DELEGATE_SECRET, "<txn>.<client id>")>.
To refuse: error=access_denied&sig=<HMAC of "<txn>.denied">.| Variable | Meaning |
|---|---|
TENANT_OAUTH |
true to enable (requires MULTI_TENANT) |
TENANT_TOKEN_SECRET[_FILE] |
Master key, 64+ hex characters; each tenant’s token key is derived from it |
OAUTH_DELEGATE_URL |
The product’s login/consent page |
OAUTH_DELEGATE_SECRET[_FILE] |
Shared HMAC secret, 32+ characters |
TENANT_ALLOWED_REDIRECT_URIS |
Optional; defaults to Claude.ai’s and ChatGPT’s callbacks |
Discovery follows RFC 8414 and RFC 9728 path insertion
(/.well-known/oauth-authorization-server/t/<tenant>,
/.well-known/oauth-protected-resource/t/<tenant>/mcp) and is also served under
/t/<tenant>/.well-known/…. Each tenant has its own token key, state file and resource URL, so a
token for one tenant never works on another. Deleting a tenant removes its OAuth state.
Single-tenant OAuth and clients.json policies are described in CLIENTS.md.