Environment Variables

Reference for every environment variable Owlat reads across the Convex backend, web app, MTA, IMAP server, and mail-sync worker.

Owlat uses environment variables to configure providers, security scanning, authentication, and feature-flag prerequisites. Convex backend variables (everything read through apps/api/convex/lib/env.ts) are set via the Convex dashboard; the web app, MTA, IMAP server, and mail-sync worker read their own process env (in self-host, all of it comes from a single .env file consumed by docker-compose.yml).

Self-hosters start here

Most readers of this page are self-hosting Owlat. The Self-hosted configuration section below is the canonical reference for what to put in your .env. Variables only matter for the features you've enabled — the setup wizard collects only the ones your enabled flags require, and you can set any single value afterwards with owlat-setup env <KEY> <VALUE>. To list exactly which variables your current flag state needs (and which are still unset), run owlat-setup env --show.

Feature-flag-driven env vars

Each entry in packages/shared/src/featureFlags.ts can declare requiredEnvVars. The setup wizard collects only the variables that match your enabled flags — you don't have to set everything. See the Feature flags developer reference for the full registry.

Self-hosted configuration

Self-hosters put everything in a single .env file at the repo root. Start from the template:

cp .env.selfhost.example .env

The template and docker-compose.yml reference these variables:

Required secrets

VariableDescriptionGenerate with
INSTANCE_SECRETConvex backend root secret. HKDF root for at-rest credential encryption (external-mailbox passwords, lib/credentialCrypto.ts) and the shared secret that gates the dev/seed endpoints (/seed/admin, /seed/demo, /dev/reset).openssl rand -hex 32
INSTANCE_SECRET_PREVIOUSOptional. The previous INSTANCE_SECRET, kept only during a secret rotation. When set, at-rest secrets still sealed under the old root keep decrypting while e2ee/lifecycleNode.ts:reSealVault re-seals every row under the new INSTANCE_SECRET. Unset it once the re-seal migration has finished.(paste the old INSTANCE_SECRET)
CONVEX_ADMIN_KEYAdmin key for deploying functions. Generate after first boot via docker compose exec convex ./generate_admin_key.sh.(see above)
MTA_API_KEYAPI key the tenant app uses to enqueue sends in the MTA.openssl rand -base64 32
MTA_WEBHOOK_SECRETHMAC secret for MTA → Convex delivery-event callbacks.openssl rand -base64 32

Public URLs

VariableDescriptionDefault
NUXT_PUBLIC_CONVEX_URLBrowser-facing Convex URL. Must match what you expose via reverse proxy.http://localhost:3210
NUXT_PUBLIC_CONVEX_SITE_URLBrowser-facing Convex HTTP-actions URL.http://localhost:3211
NUXT_PUBLIC_CONVEX_DASHBOARD_URLOptional. Explicit URL for the Convex admin dashboard (port 6791) linked from the self-host onboarding banner. Leave empty when you reach the dashboard over an SSH tunnel — the banner then guesses a default the operator can override in-app.(empty)
NUXT_PUBLIC_SITE_URLPublic URL of the web app.http://localhost:3000

MTA (mail sending)

VariableDescriptionDefault
EHLO_HOSTNAMEMust match your server's rDNS PTR record. The template ships mail.example.com.mail.localhost (compose fallback)
EHLO_HOSTNAMESOptional JSON map from each sending IP to its PTR-matched EHLO hostname.—
MTA_GENERIC_PTR_SUFFIXESExtra comma-separated provider-default PTR suffixes to warn about.—
MTA_ALLOW_UNVERIFIED_FCRDNSLab-only bypass for hard FCrDNS quarantine. Failed status remains visible.false
MTA_IPV6_ENABLEDExplicit outbound-IPv6 opt-in. Keep false until the routed address, IPv4 identity, PTR/AAAA, and exact return-path ip6: mechanism verify.false
RETURN_PATH_DOMAINDomain used for VERP bounce return-path addresses. The template ships bounces.example.com.bounces.localhost (compose fallback)
BOUNCE_VERP_KEYRequired secret that signs VERP return-path tokens (BATV/HMAC). Exact DSN/FBL attribution requires a token the MTA signed, preventing forged feedback from poisoning suppression. Setup generates it automatically; manual installs can use openssl rand -base64 32. Keep it stable. Example files leave it empty so a copied, unconfigured environment fails closed.required (at least 32 bytes; placeholders rejected)
IP_POOLS_TRANSACTIONALComma-separated IP(s) for transactional sends.127.0.0.1
IP_POOLS_CAMPAIGNComma-separated IP(s) for marketing campaigns.127.0.0.1
DKIM_KEYSJSON: {"example.com":{"selector":"s1","privateKey":"..."}}.{}
WORKER_CONCURRENCYMax concurrent SMTP deliveries.50
FBL_DEDUP_PROTOCOLRequired versioned complaint reservation protocol. Set owned-v2; there is no automatic legacy/shadow mode. See FBL deduplication upgrade.required
FBL_DEDUP_CUTOVER_ACKRequired operational acknowledgement: fresh-install only for new Redis/install state, or quiesced-v1-intake after every legacy FBL worker has been stopped and drained.required
MTA_LOG_LEVELLog verbosity (debug/info/warn/error). This is a compose host-env alias — docker-compose.yml maps it to LOG_LEVEL, which is what the MTA process actually reads.info
DANE_MODEDANE (RFC 7672) posture at send time — off (default), report, or enforce. off is byte-identical to legacy behaviour (no TLSA lookups). report looks up each recipient MX's DNSSEC-validated TLSA RRset (_25._tcp.<mx>), evaluates the certificate against it and emits the TLS-RPT result (success/validation-failure under the tlsa policy), but never requires TLS or bounces. enforce additionally authenticates the certificate against the RRset (supersedes MTA-STS; a non-match defers the message, never cleartext). report and enforce need DANE_RESOLVER_URL. Read from the MTA's own process env (apps/mta/src/config.ts), not the Convex dashboard (apps/mta/src/smtp/daneVerify.ts). An unrecognised value fails the boot.off
DANE_RESOLVER_URLDoH (DNS-over-HTTPS, RFC 8484 JSON) resolver URL for DNSSEC-aware MX/address discovery and DANE TLSA lookups. Needed for report/enforce to run — when unset, DANE is inert in every mode (no lookups, historic path). When set it must be https:// (plain http:// is accepted only for a loopback resolver, since the trusted DNSSEC AD bit must travel over a channel an on-path attacker cannot forge); the MTA validates the URL at boot in every mode. Must be a validating resolver: the AD bit is trusted, DANE is not applied through an insecure address chain, and a lookup that cannot be completed (SERVFAIL/timeout) defers delivery in enforce (and is a no-op in report) rather than downgrading. Running a local validating resolver (e.g. Unbound on 127.0.0.1) is the recommended production configuration.unset

The Default column reflects the docker-compose.yml fallback that applies when a variable is unset. The values shipped in .env.selfhost.example differ for the two domain vars above (mail.example.com / bounces.example.com), since that is what you actually copy and edit.

Deployment mode (web app & compose)

These are not Convex backend vars — they are not read through lib/env.ts. OWLAT_DEPLOYMENT_MODE is consumed by the web app (apps/web/nuxt.config.ts, exposed as runtimeConfig.public.deploymentMode). You still set OWLAT_DEPLOYMENT_MODE in .env; docker-compose.yml passes it to the web container as NUXT_PUBLIC_DEPLOYMENT_MODE, because Nitro overlays runtime config only from NUXT_PUBLIC_* names — a value passed under any other name is ignored and the app keeps whatever its image was built with. OWLAT_HOSTED_MODE is a .env flag written by the setup CLI based on the chosen deployment mode; it is inert in this OSS repo (no service reads it), so self-hosters leave it false.

