I already had a resume download gate — email required, signed link, 15-minute expiry, presigned S3 URL good for two minutes. What it didn't have was a way for me to know it happened. Someone could download my resume and I'd never find out unless I went digging through ResumeDownloadRequest rows in the admin.
This is the short follow-up: one notification email, sent to me, the moment someone actually opens the PDF.
Where to Hook It
The gate has two views:
-
ResumeRequestDownloadView.post()— fires when someone submits their email and gets sent a link -
ResumeDownloadView.get()— fires when someone clicks that link and the signed token is verified
Submitting an email isn't the same as downloading. Plenty of people fill in a form and never check their inbox. I wanted the notification tied to a confirmed download, so it belongs in ResumeDownloadView.get() — right after the token is verified and the resume is confirmed to exist, just before the redirect to the presigned S3 URL.
Finding the Right Recipient
My first instinct was to reach for settings.ADMINS, Django's built-in list for mail_admins(). But ADMINS in config/settings/base.py looked like a placeholder:
ADMINS = [("""Vicente G. Reyes""", "me@example.com")]
me@example.com — that x reads exactly like scaffolding nobody replaced. I almost went and fixed it to a real inbox before sending anything.
Then I noticed something else: .envs/.production/.django also had DJANGO_SERVER_EMAIL=x. Two placeholder-looking values with the same address wasn't a coincidence — it turned out me@example.com is a real catch-all alias on the domain, already wired to forward to an inbox that gets checked. Not a placeholder at all.
That distinction mattered for a second reason: SERVER_EMAIL and ADMINS do different jobs in Django. SERVER_EMAIL is the From address Django uses when it sends admin error emails via mail_admins(). ADMINS is the recipient list for those same emails. Confusing the two would have had me trying to "fix" a sender address as if it were a destination.
Given me@example.com was already correct and already sitting in ADMINS, there was nothing to add. I just needed to read from the setting that already existed.
The Change
project/core/views.py — inside ResumeDownloadView:
class ResumeDownloadView(APIView):
authentication_classes = []
permission_classes = []
def get(self, request):
token = request.query_params.get("token", "")
if not token:
return Response(
{
"data": None,
"message": "",
"errors": {"token": ["This field is required."]},
},
status=status.HTTP_400_BAD_REQUEST,
)
signer = signing.TimestampSigner()
data = None
with contextlib.suppress(signing.SignatureExpired, signing.BadSignature):
data = signer.unsign_object(token, max_age=RESUME_TOKEN_MAX_AGE)
if data is None:
try:
signer.unsign_object(token)
return Response(
{
"data": None,
"message": "Download link expired. Please request a new one.",
"errors": None,
},
status=status.HTTP_410_GONE,
)
except signing.BadSignature:
return Response(
{
"data": None,
"message": "Invalid download token.",
"errors": None,
},
status=status.HTTP_400_BAD_REQUEST,
)
resume = Resume.objects.filter(pk=data.get("pk")).first()
if not resume or not resume.pdf:
return Response(
{"data": None, "message": "Resume not found.", "errors": None},
status=status.HTTP_404_NOT_FOUND,
)
self._notify_owner(data.get("email", "unknown"))
return HttpResponseRedirect(resume.pdf.url)
def _notify_owner(self, downloader_email: str) -> None:
recipients = [admin_email for _, admin_email in settings.ADMINS]
if not recipients:
return
body = (
f"Someone just downloaded your resume.\n\n"
f"Email: {downloader_email}\n"
f"Time: {timezone.now().strftime('%Y-%m-%d %H:%M:%S %Z')}"
)
with contextlib.suppress(Exception):
send_mail(
subject="Resume downloaded",
message=body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=recipients,
fail_silently=True,
)
Same pattern as the existing _send_download_email right above it in the file: plain send_mail, wrapped in contextlib.suppress(Exception), fail_silently=True. If Mailgun hiccups, the downloader still gets their redirect — a failed notification email should never block the actual download.
recipients iterates all of ADMINS rather than hardcoding settings.ADMINS[0][1], so adding a second admin later needs zero changes here.
One import needed adding — django.utils.timezone, for the timestamp in the notification body.
Tests
project/core/tests.py had zero coverage for the resume download flow before this — the gate shipped without tests originally. Added a fixture for a Resume with a real uploaded file, a fixture for a signed token, and a test class covering the redirect, the notification, and the existing error paths:
import datetime
import pytest
from django.core import signing
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from rest_framework.test import APIClient
from project.blogs.models import BlogPost
from project.projects.models import Project
from project.services.models import Service
from .models import FAQ
from .models import Resume
from .models import Skill
from .models import Stat
from .models import Testimonial
# ---------------------------------------------------------------------------
# Resume download
# ---------------------------------------------------------------------------
@pytest.fixture
def resume(db):
return Resume.objects.create(
pdf=SimpleUploadedFile("resume.pdf", b"%PDF-1.4 fake", content_type="application/pdf"),
)
@pytest.fixture
def resume_token(resume):
signer = signing.TimestampSigner()
return signer.sign_object({"pk": resume.pk, "email": "downloader@example.com"})
@pytest.mark.django_db
class TestResumeDownloadView:
def test_valid_token_redirects_to_pdf(self, client, resume, resume_token):
response = client.get(reverse("api:resume-download") + f"?token={resume_token}")
assert response.status_code == 302
def test_valid_token_notifies_owner(self, client, resume, resume_token, mailoutbox):
client.get(reverse("api:resume-download") + f"?token={resume_token}")
assert len(mailoutbox) == 1
assert mailoutbox[0].subject == "Resume downloaded"
assert mailoutbox[0].to == ["me@example.com"]
assert "downloader@example.com" in mailoutbox[0].body
def test_missing_token_returns_400(self, client, db):
response = client.get(reverse("api:resume-download"))
assert response.status_code == 400
def test_invalid_token_returns_400(self, client, db):
response = client.get(reverse("api:resume-download") + "?token=garbage")
assert response.status_code == 400
def test_unknown_resume_pk_returns_404(self, client, db):
signer = signing.TimestampSigner()
token = signer.sign_object({"pk": 999, "email": "downloader@example.com"})
response = client.get(reverse("api:resume-download") + f"?token={token}")
assert response.status_code == 404
mailoutbox is a pytest-django fixture — it's Django's test EmailBackend capturing every send in a list, no mocking required. Asserting on mailoutbox[0].to doubles as a regression check: if ADMINS ever gets edited back to a placeholder, this test fails immediately instead of silently emailing nobody.
Ran inside the project's Docker dev container, matching how every other management command in this repo gets run:
docker compose -f docker-compose.local.yml run --rm django pytest project/core/tests.py -k Resume -v
project/core/tests.py::TestResumeDownloadView::test_valid_token_redirects_to_pdf PASSED
project/core/tests.py::TestResumeDownloadView::test_valid_token_notifies_owner PASSED
project/core/tests.py::TestResumeDownloadView::test_missing_token_returns_400 PASSED
project/core/tests.py::TestResumeDownloadView::test_invalid_token_returns_400 PASSED
project/core/tests.py::TestResumeDownloadView::test_unknown_resume_pk_returns_404 PASSED
5 passed in 5.31s
What I'd Do Differently
Batch the notification if downloads spike. Right now every single download sends a separate email. Fine at current traffic; if this ever got linked from somewhere with real volume, I'd want a short debounce window rather than an inbox flood.
Move the send off the request path. Same tradeoff as the original gate's send_mail call — it's synchronous, so the redirect waits on the SMTP round trip to Mailgun. No Celery in this project yet, so for now it's a few hundred milliseconds tacked onto every download. Worth revisiting if Celery ever gets added for other reasons.
Double-check ADMINS before trusting it elsewhere. This whole feature nearly took a wrong turn because a real address happened to look exactly like unfinished scaffolding. Worth a settings comment noting me@example.com is a live catch-all, not a TODO.
Top comments (0)