Files
webmail-client/backend/app/config.py
T
lukasandClaude Opus 5.5 8a1b9979e9 Productivity features, UI translations, local email translation
- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:00:31 +02:00

124 lines
7.1 KiB
Python

"""Application configuration.
SPEC §14: the complete env list is read exactly once here (pydantic-settings).
No ``os.environ`` access is permitted anywhere else in the codebase.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""All runtime configuration, sourced from the environment (SPEC §14)."""
model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_domain: str = Field(alias="APP_DOMAIN")
database_url: str = Field(alias="DATABASE_URL")
redis_url: str = Field(alias="REDIS_URL")
master_key: str = Field(alias="MASTER_KEY") # 32 bytes, base64
blob_root: str = Field(default="./var/blobs", alias="BLOB_ROOT")
search_backend: str = Field(default="pg", alias="SEARCH_BACKEND")
meili_url: str = Field(default="http://127.0.0.1:7700", alias="MEILI_URL")
meili_key: str = Field(default="", alias="MEILI_KEY")
undo_seconds: int = Field(default=10, alias="UNDO_SECONDS")
session_ttl_days: int = Field(default=30, alias="SESSION_TTL_DAYS")
max_attachment_mb: int = Field(default=25, alias="MAX_ATTACHMENT_MB")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
# Release identifier baked into the container image at build time; surfaced
# by GET /api/version so a deploy can verify what is actually running.
app_version: str = Field(default="dev", alias="APP_VERSION")
# --- Sync engine v2 (workers layer) --------------------------------------
# Worker role: "sync" (lease-owned account runners) | "jobs" (arq consumer)
# | "all" (both, single-process dev default).
worker_role: str = Field(default="all", alias="WORKER_ROLE")
interactive_concurrency: int = Field(default=20, alias="INTERACTIVE_CONCURRENCY")
bulk_concurrency: int = Field(default=2, alias="BULK_CONCURRENCY")
# Lease protocol (§2): account ownership across sync workers.
max_accounts_per_worker: int = Field(default=400, alias="MAX_ACCOUNTS_PER_WORKER")
lease_ttl: int = Field(default=60, alias="LEASE_TTL")
lease_renew: int = Field(default=20, alias="LEASE_RENEW")
# Activity tiers (§5): hot = persistent IDLE, warm/cold = transient polls.
poll_warm: int = Field(default=180, alias="POLL_WARM")
poll_cold: int = Field(default=900, alias="POLL_COLD")
tier_hot_activity_h: int = Field(default=24, alias="TIER_HOT_ACTIVITY_H")
tier_session_grace_min: int = Field(default=30, alias="TIER_SESSION_GRACE_MIN")
tier_cold_after_d: int = Field(default=3, alias="TIER_COLD_AFTER_D")
hot_budget_per_worker: int = Field(default=250, alias="HOT_BUDGET_PER_WORKER")
# Memory/connection bounds (§7, §4): peak RAM ~= PARSE_CONCURRENCY x STREAM_PARSE_MB.
per_host_conn_cap: int = Field(default=50, alias="PER_HOST_CONN_CAP")
parse_concurrency: int = Field(default=4, alias="PARSE_CONCURRENCY")
stream_parse_mb: int = Field(default=8, alias="STREAM_PARSE_MB")
oauth_google_client_id: str = Field(default="", alias="OAUTH_GOOGLE_CLIENT_ID")
oauth_google_client_secret: str = Field(default="", alias="OAUTH_GOOGLE_CLIENT_SECRET")
oauth_ms_client_id: str = Field(default="", alias="OAUTH_MS_CLIENT_ID")
oauth_ms_client_secret: str = Field(default="", alias="OAUTH_MS_CLIENT_SECRET")
# Gates the "Sign in with Google instead" OAuth fallback in the wizard (SPEC
# §6.2.1). Off until an operator configures a verified Cloud project.
google_oauth_enabled: bool = Field(default=False, alias="GOOGLE_OAUTH_ENABLED")
# Server-side sender avatars (Gravatar + domain favicon). Fetched by the server
# (never the browser) so senders can't see the user's IP or read activity.
sender_avatars_enabled: bool = Field(default=True, alias="SENDER_AVATARS_ENABLED")
# Spelling and grammar checking in the compose window (LanguageTool server,
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
# Email translation: a LibreTranslate server (app/translation.py), e.g.
# http://libretranslate:5000. Empty: translation is off.
libretranslate_url: str = Field(default="", alias="LIBRETRANSLATE_URL")
# New passwords are checked against known data breaches (Have I Been Pwned's
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
# The name the webmail gives itself when it connects to a mail server to
# send (SMTP EHLO). Empty: APP_DOMAIN — not the container's random name.
smtp_helo_name: str = Field(default="", alias="SMTP_HELO_NAME")
# Auto-purge Trash/Spam messages older than this many days (0 disables).
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
# --- Hosted mailboxes (SPEC Appendix D, app/hosted/) -----------------------
# Off: no mail server, byte-identical to before (every account external).
hosted_mailboxes: bool = Field(default=False, alias="HOSTED_MAILBOXES")
# The mail server's management API (JMAP), signed in to as its recovery admin.
stalwart_url: str = Field(default="http://stalwart:8080", alias="STALWART_URL")
stalwart_admin_secret: str = Field(default="", alias="STALWART_ADMIN_SECRET")
# The webmail worker's own sign-in, which may act for any mailbox
# (``user%worker``): no user's password is ever stored (§D.3).
stalwart_master_secret: str = Field(default="", alias="STALWART_MASTER_SECRET")
# The mail server's role on Postgres (its database, and the directory view).
stalwart_db_password: str = Field(default="", alias="STALWART_DB_PASSWORD")
stalwart_db_name: str = Field(default="stalwart", alias="STALWART_DB_NAME")
# Where the mail server reaches Postgres (its own network view of it).
stalwart_pg_host: str = Field(default="postgres", alias="STALWART_PG_HOST")
# IMAP/SMTP as the webmail reaches them (TLS, certificate not checked: the
# traffic stays on the Docker network or loopback).
stalwart_imap_host: str = Field(default="stalwart", alias="STALWART_IMAP_HOST")
stalwart_imap_port: int = Field(default=993, alias="STALWART_IMAP_PORT")
stalwart_smtp_host: str = Field(default="stalwart", alias="STALWART_SMTP_HOST")
stalwart_smtp_port: int = Field(default=465, alias="STALWART_SMTP_PORT")
# The mail server's public name (SMTP greeting, reverse DNS, what mail apps
# are told to connect to). Default: mail.APP_DOMAIN.
mail_hostname: str = Field(default="", alias="MAIL_HOSTNAME")
# Raw messages of hosted mailboxes are kept only in a cache (§D.6 item 2);
# beyond this per user, the least recently used go first.
raw_cache_mb: int = Field(default=256, alias="RAW_CACHE_MB")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return the process-wide settings singleton."""
return Settings() # values are sourced from the environment
def smtp_helo_name() -> str:
"""The name to introduce ourselves with to SMTP servers (EHLO)."""
settings = get_settings()
return settings.smtp_helo_name.strip() or settings.app_domain