VariableDescriptionDefault
OWLAT_DEPLOYMENT_MODEselfhost or hosted. Controls first-run onboarding UX and hides hosted-only UI (billing, upgrade prompts) in the web app.selfhost
OWLAT_HOSTED_MODE.env flag written by the setup CLI from the chosen deployment mode. Inert in this OSS repo — no service reads it. Self-hosters leave this false.false
OWLAT_VERSIONVersion stamped into the images at build time for in-app update checks. Set automatically by CI; the web image also exports it as NUXT_PUBLIC_OWLAT_VERSION so the browser sees it. Do not set it in compose — it describes the image, not the deployment.dev

Port overrides (optional)

All ports default to sensible values. Override only if the defaults conflict:

# CONVEX_PORT=3210
# CONVEX_SITE_PORT=3211
# DASHBOARD_PORT=6791
# WEB_PORT=3000
# MTA_HTTP_PORT=3100
# MTA_SMTP_PORT=25
# IMAP_PORT=993

Optional: PostHog analytics

# NUXT_PUBLIC_POSTHOG_API_KEY=
# NUXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com

Optional: LLM provider (ai flag)

Only needed if any AI flag is on. See Providers → LLM provider for full details.

# LLM_PROVIDER=openai          # openai | openrouter | ollama  (Claude works via openai + an OpenAI-compatible LLM_BASE_URL)
# LLM_API_KEY=sk-...
# LLM_MODEL_FAST=gpt-4o-mini   # used for classify/extract/guard/summarize
# LLM_MODEL_CAPABLE=gpt-4o     # used for draft tasks
# LLM_MODEL=                   # single-model fallback for both tiers when *_FAST/*_CAPABLE are unset
# LLM_EMBEDDING_MODEL=text-embedding-3-small
# LLM_BASE_URL=                # override for ollama / vLLM / LM Studio
# --- Local-by-default embedding plane (optional) ---
# LOCAL_EMBEDDING_BASE_URL=http://ollama:11434/v1   # an OpenAI-compatible /embeddings sidecar
# LOCAL_EMBEDDING_MODEL=nomic-embed-text            # NOTE: its width must match the vector index

AI spend budget (per-org dollar ceiling)

A pre-call gate (convex/analytics/spendBudget.ts) that caps LLM dollar spend per org, on top of the per-call-count rate limits. Spend is aggregated from analytics/llmUsage + lib/llm/pricing. When a ceiling is hit the gate fails closed: the autonomous agent degrades to draft-only (mail is never dropped — it routes to human review) and advisory, user-triggered AI is paused once remaining headroom drops within the reserve. All four are unset/0 by default, which makes the gate a no-op.

VariableMeaningDefault
AI_SPEND_DAILY_BUDGET_USDDaily USD ceiling for all LLM spend in an org. Unset or 0 ⇒ no daily limit (gate is a no-op for the day).unset (no limit)
AI_SPEND_MONTHLY_BUDGET_USDMonthly USD ceiling for all LLM spend in an org. Unset or 0 ⇒ no monthly limit.unset (no limit)
AI_SPEND_WARN_FRACTIONFraction of a ceiling (0–1] at which to start warning in the admin dashboard before the hard block.0.8
AI_SPEND_ADVISORY_RESERVE_FRACTIONFraction of a ceiling [0–1) reserved for autonomous drafting; advisory (user-triggered) AI is paused once remaining headroom drops within this reserve, while the autonomous path keeps drafting.0.2

Optional: Personal mail / IMAP (postbox flag)

The IMAP server (apps/imap) reads these on startup. Only relevant when the postbox flag is on.

# IMAP_PORT=993
# IMAP_LISTEN=0.0.0.0
# IMAP_GREETING_HOST=mail.example.com
# IMAP_TLS_CERT_FILE=/opt/owlat/certs/default.crt
# IMAP_TLS_KEY_FILE=/opt/owlat/certs/default.key
# TLS_CERT_DIR=/opt/owlat/certs        # fallback if IMAP_TLS_* not set
# REDIS_URL=                           # see note below

The IMAP server authenticates to Convex with CONVEX_ADMIN_KEY (it sets admin auth on a ConvexHttpClient and calls the mail/appPasswords:verify function directly — apps/imap/src/convex.ts). It does not read MTA_WEBHOOK_SECRET. Its log level comes from LOG_LEVEL (the VPS compose maps IMAP_LOG_LEVEL onto it).

IMAP rate-limiting fails open without Redis

REDIS_URL is not set on the shipped imap service in infra/templates/docker-compose.vps.yml. When it is unset the IMAP auth rate limiter is disabled and fails open (apps/imap/src/index.ts). Set REDIS_URL explicitly if you need login throttling on the IMAP listener.

Optional: Mail sync — external mailboxes (mail.external flag)

When the mail.external flag is on, users can connect their own IMAP/SMTP accounts. Convex hands those off to a separate mail-sync worker (infra/templates/docker-compose.vps.yml, the external-mail compose profile). Convex reaches the worker the same way it reaches the MTA — via deployment env vars set in the Convex dashboard:

VariableDescription
MAIL_SYNC_API_URLBase URL of the mail-sync worker's internal /send + /test API (e.g. http://mail-sync:3200). When unset, every recipient of an external-account send is transitioned to a delivery failure (errorCode EXTERNAL_NOT_CONFIGURED) rather than being dispatched (apps/api/convex/mail/outbound.ts).
MAIL_SYNC_API_KEYShared API key Convex uses to authenticate to the mail-sync worker.

Optional: Code worker + least-privilege proxies (inbox.codeTasks flag)

When the inbox.codeTasks flag is on, a code-worker sidecar claims queued code tasks and opens pull requests. It runs UNTRUSTED, inbound-email-driven code, so the shipped Compose stacks put it behind two sidecars rather than handing it the deployment admin key:

  • convex-fn-proxy holds the real CONVEX_ADMIN_KEY and forwards ONLY the thirteen Convex functions the worker legitimately calls (codeWorkTasks:* and plugins/workerTasks:*); any other function path is rejected 403. The worker authenticates to it with CODE_WORKER_PROXY_TOKEN, which the proxy constant-time-compares, strips, and replaces with the admin key.
  • code-worker-egress (tinyproxy) is a forward-proxy the worker's HTTP_PROXY/HTTPS_PROXY point at; it permits only the Convex proxy, the git remote and the LLM endpoint and denies all other outbound (infra/code-worker/tinyproxy.conf + tinyproxy.filter).

These variables live in the Compose .env (they are read by the worker and the proxy containers — not by the Convex backend, so they are not in lib/env.ts):

VariableDescriptionDefault
CODE_WORKER_PROXY_TOKENShared secret the code-worker presents to convex-fn-proxy and the proxy validates. In the shipped stack it is the worker's ONLY Convex credential (passed through as CODE_WORKER_CONVEX_KEY); the worker holds no admin key. Generate with openssl rand -base64 32. With it unset the worker cannot claim tasks.unset (required to run the worker in the proxied stack)
CODE_WORKER_CONVEX_KEYThe Convex key the worker authenticates with (apps/code-worker/src/convexClient.ts), presented via setAdminAuth as Authorization: Convex <key>. In the proxied stack Compose sets it to CODE_WORKER_PROXY_TOKEN. If empty/whitespace it falls back to CONVEX_ADMIN_KEY — the escape hatch for a direct, non-proxied deployment.falls back to CONVEX_ADMIN_KEY
CONVEX_ADMIN_KEYHeld by convex-fn-proxy (injected on forwarded requests). In a non-proxied deployment it is the worker's fallback credential.(see Required secrets)
Migration: enabling code tasks now needs CODE_WORKER_PROXY_TOKEN

