From 1c6cb3205b67e3c7206f185d80e9b525aa3dd8ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 01:21:33 +0000 Subject: [PATCH 1/2] Add deploy preflight checks for email configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email misconfiguration is a silent failure: the console, preview, and locmem backends all report every send as a success, so nothing raises and no exception tracker sees the loss. Anything depending on email (password resets, login links) just stops working. Add two `deploy=True` preflight checks in plain.email: - `email.backend` (error) flags a non-delivering backend. - `email.smtp_host` (warning) flags the SMTP backend still pointed at the default "localhost". Only a warning — a local mail relay is a legitimate setup — but it closes the gap where a correct-looking SMTP config fails at send time. Backend import paths move to plain/email/backends/__init__.py so the toolbar, the mailoutbox fixture, and the checks share one definition. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015tkqCu7EDmedbxieWcHSLk --- plain-email/plain/email/README.md | 21 +++++++ plain-email/plain/email/backends/__init__.py | 10 +++ plain-email/plain/email/preflight.py | 65 ++++++++++++++++++++ plain-email/plain/email/test/pytest.py | 5 +- plain-email/plain/email/toolbar.py | 2 +- plain-email/tests/public/test_preflight.py | 60 ++++++++++++++++++ 6 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 plain-email/plain/email/preflight.py create mode 100644 plain-email/tests/public/test_preflight.py diff --git a/plain-email/plain/email/README.md b/plain-email/plain/email/README.md index edfe527085..0cff636f92 100644 --- a/plain-email/plain/email/README.md +++ b/plain-email/plain/email/README.md @@ -181,6 +181,27 @@ Captures sent messages in a list instead of delivering them — intended for tes EMAIL_BACKEND = "plain.email.backends.locmem.EmailBackend" ``` +## Deploy checks + +Two [preflight](/plain/plain/preflight/README.md) checks run under `plain preflight --deploy`, catching email config that looks fine but doesn't deliver. + +| Check | Flags | Severity | +| ----------------- | ----------------------------------------------------------------- | -------- | +| `email.backend` | `EMAIL_BACKEND` set to the console, preview, or in-memory backend | Error | +| `email.smtp_host` | SMTP backend with `EMAIL_HOST` still at its default `"localhost"` | Warning | + +`email.backend` is an error because the non-delivering backends report every send as a success — nothing raises, so no exception tracker sees the loss. Anything that depends on email (password resets, login links) silently stops working. + +`email.smtp_host` is only a warning: a mail relay running on the same host is a legitimate setup, in which case `"localhost"` is correct. + +Deployments that intentionally send no email can silence either one: + +```python +PREFLIGHT_SILENCED_RESULTS = [ + "email.backend_does_not_deliver", +] +``` + ## Testing `plain.email` ships a `mailoutbox` pytest fixture. It routes email to the in-memory backend for the duration of a test and yields the captured messages: diff --git a/plain-email/plain/email/backends/__init__.py b/plain-email/plain/email/backends/__init__.py index e69de29bb2..0cd52a6f12 100644 --- a/plain-email/plain/email/backends/__init__.py +++ b/plain-email/plain/email/backends/__init__.py @@ -0,0 +1,10 @@ +"""Import paths for the email backends Plain ships. + +Defined here so the settings value, the toolbar, the test fixture, and the +preflight checks all compare against the same strings. +""" + +SMTP_BACKEND = "plain.email.backends.smtp.EmailBackend" +CONSOLE_BACKEND = "plain.email.backends.console.EmailBackend" +PREVIEW_BACKEND = "plain.email.backends.preview.EmailBackend" +LOCMEM_BACKEND = "plain.email.backends.locmem.EmailBackend" diff --git a/plain-email/plain/email/preflight.py b/plain-email/plain/email/preflight.py new file mode 100644 index 0000000000..c51c6f4e0e --- /dev/null +++ b/plain-email/plain/email/preflight.py @@ -0,0 +1,65 @@ +"""Deploy checks for email configuration. + +Both run only under `plain preflight --deploy`, and only when +`plain.email` is installed — the preflight autodiscovery imports this +module per installed package. +""" + +from __future__ import annotations + +from plain.preflight import PreflightCheck, PreflightResult, register_check +from plain.runtime import settings + +from .backends import CONSOLE_BACKEND, LOCMEM_BACKEND, PREVIEW_BACKEND, SMTP_BACKEND +from .default_settings import EMAIL_HOST as DEFAULT_EMAIL_HOST + +# What each non-delivering backend does with a message instead of sending it. +NON_DELIVERING_BACKENDS = { + CONSOLE_BACKEND: "prints email to the console", + PREVIEW_BACKEND: "writes email to .eml files in .plain/emails/", + LOCMEM_BACKEND: "keeps email in memory for tests", +} + + +@register_check(name="email.backend", deploy=True) +class CheckEmailBackend(PreflightCheck): + """Ensures EMAIL_BACKEND actually delivers email in production deployment.""" + + def run(self) -> list[PreflightResult]: + behavior = NON_DELIVERING_BACKENDS.get(settings.EMAIL_BACKEND) + if not behavior: + return [] + + return [ + PreflightResult( + fix=f"EMAIL_BACKEND {behavior} instead of delivering it. " + f"Set EMAIL_BACKEND={SMTP_BACKEND!r} (or another delivering backend) " + "so password resets, login links, and other email reach recipients. " + "Sending succeeds silently with this backend, so nothing else will " + "report the loss.", + id="email.backend_does_not_deliver", + ) + ] + + +@register_check(name="email.smtp_host", deploy=True) +class CheckEmailSMTPHost(PreflightCheck): + """Warns when the SMTP backend is still pointed at the default host.""" + + def run(self) -> list[PreflightResult]: + if settings.EMAIL_BACKEND != SMTP_BACKEND: + return [] + + if settings.EMAIL_HOST != DEFAULT_EMAIL_HOST: + return [] + + return [ + PreflightResult( + fix=f"EMAIL_HOST is still the default {DEFAULT_EMAIL_HOST!r} while using the SMTP " + "backend. That only delivers if a mail relay is running on the same host — " + "otherwise every send fails. Set EMAIL_HOST to your mail provider, or silence " + "this result if you do run a local relay.", + id="email.smtp_host_is_default", + warning=True, + ) + ] diff --git a/plain-email/plain/email/test/pytest.py b/plain-email/plain/email/test/pytest.py index 1f9cf3ea4d..d0e4bd7a1f 100644 --- a/plain-email/plain/email/test/pytest.py +++ b/plain-email/plain/email/test/pytest.py @@ -5,11 +5,10 @@ from collections.abc import Generator import pytest +from plain.email.backends import LOCMEM_BACKEND from plain.email.backends.locmem import outbox from plain.runtime import settings -_LOCMEM_BACKEND = "plain.email.backends.locmem.EmailBackend" - @pytest.fixture def mailoutbox() -> Generator[list]: @@ -20,7 +19,7 @@ def mailoutbox() -> Generator[list]: original backend afterward. """ original = settings.EMAIL_BACKEND - settings.EMAIL_BACKEND = _LOCMEM_BACKEND + settings.EMAIL_BACKEND = LOCMEM_BACKEND outbox.clear() try: yield outbox diff --git a/plain-email/plain/email/toolbar.py b/plain-email/plain/email/toolbar.py index 3365e7d859..6d23dc6386 100644 --- a/plain-email/plain/email/toolbar.py +++ b/plain-email/plain/email/toolbar.py @@ -7,9 +7,9 @@ from plain.runtime import settings from plain.toolbar import ToolbarItem, register_toolbar_item +from .backends import PREVIEW_BACKEND from .backends.preview import EMAIL_DIR -PREVIEW_BACKEND = "plain.email.backends.preview.EmailBackend" MAX_MESSAGES = 20 diff --git a/plain-email/tests/public/test_preflight.py b/plain-email/tests/public/test_preflight.py new file mode 100644 index 0000000000..8222b5b7f1 --- /dev/null +++ b/plain-email/tests/public/test_preflight.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import pytest +from plain.email.backends import ( + CONSOLE_BACKEND, + LOCMEM_BACKEND, + PREVIEW_BACKEND, + SMTP_BACKEND, +) +from plain.email.preflight import CheckEmailBackend, CheckEmailSMTPHost +from plain.runtime import settings + + +@pytest.mark.parametrize("backend", [CONSOLE_BACKEND, PREVIEW_BACKEND, LOCMEM_BACKEND]) +def test_non_delivering_backend_is_an_error(monkeypatch, backend): + monkeypatch.setattr(settings, "EMAIL_BACKEND", backend) + + results = CheckEmailBackend().run() + + assert len(results) == 1 + assert results[0].id == "email.backend_does_not_deliver" + assert not results[0].warning + + +def test_smtp_backend_passes(monkeypatch): + monkeypatch.setattr(settings, "EMAIL_BACKEND", SMTP_BACKEND) + + assert CheckEmailBackend().run() == [] + + +def test_third_party_backend_passes(monkeypatch): + monkeypatch.setattr(settings, "EMAIL_BACKEND", "myapp.email.SendgridBackend") + + assert CheckEmailBackend().run() == [] + + +def test_smtp_host_left_at_default_warns(monkeypatch): + monkeypatch.setattr(settings, "EMAIL_BACKEND", SMTP_BACKEND) + monkeypatch.setattr(settings, "EMAIL_HOST", "localhost") + + results = CheckEmailSMTPHost().run() + + assert len(results) == 1 + assert results[0].id == "email.smtp_host_is_default" + assert results[0].warning + + +def test_smtp_host_configured_passes(monkeypatch): + monkeypatch.setattr(settings, "EMAIL_BACKEND", SMTP_BACKEND) + monkeypatch.setattr(settings, "EMAIL_HOST", "smtp.example.com") + + assert CheckEmailSMTPHost().run() == [] + + +def test_smtp_host_not_checked_for_other_backends(monkeypatch): + """The backend check already covers these — don't report the host too.""" + monkeypatch.setattr(settings, "EMAIL_BACKEND", CONSOLE_BACKEND) + monkeypatch.setattr(settings, "EMAIL_HOST", "localhost") + + assert CheckEmailSMTPHost().run() == [] From 303257e35a6738fc241348f498a5aa4791254220 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 01:29:11 +0000 Subject: [PATCH 2/2] Address code review on email deploy checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - email.smtp_host now flags an empty EMAIL_HOST as an error. smtplib skips connecting when the host is falsy, so starttls() raises SMTPServerDisconnected on the first send — exactly the failure the check exists to catch, previously passing because it only matched "localhost". - Document both silenceable result ids in the README. The local-relay setup the docs call legitimate had no id to copy, and a guess would trip preflight.unused_silence. - Correct the module docstring: deploy checks also run from the admin preflight view and toolbar badge whenever DEBUG is False, not only under `plain preflight --deploy`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015tkqCu7EDmedbxieWcHSLk --- plain-email/plain/email/README.md | 16 ++++---- plain-email/plain/email/preflight.py | 45 ++++++++++++++-------- plain-email/tests/public/test_preflight.py | 12 ++++++ 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/plain-email/plain/email/README.md b/plain-email/plain/email/README.md index 0cff636f92..7865ebac86 100644 --- a/plain-email/plain/email/README.md +++ b/plain-email/plain/email/README.md @@ -183,22 +183,24 @@ EMAIL_BACKEND = "plain.email.backends.locmem.EmailBackend" ## Deploy checks -Two [preflight](/plain/plain/preflight/README.md) checks run under `plain preflight --deploy`, catching email config that looks fine but doesn't deliver. +Two [preflight](/plain/plain/preflight/README.md) checks run under `plain preflight --deploy`, catching email config that looks fine but doesn't deliver. (The admin's preflight view and toolbar badge include them too, whenever `DEBUG` is False.) -| Check | Flags | Severity | -| ----------------- | ----------------------------------------------------------------- | -------- | -| `email.backend` | `EMAIL_BACKEND` set to the console, preview, or in-memory backend | Error | -| `email.smtp_host` | SMTP backend with `EMAIL_HOST` still at its default `"localhost"` | Warning | +| Check | Result id | Flags | Severity | +| ----------------- | -------------------------------- | ----------------------------------------------------------------- | -------- | +| `email.backend` | `email.backend_does_not_deliver` | `EMAIL_BACKEND` set to the console, preview, or in-memory backend | Error | +| `email.smtp_host` | `email.smtp_host_empty` | SMTP backend with an empty `EMAIL_HOST` | Error | +| `email.smtp_host` | `email.smtp_host_is_default` | SMTP backend with `EMAIL_HOST` still at its default `"localhost"` | Warning | `email.backend` is an error because the non-delivering backends report every send as a success — nothing raises, so no exception tracker sees the loss. Anything that depends on email (password resets, login links) silently stops working. -`email.smtp_host` is only a warning: a mail relay running on the same host is a legitimate setup, in which case `"localhost"` is correct. +`email.smtp_host_is_default` is only a warning: a mail relay running on the same host is a legitimate setup, in which case `"localhost"` is correct. An empty `EMAIL_HOST` is an error — `smtplib` never connects, so every send raises `SMTPServerDisconnected`. -Deployments that intentionally send no email can silence either one: +Silence any of them by result id — a deployment that intentionally sends no email, or one that really does deliver through a local relay: ```python PREFLIGHT_SILENCED_RESULTS = [ "email.backend_does_not_deliver", + "email.smtp_host_is_default", ] ``` diff --git a/plain-email/plain/email/preflight.py b/plain-email/plain/email/preflight.py index c51c6f4e0e..89d1549fb4 100644 --- a/plain-email/plain/email/preflight.py +++ b/plain-email/plain/email/preflight.py @@ -1,7 +1,9 @@ """Deploy checks for email configuration. -Both run only under `plain preflight --deploy`, and only when -`plain.email` is installed — the preflight autodiscovery imports this +Registered with `deploy=True`, so they run under `plain preflight --deploy` +and anywhere else deploy checks are included — the admin preflight view and +the toolbar badge pull them in whenever `DEBUG` is False. They exist only +when `plain.email` is installed, since preflight autodiscovery imports this module per installed package. """ @@ -44,22 +46,35 @@ def run(self) -> list[PreflightResult]: @register_check(name="email.smtp_host", deploy=True) class CheckEmailSMTPHost(PreflightCheck): - """Warns when the SMTP backend is still pointed at the default host.""" + """Ensures the SMTP backend points at a mail server in production deployment.""" def run(self) -> list[PreflightResult]: if settings.EMAIL_BACKEND != SMTP_BACKEND: + # A non-delivering backend is email.backend's to report, and a + # third-party backend may not read EMAIL_HOST at all. return [] - if settings.EMAIL_HOST != DEFAULT_EMAIL_HOST: - return [] + if not settings.EMAIL_HOST: + # smtplib skips connecting when the host is empty, so the first + # send raises SMTPServerDisconnected. Never intentional. + return [ + PreflightResult( + fix="EMAIL_HOST is empty while using the SMTP backend, so every send fails " + "with SMTPServerDisconnected. Set EMAIL_HOST to your mail server.", + id="email.smtp_host_empty", + ) + ] - return [ - PreflightResult( - fix=f"EMAIL_HOST is still the default {DEFAULT_EMAIL_HOST!r} while using the SMTP " - "backend. That only delivers if a mail relay is running on the same host — " - "otherwise every send fails. Set EMAIL_HOST to your mail provider, or silence " - "this result if you do run a local relay.", - id="email.smtp_host_is_default", - warning=True, - ) - ] + if settings.EMAIL_HOST == DEFAULT_EMAIL_HOST: + return [ + PreflightResult( + fix=f"EMAIL_HOST is still the default {DEFAULT_EMAIL_HOST!r} while using the " + "SMTP backend. That only delivers if a mail relay is running on this host — " + "otherwise every send fails. Set EMAIL_HOST to your mail server, or silence " + "this result if you do run a local relay.", + id="email.smtp_host_is_default", + warning=True, + ) + ] + + return [] diff --git a/plain-email/tests/public/test_preflight.py b/plain-email/tests/public/test_preflight.py index 8222b5b7f1..afd8327887 100644 --- a/plain-email/tests/public/test_preflight.py +++ b/plain-email/tests/public/test_preflight.py @@ -34,6 +34,18 @@ def test_third_party_backend_passes(monkeypatch): assert CheckEmailBackend().run() == [] +def test_smtp_host_empty_is_an_error(monkeypatch): + """An empty host leaves smtplib unconnected — every send raises.""" + monkeypatch.setattr(settings, "EMAIL_BACKEND", SMTP_BACKEND) + monkeypatch.setattr(settings, "EMAIL_HOST", "") + + results = CheckEmailSMTPHost().run() + + assert len(results) == 1 + assert results[0].id == "email.smtp_host_empty" + assert not results[0].warning + + def test_smtp_host_left_at_default_warns(monkeypatch): monkeypatch.setattr(settings, "EMAIL_BACKEND", SMTP_BACKEND) monkeypatch.setattr(settings, "EMAIL_HOST", "localhost")