From 330d2ef329e5cd2cee713f4021059878a2ff1927 Mon Sep 17 00:00:00 2001 From: Atlas Date: Mon, 30 Mar 2026 17:23:09 +0800 Subject: [PATCH] Restore forgot-password email delivery in Docker deployments The Docker Compose backend service was not inheriting the SMTP and reset-password settings from .env, and the email background-task bridge tried to create an asyncio task from a Starlette threadpool worker. Together those two issues produced a success response without a delivered reset email. This change injects the .env file into the backend container and makes async email jobs runnable both with and without an active event loop. It also adds a regression test for the no-running-loop path. Constraint: Docker Compose only interpolates .env by default; it does not inject arbitrary keys into container environments Rejected: Execute reset emails inline in the request path | adds avoidable latency and couples delivery to response timing Rejected: Change forgot-password success masking behavior | out of scope and would alter account-enumeration protections Confidence: high Scope-risk: narrow Directive: Keep async email jobs compatible with both request event loops and Starlette threadpool background tasks Related: https://github.com/dataelement/Clawith/issues/235 Tested: cd backend && .venv/bin/python -m pytest tests/test_password_reset_and_notifications.py -k 'run_background_email_job_executes_awaitable_without_running_loop or hides_email_delivery_failures or send_system_email_uses_configured_timeout' -q Tested: docker compose config | rg 'SYSTEM_EMAIL_FROM_ADDRESS|PUBLIC_BASE_URL|PASSWORD_RESET_TOKEN_EXPIRE_MINUTES' Not-tested: Full backend test suite Not-tested: Live forgot-password flow against deployed Docker backend --- backend/app/services/system_email_service.py | 7 ++++++- .../tests/test_password_reset_and_notifications.py | 11 +++++++++++ docker-compose.yml | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/app/services/system_email_service.py b/backend/app/services/system_email_service.py index a0f3573d..445beac2 100644 --- a/backend/app/services/system_email_service.py +++ b/backend/app/services/system_email_service.py @@ -156,4 +156,9 @@ def run_background_email_job(job, *args, **kwargs) -> None: """Bridge Starlette background tasks to async email jobs.""" result = job(*args, **kwargs) if inspect.isawaitable(result): - fire_and_forget(result) + try: + asyncio.get_running_loop() + except RuntimeError: + asyncio.run(result) + else: + fire_and_forget(result) diff --git a/backend/tests/test_password_reset_and_notifications.py b/backend/tests/test_password_reset_and_notifications.py index 56998c79..6806572a 100644 --- a/backend/tests/test_password_reset_and_notifications.py +++ b/backend/tests/test_password_reset_and_notifications.py @@ -320,6 +320,17 @@ def sendmail(self, from_address: str, to_addresses: list[str], message: str): assert captured["to"] == ["alice@example.com"] +def test_run_background_email_job_executes_awaitable_without_running_loop(): + captured = {"value": None} + + async def fake_job(value: str): + captured["value"] = value + + system_email_service.run_background_email_job(fake_job, "sent") + + assert captured["value"] == "sent" + + @pytest.mark.asyncio async def test_reset_password_updates_user(monkeypatch): user = make_user(password_hash=auth_api.hash_password("old-password")) diff --git a/docker-compose.yml b/docker-compose.yml index 05320f3b..494fb768 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,8 @@ services: CLAWITH_PIP_TRUSTED_HOST: ${CLAWITH_PIP_TRUSTED_HOST:-} restart: unless-stopped command: ["/bin/bash", "/app/entrypoint.sh"] + env_file: + - ./.env environment: DATABASE_URL: postgresql+asyncpg://clawith:clawith@postgres:5432/clawith REDIS_URL: redis://redis:6379/0