Environment Variables

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

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

Self-hosters start here

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

Feature-flag-driven env vars

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

Self-hosted configuration

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

cp .env.selfhost.example .env

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

Required secrets

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

Public URLs

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

MTA (mail sending)

VariableDescriptionDefault
EHLO_HOSTNAMEMust match your server's rDNS PTR record. The template ships mail.example.com.mail.localhost (compose fallback)
RETURN_PATH_DOMAINDomain used for VERP bounce return-path addresses. The template ships bounces.example.com.bounces.localhost (compose fallback)
BOUNCE_VERP_KEYSecret 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_TRANSACTIONALComma-separated IP(s) for transactional sends.127.0.0.1
IP_POOLS_CAMPAIGNComma-separated IP(s) for marketing campaigns.127.0.0.1
DKIM_KEYSJSON: {"example.com":{"selector":"s1","privateKey":"..."}}.{}
WORKER_CONCURRENCYMax concurrent SMTP deliveries.50
MTA_LOG_LEVELLog verbosity (debug/info/warn/error). This is a compose host-env alias — docker-compose.yml maps it to LOG_LEVEL, which is what the MTA process actually reads.info
DANE_MODEDANE (RFC 7672) posture at send time — off (default), report, or enforce. off is byte-identical to legacy behaviour (no TLSA lookups). report looks up each recipient MX's DNSSEC-validated TLSA RRset (_25._tcp.<mx>), evaluates the certificate against it and emits the TLS-RPT result (success/validation-failure under the tlsa policy), but never requires TLS or bounces. enforce additionally authenticates the certificate against the RRset (supersedes MTA-STS; a non-match defers the message, never cleartext). report and enforce need DANE_RESOLVER_URL. Read from the MTA's own process env (apps/mta/src/config.ts), not the Convex dashboard (apps/mta/src/smtp/daneVerify.ts). An unrecognised value fails the boot.off
DANE_RESOLVER_URLDoH (DNS-over-HTTPS, RFC 8484 JSON) resolver URL for DNSSEC-aware MX/address discovery and DANE TLSA lookups. Needed for report/enforce to run — when unset, DANE is inert in every mode (no lookups, historic path). When set it must be https:// (plain http:// is accepted only for a loopback resolver, since the trusted DNSSEC AD bit must travel over a channel an on-path attacker cannot forge); the MTA validates the URL at boot in every mode. Must be a validating resolver: the AD bit is trusted, DANE is not applied through an insecure address chain, and a lookup that cannot be completed (SERVFAIL/timeout) defers delivery in enforce (and is a no-op in report) rather than downgrading. Running a local validating resolver (e.g. Unbound on 127.0.0.1) is the recommended production configuration.unset

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

Deployment mode (web app & compose)

These are not Convex backend vars — they are not read through lib/env.ts. OWLAT_DEPLOYMENT_MODE is consumed by the web app (apps/web/nuxt.config.ts, exposed as runtimeConfig.public.deploymentMode) 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.

VariableDescriptionDefault
OWLAT_DEPLOYMENT_MODEselfhost or hosted. Controls first-run onboarding UX and hides hosted-only UI (billing, upgrade prompts) in the web app.selfhost
OWLAT_HOSTED_MODE.env flag written by the setup CLI from the chosen deployment mode. Inert in this OSS repo — no service reads it. Self-hosters leave this false.false
OWLAT_VERSIONBuild-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.

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

Optional: Personal mail / IMAP (postbox flag)

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

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

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

IMAP rate-limiting fails open without Redis

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

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

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

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

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

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

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

Optional: Other Convex backend vars

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