Both shipped Compose stacks now route the worker through convex-fn-proxy and no longer pass it CONVEX_ADMIN_KEY. If you already run the inbox-codetasks profile, add CODE_WORKER_PROXY_TOKEN to your .env (and, for the VPS template, copy infra/code-worker/tinyproxy.conf + tinyproxy.filter next to your compose file, adding your LLM host to the filter). To keep the old direct behaviour instead, point CONVEX_URL back at http://convex:3210 and pass CONVEX_ADMIN_KEY to the worker.

Optional: Inbound channel webhooks (SMS / WhatsApp / generic)

These secrets authenticate inbound webhook callbacks from third-party channel providers. They are read by the webhook adapters in apps/api/convex/webhooks/adapters/. (Inbound only — outbound SMS/WhatsApp send is not wired end-to-end; see Communication Channels.)

VariableDescription
TWILIO_AUTH_TOKENValidates X-Twilio-Signature on inbound SMS/voice webhooks (webhooks/adapters/twilio.ts). The endpoint refuses requests if it is missing.
META_APP_SECRETValidates the X-Hub-Signature-256 HMAC on inbound WhatsApp/Messenger webhooks (webhooks/adapters/meta.ts).
META_VERIFY_TOKENMust match hub.verify_token during Meta's webhook subscription challenge (webhooks/adapters/meta.ts).
GENERIC_WEBHOOK_SECRETCompared against the x-webhook-secret header on the generic inbound channel adapter (webhooks/adapters/generic.ts).

Optional: Other Convex backend vars

A few more variables are read through lib/env.ts and set in the Convex dashboard. None are required for a basic self-host:

