diff --git a/plain-email/plain/email/README.md b/plain-email/plain/email/README.md index edfe527085..7865ebac86 100644 --- a/plain-email/plain/email/README.md +++ b/plain-email/plain/email/README.md @@ -181,6 +181,29 @@ 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. (The admin's preflight view and toolbar badge include them too, whenever `DEBUG` is False.) + +| 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_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`. + +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", +] +``` + ## 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..89d1549fb4 --- /dev/null +++ b/plain-email/plain/email/preflight.py @@ -0,0 +1,80 @@ +"""Deploy checks for email configuration. + +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. +""" + +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): + """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 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", + ) + ] + + 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/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..afd8327887 --- /dev/null +++ b/plain-email/tests/public/test_preflight.py @@ -0,0 +1,72 @@ +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_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") + + 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() == []