Skip to content
Merged
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
19 changes: 15 additions & 4 deletions codex/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
Codex email backend.

Reads SMTP connection params from the :class:`EmailSettings` singleton
on each :func:`get_connection` call (per Django's mail API), falling
back to :mod:`django.conf.settings` (TOML / env / default). A missing
host short-circuits :meth:`send_messages` to a no-op so feature gates
that fail open do not raise — they are guarded upstream by
each time ``django.core.mail.mailers`` builds the ``MAILERS["default"]``
connection, falling back to the ``EMAIL_CONNECTION_OPTIONS`` setting
(TOML / env / default). A missing host short-circuits
:meth:`send_messages` to a no-op so feature gates that fail open do not
raise — they are guarded upstream by
:func:`codex.settings.db.email_enabled`.
"""

Expand All @@ -16,6 +17,11 @@

from codex.settings.db import get_email_connection_kwargs

# Django's mail.handler.DEFAULT_MAILER_ALIAS — the MAILERS key in
# codex.settings. Copied because django-types ships no stub for the
# handler module, so importing it fails typechecking.
_DEFAULT_MAILER_ALIAS = "default"


class DBEmailBackend(EmailBackend):
"""SMTP backend that sources connection params from the DB on init."""
Expand All @@ -35,6 +41,11 @@ def __init__( # noqa: PLR0913, PLR0917 - signature mirrors SMTPBackend.__init__
**kwargs,
):
"""Resolve any explicit None to DB → settings before delegating to SMTP."""
# Without an alias the SMTP parent resolves missing params from
# the pre-MAILERS EMAIL_* settings, which raise AttributeError
# now that MAILERS is defined — so direct construction (the
# admin test-send view) must claim the default alias too.
kwargs.setdefault("alias", _DEFAULT_MAILER_ALIAS)
resolved = get_email_connection_kwargs()
super().__init__(
host=resolved["host"] if host is None else host,
Expand Down
41 changes: 28 additions & 13 deletions codex/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,18 +193,27 @@ def _vite_dev_server_host() -> str:
# Codex Config: Email #
##############################

EMAIL_HOST = get_str(CODEX_CONFIG, "email.host", default="")
EMAIL_PORT = get_int(CODEX_CONFIG, "email.port", default=587)
EMAIL_HOST_USER = get_str(CODEX_CONFIG, "email.user", default="")
EMAIL_HOST_PASSWORD = get_str(CODEX_CONFIG, "email.password", default="")
EMAIL_USE_TLS = get_bool(CODEX_CONFIG, "email.use_tls", default=True)
EMAIL_USE_SSL = get_bool(CODEX_CONFIG, "email.use_ssl", default=False)
EMAIL_TIMEOUT = get_int(CODEX_CONFIG, "email.timeout", default=10)
# Fall back to EMAIL_HOST_USER when from_address is blank. Many providers
# (gmail, generic SMTP) accept the auth user as sender; SES and similar
# require an explicit verified identity - admin docs call this out.
# Django deprecated the discrete EMAIL_* connection settings in favor of
# the MAILERS dict (RemovedInDjango70Warning), and once MAILERS is
# defined, reading the old names raises AttributeError. The TOML/env
# layer lives in this dict instead, keyed by EmailBackend constructor
# kwarg; ``codex.settings.db.get_email_connection_kwargs`` coalesces the
# EmailSettings DB row over it per field.
EMAIL_CONNECTION_OPTIONS = {
"host": get_str(CODEX_CONFIG, "email.host", default=""),
"port": get_int(CODEX_CONFIG, "email.port", default=587),
"username": get_str(CODEX_CONFIG, "email.user", default=""),
"password": get_str(CODEX_CONFIG, "email.password", default=""),
"use_tls": get_bool(CODEX_CONFIG, "email.use_tls", default=True),
"use_ssl": get_bool(CODEX_CONFIG, "email.use_ssl", default=False),
"timeout": get_int(CODEX_CONFIG, "email.timeout", default=10),
}
# Fall back to the SMTP auth user when from_address is blank. Many
# providers (gmail, generic SMTP) accept the auth user as sender; SES and
# similar require an explicit verified identity - admin docs call this out.
DEFAULT_FROM_EMAIL = (
get_str(CODEX_CONFIG, "email.from_address", default="") or EMAIL_HOST_USER
get_str(CODEX_CONFIG, "email.from_address", default="")
or EMAIL_CONNECTION_OPTIONS["username"]
)
SERVER_EMAIL = DEFAULT_FROM_EMAIL
EMAIL_SUBJECT_PREFIX = get_str(CODEX_CONFIG, "email.subject_prefix", default="[Codex] ")
Expand All @@ -215,11 +224,17 @@ def _vite_dev_server_host() -> str:
# ``codex.settings.db.email_enabled`` and is used by the request-time
# callers (``views/register.py``, ``views/session.py``,
# ``startup/registration.py``).
EMAIL_ENABLED = bool(EMAIL_HOST and DEFAULT_FROM_EMAIL)
EMAIL_ENABLED = bool(EMAIL_CONNECTION_OPTIONS["host"] and DEFAULT_FROM_EMAIL)
# Always use the DB-aware backend so admin edits via the Email tab
# take effect on the next send without a restart. The backend
# gracefully no-ops when neither DB nor settings provide a host.
EMAIL_BACKEND = "codex.mail.DBEmailBackend"
# No OPTIONS on purpose: baking connection values in here would defeat
# the backend's per-send DB -> EMAIL_CONNECTION_OPTIONS resolution.
MAILERS = {
"default": {
"BACKEND": "codex.mail.DBEmailBackend",
},
}

##############################
# Codex Config: Importer #
Expand Down
32 changes: 13 additions & 19 deletions codex/settings/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,31 +135,23 @@ def get_email_connection_kwargs() -> dict[str, Any]:
"""
Return kwargs to pass to :class:`django.core.mail.backends.smtp.EmailBackend`.

Pulls each field from the DB row first, falling back to the
matching Django setting (which itself sources from TOML / env /
default). Boolean toggles use ``getattr`` so a missing row doesn't
crash callers.
Pulls each field from the DB row first, falling back to the matching
entry in the ``EMAIL_CONNECTION_OPTIONS`` setting (which itself
sources from TOML / env / default).
"""
opts = settings.EMAIL_CONNECTION_OPTIONS
db = get_email_settings()
if db is None:
return {
"host": settings.EMAIL_HOST,
"port": settings.EMAIL_PORT,
"username": settings.EMAIL_HOST_USER,
"password": settings.EMAIL_HOST_PASSWORD,
"use_tls": settings.EMAIL_USE_TLS,
"use_ssl": settings.EMAIL_USE_SSL,
"timeout": settings.EMAIL_TIMEOUT,
}
return dict(opts)
return {
"host": _coalesce(db.host, settings.EMAIL_HOST),
"port": db.port or settings.EMAIL_PORT,
"username": _coalesce(db.user, settings.EMAIL_HOST_USER),
"password": _coalesce(db.password, settings.EMAIL_HOST_PASSWORD),
"host": _coalesce(db.host, opts["host"]),
"port": db.port or opts["port"],
"username": _coalesce(db.user, opts["username"]),
"password": _coalesce(db.password, opts["password"]),
# Booleans: explicit DB value wins (False is a real choice).
"use_tls": db.use_tls,
"use_ssl": db.use_ssl,
"timeout": db.timeout or settings.EMAIL_TIMEOUT,
"timeout": db.timeout or opts["timeout"],
}


Expand All @@ -169,7 +161,9 @@ def get_email_from_address() -> str:
candidates = []
if db is not None:
candidates.extend((db.from_address, db.user))
candidates.extend((settings.DEFAULT_FROM_EMAIL, settings.EMAIL_HOST_USER))
candidates.extend(
(settings.DEFAULT_FROM_EMAIL, settings.EMAIL_CONNECTION_OPTIONS["username"])
)
for value in candidates:
if value:
return value
Expand Down
28 changes: 15 additions & 13 deletions codex/views/admin/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from typing import Any

from django.conf import settings
from django.core.mail import EmailMessage, get_connection
from django.core.mail import EmailMessage
from loguru import logger
from rest_framework.response import Response

from codex.mail import DBEmailBackend
from codex.models import EmailSettings
from codex.serializers.admin.email import (
EmailSettingsSerializer,
Expand Down Expand Up @@ -114,19 +115,20 @@ def post(self, request):
return Response(response.data)

subject_prefix = _resolve_subject_prefix(payload)
message = EmailMessage(
subject=f"{subject_prefix}Test message",
body=(
"This is a test message from Codex confirming that your "
"SMTP configuration works."
),
from_email=from_address,
to=[recipient],
)
try:
with get_connection(**conn_kwargs) as connection:
message = EmailMessage(
subject=f"{subject_prefix}Test message",
body=(
"This is a test message from Codex confirming that your "
"SMTP configuration works."
),
from_email=from_address,
to=[recipient],
connection=connection,
)
sent = message.send(fail_silently=False)
# django-types declares __exit__ params non-optional, breaking
# the context-manager protocol only in the stubs.
with DBEmailBackend(**conn_kwargs) as connection: # ty: ignore[invalid-context-manager]
sent = connection.send_messages([message])
except Exception as exc:
logger.warning("Codex Email test send failed: {exc}", exc=exc)
response = EmailTestSendResponseSerializer(
Expand Down
25 changes: 21 additions & 4 deletions tests/test_password_reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ def _v4(response):
}


# Capture outbound mail in memory instead of the DB-aware SMTP backend.
_EMAIL_ON_MAILERS = {
"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"},
}
# Mirrors EMAIL_CONNECTION_OPTIONS when [email] is configured. Whole-dict
# because @override_settings replaces the setting, it doesn't merge keys.
_EMAIL_ON_CONNECTION_OPTIONS = {
"host": "smtp.example.com",
"port": 587,
"username": "",
"password": "",
"use_tls": True,
"use_ssl": False,
"timeout": 10,
}


def _ensure_admin_flags() -> None:
"""
Seed every AdminFlag row tests rely on.
Expand Down Expand Up @@ -142,8 +159,8 @@ def test_reset_password_404(self) -> None:

@override_settings(
EMAIL_ENABLED=True,
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
EMAIL_HOST="smtp.example.com",
MAILERS=_EMAIL_ON_MAILERS,
EMAIL_CONNECTION_OPTIONS=_EMAIL_ON_CONNECTION_OPTIONS,
DEFAULT_FROM_EMAIL="codex@example.com",
REST_REGISTRATION=_EMAIL_ON_REST_REGISTRATION,
)
Expand Down Expand Up @@ -403,8 +420,8 @@ def test_register_active_when_flag_off(self) -> None:

@override_settings(
EMAIL_ENABLED=True,
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
EMAIL_HOST="smtp.example.com",
MAILERS=_EMAIL_ON_MAILERS,
EMAIL_CONNECTION_OPTIONS=_EMAIL_ON_CONNECTION_OPTIONS,
DEFAULT_FROM_EMAIL="codex@example.com",
REST_REGISTRATION=_EMAIL_ON_REST_REGISTRATION,
)
Expand Down
Loading