Configure

Configuration reference

Every setting is an environment variable. Each also has a serve flag with the same name in kebab case (POSTKEEP_PUBLIC_URL is --public-url); run postkeep serve --help for the full list.

Core

Variable Default Purpose
POSTKEEP_PUBLIC_URL (required) Public origin, for example https://mail.example.com (no path)
POSTKEEP_MASTER_KEY 32-byte key, hex or base64 (postkeep generate-key)
POSTKEEP_MASTER_KEY_FILE File holding the key, instead of MASTER_KEY
POSTKEEP_PREVIOUS_MASTER_KEYS Comma-separated previous keys, only while rotating (see below)
POSTKEEP_PREVIOUS_MASTER_KEYS_FILE File with previous keys, one per line
POSTKEEP_BIND 0.0.0.0:8080 Listen address
POSTKEEP_SHUTDOWN_DRAIN 0 Seconds to keep serving after SIGINT or SIGTERM while /readyz answers 503, so load balancers stop sending requests first; a second signal ends the wait (see deployment)
POSTKEEP_DATABASE_URL sqlite://postkeep.db sqlite://... or postgres://.... For a remote PostgreSQL server, add ?sslmode=verify-full, and sslrootcert=/path/ca.pem when the provider uses a private CA
POSTKEEP_DATABASE_MIGRATE_ON_START true Apply pending migrations at start. With false the server refuses to start while any is pending; run postkeep migrate first
POSTKEEP_DATABASE_MIGRATE_URL (POSTKEEP_DATABASE_URL) Database URL postkeep migrate uses, for example a direct connection that bypasses a pooler
POSTKEEP_DATABASE_MAX_CONNECTIONS 10 Most database connections this server opens at once
POSTKEEP_DATABASE_ACQUIRE_TIMEOUT 30 Seconds a request waits for a free database connection before it fails
POSTKEEP_DATABASE_IDLE_TIMEOUT 600 Seconds an unused database connection stays open; 0 keeps it open
POSTKEEP_DATABASE_MAX_LIFETIME 1800 Seconds before a database connection is replaced; 0 never replaces it
POSTKEEP_ALLOWED_HOSTS Extra Host values accepted on /mcp besides the public host, comma-separated. *.example.com matches exactly one more label (a.example.com, not example.com or a.b.example.com). Other hosts get 403 before any token is checked
POSTKEEP_ALLOWED_ORIGINS Extra Origin values accepted on /mcp besides the public URL’s origin, comma-separated, such as https://app.example.com for an MCP client that runs in a web page there. A request with any other Origin gets 403 before any token is checked; requests without one pass. postkeep sends no CORS headers, so a page on another origin also needs a proxy that adds them
POSTKEEP_LOG info Log filter (tracing syntax)
POSTKEEP_LOG_FORMAT text text or json

POSTKEEP_LOG uses tracing filter syntax. Directives for postkeep’s own crates are kept as written, including span directives such as [serve_inner]=trace. These crates stay capped no matter what the filter says, because their trace or debug logs can include credentials or message bodies: async_imap and imap_proto at warn, rmcp at info, and lettre at warn. The cap is a separate filter on the event’s own target, so a directive with no target (trace, [serve_inner]=trace) cannot raise those crates. A directive that names one of them, such as async_imap::imap_stream=trace or rmcp::service=debug, is also dropped. lettre only logs SMTP commands when its tracing feature is compiled; this server does not enable that feature, and the cap still applies if it is.

Rotating the master key

The re-encryption sweep runs only when a process starts. With more than one replica, an old replica can still write ciphertext under the old key after a new replica has swept that row, and a replica that has not been rolled yet cannot read values a new replica has already re-encrypted. Those reads fail until that replica is rolled; the user can connect again if a request hits it in the meantime. Remove the previous key only after every replica has swept a second time.

  1. Generate a new key (postkeep generate-key).
  2. Set it as POSTKEEP_MASTER_KEY and keep the old key in POSTKEEP_PREVIOUS_MASTER_KEYS (or _FILE). Roll every replica onto that configuration and restart each one. Leave the old key in place. New values always use the new key. Replicas that are not rolled yet cannot read values a new replica has re-encrypted, so expect those reads to fail until the rollout finishes.
  3. At startup each replica decrypts stored secrets with the new key or, failing that, a previous key, and re-encrypts values that used a previous key. The log line re-encrypted stored secrets reports how many were updated and how many no key could read.
  4. After every replica is running that configuration, restart each one once more. The startup sweep runs again and re-encrypts anything an old replica wrote after the previous sweep. On each replica, check the re-encrypted stored secrets log line before continuing.
  5. Only then remove the previous keys and restart. A value that no configured key can read is left alone. The account owner signs in again to replace a password. Webhook settings that cannot be read stay on the account page: it explains that the encryption key changed and asks for the URL and auth header again. Saving replaces the unreadable row, and rotating the signing secret asks for that re-entry instead of failing the page.

