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.
| 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 |
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.
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.
is_available()
→ Error path
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.
alembic/versions/NNN_core_smtp_settings.py
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.