← Email

Data model

email · workzone

The transport stores exactly one thing: the SMTP settings. A singleton smtp_settings in the shared core, the password as ciphertext, readiness to send as a predicate on the model. The service, the modes and delivery errors — in delivery.

1 Transport configuration Singleton. One row per instance, secret at rest.
smtp_settings SMTP settings
singleton · CHECK (id = 1)
id BigInteger PKCHECK always 1 · singleton
host Text NULL server address · NULL until configured
port Integer NULL port · NULL until configured
security Text NOT NULLDEFAULTCHECK none · starttls · ssl_tls · DEFAULT 'starttls'
username Text NULL SMTP account login
password_enc Text NULL AES-256-GCM ciphertext · → encryption
from_address Text NULL RFC 5322 · display name + sender address
is_enabled Boolean NOT NULLDEFAULT master switch · DEFAULT false
last_test_ok Boolean NULL result of “Test connection” · NULL = never tested
last_test_at DateTime(tz) NULL when tested · NULL = never tested
created_at DateTime(tz) DEFAULT server_default=now()
updated_at DateTime(tz) DEFAULT now() + trigger
A singleton in core, separate from platform_settings → platform_settings
Its own table, not columns in platform_settings: a secret and runtime state (last_test_*) don't bloat the shared settings. The ORM model is in core/models.py (the shared core, like PlatformSettings): it's read by both Email and its consumers (invite, reset), with no cycles between modules. Email owns the behavior; the editing screen and the test button are in the Admin Panel.
The password — encryption at rest → Crypto core
password_enc is stored as ciphertext, not as a plaintext password. It can't be hashed — logging in to the server needs the original password, so reversible AES-256-GCM. The cipher and key handling are the crypto core of Auth / Security, and Email is its consumer, running no mechanism of its own. Decryption happens only at the moment of send; the password never leaves outward (API, export) — the UI receives only a “set / not set” flag. The credential is a service one — an app password or provider API key for a system mailbox (no-reply@…), not a person's login; the screen labels it “Password or API key” to say so, and hints at App Passwords for Gmail / Office 365.
Optionality and liveness — is_available() → Error path
Transport readiness is one predicate, a property of the model: is_enabled AND the required fields are filled (host, port, from_address). It lives on the model in core — Email and its consumers (invite, reset) read one truth, rather than reassembling it from columns. last_test_ok is advice, not a gate: “never tested” ≠ “broken”, and the real delivery insurance is queue retries. It's a best-effort pre-check for UX (dim the button, pick a fallback), not a delivery guarantee: SMTP can go down at the moment of sending — the error path. How exactly a consumer degrades (invite blocked, reset via a temporary password) is its own call, described where it lives.
2 Migration Creates the table and seeds the singleton at once.
alembic/versions/NNN_core_smtp_settings.py
Creates the smtp_settings table and, in the same step, seeds the singleton with an is_enabled = false row with NULL fields — “not configured” out of the box; the application always does an UPDATE, never an INSERT. downgrade() is DROP TABLE.