Re-encryption is idempotent and safe while serving, so a restart halfway through simply continues. Do not drop POSTKEEP_PREVIOUS_MASTER_KEYS as soon as every replica has started once: a replica that was still on the old key can have written old ciphertext after that sweep.

Who can connect

Variable Default Purpose
POSTKEEP_ALLOWED_EMAILS (anyone) Comma-separated addresses allowed to use the server

POSTKEEP_ALLOWED_EMAILS is the main access control for a personal server. Set it to your own address. It is checked case-insensitively:

  • on the sign-in page, before anything is sent to the mail provider, so addresses outside the list never cause a login attempt;
  • on every MCP request and every token refresh, so removing an address from the list cuts off its existing tokens at once;
  • on the account page, before an IMAP watcher logs in, before a Graph subscription or a Gmail watch is created or renewed, before Graph or Gmail catch-up reads mail, and before a queued webhook event is sent, so removing an address stops its webhooks after a restart, including events already waiting.

With Microsoft or Google sign-in the address is only known once the provider sends the user back, so the check runs then: a refused address gets no account, and postkeep revokes the grant it was just given (Google; Microsoft has no revocation endpoint for apps).

Leave it empty only for a server that is meant to be open to anyone.

Microsoft and Google sign-in

Step-by-step setup, including the provider consoles, publisher and app verification, and webhooks: Outlook, Gmail.

Variable Default Purpose
POSTKEEP_MICROSOFT_CLIENT_ID Application (client) ID of your Microsoft Entra app registration
POSTKEEP_MICROSOFT_CLIENT_SECRET A client secret of that app registration
POSTKEEP_GOOGLE_CLIENT_ID OAuth client ID of your Google Cloud project (web application)
POSTKEEP_GOOGLE_CLIENT_SECRET Its client secret

Each provider is optional. Its button appears on the sign-in pages only when both its ID and its secret are set; setting just one of the pair is a startup error. Register these redirect URIs with the provider, built from POSTKEEP_PUBLIC_URL:

  • Microsoft: {POSTKEEP_PUBLIC_URL}/oauth/microsoft/callback
  • Google: {POSTKEEP_PUBLIC_URL}/oauth/google/callback

The server logs both at startup. Refresh tokens from the provider are stored encrypted with the master key, like IMAP passwords. If the provider rejects a refresh token (for example after a password change or a revoked consent), the account page asks the user to sign in again.

Gmail push

New-mail webhooks for Gmail accounts need Google Cloud Pub/Sub: Gmail publishes a notification to a topic in your Google Cloud project, and an authenticated push subscription delivers it to POST /push/google on your server. Outlook accounts need nothing extra: Microsoft Graph calls POST /push/microsoft directly.

Variable Default Purpose
POSTKEEP_GOOGLE_PUBSUB_TOPIC Full topic name, projects/<project>/topics/<topic>; enables Gmail push
POSTKEEP_GOOGLE_PUBSUB_SERVICE_ACCOUNT Email of the service account the push subscription authenticates as (required with the topic)
POSTKEEP_GOOGLE_PUBSUB_AUDIENCE {POSTKEEP_PUBLIC_URL}/push/google Audience set on the push subscription

Gmail push also needs Google sign-in (POSTKEEP_GOOGLE_CLIENT_ID and POSTKEEP_GOOGLE_CLIENT_SECRET); a topic without it is a startup error. postkeep accepts a push only with a Google-signed OIDC token whose audience and service account match these settings, and renews each account’s Gmail watch daily (a watch lapses after seven days).

OAuth tokens and clients

Variable Default Purpose
POSTKEEP_ACCESS_TOKEN_TTL 3600 Access token lifetime (seconds)
POSTKEEP_REFRESH_TOKEN_TTL 2592000 Refresh token lifetime (seconds); each refresh issues a new one
POSTKEEP_MAX_CLIENTS 10000 Maximum registered OAuth clients; registration answers 503 beyond it
POSTKEEP_REGISTRATION_LIMIT 20 Client registrations allowed per client IP in each window
POSTKEEP_REGISTRATION_WINDOW 3600 Length of that window (seconds)
POSTKEEP_UNUSED_CLIENT_TTL 86400 A client that never obtained a token is deleted this long after registering
POSTKEEP_IDLE_CLIENT_TTL 2592000 A client without live grants is deleted this long after it last obtained a token

