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.
- Generate a new key (
postkeep generate-key). - Set it as
POSTKEEP_MASTER_KEYand keep the old key inPOSTKEEP_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. - 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 secretsreports how many were updated and how many no key could read. - 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 secretslog line before continuing. - 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.