VariableDescriptionDefault
OWLAT_DEV_MODEGates the dev-only endpoints (/seed/demo, /dev/reset, forceVerifyDomain). Fail-closed: leaving it unset on a production deployment refuses those endpoints (devShortcuts/_guard.ts).unset (off)
ALLOWED_ORIGINSComma-separated CORS allowlist for the Convex HTTP API (lib/cors.ts).http://localhost:3000
ADMIN_SITE_URLOptional alternate site URL trusted for auth redirects (auth/auth.ts).
MTA_SPF_INCLUDESPF include: host emitted in the generated DNS records. When unset the SPF record is omitted (DKIM+DMARC alignment still works) (domains/providers/mta/index.ts).
SPF_QUALIFIERTrailing all mechanism qualifier for the generated SPF records: ~all (soft-fail, the safe default while your authorized IP set is still settling), -all (hard-fail, once the IP set is stable), ?all, or +all. Invalid/unset falls back to ~all (RFC 7208 §5.1) (domains/spf.ts).~all
MTA_RETURN_PATH_DOMAINVERP bounce return-path domain (matches the MTA's RETURN_PATH_DOMAIN, e.g. bounces.example.com). When set together with MTA_IP_POOLS, the generated DNS bundle includes a return-path SPF TXT record authorizing the pool IPs so the bounce envelope passes SPF (domains/providers/mta/index.ts).
MTA_IP_POOLSComma-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_DOMAINThe DKIM d= domain the ACTIVE transport signs with, when it isn't the per-message From-domain. The built-in MTA signs per-From-domain (leave unset); set this to your relay's signing domain (e.g. sendgrid.net) so the outbound DMARC-alignment guard can warn when a relay's signature won't align with your sending domains (lib/outboundAlignment.ts).
MTA_DMARC_RUADMARC aggregate-report (rua) reporting URI emitted in the generated _dmarc record, e.g. mailto:dmarc-reports@yourdomain.com. When unset the record carries no rua= tag — Owlat does not provision a per-customer dmarc@<domain> mailbox, so reports would otherwise go unread (domains/dmarc.ts).
MTA_TLSRPT_RUASMTP TLS Reporting (rua) destination emitted in the generated _smtp._tls TXT record (v=TLSRPTv1; rua=…, RFC 8460 §3), e.g. mailto:tls-reports@yourdomain.com or https://yourdomain.com/tlsrpt. Lets receivers report TLS-negotiation failures delivering mail to your domain. When unset the _smtp._tls record is omitted (domains/tlsRpt.ts).
OUTBOUND_TLS_MODEOutbound TLS posture for the built-in MTA's direct-MX delivery: opportunistic (encrypt when offered, never bounce on missing/invalid TLS — the default, byte-identical to legacy behaviour), require (mandate the STARTTLS upgrade), or require-verified (mandate TLS and a valid certificate — can bounce mail to receivers with broken TLS). Written by the Delivery transport editor and surfaced read-only to it via delivery/status.ts:getStatus; the MTA reads it from its own config (apps/mta/src/config.ts).opportunistic
RATE_LIMIT_TRUSTED_PROXYWhich forwarded header to trust for per-IP rate limiting on public endpoints: cloudflare (CF-Connecting-IP), xforwarded / xforwarded:<hops> (X-Forwarded-For), or xrealip (X-Real-IP). Security-sensitive — leave unset unless the Convex backend sits behind a trusted proxy. When unset, forwarded headers are NOT trusted and all public callers share one rate-limit bucket, so a spoofed header can't multiply buckets (publicRateLimit.getClientIp).unset (headers not trusted)
GITHUB_WEBHOOK_SECRETHMAC secret validating GitHub PR-merge webhooks for the code-work feature (webhooks/githubHttp.ts).
OPENROUTER_API_KEYAlternative API key for the OpenRouter LLM provider; used by the unified LLM resolver (lib/llmProvider.ts) after LLM_API_KEY and before OPENAI_API_KEY.
LLM_COMPLEXITY_ROUTINGWhen set to 1, routes each LLM request between the fast and capable models by estimated complexity instead of always using one tier (lib/llmProvider.ts).unset (off)
CALENDAR_FREEBUSY_ICS_URLRead-only free/busy source for scheduling replies: an iCalendar (.ics) subscription URL the deployment fetches server-side to compute the owner's open slots, injected into scheduling replies on meeting-intent (mail/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_TIMEZONEIANA timezone (e.g. Europe/Berlin) used to render the free/busy open slots in scheduling replies.UTC

No billing or control-plane vars in this repo

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

Where to Set Variables

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

npx convex env set VAR_NAME value

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

Nuxt frontend (apps/web/.env):

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

Email Sending

Provider Selection

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

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

Setup Steps

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

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

Resend

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

Setup Steps

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

Generic SMTP relay

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

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

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

Setup Steps

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

Custom MTA

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

VariableDescription
MTA_API_URLMTA service URL (e.g., http://mta.internal:3100). Required when using MTA.
MTA_API_KEYShared API key for MTA authentication (Bearer token). Required when using MTA.
MTA_WEBHOOK_SECRETShared HMAC secret. Authenticates MTA → Convex delivery-event callbacks, and — when the postbox flag is on — signs the MTA's SMTP-submission credential checks against the /webhooks/mta-verify-credential endpoint (apps/mta/src/auth/postboxAuth.ts, apps/api/convex/mail/authHttp.ts). Required when using MTA.
Postbox SMTP submission auth

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

Setup Steps

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

Common Email Configuration

These variables apply regardless of which provider you use:

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

Email Security

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

Variables

Set in the Convex dashboard:

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

Set in the MTA environment:

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

Setup Steps

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

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

Analytics (PostHog)

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

Variables

Convex dashboard:

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

Nuxt frontend (apps/web/.env):

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

Setup Steps

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

Tracked Events

Client-side (automatic):

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

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

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

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

LLM provider (AI features)

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

Variables

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

Setup

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

For a local Ollama:

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

Notifications

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

Personal mail / IMAP (apps/imap)

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

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

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

Authentication

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

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

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

Nuxt Frontend

These go in apps/web/.env:

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

Complete Reference

Tenant App (apps/api)

VariableWhereRequiredDefaultPurpose
INSTANCE_SECRETConvexYesHKDF root for at-rest credential encryption (lib/credentialCrypto.ts); shared secret gating /seed/admin, /seed/demo, /dev/reset
INSTANCE_SECRET_PREVIOUSConvexNoPrevious INSTANCE_SECRET, set only during a rotation window: at-rest secrets sealed under the old root keep decrypting while e2ee/lifecycleNode.ts:reSealVault re-seals every row under the new secret. Unset once the re-seal migration completes
SITE_URLConvexYeshttp://localhost:3000Public site URL for redirects
ADMIN_SITE_URLConvexNoAlternate site URL trusted for auth redirects
ALLOWED_ORIGINSConvexNohttp://localhost:3000Comma-separated CORS allowlist for the Convex HTTP API
OWLAT_DEV_MODEConvexNooffEnables dev/seed endpoints; fail-closed when unset
EMAIL_PROVIDERConvexNomtaEmail provider (mta, ses, resend, or smtp)
AWS_SES_REGIONConvexIf SESAWS region for SES
AWS_SES_ACCESS_KEY_IDConvexIf SESAWS IAM access key
AWS_SES_SECRET_ACCESS_KEYConvexIf SESAWS IAM secret key
SES_CONFIGURATION_SETConvexNoSES Configuration Set applied to every send for feedback attribution
SES_SNS_TOPIC_ARNConvexIf SES feedbackSNS topic ARN authorized to deliver SES feedback to /webhooks/ses
RESEND_API_KEYConvexIf ResendResend API key
RESEND_WEBHOOK_SECRETConvexNoResend webhook signing secret
SMTP_RELAY_HOSTConvexIf SMTPGeneric SMTP relay hostname
SMTP_RELAY_PORTConvexNo587SMTP submission port (465 for implicit TLS)
SMTP_RELAY_SECUREConvexNofalsetrue for implicit TLS (465); STARTTLS otherwise
SMTP_RELAY_USERNAMEConvexIf SMTPSMTP relay auth username
SMTP_RELAY_PASSWORDConvexIf SMTPSMTP relay auth password
MTA_API_URLConvexIf MTAMTA service URL
MTA_API_KEYConvexIf MTAMTA API authentication key
MTA_WEBHOOK_SECRETConvexIf MTAHMAC secret for MTA delivery callbacks and (postbox) the MTA's SMTP-submission credential check
MTA_INTERNAL_URLConvexNoMTA URL for attachment scanning
MTA_SPF_INCLUDEConvexNoSPF include: host in generated DNS records (omitted when unset)
SPF_QUALIFIERConvexNo~allTrailing all qualifier for generated SPF records (~all/-all/?all/+all)
MTA_RETURN_PATH_DOMAINConvexNoVERP return-path domain; with MTA_IP_POOLS, generates a return-path SPF record
MTA_IP_POOLSConvexNoComma-separated sending IPs used to build the return-path SPF record
OUTBOUND_DKIM_DOMAINConvexNoThe active transport's DKIM d= domain when it isn't the From-domain; powers the outbound DMARC-alignment guard (relay misalignment warning)
MTA_DMARC_RUAConvexNoDMARC aggregate-report rua= URI in generated _dmarc record (omitted when unset)
MTA_TLSRPT_RUAConvexNoTLS-RPT rua= destination in generated _smtp._tls record (omitted when unset)
OUTBOUND_TLS_MODEMTA + ConvexNoopportunisticBuilt-in MTA outbound TLS floor (opportunistic/require/require-verified); surfaced read-only to the transport editor
MAIL_SYNC_API_URLConvexIf mail.external onBase URL of the mail-sync worker's internal send/test API
MAIL_SYNC_API_KEYConvexIf mail.external onShared key Convex uses to authenticate to the mail-sync worker
TWILIO_AUTH_TOKENConvexIf SMS channelValidates inbound Twilio webhook signatures
META_APP_SECRETConvexIf WhatsApp/Messenger channelValidates inbound Meta webhook HMAC
META_VERIFY_TOKENConvexIf WhatsApp/Messenger channelMeta webhook subscription challenge token
GENERIC_WEBHOOK_SECRETConvexIf generic channelShared secret for the generic inbound channel adapter
DEFAULT_FROM_EMAILConvexNonoreply@example.comDefault sender email
DEFAULT_FROM_NAMEConvexNoOwlatDefault sender name
DEFAULT_FROM_DOMAINConvexNomail.owlat.appDomain for system emails
CONVEX_SITE_URLConvexYesConvex built-in site URL (tracking/unsubscribe) — auto-derived from NUXT_PUBLIC_CONVEX_SITE_URL; do not convex env set it (rejected as EnvVarNameForbidden)
UNSUBSCRIBE_SECRETConvexYesHMAC secret for unsubscribe tokens
BETTER_AUTH_SECRETConvexYesSession signing secret
LLM_PROVIDERConvexIf ai onopenaiLLM provider (openai, openrouter, ollama; Claude via openai + OpenAI-compatible LLM_BASE_URL)
LLM_API_KEYConvexIf ai onAPI key for selected LLM provider
LLM_MODEL_FASTConvexNoprovider-specificFast-tier model id
LLM_MODEL_CAPABLEConvexNoprovider-specificCapable-tier model id
LLM_MODELConvexNoSingle-model fallback for both tiers
LLM_EMBEDDING_MODELConvexNoprovider-specificEmbedding model id (env fallback)
LLM_BASE_URLConvexNoOpenAI-compatible endpoint override
LOCAL_EMBEDDING_BASE_URLConvexNohttp://localhost:11434/v1Local OpenAI-compatible /embeddings sidecar for the local-by-default embedding plane
LOCAL_EMBEDDING_MODELConvexNonomic-embed-textLocal embedding model id; its width must match the vector index
POSTHOG_API_KEYConvexIf analytics.posthog onPostHog project API key (server-side)
POSTHOG_HOSTConvexNohttps://eu.i.posthog.comPostHog instance URL (server-side)
CONTROL_PLANE_URLConvexNo (hosted only)Analytics reporter target for the managed control plane
GOOGLE_SAFE_BROWSING_API_KEYConvexIf scan.urls onURL reputation checking (Safe Browsing API)

Frontend (apps/web)

VariableWhereRequiredDefaultPurpose
NUXT_PUBLIC_CONVEX_URLNuxt .envYesConvex deployment URL
NUXT_PUBLIC_CONVEX_SITE_URLNuxt .envYesConvex site URL for auth
NUXT_PUBLIC_SITE_URLNuxt .envYesPublic site URL
NUXT_PUBLIC_POSTHOG_API_KEYNuxt .envNoPostHog project API key (client-side)
NUXT_PUBLIC_POSTHOG_HOSTNuxt .envNohttps://eu.i.posthog.comPostHog instance URL (client-side)

MTA (apps/mta)

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

IMAP server (apps/imap)

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

VariableWhereRequiredDefaultPurpose
IMAP_PORTIMAP envNo993TCP listen port
IMAP_LISTENIMAP envNo0.0.0.0Bind address
IMAP_GREETING_HOSTIMAP envNoOS hostnameHostname in * OK greeting
IMAP_TLS_CERT / IMAP_TLS_CERT_FILEIMAP envNo (TLS off)TLS certificate (PEM or path)
IMAP_TLS_KEY / IMAP_TLS_KEY_FILEIMAP envNo (TLS off)TLS private key (PEM or path)
TLS_CERT_DIRIMAP envNo/opt/owlat/certsFallback dir for default.crt/default.key
CONVEX_URLIMAP envYesConvex deployment URL
CONVEX_ADMIN_KEYIMAP envYesConvex admin key — the IMAP server's sole auth to Convex (calls mail/appPasswords:verify directly)
LOG_LEVELIMAP envNoinfoLog verbosity. The VPS compose maps IMAP_LOG_LEVEL onto it.
REDIS_URLIMAP envNoRedis 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.