VariableDescriptionDefault
OWLAT_DEV_MODEGates the dev-only endpoints (/seed/demo, /dev/reset, forceVerifyDomain). Fail-closed: leaving it unset on a production deployment refuses those endpoints (devShortcuts/_guard.ts). Demo content for a real install does not need it — use owlat sample-data install (/sample-data/*).unset (off)
REQUIRE_EMAIL_VERIFICATIONWhen enabled, a signup or invitation acceptance must confirm a verification link before it can sign in (BetterAuth requireEmailVerification + sendOnSignUp and the org plugin's requireEmailVerificationOnInvitation, auth/auth.ts). NEW deployments default it ON — the self-host env templates and the setup CLI write it for a fresh install (the built-in MTA is present to deliver the link). The web setup wizard does not emit the flag itself, but when it is set it refuses to finish with verification on and no delivery provider configured. It requires a working mail provider; the verification mail is sent through the system transport, which fails closed when none is configured. Existing installs are deliberately left unset (fail-open) so users who signed up before verification existed are not locked out — do not turn it on for an existing install without first backfilling emailVerified for legacy users. Recovery for a stranded user: an owner/admin can mark a member's email verified out-of-band or re-send the verification link (auth/emailVerificationAdmin.ts).unset (off); true on new installs
ALLOWED_ORIGINSComma-separated CORS allowlist for the Convex HTTP API (lib/cors.ts). In production the loopback fallback is dropped: when unset it falls back to SITE_URL / ADMIN_SITE_URL, and fails closed if none are set (dev keeps http://localhost:3000).http://localhost:3000 (dev only)
ADMIN_SITE_URLOptional alternate site URL trusted for auth redirects (auth/auth.ts).—
MTA_SPF_INCLUDESPF include: host emitted in the generated DNS records. When unset the SPF record is omitted (DKIM+DMARC alignment still works) (domains/providers/mta/index.ts).—
SPF_QUALIFIERTrailing all mechanism qualifier for the generated SPF records: ~all (soft-fail, the safe default while your authorized IP set is still settling), -all (hard-fail, once the IP set is stable), ?all, or +all. Invalid/unset falls back to ~all (RFC 7208 §5.1) (domains/spf.ts).~all
MTA_RETURN_PATH_DOMAINVERP bounce return-path domain (matches the MTA's RETURN_PATH_DOMAIN, e.g. bounces.example.com). When set together with MTA_IP_POOLS, the generated DNS bundle includes a return-path SPF TXT record authorizing the pool IPs so the bounce envelope passes SPF (domains/providers/mta/index.ts).—
MTA_BOUNCE_VERP_KEYThe MTA's VERP signing key (the same value as the MTA's BOUNCE_VERP_KEY). Set it together with MTA_RETURN_PATH_DOMAIN to let RELAY sends carry Owlat's own VERP envelope sender, so bounces a third-party relay generates come back to the built-in MTA's bounce server and the relay arm produces bounce data comparable with direct-MX delivery. Additive: when unset (or when the relay is not proven to honour a custom return path) relay sends keep the composer's envelope sender and that transport is simply graded degraded-measurement — nothing is blocked (lib/sendProviders/smtp/index.ts).—
MTA_RETURN_PATH_RELAY_SPFSPF mechanism terms (space- or comma-separated; include:/a:/mx:/ip4:/ip6: only) that authorize your RELAY to send with a bounce+…@<return-path host> envelope sender. Emitted into the generated return-path SPF record, and required — published and verified — before a relay send carries our VERP address: the record otherwise authorizes the MTA pool only, so stamping it on a relay send would fail SPF on the very arm being measured and remove DMARC's SPF leg. At most 5 terms are accepted (RFC 7208 §4.6.4 budgets ten DNS lookups per evaluation and the record already spends some) — the excess is dropped, never rejected. These terms add to the pool authorization rather than replacing it, so they are emitted only when MTA_IP_POOLS is also set; on its own the value publishes nothing. Unset ⇒ no relay stamp, degraded measurement, nothing blocked (domains/spf.ts).—
MTA_IP_POOLSComma-separated list of canonical IP-pool addresses used to generate return-path SPF (ip4: for IPv4 and ip6: for IPv6). Invalid tokens are rejected.—
SNDS_DATA_FEED_URLSComma- or whitespace-separated Microsoft SNDS Automated Data Access feed URLs (https only), one per registered IP range. Polled every six hours into per-IP, per-day complaint bands, filter results and spam-trap hits (delivery/sndsPoll.ts). Each URL is a bearer capability to your SNDS data — keep it secret. Additive-only: unset is a supported configuration, the Microsoft ramp then leans on the outcomes of its own sends with a doubled dwell time and a one-phase-lower ceiling, and nothing errors or nags. When MTA_IP_POOLS is set it acts as an allowlist, so feed rows for addresses you do not own are dropped.—
OUTBOUND_DKIM_DOMAINThe DKIM d= domain the ACTIVE transport signs with, when it isn't the per-message From-domain. The built-in MTA signs per-From-domain (leave unset); set this to your relay's signing domain (e.g. sendgrid.net) so the outbound DMARC-alignment guard can warn when a relay's signature won't align with your sending domains (lib/outboundAlignment.ts).—
MTA_DMARC_RUADMARC aggregate-report (rua) reporting URI emitted in the generated _dmarc record, e.g. mailto:dmarc-reports@yourdomain.com. When unset the record carries no rua= tag — Owlat does not provision a per-customer dmarc@<domain> mailbox, so reports would otherwise go unread (domains/dmarc.ts).—
MTA_TLSRPT_RUASMTP TLS Reporting (rua) destination emitted in the generated _smtp._tls TXT record (v=TLSRPTv1; rua=…, RFC 8460 §3), e.g. mailto:tls-reports@yourdomain.com or https://yourdomain.com/tlsrpt. Lets receivers report TLS-negotiation failures delivering mail to your domain. When unset the _smtp._tls record is omitted (domains/tlsRpt.ts).—
MTA_BIMI_LOGO_URLHTTPS URL of your SVG Tiny PS brand logo, emitted as the l= tag of the BIMI record the domain wizard OFFERS once a sending domain's DMARC is at p=quarantine or stricter (domains/bimi.ts). Unset ⇒ the wizard explains BIMI and the VMC requirement and generates no record; it is never a blocker, a warning or a setup nag. A value that is not a plain https:// URL (or that carries whitespace or ;) is rejected and named on screen rather than published.—
MTA_BIMI_VMC_URLHTTPS URL of your Verified Mark Certificate PEM, emitted as the BIMI record's a= tag. Gmail and Apple Mail display a BIMI logo only with a VMC; other receivers show it from the record alone. Same validation and same optionality as MTA_BIMI_LOGO_URL.—
MTA_BIMI_SELECTORSelector label for the BIMI record host (<selector>._bimi.<domain>). A value that is not a single DNS label falls back to the default.default
OUTBOUND_TLS_MODEOutbound TLS posture for the built-in MTA's direct-MX delivery: opportunistic (encrypt when offered, never bounce on missing/invalid TLS — the default, byte-identical to legacy behaviour), require (mandate the STARTTLS upgrade), or require-verified (mandate TLS and a valid certificate — can bounce mail to receivers with broken TLS). Written by the Delivery transport editor and surfaced read-only to it via delivery/status.ts:getStatus; the MTA reads it from its own config (apps/mta/src/config.ts).opportunistic
RATE_LIMIT_TRUSTED_PROXYWhich forwarded header to trust for per-IP rate limiting on public endpoints: cloudflare (CF-Connecting-IP), xforwarded / xforwarded:<hops> (X-Forwarded-For), or xrealip (X-Real-IP). Security-sensitive — leave unset unless the Convex backend sits behind a trusted proxy. When unset, forwarded headers are NOT trusted and all public callers share one rate-limit bucket, so a spoofed header can't multiply buckets (publicRateLimit.getClientIp). It also selects the client-IP source for the BetterAuth login/reset limiter (auth/auth.ts); unset there ⇒ IP tracking off (one shared bucket).unset (headers not trusted)
RATE_LIMIT_PROXY_SECRETShared secret a trusted reverse proxy INJECTS (in the X-Owlat-Proxy-Secret header, stripping any client copy) to authenticate the cloudflare / xrealip modes of RATE_LIMIT_TRUSTED_PROXY. Those modes read an otherwise client-settable IP header, and a Convex deployment is directly reachable at its *.convex.site URL — so the forwarded IP is trusted ONLY when the request also presents this secret (constant-time compared). Unset, or a mismatched/absent secret ⇒ the header is NOT trusted and the caller falls back to the shared unknown bucket (fail closed). Unused by the bypass-resistant xforwarded mode (publicRateLimit.getClientIp).unset (cloudflare/xrealip fail closed to one shared bucket)
RATE_LIMIT_TRUSTED_PROXIESReverse-proxy IPs / CIDR ranges fronting the deployment, used only by the BetterAuth login limiter when RATE_LIMIT_TRUSTED_PROXY=xforwarded. The X-Forwarded-For chain is walked right-to-left, trusted hops are skipped, and the first UNTRUSTED entry is keyed — so a client-injected leftmost hop can't mint a fresh limiter bucket. Comma- or whitespace-separated (e.g. 10.0.0.0/8, 192.0.2.10). Unset ⇒ only a single-value header is trusted (auth/auth.ts).—
GITHUB_WEBHOOK_SECRETHMAC secret validating GitHub PR-merge webhooks for the code-work feature (webhooks/githubHttp.ts).—
OPENROUTER_API_KEYAlternative API key for the OpenRouter LLM provider; used by the unified LLM resolver (lib/llmProvider.ts) after LLM_API_KEY and before OPENAI_API_KEY.—
LLM_COMPLEXITY_ROUTINGWhen set to 1, routes each LLM request between the fast and capable models by estimated complexity instead of always using one tier (lib/llmProvider.ts).unset (off)
CALENDAR_FREEBUSY_ICS_URLRead-only free/busy source for scheduling replies: an iCalendar (.ics) subscription URL the deployment fetches server-side to compute the owner's open slots, injected into scheduling replies on meeting-intent (mail/ai/scheduling.ts, mail/availability.ts). Fetched inside the deployment to honour the privacy posture. Fail-soft: unset, unreachable, or unparseable → exactly today's sender-phrase-only behaviour.unset (feature off)
CALENDAR_TIMEZONEIANA timezone (e.g. Europe/Berlin) used to render the free/busy open slots in scheduling replies.UTC

No billing or control-plane vars in this repo

This OSS repo has no Stripe billing, tier management, or provisioning variables — those moved to the separate managed-cloud repo along with the Nest control plane. The only hosted-only var that survives here is OWLAT_HOSTED_MODE (a .env flag written by the setup CLI, inert in this OSS repo) and the optional CONTROL_PLANE_URL analytics target; self-hosters leave both unset. The sections below are general configuration that applies to every deployment.

Where to Set Variables

Convex backend (all process.env references in apps/api/):

npx convex env set VAR_NAME value

Or set them in the Convex dashboard under your deployment's Settings > Environment Variables.

Nuxt frontend (apps/web/.env):

NUXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
NUXT_PUBLIC_CONVEX_SITE_URL=https://your-deployment.convex.site
NUXT_PUBLIC_SITE_URL=http://localhost:3000

Email Sending

Provider Selection

Which send transports exist, what each one needs and what it can do is declared once, in packages/shared/src/sendProviderCatalog.ts (ADR-0055). This section restates that declaration and nothing else: apps/docs/__tests__/pageInvariants.test.ts reads both and fails when this page documents a Convex variable the backend never reads, and apps/docs/__tests__/docsVocabulary.test.ts fails on a variable named here that nothing declares. Set EMAIL_PROVIDER to a value from the first column to route every send through that kind; a per-org provider route overrides it per message type. There is no implicit default: leaving the variable unset is not "use the MTA" — resolution is fail-closed, so an instance with no route config and no value here refuses its sends rather than dispatching to a phantom MTA.

EMAIL_PROVIDERProviderRequiredOptionalSetup
mtaOwlat MTA — the built-in sender; what .env.selfhost.example ships withMTA_API_URL, MTA_API_KEYOUTBOUND_TLS_MODE, MTA_WEBHOOK_SECRETCustom MTA
sesAmazon SESAWS_SES_REGION, AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEYSES_CONFIGURATION_SET, SES_SNS_TOPIC_ARNAWS SES
resendResendRESEND_API_KEYRESEND_WEBHOOK_SECRETResend
smtpSMTP relay — Mailgun, Postmark, SendGrid, Brevo or your own submission hostSMTP_RELAY_HOST, SMTP_RELAY_USERNAME, SMTP_RELAY_PASSWORDSMTP_RELAY_PORT, SMTP_RELAY_SECUREGeneric SMTP relay
mandrillMailchimp Transactional (Mandrill)MANDRILL_API_KEYMANDRILL_WEBHOOK_KEY, MANDRILL_SUBACCOUNT, MANDRILL_IP_POOLMailchimp Transactional (Mandrill)
emailitEmailitEMAILIT_API_KEYEMAILIT_WEBHOOK_SECRETEmailit
npx convex env set EMAIL_PROVIDER mandrill   # or any other value from the table above

Required is the presence gate the rest of the system reads: until every variable in that cell is set, the kind is not configured — it cannot be routed to, and it is not eligible as the deliverability fallback relay. Optional variables refine a transport that already works (a feedback signing key, a TLS floor, a dedicated IP pool), so a Convex deployment that never sets one still dispatches.

Both columns describe the Convex side, which is the only side the catalog knows about. A service you run yourself can hold its own contract on top: MTA_WEBHOOK_SECRET is optional to the gate above and mandatory to apps/mta, whose loadConfig() refuses to boot without it — so on a self-hosted stack, set it wherever the MTA's own reference below says to.

The optional column may name more than the catalog does, never fewer, and SES is where the two cases sit side by side. SES_CONFIGURATION_SET is read by the transport, on every send: the adapter stamps it on the SendEmail/SendRawEmail command so SES event publishing can attribute the resulting feedback — which is why the catalog declares it optional (a send without it works; only the attribution is lost, and setting it later applies to later sends only, never retroactively). SES_SNS_TOPIC_ARN is read only by the feedback verifier (webhooks/adapters/ses.ts) — the send path never looks at it, and it is not a signing key either, since SNS signs with a certificate — so no catalog field describes it and this reference is the only place it is written down. Neither gates whether SES can send, which is why neither is in the presence gate. A bundled plugin transport declares its own variables in its contribution instead — they are PLUGIN_-prefixed and never reach lib/env.ts, so they are documented by the plugin's README rather than here.

AWS SES

VariableDescription
AWS_SES_REGIONAWS region for SES (e.g., eu-west-1, us-east-1). Required.
AWS_SES_ACCESS_KEY_IDIAM access key ID with SES permissions. Required.
AWS_SES_SECRET_ACCESS_KEYIAM secret access key. Required.
SES_CONFIGURATION_SETOptional. Name of an SES Configuration Set applied to every send so its event publishing attributes bounce/complaint/delivery feedback back to the originating message. Recommended when you enable the feedback loop below.
SES_SNS_TOPIC_ARNRequired to enable the feedback loop below. The exact ARN of the SNS topic that delivers SES feedback to /webhooks/ses. A valid SNS signature only proves the message came from AWS, not from your topic, so Owlat rejects feedback from any other topic and keeps the endpoint closed until this is set.

Setup Steps

  1. Create an IAM user in AWS with programmatic access and the AmazonSESFullAccess policy (or a scoped policy allowing ses:SendEmail and ses:SendRawEmail).
  2. Verify your sending domain in the SES console. SES requires domain verification before you can send emails.
  3. Request production access if your SES account is in sandbox mode. Sandbox mode limits sending to verified addresses only.
  4. Set the environment variables:
    npx convex env set EMAIL_PROVIDER ses
    npx convex env set AWS_SES_REGION eu-west-1
    npx convex env set AWS_SES_ACCESS_KEY_ID AKIA...
    npx convex env set AWS_SES_SECRET_ACCESS_KEY ...
    
  5. (Recommended) Enable the bounce & complaint feedback loop. Without it, SES accepts your mail but never tells Owlat when a message hard-bounces or is marked as spam, so those addresses are not suppressed and your sender reputation silently decays. Owlat receives SES feedback through an Amazon SNS topic:
    • Create an SNS topic (e.g. owlat-ses-feedback) and add an HTTPS subscription pointing at https://<your-convex-site-url>/webhooks/ses. Owlat confirms the subscription automatically (it verifies the SNS message signature and then calls the SubscribeURL).
    • Set SES_SNS_TOPIC_ARN to that topic's ARN so Owlat accepts feedback only from your topic (until it is set the endpoint stays closed):
      npx convex env set SES_SNS_TOPIC_ARN arn:aws:sns:eu-west-1:123456789012:owlat-ses-feedback
      
    • In the SES console, create a Configuration Set, add an event destination publishing Bounce, Complaint and Delivery events to that SNS topic, and set SES_CONFIGURATION_SET to its name so every send is attributed:
      npx convex env set SES_CONFIGURATION_SET owlat-ses
      
    • The Delivery page (Settings → Delivery) shows a live "last event received" line so you can confirm feedback is arriving.

    Every SNS request is signature-verified fail-closed: its SigningCertURL is pinned to an sns.*.amazonaws.com host, its Timestamp must be recent, and its TopicArn must match SES_SNS_TOPIC_ARN, so forged or replayed feedback is rejected.

Resend

VariableDescription
RESEND_API_KEYResend API key. Required when using Resend.
RESEND_WEBHOOK_SECRETWebhook signing secret (format: whsec_<base64>). Required for delivery event tracking.

Setup Steps

  1. Create a Resend account at resend.com and add your sending domain.
  2. Generate an API key from the Resend dashboard.
  3. (Optional) Set up webhooks for delivery tracking:
    • In Resend, create a webhook pointing to your Convex site URL's webhook endpoint
    • Copy the webhook signing secret
  4. Set the environment variables:
    npx convex env set EMAIL_PROVIDER resend
    npx convex env set RESEND_API_KEY re_...
    npx convex env set RESEND_WEBHOOK_SECRET whsec_...
    

Emailit

VariableDescription
EMAILIT_API_KEYBearer token used by the Emailit v2 send and setup-probe APIs. Required when using Emailit.
EMAILIT_WEBHOOK_SECRETFull whsec_… signing secret for POST /webhooks/emailit. Required for verified delivery feedback.

Set EMAIL_PROVIDER=emailit and EMAILIT_API_KEY, then create an Emailit webhook pointing at <CONVEX_SITE_URL>/webhooks/emailit. Enable accepted, attempted, delivered, bounced, complained, failed, rejected, and suppressed email events. Owlat verifies X-Emailit-Signature as HMAC-SHA256 over <X-Emailit-Timestamp>.<raw body> with a five-minute freshness bound. Provider open/click tracking stays disabled on sends because Owlat's first-party tracking remains authoritative.

npx convex env set EMAIL_PROVIDER emailit
npx convex env set EMAILIT_API_KEY em_...
npx convex env set EMAILIT_WEBHOOK_SECRET whsec_...

Generic SMTP relay

When you already have an SMTP submission host — Mailgun, Postmark, SendGrid, Brevo, or a self-run relay — set EMAIL_PROVIDER=smtp and point Owlat at it. There is no per-provider API adapter: any relay that speaks SMTP works.

VariableDescription
SMTP_RELAY_HOSTRelay hostname (e.g., smtp.mailgun.org). Required when using an SMTP relay.
SMTP_RELAY_PORTSubmission port. Optional — defaults to 587 (STARTTLS). Use 465 for implicit TLS.
SMTP_RELAY_SECUREtrue for an implicit-TLS connection (port 465); unset/false connects and upgrades via STARTTLS (port 587). Optional.
SMTP_RELAY_USERNAMESMTP auth username. Required when using an SMTP relay.
SMTP_RELAY_PASSWORDSMTP auth password. Required when using an SMTP relay.

With a relay, the sending IPs and DKIM signing belong to the relay provider — configure SPF and DKIM for your From-domain in the relay's dashboard, not through the built-in MTA DNS bundle.

Mailchimp Transactional (Mandrill)

Set EMAIL_PROVIDER=mandrill to send through an existing Mailchimp Transactional account. This is the migration arm: a team arriving from Mailchimp keeps their current deliverability on day one, and Owlat's ramp controller then moves traffic onto the built-in MTA cell by cell as the measurements allow.

VariableDescription
MANDRILL_API_KEYMandrill API key (Settings → API keys). Required when using Mandrill.
MANDRILL_WEBHOOK_KEYPer-webhook signing key shown after you create the webhook. Required for delivery/bounce event tracking.
MANDRILL_SUBACCOUNTSubaccount isolating Owlat's traffic — and its reputation — inside a shared Mandrill account. Optional.
MANDRILL_IP_POOLDefault dedicated-IP pool name. A provider route's own IP pool overrides it per send. Optional.

Owlat composes the whole MIME message itself and posts it to Mandrill's messages/send-raw, with Mandrill's open/click tracking, link rewriting and auto-text generation explicitly off. Tracking stays first-party, so both sending arms are measured on identical instrumentation — enable send, deferral, hard_bounce, soft_bounce, spam, unsub and reject on the webhook, but not open/click.

Create the webhook at Mailchimp Transactional → Settings → Webhooks, pointing at <CONVEX_SITE_URL>/webhooks/mandrill. Mandrill shows the signing key once, immediately after saving — that value is MANDRILL_WEBHOOK_KEY. Until it is set, Owlat rejects every posted batch rather than trusting an unsigned one, so sending looks healthy while bounces, complaints and reject-list hits silently never arrive. Delivery → Provider shows the exact URL, the event list, and whether the key is present. Only MANDRILL_API_KEY is written by the in-app transport editor; the other three are .env-only, so rotating the sending key can never unset a working feedback loop.

Two transports of the same kind

Every transport kind above gets one default instance for free, whose id is the kind itself (mta, ses, resend, smtp, mandrill, emailit) and which reads the unsuffixed variables. Declare named instances when you want two configurations of the same kind side by side — keeping a warm fallback relay while trialling a second one, for example.

VariableDescription
SEND_TRANSPORT_INSTANCESComma-separated list of extra transport instances, each <kind>#<instanceKey> (e.g. smtp#backup,resend#trial). Instance keys are lowercase [a-z0-9][a-z0-9_-]{0,31}. Optional — unset means exactly one transport per kind.

A named instance reads its kind's variables suffixed with __<INSTANCEKEY>, so smtp#backup reads SMTP_RELAY_HOST__BACKUP, SMTP_RELAY_USERNAME__BACKUP and SMTP_RELAY_PASSWORD__BACKUP:

npx convex env set SEND_TRANSPORT_INSTANCES smtp#backup
npx convex env set SMTP_RELAY_HOST__BACKUP smtp.postmarkapp.com
npx convex env set SMTP_RELAY_USERNAME__BACKUP ...
npx convex env set SMTP_RELAY_PASSWORD__BACKUP ...

Removing an instance from the list, or removing its variables, makes sends addressed to that transport id fail closed — they never silently fall back to another transport's credentials. Malformed entries are ignored rather than crashing dispatch, as is a second entry whose key derives the same variable suffix as an earlier one (a-b and a_b both give __A_B). A plugin-contributed transport can have named instances too, on the same terms, when its contribution declares configuration variables of its own — those are PLUGIN_-prefixed and read the same __<INSTANCEKEY> suffix. One that declares none reads the plugin's own deployment-wide variables, which no suffix reaches, so declaring an instance of it is rejected rather than silently sending with the default instance's credentials.

Setup Steps

  1. Create an SMTP credential in your relay provider's dashboard (a domain- or account-scoped SMTP username and password).
  2. Verify your sending domain with the relay (publish its SPF/DKIM records) so your mail authenticates.
  3. Set the environment variables:
    npx convex env set EMAIL_PROVIDER smtp
    npx convex env set SMTP_RELAY_HOST smtp.mailgun.org
    npx convex env set SMTP_RELAY_PORT 587
    npx convex env set SMTP_RELAY_USERNAME postmaster@mg.example.com
    npx convex env set SMTP_RELAY_PASSWORD ...
    

Custom MTA

When using the custom MTA (EMAIL_PROVIDER=mta), the Convex backend connects to the MTA service over HTTP. See the MTA System docs for full architecture details.

VariableDescription
MTA_API_URLMTA service URL (e.g., http://mta.internal:3100). Required when using MTA.
MTA_API_KEYShared API key for MTA authentication (Bearer token). Required when using MTA.
MTA_WEBHOOK_SECRETShared HMAC secret. Authenticates MTA → Convex delivery-event callbacks, and — when the postbox flag is on — signs the MTA's SMTP-submission credential checks against the /webhooks/mta-verify-credential endpoint (apps/mta/src/auth/postboxAuth.ts, apps/api/convex/mail/authHttp.ts). Set it on both sides. It sits outside the Convex-side presence gate — the catalog declares it beside OUTBOUND_TLS_MODE, so it is not what decides whether mta counts as configured — but it is required by the MTA service itself at startup (apps/mta/src/config.ts reads it through requiredEnv, so the container exits on an unset or empty value), required to receive delivery-event callbacks, and required whenever the postbox flag is on.
Postbox SMTP submission auth

When a desktop mail client (Apple Mail, Thunderbird, …) submits mail over SMTP, the MTA verifies the (mailbox-address, app-password) pair by HMAC-signing a request (with MTA_WEBHOOK_SECRET) to Convex's /webhooks/mta-verify-credential endpoint. This is the MTA's SMTP-submission check — it is distinct from the IMAP server's auth path, which uses CONVEX_ADMIN_KEY to call mail/appPasswords:verify directly. See Postbox Architecture.

Setup Steps

  1. Deploy the MTA service from apps/mta/ (see the MTA System docs for configuration).
  2. Set the environment variables in the Convex dashboard:
    npx convex env set EMAIL_PROVIDER mta
    npx convex env set MTA_API_URL http://mta.internal:3100
    npx convex env set MTA_API_KEY your-shared-api-key
    npx convex env set MTA_WEBHOOK_SECRET your-webhook-secret
    

Common Email Configuration

These variables apply regardless of which provider you use:

VariableDescriptionDefault
DEFAULT_FROM_EMAILDefault sender email address for transactional emailsnoreply@example.com
DEFAULT_FROM_NAMEDefault sender display nameOwlat
DEFAULT_FROM_DOMAINDomain used for system emails (e.g., invitation emails). Prepended with noreply@.mail.owlat.app
CONVEX_SITE_URLYour Convex site URL. Used for tracking pixels and unsubscribe links in campaign emails.—
UNSUBSCRIBE_SECRETSecret key for generating HMAC-signed unsubscribe tokens. Required.—

Email Security

Optional environment variables for email content and attachment scanning. See the Email Security docs for details on the scanning pipeline.

Variables

Set in the Convex dashboard:

VariableDescription
GOOGLE_SAFE_BROWSING_API_KEYGoogle Safe Browsing API v4 key for URL reputation checking. Free tier: 10,000 requests/day.
MTA_INTERNAL_URLMTA internal URL for attachment scanning (e.g., http://mta.internal:3100). Required for ClamAV integration.

Set in the MTA environment:

VariableDefaultDescription
CLAMAV_HOSTclamavClamAV daemon hostname (matches the clamav sidecar service name; docker-compose.yml hardcodes it)
CLAMAV_PORT3310ClamAV daemon port

Setup Steps

  1. (Optional) Enable URL reputation checking — get a Google Safe Browsing API key from the Google Cloud Console:
    npx convex env set GOOGLE_SAFE_BROWSING_API_KEY AIza...
    
  2. (Optional) Enable ClamAV malware scanning — deploy ClamAV alongside the MTA (see MTA System > ClamAV Sidecar) and set the internal URL:
    npx convex env set MTA_INTERNAL_URL http://mta.internal:3100
    

Content scanning (spam keywords, phishing URLs, homoglyphs) and file type validation (magic bytes, double extensions) work out of the box with no additional configuration. Only URL reputation and ClamAV require env vars.

Analytics (PostHog)

Owlat integrates with PostHog for product analytics and error tracking. The integration is optional — everything works without it.

Variables

Convex dashboard:

VariableDescription
POSTHOG_API_KEYPostHog project API key (starts with phc_). Required to enable server-side tracking.
POSTHOG_HOSTPostHog instance URL. Defaults to https://eu.i.posthog.com (EU cloud).

Nuxt frontend (apps/web/.env):

VariableDescription
NUXT_PUBLIC_POSTHOG_API_KEYPostHog project API key. Required to enable client-side tracking.
NUXT_PUBLIC_POSTHOG_HOSTPostHog instance URL. Defaults to https://eu.i.posthog.com.

Setup Steps

  1. Create a PostHog project at posthog.com and copy the project API key.
  2. Add the organization group type in PostHog: go to Settings → Groups → add a group type called organization. This enables organization-level analytics.
  3. Set the environment variables:
    # Convex (server-side events)
    npx convex env set POSTHOG_API_KEY phc_...
    npx convex env set POSTHOG_HOST https://eu.i.posthog.com
    
    # apps/web/.env (client-side events)
    NUXT_PUBLIC_POSTHOG_API_KEY=phc_...
    NUXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
    

Tracked Events

Client-side (automatic):

  • $pageview — SPA page navigations
  • $pageleave — page leave events
  • $exception — Vue errors and unhandled promise rejections
  • User identification and organization group association

Server-side (fire-and-forget from mutations):

  • campaign_created, campaign_sent
  • contact_created
  • automation_created, automation_activated, automation_paused
  • topic_created

All server events include organizationId as a property and PostHog group.

LLM provider (AI features)

The ai flag and every flag that depends on it require an LLM provider. The same provider powers email template translations, the AI agent, knowledge graph embeddings, and dashboard generation. See Providers → LLM provider for the interface contract and how to add a new provider.

Variables

VariableDescriptionDefault
LLM_PROVIDERopenai (default), openrouter, or ollama. Claude works only via openai with an OpenAI-compatible LLM_BASE_URL — anthropic is not a recognized value.openai
LLM_API_KEYAPI key for the selected provider. Accepted fallback keys, in order: LLM_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY.—
LLM_MODEL_FASTModel id for classify/extract/guard/summarize tasksprovider-specific
LLM_MODEL_CAPABLEModel id for draft tasksprovider-specific
LLM_MODELSingle-model fallback used for both tiers when LLM_MODEL_FAST/LLM_MODEL_CAPABLE are unset—
LLM_EMBEDDING_MODELEmbedding model id for the knowledge graph (env fallback)provider-specific
LLM_BASE_URLOverride for OpenAI-compatible endpoints (Ollama, vLLM, LM Studio)—
LOCAL_EMBEDDING_BASE_URLBase URL of a local, OpenAI-compatible /embeddings sidecar (e.g. Ollama). The embedding plane is local-by-default so retrieval works under any language provider.adapter default (http://localhost:11434/v1)
LOCAL_EMBEDDING_MODELLocal embedding model id (e.g. nomic-embed-text). Its native vector width must match the vector index (EMBEDDING_DIMENSIONS), or writes are rejected by the dimension guard.nomic-embed-text

Setup

npx convex env set LLM_PROVIDER openai
npx convex env set LLM_API_KEY sk-...
# Override defaults if you want a specific model:
npx convex env set LLM_MODEL_FAST gpt-4o-mini
npx convex env set LLM_MODEL_CAPABLE gpt-4o

For a local Ollama:

npx convex env set LLM_PROVIDER ollama
npx convex env set LLM_BASE_URL http://host.docker.internal:11434/v1
npx convex env set LLM_MODEL_FAST llama3.1:8b
npx convex env set LLM_MODEL_CAPABLE llama3.1:70b

Notifications

Notifications are handled client-side by the desktop app; there is no server-side notification-provider env var. All knowledge retrieval uses Convex's built-in vector index (ctx.vectorSearch), so there is no vector-store env var either.

Personal mail / IMAP (apps/imap)

These are read by the IMAP server, not the Convex backend. Only relevant when the postbox flag is on.

VariableDescriptionDefault
IMAP_PORTTCP listen port993
IMAP_LISTENBind address0.0.0.0
IMAP_GREETING_HOSTHostname in the * OK greetingOS hostname
IMAP_TLS_CERT / IMAP_TLS_CERT_FILETLS certificate (inline PEM or path)—
IMAP_TLS_KEY / IMAP_TLS_KEY_FILETLS private key (inline PEM or path)—
TLS_CERT_DIRFallback directory containing default.crt and default.key/opt/owlat/certs
CONVEX_URLConvex deployment URL (required)—
CONVEX_ADMIN_KEYConvex admin key the IMAP server uses to authenticate to Convex (required)—
LOG_LEVELLog verbosity. The VPS compose maps IMAP_LOG_LEVEL onto it.info
REDIS_URLOptional Redis URL for rate-limit storage. Unset on the shipped compose service → rate-limiting fails open.—

The IMAP server authenticates to Convex with CONVEX_ADMIN_KEY (it sets admin auth on a ConvexHttpClient and calls mail/appPasswords:verify directly — apps/imap/src/convex.ts). It does not read MTA_WEBHOOK_SECRET. The HMAC /webhooks/mta-verify-credential endpoint, by contrast, is the MTA's SMTP-submission credential check (see the Custom MTA section above and Postbox Architecture).

Authentication

VariableDescriptionDefault
BETTER_AUTH_SECRETSecret key for BetterAuth session signing. Required.—
SITE_URLPublic site URL for auth redirects and callbacks.http://localhost:3000

Set BETTER_AUTH_SECRET in the Convex dashboard. Use a long, random string:

npx convex env set BETTER_AUTH_SECRET $(openssl rand -base64 32)

Nuxt Frontend

These go in apps/web/.env:

VariableDescription
NUXT_PUBLIC_CONVEX_URLYour Convex deployment URL (e.g., https://your-deployment.convex.cloud)
NUXT_PUBLIC_CONVEX_SITE_URLYour Convex site URL (e.g., https://your-deployment.convex.site). Used for auth proxy.
NUXT_PUBLIC_SITE_URLPublic site URL. Must match SITE_URL set in Convex.

Complete Reference

Tenant App (apps/api)

VariableWhereRequiredDefaultPurpose
INSTANCE_SECRETConvexYes—HKDF root for at-rest credential encryption (lib/credentialCrypto.ts); shared secret gating /seed/admin, /seed/demo, /dev/reset
INSTANCE_SECRET_PREVIOUSConvexNo—Previous INSTANCE_SECRET, set only during a rotation window: at-rest secrets sealed under the old root keep decrypting while e2ee/lifecycleNode.ts:reSealVault re-seals every row under the new secret. Unset once the re-seal migration completes
SITE_URLConvexYeshttp://localhost:3000Public site URL for redirects
ADMIN_SITE_URLConvexNo—Alternate site URL trusted for auth redirects
ALLOWED_ORIGINSConvexNohttp://localhost:3000Comma-separated CORS allowlist for the Convex HTTP API
OWLAT_DEV_MODEConvexNooffEnables dev/seed endpoints; fail-closed when unset
EMAIL_PROVIDERConvexNo—Instance-wide email provider (mta, ses, resend, smtp, mandrill, or emailit); no default — unset resolves to unconfigured and sends are refused. A per-org provider route overrides it
AWS_SES_REGIONConvexIf SES—AWS region for SES
AWS_SES_ACCESS_KEY_IDConvexIf SES—AWS IAM access key
AWS_SES_SECRET_ACCESS_KEYConvexIf SES—AWS IAM secret key
SES_CONFIGURATION_SETConvexNo—SES Configuration Set applied to every send for feedback attribution
SES_SNS_TOPIC_ARNConvexIf SES feedback—SNS topic ARN authorized to deliver SES feedback to /webhooks/ses
RESEND_API_KEYConvexIf Resend—Resend API key
RESEND_WEBHOOK_SECRETConvexNo—Resend webhook signing secret
SMTP_RELAY_HOSTConvexIf SMTP—Generic SMTP relay hostname
SMTP_RELAY_PORTConvexNo587SMTP submission port (465 for implicit TLS)
SMTP_RELAY_SECUREConvexNofalsetrue for implicit TLS (465); STARTTLS otherwise
SMTP_RELAY_USERNAMEConvexIf SMTP—SMTP relay auth username
SMTP_RELAY_PASSWORDConvexIf SMTP—SMTP relay auth password
MANDRILL_API_KEYConvexIf Mandrill—Mailchimp Transactional (Mandrill) API key
MANDRILL_WEBHOOK_KEYConvexIf Mandrill feedback—Per-webhook signing key for /webhooks/mandrill
EMAILIT_API_KEYConvexIf Emailit sends—Emailit v2 bearer token
EMAILIT_WEBHOOK_SECRETConvexIf Emailit feedback—HMAC secret for /webhooks/emailit
MANDRILL_SUBACCOUNTConvexNo—Mandrill subaccount isolating Owlat's traffic and reputation
MANDRILL_IP_POOLConvexNo—Default Mandrill dedicated-IP pool; a provider route's own pool overrides it
MTA_API_URLConvexIf MTA—MTA service URL
MTA_API_KEYConvexIf MTA—MTA API authentication key
MTA_WEBHOOK_SECRETConvexIf MTA—HMAC secret for MTA delivery callbacks and (postbox) the MTA's SMTP-submission credential check. Outside the Convex-side send-presence gate, but the mta container itself refuses to start without it
MTA_INTERNAL_URLConvexNo—MTA URL for attachment scanning
MTA_SPF_INCLUDEConvexNo—SPF include: host in generated DNS records (omitted when unset)
SPF_QUALIFIERConvexNo~allTrailing all qualifier for generated SPF records (~all/-all/?all/+all)
MTA_RETURN_PATH_DOMAINConvexNo—VERP return-path domain; with MTA_IP_POOLS, generates a return-path SPF record
MTA_BOUNCE_VERP_KEYConvexNo—VERP signing key (same as the MTA's BOUNCE_VERP_KEY); lets relay sends carry our own VERP return path so relayed bounces are attributable
MTA_RETURN_PATH_RELAY_SPFConvexNo—SPF terms authorizing the relay on the return-path host; required before a relay send carries our VERP envelope sender
MTA_IP_POOLSConvexNo—Comma-separated sending IPs used to build the return-path SPF record
SNDS_DATA_FEED_URLSConvexNo—Microsoft SNDS Automated Data Access feed URLs; additive-only external reputation signal for the Microsoft cell
OUTBOUND_DKIM_DOMAINConvexNo—The active transport's DKIM d= domain when it isn't the From-domain; powers the outbound DMARC-alignment guard (relay misalignment warning)
MTA_DMARC_RUAConvexNo—DMARC aggregate-report rua= URI in generated _dmarc record (omitted when unset)
MTA_TLSRPT_RUAConvexNo—TLS-RPT rua= destination in generated _smtp._tls record (omitted when unset)
MTA_BIMI_LOGO_URLConvexNo—HTTPS URL of the SVG logo in the offered BIMI record (l=); unset ⇒ no record, never a blocker
MTA_BIMI_VMC_URLConvexNo—HTTPS URL of the VMC PEM in the offered BIMI record (a=); required by Gmail/Apple only
MTA_BIMI_SELECTORConvexNodefaultSelector label for <selector>._bimi.<domain>
OUTBOUND_TLS_MODEMTA + ConvexNoopportunisticBuilt-in MTA outbound TLS floor (opportunistic/require/require-verified); surfaced read-only to the transport editor
MAIL_SYNC_API_URLConvexIf mail.external on—Base URL of the mail-sync worker's internal send/test API
MAIL_SYNC_API_KEYConvexIf mail.external on—Shared key Convex uses to authenticate to the mail-sync worker
TWILIO_AUTH_TOKENConvexIf SMS channel—Validates inbound Twilio webhook signatures
META_APP_SECRETConvexIf WhatsApp/Messenger channel—Validates inbound Meta webhook HMAC
META_VERIFY_TOKENConvexIf WhatsApp/Messenger channel—Meta webhook subscription challenge token
GENERIC_WEBHOOK_SECRETConvexIf generic channel—Shared secret for the generic inbound channel adapter
DEFAULT_FROM_EMAILConvexNonoreply@example.comDefault sender email
DEFAULT_FROM_NAMEConvexNoOwlatDefault sender name
DEFAULT_FROM_DOMAINConvexNomail.owlat.appDomain for system emails
CONVEX_SITE_URLConvexYes—Convex built-in site URL (tracking/unsubscribe) — auto-derived from NUXT_PUBLIC_CONVEX_SITE_URL; do not convex env set it (rejected as EnvVarNameForbidden)
UNSUBSCRIBE_SECRETConvexYes—HMAC secret for unsubscribe tokens
BETTER_AUTH_SECRETConvexYes—Session signing secret
LLM_PROVIDERConvexIf ai onopenaiLLM provider (openai, openrouter, ollama; Claude via openai + OpenAI-compatible LLM_BASE_URL)
LLM_API_KEYConvexIf ai on—API key for selected LLM provider
LLM_MODEL_FASTConvexNoprovider-specificFast-tier model id
LLM_MODEL_CAPABLEConvexNoprovider-specificCapable-tier model id
LLM_MODELConvexNo—Single-model fallback for both tiers
LLM_EMBEDDING_MODELConvexNoprovider-specificEmbedding model id (env fallback)
LLM_BASE_URLConvexNo—OpenAI-compatible endpoint override
LOCAL_EMBEDDING_BASE_URLConvexNohttp://localhost:11434/v1Local OpenAI-compatible /embeddings sidecar for the local-by-default embedding plane
LOCAL_EMBEDDING_MODELConvexNonomic-embed-textLocal embedding model id; its width must match the vector index
POSTHOG_API_KEYConvexIf analytics.posthog on—PostHog project API key (server-side)
POSTHOG_HOSTConvexNohttps://eu.i.posthog.comPostHog instance URL (server-side)
CONTROL_PLANE_URLConvexNo (hosted only)—Analytics reporter target for the managed control plane
GOOGLE_SAFE_BROWSING_API_KEYConvexIf scan.urls on—URL reputation checking (Safe Browsing API)

Frontend (apps/web)

VariableWhereRequiredDefaultPurpose
NUXT_PUBLIC_CONVEX_URLNuxt .envYes—Convex deployment URL
NUXT_PUBLIC_CONVEX_SITE_URLNuxt .envYes—Convex site URL for auth
NUXT_PUBLIC_SITE_URLNuxt .envYes—Public site URL
NUXT_PUBLIC_POSTHOG_API_KEYNuxt .envNo—PostHog project API key (client-side)
NUXT_PUBLIC_POSTHOG_HOSTNuxt .envNohttps://eu.i.posthog.comPostHog instance URL (client-side)
NUXT_PUBLIC_OFFLINE_SHELLNuxt .envNotrueOffline app shell service worker. Set to false (at build time) to disable it and unregister it from browsers that already have it

MTA (apps/mta)

VariableWhereRequiredDefaultPurpose
CLAMAV_HOSTMTA envNoclamavClamAV daemon hostname (docker-compose.yml hardcodes the sidecar name)
CLAMAV_PORTMTA envNo3310ClamAV daemon port
LOG_LEVELMTA envNoinfoLog verbosity. docker-compose.yml maps the MTA_LOG_LEVEL host var onto it.

IMAP server (apps/imap)

Started only when the postbox flag is on (via the personal-mail Docker Compose profile).

VariableWhereRequiredDefaultPurpose
IMAP_PORTIMAP envNo993TCP listen port
IMAP_LISTENIMAP envNo0.0.0.0Bind address
IMAP_GREETING_HOSTIMAP envNoOS hostnameHostname in * OK greeting
IMAP_TLS_CERT / IMAP_TLS_CERT_FILEIMAP envNo (TLS off)—TLS certificate (PEM or path)
IMAP_TLS_KEY / IMAP_TLS_KEY_FILEIMAP envNo (TLS off)—TLS private key (PEM or path)
TLS_CERT_DIRIMAP envNo/opt/owlat/certsFallback dir for default.crt/default.key
CONVEX_URLIMAP envYes—Convex deployment URL
CONVEX_ADMIN_KEYIMAP envYes—Convex admin key — the IMAP server's sole auth to Convex (calls mail/appPasswords:verify directly)
LOG_LEVELIMAP envNoinfoLog verbosity. The VPS compose maps IMAP_LOG_LEVEL onto it.
REDIS_URLIMAP envNo—Redis URL for rate-limit storage. Unset on the shipped compose service → rate-limiting fails open.

The IMAP server does not read MTA_WEBHOOK_SECRET. Its only required Convex credential is CONVEX_ADMIN_KEY.