Configure SMTP from the interface, with the environment still winning #84
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Depends on
Reference
Postulo/postulo#84
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
What exists today
SMTP is configured only through the environment, read once at import into
MAILERS:Server settings → Email is read-only:
_mailer_summary()prints the backend, host,port, username, TLS and from-address, and
EmailTestViewsends one test message. Nothing onthat page can change anything, so configuring email means editing a file and restarting the
container.
The pattern asked for already exists, for four other settings:
site.ENV_OVERRIDESmapsa field to the variable that pins it and
site.overridden_by()says whether one is set, withthe value stored on
SiteSettingswhen it is not. This extends that mechanism rather thaninventing one — which is most of why it is worth doing this way.
Shape
SiteSettingsbeside the other policy: host, port, username, password,TLS, timeout, and the from-address (
DEFAULT_FROM_EMAIL, env-only today and part of thesame question).
ENV_OVERRIDES. When pinned, thefield shows the effective value and cannot be edited; when not, it is blank with a
placeholder and editable.
STARTTLS, authenticate,
NOOP, quit — proving the credentials without sending anything toanybody.
The parts that are not the form
The password must be encrypted at rest
Postulo already encrypts plugin connection secrets under a key derived from
SECRET_KEY, orfrom
POSTULO_FIELD_KEYwhen that is set (plugins/secrets.py). An SMTP password stored inthe database must use the same machinery. A plaintext password in
SiteSettingswould be anew class of secret in a system that had deliberately avoided one.
MAILERSis read at import timeThis is the actual engineering content. Editing a setting in the interface must take effect
without restarting the container, so mail can no longer be sent through a backend frozen at
startup — the connection has to be built from the stored values at send time, behind a
backend of Postulo's own. Anything less produces a page that appears to save and changes
nothing until somebody restarts, which is worse than not having the page.
"Greyed out" has to mean readonly, not disabled
A
disabledinput is skipped by keyboard navigation and announced inconsistently by screenreaders, which the accessibility commitment does not allow.
readonly, witharia-describedbypointing at a line naming the variable that pins it — "set byPOSTULO_EMAIL_HOST; change it there" — keeps the value reachable, copyable andexplained.
And the server must refuse the write regardless. A readonly attribute is presentation;
a form that accepts a pinned field because the browser was told not to send it is a form
that can be posted directly.
The password is never shown, not even its length
Pinned or stored, the page says whether one is set and nothing else. A masked field of the
right length leaks the length.
The SMTP host is deliberately not subject to the private-address rule
Capture refuses private addresses because the URL came from a stranger's page. An SMTP relay
on
10.0.0.0/8is the normal case for a self-hosted instance, and this is an administratortyping their own infrastructure's address.
POSTULO_CONNECTIONS_ALLOW_PRIVATEmust not applyhere — written down so nobody later "fixes" it by applying the capture rule.
Test the values in the form, not the values in the database
Otherwise a broken configuration has to be saved before it can be tested, and testing means
first breaking whatever worked. The connection test takes what is on screen.
Open questions
over, and mail starts going somewhere else without anybody editing anything. Proposal: the
page says plainly, whenever both exist, that a stored value is being shadowed and what
would happen if the variable went away.
everyone except the code.
configuring a relay that is not up yet, and refusing to save what somebody typed is
rarely right; warn and save.
Classification
Enhancement. Not breaking: an instance configured through the environment keeps working
exactly as it does, which is what makes this safe to land.
Done in
b433a62. Every part of the shape above is in, and the three open questions took the answers proposed: the from-address moved, a failing connection test warns rather than blocks, and shadowing is said out loud.The engineering content was where the issue said it would be.
MAILERSis built once at import, so the fix is that Postulo's own backend resolves the settings when a message is sent. What made that clean is a detail of Django 6.1 worth recording:MailersHandler.create_connectioncaches nothing — it constructs a fresh backend for every send — so resolving in__init__picks up a change on the next message with no restart and no signal to wire up. The backend also takes noOPTIONS, and drops any it is handed:OPTIONSare precisely the frozen values this exists to avoid, and accepting them would leave two places to look when the wrong relay is being used.The from-address needed the same place.
DEFAULT_FROM_EMAILis read at send time by Django's code and allauth's, and neither offers a hook, so the backend stamps it — on messages that did not name a sender, leaving one that did alone."Greyed out has to mean readonly, not disabled" turned up a case the issue did not anticipate:
email_use_tlsis a<select>, andreadonlydoes nothing on a select. Marking one readonly lets the browser change it and has the server refuse in silence, which is the worst of both. A pinned choice therefore renders as a readonly text box holding the label it resolves to — still a labelled control in the tab order, simply one whose value is settled elsewhere. "Greyed out" itself is aread-only:variant on the field style: muted ground,cursor-not-allowed, text at full contrast.Two bugs found on the way, from one cause.
overridden_byreadsos.environ, and settings are read from.envintoos.environat import — so the test suite a machine ran depended on whether that machine had a.env. My test that saves a from-address failed locally and would have passed in CI, becausePOSTULO_DEFAULT_FROM_EMAIL=postulo@localhostin an untracked file was pinning the field. An autouse fixture now clears the override variables for every test.That in turn exposed something worse, and it belongs to #82 rather than here:
/healthzreturned 500 instead of 503 on a broken database.UserPreferencesMiddlewarereads the instance time zone from the table, in front of every request including the probe, and the existing test only passed because a local.envhappened to pinPOSTULO_TIME_ZONE. The endpoint written to answer while the database is down could not. Fixed here, since the fixture is what surfaced it: the middleware falls back to the environment rather than taking the request down.Not in scope, and worth its own decision: implicit TLS on port 465 (
use_ssl). The issue names the field list explicitly and 465 is not in it, nor in the environment today, so nothing regressed — but an operator whose relay only speaks 465 still cannot use this page. The STARTTLS field says so in its help text. Say the word and I will file it.Tests are in
tests/test_email_settings.py: the environment cannot be written over by posting anyway; the password is encrypted at rest and never rendered; the backend takes the settings in force when it is built and ignoresOPTIONS; a password under a rotated key falls back rather than stopping mail; the connection test uses what is on screen and falls back to the stored password; a failure does not block saving. 27 new strings, translated into all 24 catalogues and flaggeddraft.