A self-hosted LanguageTool (dev: docker/docker-compose.yml, started by `make up`) checks what the user writes in the compose window, through POST /api/spellcheck — the browser never talks to the engine directly. - Marks are CSS highlights over the text, never written into the message: red wavy for spelling, blue for grammar/style. Clicking one shows the message, up to five suggestions (applied as typed, so Ctrl+Z undoes them) and Ignore. The popup, like the link popup, opens above its anchor when there is no room below. - Checked after a pause in typing; the signature and quoted original are skipped; marks on words just edited drop at once; a check overtaken by a newer one, by turning checking off, or by closing the window is discarded. - With "auto", text is checked in chunks of lines, each in its own detected language — LanguageTool settles on one language per request, so a mixed Polish/English message had every English word flagged as a Polish typo. - Settings > Appearance: on/off and language (Auto-detect or any of the engine's languages); the compose window's More menu overrides it for one message. The browser's own spellcheck steps aside while ours runs, and comes back when it is off or the server has no engine (503 from the endpoint). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
4.3 KiB
Python
82 lines
4.3 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")
|
|
# Auto-purge Trash/Spam messages older than this many days (0 disables).
|
|
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def get_settings() -> Settings:
|
|
"""Return the process-wide settings singleton."""
|
|
return Settings() # values are sourced from the environment
|