Skip to main content

Configuration

Copy .env.example to .env. Production (DEBUG=false) refuses to boot without:

  • SECRET_KEY
  • IDENTITY_ISSUER and IDENTITY_AUDIENCE
  • IDENTITY_JWKS or IDENTITY_JWKS_FILE (a pinned document, not a runtime JWKS URL)
  • POSTGRES_DATABASE_URL
  • REDIS_URL (rate limits)
  • EMAIL_CREDENTIALS_KEY, EMAIL_VARIABLES_KEY, EMAIL_HASH_PEPPER (Fernet keys and the HMAC pepper)

Generate a Fernet key:

python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

When DEBUG=true, those three keys are derived from SECRET_KEY so local runs need no extra secrets. Do not use that derivation in production.

First superuser​

With DEBUG=true, an empty database shows a one-time form on /. In production, leave SETUP_TOKEN empty and create the first superuser with:

uv run python manage.py createsuperuser

The web form stays closed when DEBUG=false and SETUP_TOKEN is empty. Set SETUP_TOKEN only when you need that form once, then open /?setup_token=<token>.

Addresses​

VariableDefaultRole
EMAIL_SERVICE_PORT8003Host port for Docker Compose. The container still listens on 8000. Identity is 8000, storage 8001, hosting 8002.
PUBLIC_BASE_URLhttps://email.shellui.comUnsubscribe links and the public origin
EMAIL_PUBLIC_URLPUBLIC_BASE_URLOrigin recipients load library images from ({EMAIL_PUBLIC_URL}/static/library/). It must be reachable from the internet.
DEFAULT_FROM_EMAILno-reply@shellui.comAuth and transactional fallback
DEFAULT_FROM_NAMEShelluiDisplay name
BULK_FROM_EMAILnews@news.shellui.comBroadcast sender for Shellui companies without a Bulk From
BULK_FROM_NAMEShelluiDisplay name for BULK_FROM_EMAIL
EMAIL_AUTH_LINK_HOSTSid.shellui.com plus localhost only when DEBUG=trueHosts allowed in auth URL variables. DEBUG=false drops localhost, 127.0.0.1, and ::1.
EMAIL_PLATFORM_COMPANY_IDSemptyCompany ids that may use the platform From for non-auth mail
EMAIL_ALLOW_COMPANY_SMTPfalseAllow a company to store an SMTP relay
EMAIL_COMPANY_AUTH_LIMIT30Auth messages per company per window
EMAIL_COMPANY_AUTH_WINDOW_SECONDS60Window for the company auth cap
CORS_ALLOW_ALL_ORIGINStrue when DEBUG=true, otherwise falseSet explicitly to widen browser origins

email.shellui.com is the API host. Mail is not sent from that domain.

Provider fallback​

VariableDefaultRole
EMAIL_FALLBACK_PROVIDERresendresend, smtp, or none
RESEND_API_KEYemptyPlatform Resend key
RESEND_WEBHOOK_SECRETemptySvix secret for the platform Resend account
EMAIL_HOST and relatedemptyPlatform SMTP

Broadcasts​

VariableDefaultRole
IDENTITY_SERVICE_URLhttp://localhost:8000 when DEBUG=true, otherwise emptyIdentity origin. Broadcasts read their audience from /api/v1/users/audience with the caller's JWT.
EMAIL_BROADCAST_MAX_RECIPIENTS10000Largest audience one broadcast may have
EMAIL_RESEND_BROADCAST_RPS4Resend calls per second while preparing a broadcast. Resend allows 10 per team, shared with every other call on the key.
EMAIL_BROADCAST_STEP_SECONDS20Time one worker pass spends on broadcasts before it returns to messages

See broadcasts.md.

Newsletters​

VariableDefaultRole
EMAIL_NEWSLETTER_IP_PER_HOUR10Public sign-ups per IP per hour
EMAIL_NEWSLETTER_ADDRESS_PER_DAY3Confirmation emails per address and list per day
EMAIL_NEWSLETTER_LIST_PER_HOUR500Public sign-ups per list per hour
EMAIL_NEWSLETTER_RESEND_MINUTES10Minimum wait before a pending address gets another confirmation email
EMAIL_NEWSLETTER_CONFIRM_HOURS48How long a confirmation link works
EMAIL_NEWSLETTER_PENDING_DAYS7Unconfirmed sign-ups are deleted, and addresses of unsubscribed people cleared, after this many days (purge_expired_data)
EMAIL_CLIENT_IP_HEADERemptyHeader carrying the visitor IP behind a proxy, for example CF-Connecting-IP. Empty uses the connection address.
EMAIL_TURNSTILE_VERIFY_URLCloudflare siteverifyTurnstile verification endpoint

See newsletters.md.

Composing​

VariableDefaultRole
EMAIL_NODE_BINARYnodeNode used to run renderer/compose.mjs on every save. Run npm ci in the service root first.
EMAIL_COMPOSE_TIMEOUT_SECONDS60A compose that runs longer fails with 503 renderer_unavailable
EMAIL_MAX_DOCUMENT_BYTES524288Largest editor document accepted, as JSON (too_large above it)

Workers and retention​

VariableDefault
EMAIL_DELIVER_SYNCfalse
EMAIL_AUTH_DEFAULT_TTL_SECONDS120
EMAIL_AUTH_MAX_TTL_SECONDS300
EMAIL_MESSAGE_RETENTION_DAYS30
EMAIL_IDEMPOTENCY_HOURS24
EMAIL_COMPANY_TRANSACTIONAL_PER_HOUR1000
EMAIL_RECIPIENT_AUTH_LIMIT5
EMAIL_RECIPIENT_AUTH_WINDOW_SECONDS600
EVENT_LOG_RETENTION_DAYS7
ACTIONS_WEBHOOK_TIMEOUT_SECONDS5
ACTIONS_OUTBOX_MAX_ATTEMPTS8

Processes:

  • Gunicorn serves HTTP (the image entrypoint).
  • manage.py run_email_worker sends queued mail.
  • manage.py retry_webhooks every minute.
  • manage.py purge_expired_data every hour.