Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions plain-email/plain/email/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions plain-email/plain/email/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
80 changes: 80 additions & 0 deletions plain-email/plain/email/preflight.py
Original file line number Diff line number Diff line change
@@ -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 []
5 changes: 2 additions & 3 deletions plain-email/plain/email/test/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plain-email/plain/email/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
72 changes: 72 additions & 0 deletions plain-email/tests/public/test_preflight.py
Original file line number Diff line number Diff line change
@@ -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() == []
Loading