diff --git a/CLAUDE.md b/CLAUDE.md
index b257c58..9de3d9e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -164,6 +164,18 @@ The parts that bite if you miss them:
it) only translate. The emails are queued from the services, so every surface sends them.
- **Never serialise `str(user)` on a comment** - it is the email. Use `services.serialize()`.
+## The newsletter lives in `apps/newsletter/`
+
+`DEVELOPMENT.md` has the details. What bites:
+
+- **Double opt-in.** Subscribing stores `pending` and mails a link; only the confirm click makes a
+ row `active`, and only `active` rows may ever be mailed. Both landing pages act on a button,
+ never on load, because mail scanners open links.
+- **Subscribing answers 204 whatever the address's state** - never reveal who is on the list. An
+ active address keeps its topics until the owner confirms new ones (they ride in the token).
+- **Links are `django.core.signing` tokens, nothing stored**, and the email tasks take the row,
+ never a link, since django-q keeps task arguments in the database.
+
## Email
Templates live in `backend/apps/core/templates/email//`; `DEVELOPMENT.md` has the
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index a436cb8..31bc93a 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -556,6 +556,34 @@ comments one account may leave waiting. Bodies are plain text and render as text
markdown. The public thread never carries an email address: `User.__str__` is one, so names come
from `user.name`, falling back to "Member".
+## Newsletter
+
+The footer form (`src/features/newsletter/newsletter-signup.tsx`) signs a reader up for any of
+three topics - Reviews, New tools, Benchmarks - and `backend/apps/newsletter/` keeps the list.
+It is **double opt-in**: `POST /newsletter/subscriptions` stores the address as `pending` and
+mails a confirmation link; only the click (`/newsletter/confirm`, which calls
+`/newsletter/confirmations`) makes it `active` and sends the welcome email. Only `active` rows may
+ever be mailed. Sending the actual updates is not built yet.
+
+**The links are signed, not stored** (`apps/newsletter/tokens.py`, `django.core.signing`). The
+confirmation carries the row and the topics and lasts 7 days; the unsubscribe link carries the
+row and never expires, because every mail carries one (the welcome also sets `List-Unsubscribe`).
+Rotating `SECRET_KEY` kills every outstanding link.
+
+What bites:
+
+- **Subscribing answers 204 whatever the address's state**, so the form cannot tell anyone who is
+ on the list. An address that is already `active` keeps its topics until its owner confirms the
+ new ones - the topics ride in the token, not in the row.
+- **Both landing pages act on a button, never on load**: mail scanners open links before people
+ do, and an auto-firing confirm would make the opt-in single.
+- **Task arguments are the row and the topics, never a link** - django-q stores arguments in the
+ database. The task builds the link when it sends.
+- Writes go through `apps/newsletter/services.py`; the routes only translate. Staff see the list,
+ with its `confirmed_at` / `unsubscribed_at` trail, in the Django admin.
+- Abuse control is `NEWSLETTER_SUBSCRIBE_RATE` per IP: each request mails whatever address it
+ names.
+
## Rendering and the API-down build
Catalog routes and `sitemap.ts` declare `export const dynamic = 'force-dynamic'`. The frontend
diff --git a/backend/.env.example b/backend/.env.example
index 86c50fc..94c538b 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -72,6 +72,10 @@ PUBLIC_MEDIA_URL=http://localhost:8007/media
# COMMENTS_MAX_LENGTH=5000
# COMMENTS_MAX_DEPTH=8
+# --- Newsletter -----------------------------------------------------------
+# Per-IP rate of footer sign-ups; each one mails a confirmation link.
+# NEWSLETTER_SUBSCRIBE_RATE=10/hour
+
# --- Benchmarks -----------------------------------------------------------
# Largest redacted PDF a submission may carry, the per-account write rate on
# submissions, the screenshots one run may carry, and the per-run scoring timeout
diff --git a/backend/apps/core/email.py b/backend/apps/core/email.py
index 82ad655..505243d 100644
--- a/backend/apps/core/email.py
+++ b/backend/apps/core/email.py
@@ -27,7 +27,9 @@ def email_names() -> list[str]:
)
-def send_templated_email(name: str, context: dict, *, to: list[str]) -> None:
+def send_templated_email(
+ name: str, context: dict, *, to: list[str], headers: dict[str, str] | None = None
+) -> None:
context = {"site_url": settings.FRONTEND_URL, **context}
folder = f"email/{name}"
@@ -39,6 +41,7 @@ def send_templated_email(name: str, context: dict, *, to: list[str]) -> None:
body=render_to_string(f"{folder}/body.txt", context),
from_email=settings.DEFAULT_FROM_EMAIL,
to=to,
+ headers=headers,
)
message.attach_alternative(render_to_string(f"{folder}/body.html", context), "text/html")
message.send()
diff --git a/backend/apps/core/templates/email/newsletter_confirm/body.html b/backend/apps/core/templates/email/newsletter_confirm/body.html
new file mode 100644
index 0000000..0aa0df1
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_confirm/body.html
@@ -0,0 +1,214 @@
+
+
+
+
+ Confirm your Redaction Tools subscription
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
One click and you will hear about new reviews, tools and benchmarks.
+
+
+
\ No newline at end of file
diff --git a/backend/apps/core/templates/email/newsletter_confirm/body.mjml b/backend/apps/core/templates/email/newsletter_confirm/body.mjml
new file mode 100644
index 0000000..b89ed8b
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_confirm/body.mjml
@@ -0,0 +1,24 @@
+
+
+ Confirm your Redaction Tools subscription
+ One click and you will hear about new reviews, tools and benchmarks.
+
+
+
+
+
+
+ Hi,
+ Someone - hopefully you - asked to get Redaction Tools updates at this address:
+
+
+ {% for topic in topics %}
{{ topic }}
{% endfor %}
+
+
+ Confirm subscription
+ The link works for 7 days. If this wasn't you, ignore this email: nothing is sent until the link is clicked.
+
+
+
+
+
diff --git a/backend/apps/core/templates/email/newsletter_confirm/body.txt b/backend/apps/core/templates/email/newsletter_confirm/body.txt
new file mode 100644
index 0000000..8534dd6
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_confirm/body.txt
@@ -0,0 +1,16 @@
+{% autoescape off %}Hi,
+
+Someone - hopefully you - asked to get Redaction Tools updates at this address:
+
+{% for topic in topics %}- {{ topic }}
+{% endfor %}
+Confirm the subscription here (the link works for 7 days):
+
+{{ confirm_url }}
+
+If this wasn't you, ignore this email: nothing is sent until the link is clicked.
+
+--
+Redaction Tools
+{{ site_url }}
+{% endautoescape %}
diff --git a/backend/apps/core/templates/email/newsletter_confirm/preview.json b/backend/apps/core/templates/email/newsletter_confirm/preview.json
new file mode 100644
index 0000000..ca239ae
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_confirm/preview.json
@@ -0,0 +1,4 @@
+{
+ "topics": ["Reviews", "New tools", "Benchmarks"],
+ "confirm_url": "http://localhost:3007/newsletter/confirm?token=preview"
+}
diff --git a/backend/apps/core/templates/email/newsletter_confirm/subject.txt b/backend/apps/core/templates/email/newsletter_confirm/subject.txt
new file mode 100644
index 0000000..67ae19d
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_confirm/subject.txt
@@ -0,0 +1 @@
+{% autoescape off %}Confirm your Redaction Tools subscription{% endautoescape %}
diff --git a/backend/apps/core/templates/email/newsletter_welcome/body.html b/backend/apps/core/templates/email/newsletter_welcome/body.html
new file mode 100644
index 0000000..9f8d790
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_welcome/body.html
@@ -0,0 +1,235 @@
+
+
+
+
+ You're subscribed to Redaction Tools updates
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
We'll write when there is a new review, tool or benchmark.
+
+
+
\ No newline at end of file
diff --git a/backend/apps/core/templates/email/newsletter_welcome/body.mjml b/backend/apps/core/templates/email/newsletter_welcome/body.mjml
new file mode 100644
index 0000000..c74688c
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_welcome/body.mjml
@@ -0,0 +1,33 @@
+
+
+ You're subscribed to Redaction Tools updates
+ We'll write when there is a new review, tool or benchmark.
+
+
+
+
+
+
+ Hi,
+ Thanks for confirming - you're on the Redaction Tools list. We'll write when there is something new in:
+
+
+ {% for topic in topics %}
{{ topic }}
{% endfor %}
+
+
+ No fixed schedule and no filler: a mail only goes out when we publish something.
+ While you wait:
+
+
+
Compare tools by price, detection method and deployment.
+
See the benchmarks: how many sensitive values each tool leaves recoverable.
+
+ Browse the catalog
+ Changed your mind? Unsubscribe in one click.
+
+
+
+
+
diff --git a/backend/apps/core/templates/email/newsletter_welcome/body.txt b/backend/apps/core/templates/email/newsletter_welcome/body.txt
new file mode 100644
index 0000000..c0fd140
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_welcome/body.txt
@@ -0,0 +1,20 @@
+{% autoescape off %}Hi,
+
+Thanks for confirming - you're on the Redaction Tools list. We'll write when there is something new in:
+
+{% for topic in topics %}- {{ topic }}
+{% endfor %}
+No fixed schedule and no filler: a mail only goes out when we publish something.
+
+While you wait:
+
+- Compare tools by price, detection method and deployment: {{ site_url }}/
+- See how many sensitive values each tool leaves recoverable: {{ site_url }}/benchmarks
+- Read the reviews on the blog: {{ site_url }}/blog
+
+Changed your mind? Unsubscribe in one click: {{ unsubscribe_url }}
+
+--
+Redaction Tools
+{{ site_url }}
+{% endautoescape %}
diff --git a/backend/apps/core/templates/email/newsletter_welcome/preview.json b/backend/apps/core/templates/email/newsletter_welcome/preview.json
new file mode 100644
index 0000000..39619c1
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_welcome/preview.json
@@ -0,0 +1,4 @@
+{
+ "topics": ["Reviews", "New tools", "Benchmarks"],
+ "unsubscribe_url": "http://localhost:3007/newsletter/unsubscribe?token=preview"
+}
diff --git a/backend/apps/core/templates/email/newsletter_welcome/subject.txt b/backend/apps/core/templates/email/newsletter_welcome/subject.txt
new file mode 100644
index 0000000..8d01a01
--- /dev/null
+++ b/backend/apps/core/templates/email/newsletter_welcome/subject.txt
@@ -0,0 +1 @@
+{% autoescape off %}You're subscribed to Redaction Tools updates{% endautoescape %}
diff --git a/backend/apps/newsletter/__init__.py b/backend/apps/newsletter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/apps/newsletter/admin.py b/backend/apps/newsletter/admin.py
new file mode 100644
index 0000000..17b8361
--- /dev/null
+++ b/backend/apps/newsletter/admin.py
@@ -0,0 +1,14 @@
+from django.contrib import admin
+from unfold.admin import ModelAdmin
+
+from apps.newsletter.models import Subscriber
+
+
+@admin.register(Subscriber)
+class SubscriberAdmin(ModelAdmin):
+ list_display = ("email", "status", "reviews", "new_tools", "benchmarks", "created_at")
+ list_filter = ("status", "reviews", "new_tools", "benchmarks")
+ search_fields = ("email",)
+ ordering = ("-created_at",)
+ # The consent trail: when they asked, confirmed and left. Staff never forge it.
+ readonly_fields = ("created_at", "updated_at", "confirmed_at", "unsubscribed_at")
diff --git a/backend/apps/newsletter/api.py b/backend/apps/newsletter/api.py
new file mode 100644
index 0000000..43d6251
--- /dev/null
+++ b/backend/apps/newsletter/api.py
@@ -0,0 +1,52 @@
+"""The public newsletter endpoints: subscribe from the footer, confirm, unsubscribe."""
+
+from django.http import HttpRequest
+from ninja import Router, Status
+from ninja.errors import HttpError
+
+from apps.newsletter import services
+from apps.newsletter.models import TOPICS
+from apps.newsletter.schemas import SubscribeIn, SubscriptionOut, TokenIn
+from apps.newsletter.throttles import NewsletterSubscribeThrottle
+from apps.newsletter.tokens import InvalidToken
+
+router = Router(tags=["newsletter"])
+
+
+@router.post(
+ "/subscriptions",
+ response={204: None},
+ throttle=[NewsletterSubscribeThrottle()],
+ summary="Subscribe to the newsletter",
+)
+def subscribe_newsletter(request: HttpRequest, payload: SubscribeIn):
+ topics = [topic for topic in TOPICS if getattr(payload, topic)]
+ try:
+ services.subscribe(payload.email, topics)
+ except services.SubscribeError as exc:
+ raise HttpError(422, str(exc)) from exc
+ return Status(204, None)
+
+
+@router.post(
+ "/confirmations", response={200: SubscriptionOut}, summary="Confirm a newsletter subscription"
+)
+def confirm_newsletter(request: HttpRequest, payload: TokenIn):
+ try:
+ subscriber = services.confirm(payload.token)
+ except InvalidToken as exc:
+ raise _dead_link() from exc
+ return {"email": subscriber.email, "topics": subscriber.topics}
+
+
+@router.post("/unsubscriptions", response={204: None}, summary="Leave the newsletter")
+def unsubscribe_newsletter(request: HttpRequest, payload: TokenIn):
+ try:
+ services.unsubscribe(payload.token)
+ except InvalidToken as exc:
+ raise _dead_link() from exc
+ return Status(204, None)
+
+
+def _dead_link():
+ return HttpError(400, "This link is invalid or has expired.")
diff --git a/backend/apps/newsletter/apps.py b/backend/apps/newsletter/apps.py
new file mode 100644
index 0000000..434f6fe
--- /dev/null
+++ b/backend/apps/newsletter/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class NewsletterConfig(AppConfig):
+ name = "apps.newsletter"
+ label = "newsletter"
+ verbose_name = "Newsletter"
diff --git a/backend/apps/newsletter/emails.py b/backend/apps/newsletter/emails.py
new file mode 100644
index 0000000..a7d1296
--- /dev/null
+++ b/backend/apps/newsletter/emails.py
@@ -0,0 +1,68 @@
+"""Mail to subscribers: the confirmation link, then a welcome once they click it.
+
+Each is its own task, queued once the row commits. The task gets the row and the
+topics, never a link: django-q keeps task arguments in the database, and a
+confirmation link is a credential for that address.
+"""
+
+from urllib.parse import urlencode
+
+from django.conf import settings
+from django.db import transaction
+
+from apps.core.email import send_templated_email
+from apps.newsletter import tokens
+from apps.newsletter.models import TOPICS, Subscriber
+
+
+def queue_confirmation(subscriber: Subscriber, topics: list[str]) -> None:
+ _queue("send_confirmation", subscriber.pk, topics)
+
+
+def queue_welcome(subscriber: Subscriber) -> None:
+ _queue("send_welcome", subscriber.pk)
+
+
+def _queue(task, *args):
+ from django_q.tasks import async_task
+
+ transaction.on_commit(
+ lambda: async_task(
+ f"apps.newsletter.emails.{task}", *args, task_name=f"newsletter {task} {args[0]}"
+ )
+ )
+
+
+def unsubscribe_url(subscriber: Subscriber) -> str:
+ return _url("unsubscribe", tokens.unsubscribe_token(subscriber.pk))
+
+
+def send_confirmation(subscriber_pk: int, topics: list[str]) -> None:
+ subscriber = Subscriber.objects.get(pk=subscriber_pk)
+ send_templated_email(
+ "newsletter_confirm",
+ {
+ "topics": _labels(topics),
+ "confirm_url": _url("confirm", tokens.confirm_token(subscriber.pk, topics)),
+ },
+ to=[subscriber.email],
+ )
+
+
+def send_welcome(subscriber_pk: int) -> None:
+ subscriber = Subscriber.objects.get(pk=subscriber_pk)
+ url = unsubscribe_url(subscriber)
+ send_templated_email(
+ "newsletter_welcome",
+ {"topics": _labels(subscriber.topics), "unsubscribe_url": url},
+ to=[subscriber.email],
+ headers={"List-Unsubscribe": f"<{url}>"},
+ )
+
+
+def _url(page: str, token: str) -> str:
+ return f"{settings.FRONTEND_URL}/newsletter/{page}?{urlencode({'token': token})}"
+
+
+def _labels(topics: list[str]) -> list[str]:
+ return [label for topic, label in TOPICS.items() if topic in topics]
diff --git a/backend/apps/newsletter/migrations/0001_initial.py b/backend/apps/newsletter/migrations/0001_initial.py
new file mode 100644
index 0000000..544bed7
--- /dev/null
+++ b/backend/apps/newsletter/migrations/0001_initial.py
@@ -0,0 +1,32 @@
+# Generated by Django 6.1.1 on 2026-10-08 18:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Subscriber',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('email', models.EmailField(max_length=254, unique=True)),
+ ('status', models.CharField(choices=[('pending', 'Awaiting confirmation'), ('active', 'Subscribed'), ('unsubscribed', 'Unsubscribed')], default='pending', max_length=16)),
+ ('reviews', models.BooleanField(default=False)),
+ ('new_tools', models.BooleanField(default=False)),
+ ('benchmarks', models.BooleanField(default=False)),
+ ('confirmed_at', models.DateTimeField(blank=True, null=True)),
+ ('unsubscribed_at', models.DateTimeField(blank=True, null=True)),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/backend/apps/newsletter/migrations/__init__.py b/backend/apps/newsletter/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/apps/newsletter/models.py b/backend/apps/newsletter/models.py
new file mode 100644
index 0000000..0c0311b
--- /dev/null
+++ b/backend/apps/newsletter/models.py
@@ -0,0 +1,42 @@
+"""Who asked to hear about new reviews, tools and benchmarks, and what they asked for.
+
+Double opt-in: an address is `pending` until its owner clicks the confirmation link,
+and only `active` rows may ever be mailed. The timestamps are the consent trail.
+"""
+
+from django.db import models
+
+from apps.core.models import TimeStampedModel
+
+# The topics a reader can pick, in the order the form and the emails list them.
+TOPICS = {
+ "reviews": "Reviews",
+ "new_tools": "New tools",
+ "benchmarks": "Benchmarks",
+}
+
+
+class Subscriber(TimeStampedModel):
+ class Status(models.TextChoices):
+ PENDING = "pending", "Awaiting confirmation"
+ ACTIVE = "active", "Subscribed"
+ UNSUBSCRIBED = "unsubscribed", "Unsubscribed"
+
+ # Stored lowercased, so one inbox is one row however it was typed.
+ email = models.EmailField(unique=True)
+ status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
+ reviews = models.BooleanField(default=False)
+ new_tools = models.BooleanField(default=False)
+ benchmarks = models.BooleanField(default=False)
+ confirmed_at = models.DateTimeField(null=True, blank=True)
+ unsubscribed_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+
+ def __str__(self):
+ return self.email
+
+ @property
+ def topics(self) -> list[str]:
+ return [topic for topic in TOPICS if getattr(self, topic)]
diff --git a/backend/apps/newsletter/schemas.py b/backend/apps/newsletter/schemas.py
new file mode 100644
index 0000000..723ec64
--- /dev/null
+++ b/backend/apps/newsletter/schemas.py
@@ -0,0 +1,19 @@
+"""Request and response schemas for the newsletter API."""
+
+from ninja import Schema
+
+
+class SubscribeIn(Schema):
+ email: str
+ reviews: bool = False
+ new_tools: bool = False
+ benchmarks: bool = False
+
+
+class TokenIn(Schema):
+ token: str
+
+
+class SubscriptionOut(Schema):
+ email: str
+ topics: list[str]
diff --git a/backend/apps/newsletter/services.py b/backend/apps/newsletter/services.py
new file mode 100644
index 0000000..5679357
--- /dev/null
+++ b/backend/apps/newsletter/services.py
@@ -0,0 +1,71 @@
+"""Every change to the list goes through here; the API only translates."""
+
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.db import transaction
+from django.utils import timezone
+
+from apps.newsletter import emails, tokens
+from apps.newsletter.models import TOPICS, Subscriber
+
+
+class SubscribeError(Exception):
+ """A request the reader can fix, shown to them verbatim."""
+
+
+@transaction.atomic
+def subscribe(email: str, topics: list[str]) -> None:
+ """Record the request and mail a confirmation link, whatever state the address is in.
+
+ It says nothing back either way, so the form cannot be used to learn who is on the
+ list. An address already on it keeps its topics until its owner confirms the new ones.
+ """
+ email = email.strip().lower()
+ try:
+ validate_email(email)
+ except ValidationError as exc:
+ raise SubscribeError("Enter a valid email address.") from exc
+ if not topics:
+ raise SubscribeError("Pick at least one topic.")
+ subscriber, _ = Subscriber.objects.select_for_update().get_or_create(email=email)
+ if subscriber.status != Subscriber.Status.ACTIVE:
+ subscriber.status = Subscriber.Status.PENDING
+ for topic in TOPICS:
+ setattr(subscriber, topic, topic in topics)
+ subscriber.save()
+ emails.queue_confirmation(subscriber, topics)
+
+
+@transaction.atomic
+def confirm(token: str) -> Subscriber:
+ pk, topics = tokens.read_confirm_token(token)
+ subscriber = _locked(pk)
+ for topic in TOPICS:
+ setattr(subscriber, topic, topic in topics)
+ # An address already on the list is only changing its topics: no second welcome.
+ joining = subscriber.status != Subscriber.Status.ACTIVE
+ if joining:
+ subscriber.status = Subscriber.Status.ACTIVE
+ subscriber.confirmed_at = timezone.now()
+ subscriber.unsubscribed_at = None
+ subscriber.save()
+ if joining:
+ emails.queue_welcome(subscriber)
+ return subscriber
+
+
+@transaction.atomic
+def unsubscribe(token: str) -> None:
+ subscriber = _locked(tokens.read_unsubscribe_token(token))
+ if subscriber.status != Subscriber.Status.UNSUBSCRIBED:
+ subscriber.status = Subscriber.Status.UNSUBSCRIBED
+ subscriber.unsubscribed_at = timezone.now()
+ subscriber.save()
+
+
+def _locked(pk: int) -> Subscriber:
+ # A row staff deleted reads as a dead link, not a server error.
+ try:
+ return Subscriber.objects.select_for_update().get(pk=pk)
+ except Subscriber.DoesNotExist as exc:
+ raise tokens.InvalidToken from exc
diff --git a/backend/apps/newsletter/throttles.py b/backend/apps/newsletter/throttles.py
new file mode 100644
index 0000000..cda4a0c
--- /dev/null
+++ b/backend/apps/newsletter/throttles.py
@@ -0,0 +1,12 @@
+"""Per IP: subscribing needs no account, and every request sends a mail to whatever
+address it names, so this is what stops the form being used to flood an inbox."""
+
+from django.conf import settings
+from ninja.throttling import AnonRateThrottle
+
+
+class NewsletterSubscribeThrottle(AnonRateThrottle):
+ scope = "newsletter_subscribe"
+
+ def __init__(self):
+ super().__init__(settings.NEWSLETTER_SUBSCRIBE_RATE)
diff --git a/backend/apps/newsletter/tokens.py b/backend/apps/newsletter/tokens.py
new file mode 100644
index 0000000..8e28c57
--- /dev/null
+++ b/backend/apps/newsletter/tokens.py
@@ -0,0 +1,42 @@
+"""Signed links, so nothing secret is stored and nothing needs cleaning up.
+
+The confirmation carries the topics as well as the row: re-subscribing an address
+that is already on the list changes nothing until its owner clicks, so a stranger
+cannot rewrite someone else's choices. It expires; the unsubscribe link does not,
+because every mail we ever send carries one.
+"""
+
+from datetime import timedelta
+
+from django.core import signing
+
+CONFIRM_MAX_AGE = timedelta(days=7)
+_CONFIRM_SALT = "newsletter-confirm"
+_UNSUBSCRIBE_SALT = "newsletter-unsubscribe"
+
+
+class InvalidToken(Exception):
+ pass
+
+
+def confirm_token(subscriber_pk: int, topics: list[str]) -> str:
+ return signing.dumps({"id": subscriber_pk, "topics": topics}, salt=_CONFIRM_SALT)
+
+
+def read_confirm_token(token: str) -> tuple[int, list[str]]:
+ try:
+ data = signing.loads(token, salt=_CONFIRM_SALT, max_age=CONFIRM_MAX_AGE)
+ except signing.BadSignature as exc: # SignatureExpired is a subclass
+ raise InvalidToken from exc
+ return data["id"], data["topics"]
+
+
+def unsubscribe_token(subscriber_pk: int) -> str:
+ return signing.dumps(subscriber_pk, salt=_UNSUBSCRIBE_SALT)
+
+
+def read_unsubscribe_token(token: str) -> int:
+ try:
+ return signing.loads(token, salt=_UNSUBSCRIBE_SALT)
+ except signing.BadSignature as exc:
+ raise InvalidToken from exc
diff --git a/backend/config/api.py b/backend/config/api.py
index f38e775..fe0ed11 100644
--- a/backend/config/api.py
+++ b/backend/config/api.py
@@ -16,6 +16,7 @@
from apps.comments.api import router as comments_router
from apps.comments.staff_api import router as comments_staff_router
from apps.core.api import router as core_router
+from apps.newsletter.api import router as newsletter_router
class RedactionAPI(NinjaAPI):
@@ -50,6 +51,7 @@ def get_openapi_operation_id(self, operation: Operation) -> str:
api.add_router("/benchmarks/", benchmarks_router)
api.add_router("/comments/staff/", comments_staff_router)
api.add_router("/comments/", comments_router)
+api.add_router("/newsletter/", newsletter_router)
@api.exception_handler(StaffError)
diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py
index bd055bf..711a474 100644
--- a/backend/config/settings/base.py
+++ b/backend/config/settings/base.py
@@ -76,6 +76,7 @@
"apps.catalog",
"apps.benchmarks",
"apps.comments",
+ "apps.newsletter",
]
MIDDLEWARE = [
@@ -308,6 +309,10 @@
# Replies to replies, at most this deep. The page stops indenting long before.
COMMENTS_MAX_DEPTH = env.int("COMMENTS_MAX_DEPTH", default=8)
+# --- Newsletter ------------------------------------------------------------
+# Per IP. Subscribing needs no account and mails whatever address it is given.
+NEWSLETTER_SUBSCRIBE_RATE = env("NEWSLETTER_SUBSCRIBE_RATE", default="10/hour")
+
# --- Benchmarks ------------------------------------------------------------
# A redacted one-page case is a few hundred KB; a tool that rasterises every page
# at print resolution can reach a few MB. Well above that is not a redaction.
diff --git a/backend/openapi.json b/backend/openapi.json
index f526b59..27a709c 100644
--- a/backend/openapi.json
+++ b/backend/openapi.json
@@ -3531,6 +3531,88 @@
}
]
}
+ },
+ "/api/v1/newsletter/subscriptions": {
+ "post": {
+ "operationId": "subscribe_newsletter",
+ "summary": "Subscribe to the newsletter",
+ "parameters": [],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ }
+ },
+ "tags": [
+ "newsletter"
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubscribeIn"
+ }
+ }
+ },
+ "required": true
+ }
+ }
+ },
+ "/api/v1/newsletter/confirmations": {
+ "post": {
+ "operationId": "confirm_newsletter",
+ "summary": "Confirm a newsletter subscription",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubscriptionOut"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "newsletter"
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TokenIn"
+ }
+ }
+ },
+ "required": true
+ }
+ }
+ },
+ "/api/v1/newsletter/unsubscriptions": {
+ "post": {
+ "operationId": "unsubscribe_newsletter",
+ "summary": "Leave the newsletter",
+ "parameters": [],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ }
+ },
+ "tags": [
+ "newsletter"
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TokenIn"
+ }
+ }
+ },
+ "required": true
+ }
+ }
}
},
"components": {
@@ -8468,6 +8550,68 @@
],
"title": "CommentEditIn",
"type": "object"
+ },
+ "SubscribeIn": {
+ "properties": {
+ "email": {
+ "title": "Email",
+ "type": "string"
+ },
+ "reviews": {
+ "default": false,
+ "title": "Reviews",
+ "type": "boolean"
+ },
+ "new_tools": {
+ "default": false,
+ "title": "New Tools",
+ "type": "boolean"
+ },
+ "benchmarks": {
+ "default": false,
+ "title": "Benchmarks",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "title": "SubscribeIn",
+ "type": "object"
+ },
+ "SubscriptionOut": {
+ "properties": {
+ "email": {
+ "title": "Email",
+ "type": "string"
+ },
+ "topics": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Topics",
+ "type": "array"
+ }
+ },
+ "required": [
+ "email",
+ "topics"
+ ],
+ "title": "SubscriptionOut",
+ "type": "object"
+ },
+ "TokenIn": {
+ "properties": {
+ "token": {
+ "title": "Token",
+ "type": "string"
+ }
+ },
+ "required": [
+ "token"
+ ],
+ "title": "TokenIn",
+ "type": "object"
}
},
"securitySchemes": {
diff --git a/backend/tests/test_newsletter.py b/backend/tests/test_newsletter.py
new file mode 100644
index 0000000..9bb759b
--- /dev/null
+++ b/backend/tests/test_newsletter.py
@@ -0,0 +1,214 @@
+"""The newsletter list: double opt-in from the footer, a welcome on confirming, and a way out."""
+
+import re
+import time
+from datetime import timedelta
+
+import pytest
+from django.core import mail
+
+pytestmark = pytest.mark.django_db
+
+SUBSCRIBE = "/api/v1/newsletter/subscriptions"
+CONFIRM = "/api/v1/newsletter/confirmations"
+UNSUBSCRIBE = "/api/v1/newsletter/unsubscriptions"
+ALL_TOPICS = {"reviews": True, "new_tools": True, "benchmarks": True}
+
+
+@pytest.fixture(autouse=True)
+def clear_throttle_cache():
+ from django.core.cache import cache
+
+ cache.clear()
+
+
+def _subscribe(client, capture, email="reader@example.com", **topics):
+ with capture(execute=True):
+ return client.post(
+ SUBSCRIBE,
+ {"email": email, **(topics or ALL_TOPICS)},
+ content_type="application/json",
+ )
+
+
+def test_subscribing_stores_a_pending_address(client, django_capture_on_commit_callbacks):
+ from apps.newsletter.models import Subscriber
+
+ response = _subscribe(client, django_capture_on_commit_callbacks)
+
+ assert response.status_code == 204
+ subscriber = Subscriber.objects.get()
+ assert subscriber.email == "reader@example.com"
+ assert subscriber.status == Subscriber.Status.PENDING
+
+
+def test_subscribing_emails_a_link_that_confirms_the_address(
+ client, settings, django_capture_on_commit_callbacks
+):
+ settings.FRONTEND_URL = "https://redaction-tools.com"
+
+ _subscribe(client, django_capture_on_commit_callbacks)
+
+ [message] = mail.outbox
+ assert message.to == ["reader@example.com"]
+ assert message.subject == "Confirm your Redaction Tools subscription"
+ match = re.search(r"https://redaction-tools\.com/newsletter/confirm\?token=(\S+)", message.body)
+ assert match, message.body
+
+
+def _confirm_token(message):
+ from urllib.parse import unquote
+
+ return unquote(re.search(r"/newsletter/confirm\?token=(\S+)", message.body).group(1))
+
+
+def _confirm(client, capture, token):
+ with capture(execute=True):
+ return client.post(CONFIRM, {"token": token}, content_type="application/json")
+
+
+def test_confirming_activates_the_address_and_sends_a_welcome(
+ client, django_capture_on_commit_callbacks
+):
+ from apps.newsletter.models import Subscriber
+
+ _subscribe(client, django_capture_on_commit_callbacks, reviews=True, benchmarks=True)
+ token = _confirm_token(mail.outbox[0])
+
+ response = _confirm(client, django_capture_on_commit_callbacks, token)
+
+ assert response.status_code == 200
+ assert response.json() == {"email": "reader@example.com", "topics": ["reviews", "benchmarks"]}
+ subscriber = Subscriber.objects.get()
+ assert subscriber.status == Subscriber.Status.ACTIVE
+ assert subscriber.confirmed_at is not None
+ welcome = mail.outbox[1]
+ assert welcome.to == ["reader@example.com"]
+ assert welcome.subject == "You're subscribed to Redaction Tools updates"
+ assert "- Reviews\n- Benchmarks\n" in welcome.body
+ assert "New tools" not in welcome.body
+
+
+def test_clicking_the_link_again_sends_no_second_welcome(
+ client, django_capture_on_commit_callbacks
+):
+ _subscribe(client, django_capture_on_commit_callbacks)
+ token = _confirm_token(mail.outbox[0])
+ _confirm(client, django_capture_on_commit_callbacks, token)
+
+ response = _confirm(client, django_capture_on_commit_callbacks, token)
+
+ assert response.status_code == 200
+ assert len(mail.outbox) == 2
+
+
+@pytest.mark.parametrize("token", ["", "not-a-token", "eyJpZCI6MX0:forged:signature"])
+def test_a_bad_confirmation_link_is_refused(client, django_capture_on_commit_callbacks, token):
+ response = _confirm(client, django_capture_on_commit_callbacks, token)
+
+ assert response.status_code == 400
+ assert "link" in response.json()["detail"]
+
+
+def test_an_expired_confirmation_link_is_refused(
+ client, django_capture_on_commit_callbacks, monkeypatch
+):
+ _subscribe(client, django_capture_on_commit_callbacks)
+ token = _confirm_token(mail.outbox[0])
+
+ eight_days_on = time.time() + timedelta(days=8).total_seconds()
+ monkeypatch.setattr("django.core.signing.time.time", lambda: eight_days_on)
+
+ assert _confirm(client, django_capture_on_commit_callbacks, token).status_code == 400
+
+
+def test_an_address_is_stored_once_however_it_is_typed(client, django_capture_on_commit_callbacks):
+ from apps.newsletter.models import Subscriber
+
+ _subscribe(client, django_capture_on_commit_callbacks, email=" Reader@Example.COM ")
+ response = _subscribe(client, django_capture_on_commit_callbacks, email="reader@example.com")
+
+ assert response.status_code == 204
+ assert list(Subscriber.objects.values_list("email", flat=True)) == ["reader@example.com"]
+
+
+@pytest.mark.parametrize(
+ ("email", "topics"),
+ [
+ ("reader@example.com", {"reviews": False, "new_tools": False, "benchmarks": False}),
+ ("not an address", ALL_TOPICS),
+ ],
+)
+def test_a_subscription_needs_an_address_and_a_topic(
+ client, django_capture_on_commit_callbacks, email, topics
+):
+ from apps.newsletter.models import Subscriber
+
+ response = _subscribe(client, django_capture_on_commit_callbacks, email=email, **topics)
+
+ assert response.status_code == 422
+ assert isinstance(response.json()["detail"], str)
+ assert not Subscriber.objects.exists()
+ assert mail.outbox == []
+
+
+def test_resubscribing_an_active_address_changes_nothing_until_confirmed(
+ client, django_capture_on_commit_callbacks
+):
+ """Otherwise anyone could rewrite a stranger's topics from the footer."""
+ from apps.newsletter.models import Subscriber
+
+ _subscribe(client, django_capture_on_commit_callbacks)
+ _confirm(client, django_capture_on_commit_callbacks, _confirm_token(mail.outbox[0]))
+
+ response = _subscribe(client, django_capture_on_commit_callbacks, new_tools=True)
+
+ assert response.status_code == 204
+ subscriber = Subscriber.objects.get()
+ assert subscriber.status == Subscriber.Status.ACTIVE
+ assert subscriber.topics == ["reviews", "new_tools", "benchmarks"]
+
+ _confirm(client, django_capture_on_commit_callbacks, _confirm_token(mail.outbox[2]))
+
+ subscriber.refresh_from_db()
+ assert subscriber.topics == ["new_tools"]
+ assert len(mail.outbox) == 3 # the second confirmation, but no second welcome
+
+
+def _unsubscribe_token(message):
+ from urllib.parse import unquote
+
+ return unquote(re.search(r"/newsletter/unsubscribe\?token=(\S+)", message.body).group(1))
+
+
+def test_the_welcome_carries_a_link_that_unsubscribes(client, django_capture_on_commit_callbacks):
+ from apps.newsletter.models import Subscriber
+
+ _subscribe(client, django_capture_on_commit_callbacks)
+ _confirm(client, django_capture_on_commit_callbacks, _confirm_token(mail.outbox[0]))
+ welcome = mail.outbox[1]
+ token = _unsubscribe_token(welcome)
+ assert "/newsletter/unsubscribe?token=" in welcome.extra_headers["List-Unsubscribe"]
+
+ for _ in range(2): # a second click is harmless
+ response = client.post(UNSUBSCRIBE, {"token": token}, content_type="application/json")
+ assert response.status_code == 204
+
+ subscriber = Subscriber.objects.get()
+ assert subscriber.status == Subscriber.Status.UNSUBSCRIBED
+ assert subscriber.unsubscribed_at is not None
+
+
+def test_a_bad_unsubscribe_link_is_refused(client):
+ response = client.post(UNSUBSCRIBE, {"token": "forged"}, content_type="application/json")
+
+ assert response.status_code == 400
+
+
+def test_subscribing_is_throttled_per_ip(client, django_capture_on_commit_callbacks):
+ """At the default 10/hour: the throttle reads its rate once, at import."""
+ for n in range(10):
+ response = _subscribe(client, django_capture_on_commit_callbacks, email=f"r{n}@example.com")
+ assert response.status_code == 204
+
+ assert _subscribe(client, django_capture_on_commit_callbacks).status_code == 429
diff --git a/frontend/src/app/(site)/about/page.tsx b/frontend/src/app/(site)/about/page.tsx
index bb80995..c5bcee8 100644
--- a/frontend/src/app/(site)/about/page.tsx
+++ b/frontend/src/app/(site)/about/page.tsx
@@ -70,8 +70,10 @@ export default function AboutPage() {
{SITE_NAME} is operated by{' '}
{OPERATOR_NAME}, a software company based
in Marki, Poland, that builds AI-powered document processing software. You can reach us at{' '}
- info@stabrise.com or on{' '}
- r/RedactionTools.
+
+ info@redaction-tools.com
+ {' '}
+ or on r/RedactionTools.
@@ -133,8 +135,10 @@ export default function AboutPage() {
If a listing looks unfair to you, whether it is ours or a competitor's, tell us at{' '}
- info@stabrise.com. Vendors
- can also claim their listing and correct it.
+
+ info@redaction-tools.com
+
+ . Vendors can also claim their listing and correct it.
diff --git a/frontend/src/app/(site)/newsletter/confirm/page.tsx b/frontend/src/app/(site)/newsletter/confirm/page.tsx
new file mode 100644
index 0000000..355f87b
--- /dev/null
+++ b/frontend/src/app/(site)/newsletter/confirm/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from 'next'
+
+import { NewsletterConfirm } from '@/features/newsletter/newsletter-link'
+
+export const metadata: Metadata = {
+ title: 'Confirm your subscription',
+ robots: { index: false, follow: false },
+ // The token in the query is the whole credential: a click out of this page
+ // must not hand it to the next site in a Referer header.
+ referrer: 'no-referrer',
+}
+
+export default async function NewsletterConfirmPage({
+ searchParams,
+}: PageProps<'/newsletter/confirm'>) {
+ const { token } = await searchParams
+ return (
+