Expired codes, tokens, pending sign-ins and abandoned client registrations are removed every 10 minutes.

Abuse limits

Variable Default Purpose
POSTKEEP_CLIENT_IP_HEADER (peer address) Header with the real client IP, set by your reverse proxy
POSTKEEP_RATE_LIMIT 120 Requests per minute per client IP on /register, /authorize, /token, /revoke, /account and /account/*, and /oauth/{provider}/start and /oauth/{provider}/callback
POSTKEEP_ACCOUNT_BACKOFF_MAX 60 Longest sign-in backoff for one address (seconds)
POSTKEEP_IP_BACKOFF_MAX 900 Longest sign-in backoff for one client IP (seconds)

POSTKEEP_RATE_LIMIT does not cover /, /mcp, /healthz, /readyz, /.well-known/*, static assets, or the provider push endpoints (/push/microsoft, /push/microsoft/lifecycle, /push/google). /healthz and /readyz are also outside the global concurrency cap, the body limit and the request timeout.

Behind a reverse proxy, every request arrives from the proxy’s address, so per-client limits would lump all users together. Set POSTKEEP_CLIENT_IP_HEADER to the header your proxy sets:

Proxy Header
Fly.io Fly-Client-IP
Cloudflare CF-Connecting-IP
Caddy, nginx, Traefik, most load balancers X-Forwarded-For (the last entry is used)

Only set it when the proxy always overwrites or appends to that header; otherwise clients can forge their address. IPv6 clients are grouped by /64.

Sign-in backoff

Failed sign-ins slow down instead of locking anyone out. After five failures, each further failure doubles the wait before the next attempt, starting at one second:

  • per client IP, up to 15 minutes, so someone guessing passwords is slowed down hard;
  • per address, up to only one minute, so someone guessing at your address can make you wait at most a minute, from anywhere.

A successful sign-in clears the address’s backoff but not the IP’s, so signing in to one’s own account does not reset the backoff for guessing at others. The limits are kept in memory per process.

Request limits

Variable Default Purpose
POSTKEEP_MAX_BODY_BYTES 1048576 Largest accepted request body (OAuth endpoints: 16 KiB)
POSTKEEP_REQUEST_TIMEOUT 120 Seconds a request may take, including reading its body, before 503
POSTKEEP_MAX_CONCURRENT_REQUESTS 256 Requests handled at once; more get 503 with Retry-After
POSTKEEP_ACCOUNT_CONCURRENCY 4 MCP requests handled at once per account; more wait up to 10 s, then 503
POSTKEEP_MAX_CONNECTIONS 1024 Open HTTP connections; further connections are closed at once

Request headers must arrive within 15 seconds of a connection becoming active, which stops slow-header (“slowloris”) attacks. /healthz and /readyz are exempt from the concurrency and timeout limits so a busy server is not restarted by its health check or taken out of rotation by its readiness check. Each mail operation also has its own timeout (POSTKEEP_MAIL_TIMEOUT, 30 seconds).

IMAP connection reuse

Variable Default Purpose
POSTKEEP_IMAP_POOL_IDLE 60 Seconds an idle IMAP session is kept for the next tool call of the same account; 0 turns reuse off

Tool calls reuse an idle IMAP session of the same account instead of connecting and logging in again. Sessions are keyed by account and by a fingerprint of the stored credentials, so they are never shared between accounts and a changed password never reuses an old session. A session goes back to the pool only after a clean result, must answer NOOP before reuse, and every operation selects its folder again. At most two idle sessions are kept per account (256 in total); they are closed after the idle time and at shutdown. Sending mail always uses a fresh SMTP connection.

New-mail webhooks

Variable Default Purpose
POSTKEEP_WEBHOOKS true Offer opt-in new-mail webhooks on the account page; false hides them and starts no watchers
POSTKEEP_WEBHOOK_MAX_WATCHERS 100 Accounts watched at once. An IMAP webhook keeps one IMAP connection open; Outlook and Gmail do not. A paused webhook still uses a slot: IMAP keeps that connection, and Gmail and Graph keep renewing their watches. Turning the webhook off frees the slot.
POSTKEEP_WEBHOOK_RETRY_INTERVAL 3600 Seconds between delivery attempts while a webhook is paused after repeated failures, and the longest wait before it is paused. From 1 to 86400.
POSTKEEP_WEBHOOK_OUTBOX_RETENTION 604800 Seconds events are kept when the account has no override (7 days). The cap counts delivered and undelivered events. From 3600 to 31622400, and at most POSTKEEP_WEBHOOK_OUTBOX_MAX_RETENTION.
POSTKEEP_WEBHOOK_OUTBOX_MAX_EVENTS 1000 Events kept per account, delivered or not, when the account has no override. From 1 to 100000, and at most POSTKEEP_WEBHOOK_OUTBOX_MAX_EVENTS_LIMIT.
POSTKEEP_WEBHOOK_OUTBOX_MAX_RETENTION 2592000 Longest retention an account may set (30 days). From 3600 to 31622400.
POSTKEEP_WEBHOOK_OUTBOX_MAX_EVENTS_LIMIT 10000 Most events one account may keep. From 1 to 100000. An event is about 1 KB of encrypted JSON, so 10000 events is about 10 MB per account, and 100 accounts at that ceiling is about 1 GB. The event cap is the disk bound: a busy inbox reaches it before the retention clock does.

Users turn webhooks on for their own account at /account. A watcher per enabled webhook waits for new Inbox mail: IMAP IDLE (renewed every 25 minutes, or a poll every minute if the server has no IDLE), a Graph subscription, or a Gmail watch. A paused webhook stays enabled, so it still uses one slot: IMAP keeps its connection, and Gmail (users.watch) and Graph (the subscription) keep renewing on their usual schedule. A pause does not add provider calls. Turning the webhook off stops that watcher, frees the slot, and stops the provider watch (Gmail users.stop, Graph subscription delete). It waits 3 seconds to batch a burst. A large backlog can be several events. Watchers need a process that keeps running: on platforms that stop idle instances, events are only sent while an instance is up (see deployment). The payload, signature and retry rules are in webhooks.

Mail providers

Users sign in with an app password at one of the providers this server offers. The sign-in page shows a provider picker when more than one is offered; “Detect from my address” picks the provider from the address’s domain (@icloud.com, @fastmail.com, @yahoo.com, …) and falls back to the default provider for other domains. An address at a provider’s own domain always uses that provider, even when the picker names another one.

Variable Default Meaning
POSTKEEP_IMAP_PROVIDERS icloud,fastmail,yahoo (none when POSTKEEP_IMAP_HOST is set) Comma-separated presets to offer: icloud, fastmail, yahoo
POSTKEEP_IMAP_HOST (unset) IMAP host of a custom mail server, offered alongside the presets
POSTKEEP_IMAP_NAME Custom mail server Name of the custom server on the sign-in page
POSTKEEP_IMAP_PORT 993 IMAP port of the custom server
POSTKEEP_IMAP_SECURITY tls tls or plaintext (local test servers only)
POSTKEEP_SMTP_HOST the IMAP host SMTP host of the custom server
POSTKEEP_SMTP_PORT 587 SMTP port of the custom server
POSTKEEP_SMTP_SECURITY starttls tls, starttls or plaintext (local test servers only)
POSTKEEP_MAIL_TIMEOUT 30 Timeout for each mail server operation (seconds)
Preset IMAP SMTP App passwords
icloud imap.mail.me.com:993 (TLS) smtp.mail.me.com:587 (STARTTLS) Apple
fastmail imap.fastmail.com:993 (TLS) smtp.fastmail.com:465 (TLS) Fastmail
yahoo imap.mail.yahoo.com:993 (TLS) smtp.mail.yahoo.com:465 (TLS) Yahoo

Users can’t type in a server address: only the operator can add a custom server, so the sign-in form can’t be used to make this server connect to arbitrary hosts.

Each account records the provider it signed in with. Accounts created by versions before 0.2 recorded none; at startup they are assigned the default provider (the custom server if POSTKEEP_IMAP_HOST is set, otherwise iCloud Mail), which is the server they were already using. Removing a provider from POSTKEEP_IMAP_PROVIDERS stops its accounts from working until it is offered again; their data is kept.

Upgrading from 0.1 with the default settings changes nothing for existing iCloud accounts. To keep offering only iCloud Mail, set POSTKEEP_IMAP_PROVIDERS=icloud. Before 0.2, setting POSTKEEP_IMAP_HOST replaced iCloud; it still does unless POSTKEEP_IMAP_PROVIDERS is also set. The tests use it to point the server at GreenMail.