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
48 changes: 48 additions & 0 deletions apps/common/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,38 @@ paths:
$ref: '#/components/schemas/SanityChecks'
tags:
- common-management
/api/v1/management/common/sanity-checks/test-email/:
post:
operationId: management-sanity-checks-test-email
security:
- api_key: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TestEmailRequest'
responses:
200:
description: Test email sent successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/TestEmailResult'
400:
description: Invalid recipient address, or SMTP is not configured.
content:
application/json:
schema:
$ref: '#/components/schemas/TestEmailResult'
502:
description: SMTP is configured but delivery failed.
content:
application/json:
schema:
$ref: '#/components/schemas/TestEmailResult'
tags:
- common-management
/api/v1/search/management/stats/:
get:
operationId: management-search-stats
Expand Down Expand Up @@ -269,6 +301,22 @@ components:
type: string
writable:
type: boolean
TestEmailRequest:
type: object
required:
- recipient
properties:
recipient:
type: string
format: email
TestEmailResult:
type: object
properties:
sent:
type: boolean
detail:
type: string
nullable: true
SearchManagementIndexStat:
type: object
properties:
Expand Down
34 changes: 34 additions & 0 deletions apps/common/services/sanity_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
import logging
import os
from pathlib import Path
from smtplib import SMTPException
from typing import Any

from django.conf import settings
from django.core.cache import caches
from django.core.mail import send_mail
from django.db import connection
from django.db.migrations.executor import MigrationExecutor

Expand Down Expand Up @@ -106,6 +108,38 @@ def smtp_configured() -> bool:
return bool(host) and host != _DJANGO_DEFAULT_EMAIL_HOST


def send_test_email(recipient: str) -> dict[str, Any]:
"""Send a one-off test email to `recipient` to verify SMTP delivery actually works.

Callers must check `smtp_configured()` first — this makes no such check itself
and will happily (and pointlessly) attempt delivery via Django's unconfigured
"localhost" default otherwise.

Unlike check_database/check_redis/check_meilisearch/check_celery_broker above,
this deliberately does *not* catch a bare `Exception`: those checks report on
dependencies outside our code, so any failure there is a legitimate "not ok".
Here, only smtplib's own exception hierarchy and connection-level OSErrors
(e.g. connection refused, DNS failure, timeout) are treated as an SMTP
delivery problem — a bug in this function or its caller should raise and be
surfaced as a 500, not get reported to the superuser as "SMTP is broken".
"""
try:
send_mail(
subject="Archetype V3 — test email",
message=(
"This is a test email sent from the sanity-checks endpoint to confirm "
"that outgoing SMTP delivery is working."
),
from_email=None,
recipient_list=[recipient],
fail_silently=False,
)
except (SMTPException, OSError) as exc:
logger.warning("Test email to %s failed to send: %s", recipient, exc)
return {"sent": False, "detail": str(exc)}
return {"sent": True, "detail": f"Test email sent to {recipient}."}


