Configure SMTP from the interface, with the environment still winning #84

Closed
opened 2026-09-07 13:43:54 +00:00 by tiagoagueda · 1 comment
Owner

Observation

i want that email sending settings smtp, be defined on the UI, (they could be overwritten
on the env, but in that case, the fields show show the values (except password) and greyed
out. if not setted in env, they remain blanck or with placeholder and editible, and should
be accompanied by a test botton, for testing connection setting besides the already
existing one for sending a test email

What exists today

SMTP is configured only through the environment, read once at import into MAILERS:

"host": env("POSTULO_EMAIL_HOST", default="localhost"),
"port": env.int("POSTULO_EMAIL_PORT", default=25),
"username": env("POSTULO_EMAIL_HOST_USER", default=""),
"password": env("POSTULO_EMAIL_HOST_PASSWORD", default=""),
"use_tls": env.bool("POSTULO_EMAIL_USE_TLS", default=True),
"timeout": env.int("POSTULO_EMAIL_TIMEOUT", default=10),

Server settings → Email is read-only: _mailer_summary() prints the backend, host,
port, username, TLS and from-address, and EmailTestView sends one test message. Nothing on
that 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_OVERRIDES maps
a field to the variable that pins it and site.overridden_by() says whether one is set, with
the value stored on SiteSettings when it is not. This extends that mechanism rather than
inventing one — which is most of why it is worth doing this way.

Shape

  1. The fields, on SiteSettings beside the other policy: host, port, username, password,
    TLS, timeout, and the from-address (DEFAULT_FROM_EMAIL, env-only today and part of the
    same question).
  2. Environment wins, and says so. Register each in ENV_OVERRIDES. When pinned, the
    field shows the effective value and cannot be edited; when not, it is blank with a
    placeholder and editable.
  3. A test-connection button beside the existing send-a-test-message one: open the socket,
    STARTTLS, authenticate, NOOP, quit — proving the credentials without sending anything to
    anybody.

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, or
from POSTULO_FIELD_KEY when that is set (plugins/secrets.py). An SMTP password stored in
the database must use the same machinery. A plaintext password in SiteSettings would be a
new class of secret in a system that had deliberately avoided one.

MAILERS is read at import time

This 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 disabled input is skipped by keyboard navigation and announced inconsistently by screen
readers, which the accessibility commitment does not allow. readonly, with
aria-describedby pointing at a line naming the variable that pins it — "set by
POSTULO_EMAIL_HOST; change it there"
— keeps the value reachable, copyable and
explained.

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/8 is the normal case for a self-hosted instance, and this is an administrator
typing their own infrastructure's address. POSTULO_CONNECTIONS_ALLOW_PRIVATE must not apply
here — 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

  1. What happens when a pinned variable is later removed? The stored value silently takes
    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.
  2. Does the from-address move too? Proposal: yes. It is part of "email settings" to
    everyone except the code.
  3. Should a failed connection test block saving? Proposal: no. An administrator may be
    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.

## Observation > i want that email sending settings smtp, be defined on the UI, (they could be overwritten > on the env, but in that case, the fields show show the values (except password) and greyed > out. if not setted in env, they remain blanck or with placeholder and editible, and should > be accompanied by a test botton, for testing connection setting besides the already > existing one for sending a test email ## What exists today SMTP is configured **only** through the environment, read once at import into `MAILERS`: ```python "host": env("POSTULO_EMAIL_HOST", default="localhost"), "port": env.int("POSTULO_EMAIL_PORT", default=25), "username": env("POSTULO_EMAIL_HOST_USER", default=""), "password": env("POSTULO_EMAIL_HOST_PASSWORD", default=""), "use_tls": env.bool("POSTULO_EMAIL_USE_TLS", default=True), "timeout": env.int("POSTULO_EMAIL_TIMEOUT", default=10), ``` *Server settings → Email* is **read-only**: `_mailer_summary()` prints the backend, host, port, username, TLS and from-address, and `EmailTestView` sends one test message. Nothing on that 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_OVERRIDES` maps a field to the variable that pins it and `site.overridden_by()` says whether one is set, with the value stored on `SiteSettings` when it is not. This extends that mechanism rather than inventing one — which is most of why it is worth doing this way. ## Shape 1. **The fields**, on `SiteSettings` beside the other policy: host, port, username, password, TLS, timeout, and the from-address (`DEFAULT_FROM_EMAIL`, env-only today and part of the same question). 2. **Environment wins, and says so.** Register each in `ENV_OVERRIDES`. When pinned, the field shows the effective value and cannot be edited; when not, it is blank with a placeholder and editable. 3. **A test-connection button** beside the existing send-a-test-message one: open the socket, STARTTLS, authenticate, `NOOP`, quit — proving the credentials without sending anything to anybody. ## 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`, or from `POSTULO_FIELD_KEY` when that is set (`plugins/secrets.py`). An SMTP password stored in the database must use the same machinery. A plaintext password in `SiteSettings` would be a new class of secret in a system that had deliberately avoided one. ### `MAILERS` is read at import time This 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 `disabled` input is skipped by keyboard navigation and announced inconsistently by screen readers, which the accessibility commitment does not allow. `readonly`, with `aria-describedby` pointing at a line naming the variable that pins it — *"set by `POSTULO_EMAIL_HOST`; change it there"* — keeps the value reachable, copyable and explained. **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/8` is the normal case for a self-hosted instance, and this is an administrator typing their own infrastructure's address. `POSTULO_CONNECTIONS_ALLOW_PRIVATE` must not apply here — 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 1. **What happens when a pinned variable is later removed?** The stored value silently takes 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. 2. **Does the from-address move too?** Proposal: yes. It is part of "email settings" to everyone except the code. 3. **Should a failed connection test block saving?** Proposal: no. An administrator may be 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.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 13:43:54 +00:00
Author
Owner

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. MAILERS is 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_connection caches 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 no OPTIONS, and drops any it is handed: OPTIONS are 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_EMAIL is 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_tls is a <select>, and readonly does 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 a read-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_by reads os.environ, and settings are read from .env into os.environ at 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, because POSTULO_DEFAULT_FROM_EMAIL=postulo@localhost in 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: /healthz returned 500 instead of 503 on a broken database. UserPreferencesMiddleware reads the instance time zone from the table, in front of every request including the probe, and the existing test only passed because a local .env happened to pin POSTULO_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 ignores OPTIONS; 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 flagged draft.

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.** `MAILERS` is 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_connection` **caches 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 no `OPTIONS`, and drops any it is handed: `OPTIONS` are 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_EMAIL` is 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_tls` is a `<select>`, and **`readonly` does 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 a `read-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_by` reads `os.environ`, and settings are read from `.env` into `os.environ` at 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, because `POSTULO_DEFAULT_FROM_EMAIL=postulo@localhost` in 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: **`/healthz` returned 500 instead of 503 on a broken database.** `UserPreferencesMiddleware` reads the instance time zone from the table, in front of every request including the probe, and the existing test only passed because a local `.env` happened to pin `POSTULO_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 ignores `OPTIONS`; 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 flagged `draft`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#84
No description provided.