Skip to content

Commit 19b82f2

Browse files
Fix hostless links in notification emails (#198)
Score/peer/project notification emails are built without a request, so public_url() relied solely on PUBLIC_BASE_URL. When that was unset or lacked a host, it emitted hostless links like http:///llm-zoomcamp-2026/leaderboard. Fall back to a concrete ALLOWED_HOSTS host (or the canonical prod URL) so notification links are always absolute, and strip whitespace from PUBLIC_BASE_URL.
1 parent 9e73b84 commit 19b82f2

3 files changed

Lines changed: 80 additions & 6 deletions

File tree

course_management/datamailer/client.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import logging
12
from dataclasses import dataclass
23
from typing import Any
3-
from urllib.parse import urljoin
4+
from urllib.parse import urljoin, urlparse
45

56
import requests
67
from django.conf import settings
78

9+
logger = logging.getLogger(__name__)
10+
811
from .client_campaigns import DatamailerCampaignClient
912
from .client_contacts import DatamailerContactClient
1013
from .client_recipient_lists import DatamailerRecipientListClients
@@ -94,9 +97,39 @@ def datamailer_enabled() -> bool:
9497

9598

9699
def public_url(path: str) -> str:
97-
base_url = getattr(settings, "PUBLIC_BASE_URL", "")
98-
if not base_url:
99-
return path
100-
normalized_base_url = f"{base_url.rstrip('/')}/"
100+
base_url = _notification_base_url()
101+
normalized_base_url = f"{base_url}/"
101102
normalized_path = path.lstrip("/")
102103
return urljoin(normalized_base_url, normalized_path)
104+
105+
106+
def _notification_base_url() -> str:
107+
"""Absolute base URL for links in notification emails.
108+
109+
Notifications are built without a request, so they can't fall back to the
110+
request host the way in-app confirmation emails do. If ``PUBLIC_BASE_URL``
111+
is unset or lacks a scheme/host we must still emit an absolute URL with a
112+
real host — otherwise emails go out with unusable hostless links like
113+
``http:///course-slug/leaderboard``.
114+
"""
115+
base_url = (getattr(settings, "PUBLIC_BASE_URL", "") or "").strip().rstrip("/")
116+
parsed = urlparse(base_url)
117+
if parsed.scheme and parsed.netloc:
118+
return base_url
119+
120+
fallback = _fallback_base_url()
121+
if base_url:
122+
logger.warning(
123+
"PUBLIC_BASE_URL=%r has no scheme/host; using %r for "
124+
"notification links.",
125+
base_url,
126+
fallback,
127+
)
128+
return fallback
129+
130+
131+
def _fallback_base_url() -> str:
132+
for host in getattr(settings, "ALLOWED_HOSTS", []):
133+
if host and host != "*":
134+
return f"https://{host}"
135+
return "https://courses.datatalks.club"

course_management/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@
183183
VERSION = os.getenv(
184184
"VERSION", "local-development-build-version-not-configured"
185185
)
186-
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").rstrip("/")
186+
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").strip().rstrip("/")
187187

188188
DATAMAILER_URL = os.getenv("DATAMAILER_URL", "")
189189
DATAMAILER_API_KEY = os.getenv("DATAMAILER_API_KEY", "")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from urllib.parse import urlparse
2+
3+
from django.test import SimpleTestCase, override_settings
4+
5+
from course_management.datamailer.client import public_url
6+
7+
8+
class PublicUrlTest(SimpleTestCase):
9+
@override_settings(PUBLIC_BASE_URL="https://courses.datatalks.club")
10+
def test_uses_public_base_url_when_configured(self):
11+
self.assertEqual(
12+
public_url("/llm-zoomcamp-2026/leaderboard"),
13+
"https://courses.datatalks.club/llm-zoomcamp-2026/leaderboard",
14+
)
15+
16+
@override_settings(
17+
PUBLIC_BASE_URL="",
18+
ALLOWED_HOSTS=["courses.datatalks.club"],
19+
)
20+
def test_falls_back_to_host_when_base_url_missing(self):
21+
url = public_url("/llm-zoomcamp-2026/leaderboard")
22+
23+
# Emails must never contain a hostless link like
24+
# http:///llm-zoomcamp-2026/leaderboard or a bare /path.
25+
self.assertTrue(urlparse(url).netloc, f"hostless URL: {url!r}")
26+
self.assertEqual(
27+
url,
28+
"https://courses.datatalks.club/llm-zoomcamp-2026/leaderboard",
29+
)
30+
31+
@override_settings(
32+
PUBLIC_BASE_URL="http://",
33+
ALLOWED_HOSTS=["courses.datatalks.club"],
34+
)
35+
def test_falls_back_when_base_url_has_no_host(self):
36+
url = public_url("/llm-zoomcamp-2026/leaderboard")
37+
38+
self.assertFalse(
39+
url.startswith("http:///"), f"hostless URL: {url!r}"
40+
)
41+
self.assertTrue(urlparse(url).netloc, f"hostless URL: {url!r}")

0 commit comments

Comments
 (0)