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.
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + +
+
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.
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+
You received this because of an action on Redaction Tools.
+
+
+
+ +
+
+ +
+ + + \ 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.
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
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:
+
+
+ +
+
+ + + + + + +
+ Browse the catalog +
+
+
Changed your mind? Unsubscribe in one click.
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+
You received this because of an action on Redaction Tools.
+
+
+
+ +
+
+ +
+ + + \ 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: + + + + 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 ( +
+ +
+ ) +} diff --git a/frontend/src/app/(site)/newsletter/unsubscribe/page.tsx b/frontend/src/app/(site)/newsletter/unsubscribe/page.tsx new file mode 100644 index 0000000..52edf16 --- /dev/null +++ b/frontend/src/app/(site)/newsletter/unsubscribe/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next' + +import { NewsletterUnsubscribe } from '@/features/newsletter/newsletter-link' + +export const metadata: Metadata = { + title: 'Unsubscribe', + robots: { index: false, follow: false }, + referrer: 'no-referrer', +} + +export default async function NewsletterUnsubscribePage({ + searchParams, +}: PageProps<'/newsletter/unsubscribe'>) { + const { token } = await searchParams + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/layout/site-footer.test.tsx b/frontend/src/components/layout/site-footer.test.tsx index 3289226..16e88d8 100644 --- a/frontend/src/components/layout/site-footer.test.tsx +++ b/frontend/src/components/layout/site-footer.test.tsx @@ -1,7 +1,8 @@ -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { SiteFooter } from '@/components/layout/site-footer' +import { renderWithProviders as render } from '@/test/render' describe('SiteFooter', () => { it('carries the brand mark back to the catalog', () => { @@ -105,4 +106,11 @@ describe('SiteFooter', () => { expect(screen.getByRole('link', { name: 'RSS' })).toHaveAttribute('href', '/blog/rss.xml') }) + + it('offers the newsletter sign-up', () => { + render() + + expect(screen.getByRole('heading', { name: /reviews and benchmarks by email/i })).toBeVisible() + expect(screen.getByRole('button', { name: 'Subscribe' })).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/layout/site-footer.tsx b/frontend/src/components/layout/site-footer.tsx index 67c23e5..fb10fea 100644 --- a/frontend/src/components/layout/site-footer.tsx +++ b/frontend/src/components/layout/site-footer.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { Container } from '@/components/layout/container' import { Logo } from '@/components/layout/logo' +import { NewsletterSignup } from '@/features/newsletter/newsletter-signup' import { LINKEDIN_URL, OPERATOR_NAME, @@ -51,47 +52,60 @@ export function SiteFooter() { return (