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).
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.
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
| Variable | Description | Generate with |
|---|---|---|
INSTANCE_SECRET | Convex 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_PREVIOUS | Optional. 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_KEY | Admin key for deploying functions. Generate after first boot via docker compose exec convex ./generate_admin_key.sh. | (see above) |
MTA_API_KEY | API key the tenant app uses to enqueue sends in the MTA. | openssl rand -base64 32 |
MTA_WEBHOOK_SECRET | HMAC secret for MTA → Convex delivery-event callbacks. | openssl rand -base64 32 |
Public URLs
| Variable | Description | Default |
|---|---|---|
NUXT_PUBLIC_CONVEX_URL | Browser-facing Convex URL. Must match what you expose via reverse proxy. | http://localhost:3210 |
NUXT_PUBLIC_CONVEX_SITE_URL | Browser-facing Convex HTTP-actions URL. | http://localhost:3211 |
NUXT_PUBLIC_CONVEX_DASHBOARD_URL | Optional. 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_URL | Public URL of the web app. | http://localhost:3000 |
MTA (mail sending)
| Variable | Description | Default |
|---|---|---|
EHLO_HOSTNAME | Must match your server's rDNS PTR record. The template ships mail.example.com. | mail.localhost (compose fallback) |
EHLO_HOSTNAMES | Optional JSON map from each sending IP to its PTR-matched EHLO hostname. | — |
MTA_GENERIC_PTR_SUFFIXES | Extra comma-separated provider-default PTR suffixes to warn about. | — |
MTA_ALLOW_UNVERIFIED_FCRDNS | Lab-only bypass for hard FCrDNS quarantine. Failed status remains visible. | false |
MTA_IPV6_ENABLED | Explicit outbound-IPv6 opt-in. Keep false until the routed address, IPv4 identity, PTR/AAAA, and exact return-path ip6: mechanism verify. | false |
RETURN_PATH_DOMAIN | Domain used for VERP bounce return-path addresses. The template ships bounces.example.com. | bounces.localhost (compose fallback) |
BOUNCE_VERP_KEY | Required 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_TRANSACTIONAL | Comma-separated IP(s) for transactional sends. | 127.0.0.1 |
IP_POOLS_CAMPAIGN | Comma-separated IP(s) for marketing campaigns. | 127.0.0.1 |
DKIM_KEYS | JSON: {"example.com":{"selector":"s1","privateKey":"..."}}. | {} |
WORKER_CONCURRENCY | Max concurrent SMTP deliveries. | 50 |
FBL_DEDUP_PROTOCOL | Required versioned complaint reservation protocol. Set owned-v2; there is no automatic legacy/shadow mode. See FBL deduplication upgrade. | required |
FBL_DEDUP_CUTOVER_ACK | Required 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_LEVEL | Log 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_MODE | DANE (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_URL | DoH (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.
| Variable | Description | Default |
|---|---|---|
OWLAT_DEPLOYMENT_MODE | selfhost 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_VERSION | Version 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.
| Variable | Meaning | Default |
|---|---|---|
AI_SPEND_DAILY_BUDGET_USD | Daily 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_USD | Monthly USD ceiling for all LLM spend in an org. Unset or 0 ⇒ no monthly limit. | unset (no limit) |
AI_SPEND_WARN_FRACTION | Fraction of a ceiling (0–1] at which to start warning in the admin dashboard before the hard block. | 0.8 |
AI_SPEND_ADVISORY_RESERVE_FRACTION | Fraction 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).
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:
| Variable | Description |
|---|---|
MAIL_SYNC_API_URL | Base 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_KEY | Shared 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-proxyholds the realCONVEX_ADMIN_KEYand forwards ONLY the thirteen Convex functions the worker legitimately calls (codeWorkTasks:*andplugins/workerTasks:*); any other function path is rejected403. The worker authenticates to it withCODE_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'sHTTP_PROXY/HTTPS_PROXYpoint 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):
| Variable | Description | Default |
|---|---|---|
CODE_WORKER_PROXY_TOKEN | Shared 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_KEY | The 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_KEY | Held by convex-fn-proxy (injected on forwarded requests). In a non-proxied deployment it is the worker's fallback credential. | (see Required secrets) |
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.)
| Variable | Description |
|---|---|
TWILIO_AUTH_TOKEN | Validates X-Twilio-Signature on inbound SMS/voice webhooks (webhooks/adapters/twilio.ts). The endpoint refuses requests if it is missing. |
META_APP_SECRET | Validates the X-Hub-Signature-256 HMAC on inbound WhatsApp/Messenger webhooks (webhooks/adapters/meta.ts). |
META_VERIFY_TOKEN | Must match hub.verify_token during Meta's webhook subscription challenge (webhooks/adapters/meta.ts). |
GENERIC_WEBHOOK_SECRET | Compared 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:
| Variable | Description | Default |
|---|---|---|
OWLAT_DEV_MODE | Gates 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_VERIFICATION | When 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_ORIGINS | Comma-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_URL | Optional alternate site URL trusted for auth redirects (auth/auth.ts). | — |
MTA_SPF_INCLUDE | SPF 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_QUALIFIER | Trailing 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_DOMAIN | VERP 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_KEY | The 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_SPF | SPF 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_POOLS | Comma-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_URLS | Comma- 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_DOMAIN | The 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_RUA | DMARC 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_RUA | SMTP 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_URL | HTTPS 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_URL | HTTPS 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_SELECTOR | Selector 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_MODE | Outbound 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_PROXY | Which 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_SECRET | Shared 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_PROXIES | Reverse-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_SECRET | HMAC secret validating GitHub PR-merge webhooks for the code-work feature (webhooks/githubHttp.ts). | — |
OPENROUTER_API_KEY | Alternative 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_ROUTING | When 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_URL | Read-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_TIMEZONE | IANA timezone (e.g. Europe/Berlin) used to render the free/busy open slots in scheduling replies. | UTC |
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_PROVIDER | Provider | Required | Optional | Setup |
|---|---|---|---|---|
mta | Owlat MTA — the built-in sender; what .env.selfhost.example ships with | MTA_API_URL, MTA_API_KEY | OUTBOUND_TLS_MODE, MTA_WEBHOOK_SECRET | Custom MTA |
ses | Amazon SES | AWS_SES_REGION, AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY | SES_CONFIGURATION_SET, SES_SNS_TOPIC_ARN | AWS SES |
resend | Resend | RESEND_API_KEY | RESEND_WEBHOOK_SECRET | Resend |
smtp | SMTP relay — Mailgun, Postmark, SendGrid, Brevo or your own submission host | SMTP_RELAY_HOST, SMTP_RELAY_USERNAME, SMTP_RELAY_PASSWORD | SMTP_RELAY_PORT, SMTP_RELAY_SECURE | Generic SMTP relay |
mandrill | Mailchimp Transactional (Mandrill) | MANDRILL_API_KEY | MANDRILL_WEBHOOK_KEY, MANDRILL_SUBACCOUNT, MANDRILL_IP_POOL | Mailchimp Transactional (Mandrill) |
emailit | Emailit | EMAILIT_API_KEY | EMAILIT_WEBHOOK_SECRET | Emailit |
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
| Variable | Description |
|---|---|
AWS_SES_REGION | AWS region for SES (e.g., eu-west-1, us-east-1). Required. |
AWS_SES_ACCESS_KEY_ID | IAM access key ID with SES permissions. Required. |
AWS_SES_SECRET_ACCESS_KEY | IAM secret access key. Required. |
SES_CONFIGURATION_SET | Optional. 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_ARN | Required 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
- Create an IAM user in AWS with programmatic access and the
AmazonSESFullAccesspolicy (or a scoped policy allowingses:SendEmailandses:SendRawEmail). - Verify your sending domain in the SES console. SES requires domain verification before you can send emails.
- Request production access if your SES account is in sandbox mode. Sandbox mode limits sending to verified addresses only.
- 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 ... - (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 athttps://<your-convex-site-url>/webhooks/ses. Owlat confirms the subscription automatically (it verifies the SNS message signature and then calls theSubscribeURL). - Set
SES_SNS_TOPIC_ARNto 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,ComplaintandDeliveryevents to that SNS topic, and setSES_CONFIGURATION_SETto 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: itsSigningCertURLis pinned to ansns.*.amazonaws.comhost, itsTimestampmust be recent, and itsTopicArnmust matchSES_SNS_TOPIC_ARN, so forged or replayed feedback is rejected. - Create an SNS topic (e.g.
Resend
| Variable | Description |
|---|---|
RESEND_API_KEY | Resend API key. Required when using Resend. |
RESEND_WEBHOOK_SECRET | Webhook signing secret (format: whsec_<base64>). Required for delivery event tracking. |
Setup Steps
- Create a Resend account at resend.com and add your sending domain.
- Generate an API key from the Resend dashboard.
- (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
- 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
| Variable | Description |
|---|---|
EMAILIT_API_KEY | Bearer token used by the Emailit v2 send and setup-probe APIs. Required when using Emailit. |
EMAILIT_WEBHOOK_SECRET | Full 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.
| Variable | Description |
|---|---|
SMTP_RELAY_HOST | Relay hostname (e.g., smtp.mailgun.org). Required when using an SMTP relay. |
SMTP_RELAY_PORT | Submission port. Optional — defaults to 587 (STARTTLS). Use 465 for implicit TLS. |
SMTP_RELAY_SECURE | true for an implicit-TLS connection (port 465); unset/false connects and upgrades via STARTTLS (port 587). Optional. |
SMTP_RELAY_USERNAME | SMTP auth username. Required when using an SMTP relay. |
SMTP_RELAY_PASSWORD | SMTP 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.
| Variable | Description |
|---|---|
MANDRILL_API_KEY | Mandrill API key (Settings → API keys). Required when using Mandrill. |
MANDRILL_WEBHOOK_KEY | Per-webhook signing key shown after you create the webhook. Required for delivery/bounce event tracking. |
MANDRILL_SUBACCOUNT | Subaccount isolating Owlat's traffic — and its reputation — inside a shared Mandrill account. Optional. |
MANDRILL_IP_POOL | Default 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.
| Variable | Description |
|---|---|
SEND_TRANSPORT_INSTANCES | Comma-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
- Create an SMTP credential in your relay provider's dashboard (a domain- or account-scoped SMTP username and password).
- Verify your sending domain with the relay (publish its SPF/DKIM records) so your mail authenticates.
- 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.
| Variable | Description |
|---|---|
MTA_API_URL | MTA service URL (e.g., http://mta.internal:3100). Required when using MTA. |
MTA_API_KEY | Shared API key for MTA authentication (Bearer token). Required when using MTA. |
MTA_WEBHOOK_SECRET | Shared 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. |
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
- Deploy the MTA service from
apps/mta/(see the MTA System docs for configuration). - 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:
| Variable | Description | Default |
|---|---|---|
DEFAULT_FROM_EMAIL | Default sender email address for transactional emails | noreply@example.com |
DEFAULT_FROM_NAME | Default sender display name | Owlat |
DEFAULT_FROM_DOMAIN | Domain used for system emails (e.g., invitation emails). Prepended with noreply@. | mail.owlat.app |
CONVEX_SITE_URL | Your Convex site URL. Used for tracking pixels and unsubscribe links in campaign emails. | — |
UNSUBSCRIBE_SECRET | Secret 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:
| Variable | Description |
|---|---|
GOOGLE_SAFE_BROWSING_API_KEY | Google Safe Browsing API v4 key for URL reputation checking. Free tier: 10,000 requests/day. |
MTA_INTERNAL_URL | MTA internal URL for attachment scanning (e.g., http://mta.internal:3100). Required for ClamAV integration. |
Set in the MTA environment:
| Variable | Default | Description |
|---|---|---|
CLAMAV_HOST | clamav | ClamAV daemon hostname (matches the clamav sidecar service name; docker-compose.yml hardcodes it) |
CLAMAV_PORT | 3310 | ClamAV daemon port |
Setup Steps
- (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... - (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:
| Variable | Description |
|---|---|
POSTHOG_API_KEY | PostHog project API key (starts with phc_). Required to enable server-side tracking. |
POSTHOG_HOST | PostHog instance URL. Defaults to https://eu.i.posthog.com (EU cloud). |
Nuxt frontend (apps/web/.env):
| Variable | Description |
|---|---|
NUXT_PUBLIC_POSTHOG_API_KEY | PostHog project API key. Required to enable client-side tracking. |
NUXT_PUBLIC_POSTHOG_HOST | PostHog instance URL. Defaults to https://eu.i.posthog.com. |
Setup Steps
- Create a PostHog project at posthog.com and copy the project API key.
- Add the
organizationgroup type in PostHog: go to Settings → Groups → add a group type calledorganization. This enables organization-level analytics. - 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_sentcontact_createdautomation_created,automation_activated,automation_pausedtopic_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
| Variable | Description | Default |
|---|---|---|
LLM_PROVIDER | openai (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_KEY | API key for the selected provider. Accepted fallback keys, in order: LLM_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY. | — |
LLM_MODEL_FAST | Model id for classify/extract/guard/summarize tasks | provider-specific |
LLM_MODEL_CAPABLE | Model id for draft tasks | provider-specific |
LLM_MODEL | Single-model fallback used for both tiers when LLM_MODEL_FAST/LLM_MODEL_CAPABLE are unset | — |
LLM_EMBEDDING_MODEL | Embedding model id for the knowledge graph (env fallback) | provider-specific |
LLM_BASE_URL | Override for OpenAI-compatible endpoints (Ollama, vLLM, LM Studio) | — |
LOCAL_EMBEDDING_BASE_URL | Base 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_MODEL | Local 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.
| Variable | Description | Default |
|---|---|---|
IMAP_PORT | TCP listen port | 993 |
IMAP_LISTEN | Bind address | 0.0.0.0 |
IMAP_GREETING_HOST | Hostname in the * OK greeting | OS hostname |
IMAP_TLS_CERT / IMAP_TLS_CERT_FILE | TLS certificate (inline PEM or path) | — |
IMAP_TLS_KEY / IMAP_TLS_KEY_FILE | TLS private key (inline PEM or path) | — |
TLS_CERT_DIR | Fallback directory containing default.crt and default.key | /opt/owlat/certs |
CONVEX_URL | Convex deployment URL (required) | — |
CONVEX_ADMIN_KEY | Convex admin key the IMAP server uses to authenticate to Convex (required) | — |
LOG_LEVEL | Log verbosity. The VPS compose maps IMAP_LOG_LEVEL onto it. | info |
REDIS_URL | Optional 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
| Variable | Description | Default |
|---|---|---|
BETTER_AUTH_SECRET | Secret key for BetterAuth session signing. Required. | — |
SITE_URL | Public 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:
| Variable | Description |
|---|---|
NUXT_PUBLIC_CONVEX_URL | Your Convex deployment URL (e.g., https://your-deployment.convex.cloud) |
NUXT_PUBLIC_CONVEX_SITE_URL | Your Convex site URL (e.g., https://your-deployment.convex.site). Used for auth proxy. |
NUXT_PUBLIC_SITE_URL | Public site URL. Must match SITE_URL set in Convex. |
Complete Reference
Tenant App (apps/api)
| Variable | Where | Required | Default | Purpose |
|---|---|---|---|---|
INSTANCE_SECRET | Convex | Yes | — | HKDF root for at-rest credential encryption (lib/credentialCrypto.ts); shared secret gating /seed/admin, /seed/demo, /dev/reset |
INSTANCE_SECRET_PREVIOUS | Convex | No | — | 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_URL | Convex | Yes | http://localhost:3000 | Public site URL for redirects |
ADMIN_SITE_URL | Convex | No | — | Alternate site URL trusted for auth redirects |
ALLOWED_ORIGINS | Convex | No | http://localhost:3000 | Comma-separated CORS allowlist for the Convex HTTP API |
OWLAT_DEV_MODE | Convex | No | off | Enables dev/seed endpoints; fail-closed when unset |
EMAIL_PROVIDER | Convex | No | — | 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_REGION | Convex | If SES | — | AWS region for SES |
AWS_SES_ACCESS_KEY_ID | Convex | If SES | — | AWS IAM access key |
AWS_SES_SECRET_ACCESS_KEY | Convex | If SES | — | AWS IAM secret key |
SES_CONFIGURATION_SET | Convex | No | — | SES Configuration Set applied to every send for feedback attribution |
SES_SNS_TOPIC_ARN | Convex | If SES feedback | — | SNS topic ARN authorized to deliver SES feedback to /webhooks/ses |
RESEND_API_KEY | Convex | If Resend | — | Resend API key |
RESEND_WEBHOOK_SECRET | Convex | No | — | Resend webhook signing secret |
SMTP_RELAY_HOST | Convex | If SMTP | — | Generic SMTP relay hostname |
SMTP_RELAY_PORT | Convex | No | 587 | SMTP submission port (465 for implicit TLS) |
SMTP_RELAY_SECURE | Convex | No | false | true for implicit TLS (465); STARTTLS otherwise |
SMTP_RELAY_USERNAME | Convex | If SMTP | — | SMTP relay auth username |
SMTP_RELAY_PASSWORD | Convex | If SMTP | — | SMTP relay auth password |
MANDRILL_API_KEY | Convex | If Mandrill | — | Mailchimp Transactional (Mandrill) API key |
MANDRILL_WEBHOOK_KEY | Convex | If Mandrill feedback | — | Per-webhook signing key for /webhooks/mandrill |
EMAILIT_API_KEY | Convex | If Emailit sends | — | Emailit v2 bearer token |
EMAILIT_WEBHOOK_SECRET | Convex | If Emailit feedback | — | HMAC secret for /webhooks/emailit |
MANDRILL_SUBACCOUNT | Convex | No | — | Mandrill subaccount isolating Owlat's traffic and reputation |
MANDRILL_IP_POOL | Convex | No | — | Default Mandrill dedicated-IP pool; a provider route's own pool overrides it |
MTA_API_URL | Convex | If MTA | — | MTA service URL |
MTA_API_KEY | Convex | If MTA | — | MTA API authentication key |
MTA_WEBHOOK_SECRET | Convex | If 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_URL | Convex | No | — | MTA URL for attachment scanning |
MTA_SPF_INCLUDE | Convex | No | — | SPF include: host in generated DNS records (omitted when unset) |
SPF_QUALIFIER | Convex | No | ~all | Trailing all qualifier for generated SPF records (~all/-all/?all/+all) |
MTA_RETURN_PATH_DOMAIN | Convex | No | — | VERP return-path domain; with MTA_IP_POOLS, generates a return-path SPF record |
MTA_BOUNCE_VERP_KEY | Convex | No | — | 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_SPF | Convex | No | — | SPF terms authorizing the relay on the return-path host; required before a relay send carries our VERP envelope sender |
MTA_IP_POOLS | Convex | No | — | Comma-separated sending IPs used to build the return-path SPF record |
SNDS_DATA_FEED_URLS | Convex | No | — | Microsoft SNDS Automated Data Access feed URLs; additive-only external reputation signal for the Microsoft cell |
OUTBOUND_DKIM_DOMAIN | Convex | No | — | 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_RUA | Convex | No | — | DMARC aggregate-report rua= URI in generated _dmarc record (omitted when unset) |
MTA_TLSRPT_RUA | Convex | No | — | TLS-RPT rua= destination in generated _smtp._tls record (omitted when unset) |
MTA_BIMI_LOGO_URL | Convex | No | — | HTTPS URL of the SVG logo in the offered BIMI record (l=); unset ⇒ no record, never a blocker |
MTA_BIMI_VMC_URL | Convex | No | — | HTTPS URL of the VMC PEM in the offered BIMI record (a=); required by Gmail/Apple only |
MTA_BIMI_SELECTOR | Convex | No | default | Selector label for <selector>._bimi.<domain> |
OUTBOUND_TLS_MODE | MTA + Convex | No | opportunistic | Built-in MTA outbound TLS floor (opportunistic/require/require-verified); surfaced read-only to the transport editor |
MAIL_SYNC_API_URL | Convex | If mail.external on | — | Base URL of the mail-sync worker's internal send/test API |
MAIL_SYNC_API_KEY | Convex | If mail.external on | — | Shared key Convex uses to authenticate to the mail-sync worker |
TWILIO_AUTH_TOKEN | Convex | If SMS channel | — | Validates inbound Twilio webhook signatures |
META_APP_SECRET | Convex | If WhatsApp/Messenger channel | — | Validates inbound Meta webhook HMAC |
META_VERIFY_TOKEN | Convex | If WhatsApp/Messenger channel | — | Meta webhook subscription challenge token |
GENERIC_WEBHOOK_SECRET | Convex | If generic channel | — | Shared secret for the generic inbound channel adapter |
DEFAULT_FROM_EMAIL | Convex | No | noreply@example.com | Default sender email |
DEFAULT_FROM_NAME | Convex | No | Owlat | Default sender name |
DEFAULT_FROM_DOMAIN | Convex | No | mail.owlat.app | Domain for system emails |
CONVEX_SITE_URL | Convex | Yes | — | 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_SECRET | Convex | Yes | — | HMAC secret for unsubscribe tokens |
BETTER_AUTH_SECRET | Convex | Yes | — | Session signing secret |
LLM_PROVIDER | Convex | If ai on | openai | LLM provider (openai, openrouter, ollama; Claude via openai + OpenAI-compatible LLM_BASE_URL) |
LLM_API_KEY | Convex | If ai on | — | API key for selected LLM provider |
LLM_MODEL_FAST | Convex | No | provider-specific | Fast-tier model id |
LLM_MODEL_CAPABLE | Convex | No | provider-specific | Capable-tier model id |
LLM_MODEL | Convex | No | — | Single-model fallback for both tiers |
LLM_EMBEDDING_MODEL | Convex | No | provider-specific | Embedding model id (env fallback) |
LLM_BASE_URL | Convex | No | — | OpenAI-compatible endpoint override |
LOCAL_EMBEDDING_BASE_URL | Convex | No | http://localhost:11434/v1 | Local OpenAI-compatible /embeddings sidecar for the local-by-default embedding plane |
LOCAL_EMBEDDING_MODEL | Convex | No | nomic-embed-text | Local embedding model id; its width must match the vector index |
POSTHOG_API_KEY | Convex | If analytics.posthog on | — | PostHog project API key (server-side) |
POSTHOG_HOST | Convex | No | https://eu.i.posthog.com | PostHog instance URL (server-side) |
CONTROL_PLANE_URL | Convex | No (hosted only) | — | Analytics reporter target for the managed control plane |
GOOGLE_SAFE_BROWSING_API_KEY | Convex | If scan.urls on | — | URL reputation checking (Safe Browsing API) |
Frontend (apps/web)
| Variable | Where | Required | Default | Purpose |
|---|---|---|---|---|
NUXT_PUBLIC_CONVEX_URL | Nuxt .env | Yes | — | Convex deployment URL |
NUXT_PUBLIC_CONVEX_SITE_URL | Nuxt .env | Yes | — | Convex site URL for auth |
NUXT_PUBLIC_SITE_URL | Nuxt .env | Yes | — | Public site URL |
NUXT_PUBLIC_POSTHOG_API_KEY | Nuxt .env | No | — | PostHog project API key (client-side) |
NUXT_PUBLIC_POSTHOG_HOST | Nuxt .env | No | https://eu.i.posthog.com | PostHog instance URL (client-side) |
NUXT_PUBLIC_OFFLINE_SHELL | Nuxt .env | No | true | Offline 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)
| Variable | Where | Required | Default | Purpose |
|---|---|---|---|---|
CLAMAV_HOST | MTA env | No | clamav | ClamAV daemon hostname (docker-compose.yml hardcodes the sidecar name) |
CLAMAV_PORT | MTA env | No | 3310 | ClamAV daemon port |
LOG_LEVEL | MTA env | No | info | Log 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).
| Variable | Where | Required | Default | Purpose |
|---|---|---|---|---|
IMAP_PORT | IMAP env | No | 993 | TCP listen port |
IMAP_LISTEN | IMAP env | No | 0.0.0.0 | Bind address |
IMAP_GREETING_HOST | IMAP env | No | OS hostname | Hostname in * OK greeting |
IMAP_TLS_CERT / IMAP_TLS_CERT_FILE | IMAP env | No (TLS off) | — | TLS certificate (PEM or path) |
IMAP_TLS_KEY / IMAP_TLS_KEY_FILE | IMAP env | No (TLS off) | — | TLS private key (PEM or path) |
TLS_CERT_DIR | IMAP env | No | /opt/owlat/certs | Fallback dir for default.crt/default.key |
CONVEX_URL | IMAP env | Yes | — | Convex deployment URL |
CONVEX_ADMIN_KEY | IMAP env | Yes | — | Convex admin key — the IMAP server's sole auth to Convex (calls mail/appPasswords:verify directly) |
LOG_LEVEL | IMAP env | No | info | Log verbosity. The VPS compose maps IMAP_LOG_LEVEL onto it. |
REDIS_URL | IMAP env | No | — | 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.