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) |
RETURN_PATH_DOMAIN | Domain used for VERP bounce return-path addresses. The template ships bounces.example.com. | bounces.localhost (compose fallback) |
BOUNCE_VERP_KEY | Secret that signs the VERP return-path token (BATV/HMAC). When set, only bounce DSNs addressed to a token the MTA actually signed are attributed, so a forged DSN cannot poison the suppression list (RFC 5321: anyone may submit a DSN). Leave unset to keep legacy unsigned tokens. Generate with openssl rand -base64 32 and keep it stable. | unset (unsigned tokens) |
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 |
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) and passed to the web container by docker-compose.yml. 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 | Build-time version injected into images for in-app update checks. Set automatically by CI. | 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: 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). | unset (off) |
ALLOWED_ORIGINS | Comma-separated CORS allowlist for the Convex HTTP API (lib/cors.ts). | http://localhost:3000 |
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_IP_POOLS | Comma-separated list of the IP-pool addresses the MTA sends from, used to generate the return-path SPF record (each IP authorized via ip4:) (domains/providers/mta/index.ts). | — |
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). | — |
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). | unset (headers not trusted) |
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/aiScheduling.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
Owlat supports four email providers: a custom MTA for direct SMTP delivery (default), AWS SES, Resend, and a generic SMTP relay. Set the EMAIL_PROVIDER variable in the Convex dashboard:
npx convex env set EMAIL_PROVIDER mta # default
npx convex env set EMAIL_PROVIDER ses
npx convex env set EMAIL_PROVIDER resend
npx convex env set EMAIL_PROVIDER smtp
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_...
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.
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). Required when using MTA. |
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 | mta | Email provider (mta, ses, resend, or smtp) |
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 |
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 |
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_IP_POOLS | Convex | No | — | Comma-separated sending IPs used to build the return-path SPF record |
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) |
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) |
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.