def get_database_size_bytes() -> int | None:
"""Postgres-only: `pg_database_size(current_database())`. None on other backends (e.g. sqlite in tests)."""
if connection.vendor != "postgresql":
Expand Down
87 changes: 87 additions & 0 deletions apps/common/tests/test_sanity_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@
- get_database_size_bytes is None on non-Postgres backends (sqlite in tests)
- media_root resolves a relative MEDIA_ROOT against BASE_DIR
- the endpoint is superuser-gated and thin (delegates to run_sanity_checks)
- send_test_email calls django.core.mail.send_mail and reports {"sent": bool,
"detail": ...}, only swallowing smtplib/OSError delivery failures
- the test-email endpoint is superuser-gated, short-circuits without calling
send_mail when SMTP isn't configured, and surfaces send failures as 502
rather than raising
"""

from __future__ import annotations

from pathlib import Path
from smtplib import SMTPException
from unittest.mock import MagicMock, patch

from django.test import override_settings
Expand All @@ -23,6 +29,7 @@
from apps.common.services import sanity_checks as sc

URL = "/api/v1/management/common/sanity-checks/"
TEST_EMAIL_URL = "/api/v1/management/common/sanity-checks/test-email/"


class TestGetPendingMigrations:
Expand Down Expand Up @@ -225,3 +232,83 @@ def test_view_delegates_to_service_without_inline_logic(self, management_client)
run_mock.return_value = {}
management_client.get(URL)
run_mock.assert_called_once_with()


class TestSendTestEmail:
def test_calls_send_mail_and_reports_success(self):
with patch("apps.common.services.sanity_checks.send_mail") as send_mail_mock:
result = sc.send_test_email("someone@example.com")

assert result == {"sent": True, "detail": "Test email sent to someone@example.com."}
send_mail_mock.assert_called_once()
_args, kwargs = send_mail_mock.call_args
assert kwargs["recipient_list"] == ["someone@example.com"]
assert kwargs["fail_silently"] is False

def test_smtp_exception_is_reported_without_raising(self):
with patch("apps.common.services.sanity_checks.send_mail", side_effect=SMTPException("bad hello")):
result = sc.send_test_email("someone@example.com")
assert result["sent"] is False
assert "bad hello" in result["detail"]

def test_connection_oserror_is_reported_without_raising(self):
with patch("apps.common.services.sanity_checks.send_mail", side_effect=ConnectionRefusedError("refused")):
result = sc.send_test_email("someone@example.com")
assert result["sent"] is False
assert "refused" in result["detail"]

def test_unrelated_exceptions_propagate(self):
# Only smtplib/OSError delivery failures are swallowed here — a bug
# elsewhere (e.g. a bad argument) should raise, not be reported as an
# "SMTP problem".
with patch("apps.common.services.sanity_checks.send_mail", side_effect=ValueError("not smtp related")):
with pytest.raises(ValueError):
sc.send_test_email("someone@example.com")


@pytest.mark.django_db
class TestSanityCheckTestEmailView:
def test_anonymous_is_rejected(self, api_client):
response = api_client.post(TEST_EMAIL_URL, {"recipient": "someone@example.com"})
assert response.status_code in (401, 403)

def test_regular_user_is_forbidden(self, authenticated_client):
response = authenticated_client.post(TEST_EMAIL_URL, {"recipient": "someone@example.com"})
assert response.status_code == 403

def test_invalid_recipient_is_rejected(self, management_client):
with patch("apps.common.views.send_test_email") as send_mock:
response = management_client.post(TEST_EMAIL_URL, {"recipient": "not-an-email"})
assert response.status_code == 400
send_mock.assert_not_called()

def test_smtp_not_configured_short_circuits_without_sending(self, management_client):
with (
patch("apps.common.views.smtp_configured", return_value=False),
patch("apps.common.views.send_test_email") as send_mock,
):
response = management_client.post(TEST_EMAIL_URL, {"recipient": "someone@example.com"})
assert response.status_code == 400
assert response.data["sent"] is False
send_mock.assert_not_called()

def test_successful_send_returns_200_with_expected_args(self, management_client):
with (
patch("apps.common.views.smtp_configured", return_value=True),
patch("apps.common.views.send_test_email") as send_mock,
):
send_mock.return_value = {"sent": True, "detail": "Test email sent to someone@example.com."}
response = management_client.post(TEST_EMAIL_URL, {"recipient": "someone@example.com"})
assert response.status_code == 200
assert response.data["sent"] is True
send_mock.assert_called_once_with("someone@example.com")

def test_send_failure_returns_error_response_without_500(self, management_client):
with (
patch("apps.common.views.smtp_configured", return_value=True),
patch("apps.common.views.send_test_email") as send_mock,
):
send_mock.return_value = {"sent": False, "detail": "Connection refused"}
response = management_client.post(TEST_EMAIL_URL, {"recipient": "someone@example.com"})
assert response.status_code == 502
assert response.data == {"sent": False, "detail": "Connection refused"}
7 changes: 6 additions & 1 deletion apps/common/urls.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from django.urls import path
from rest_framework.routers import DefaultRouter

from .views import DateManagementViewSet, SanityChecksView, SiteLabelsView
from .views import DateManagementViewSet, SanityChecksView, SanityCheckTestEmailView, SiteLabelsView

router = DefaultRouter()
router.register("management/common/dates", DateManagementViewSet, basename="management-dates")

urlpatterns = router.urls + [
path("site-labels/", SiteLabelsView.as_view(), name="site-labels"),
path("management/common/sanity-checks/", SanityChecksView.as_view(), name="management-sanity-checks"),
path(
"management/common/sanity-checks/test-email/",
SanityCheckTestEmailView.as_view(),
name="management-sanity-checks-test-email",
),
]
38 changes: 36 additions & 2 deletions apps/common/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
from typing import Any

from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from django.db import transaction
from django.views.generic import TemplateView
from django_filters import rest_framework as filters
from rest_framework import serializers, viewsets
from rest_framework import serializers, status, viewsets
from rest_framework.filters import SearchFilter
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -15,7 +17,7 @@
from apps.common.audit import audit_actor
from apps.common.models import Date, SiteLabel
from apps.common.permissions import IsSuperuser, IsSuperuserOrReadOnly
from apps.common.services.sanity_checks import run_sanity_checks
from apps.common.services.sanity_checks import run_sanity_checks, send_test_email, smtp_configured

from .serializers import DateManagementSerializer

Expand Down Expand Up @@ -137,6 +139,38 @@
return Response(run_sanity_checks())


class SanityCheckTestEmailView(APIView):
"""Superuser-only: send a real test email to verify SMTP delivery end-to-end.

Short-circuits with 400 when `smtp_configured()` reports SMTP isn't set up,
rather than attempting (and failing) delivery via Django's unconfigured
"localhost" default. This project has no configured "send admin
notifications here" address (no ADMINS/MANAGERS/DEFAULT_FROM_EMAIL pointing
at a real inbox — see apps.common.services.sanity_checks.smtp_configured's
docstring), so the recipient is supplied by the caller and validated as an
email address rather than inferred from settings.
"""

permission_classes = [IsSuperuser]

def post(self, request: Request) -> Response:
recipient = request.data.get("recipient", "")
try:
validate_email(recipient)
except DjangoValidationError as exc:
raise serializers.ValidationError({"recipient": "Enter a valid email address."}) from exc

if not smtp_configured():
return Response(
{"sent": False, "detail": "SMTP is not configured (EMAIL_HOST is unset or still the default)."},
status=status.HTTP_400_BAD_REQUEST,
)

result = send_test_email(recipient)
response_status = status.HTTP_200_OK if result["sent"] else status.HTTP_502_BAD_GATEWAY
return Response(result, status=response_status)


class DateManagementViewSet(UnpaginatedPrivilegedViewSet):
queryset = Date.objects.all()
serializer_class = DateManagementSerializer
Expand Down
Loading