From c3da86ad12b5c1899e8b6f20535180248af7363e Mon Sep 17 00:00:00 2001
From: Mykola Melnyk
Date: Thu, 8 Oct 2026 21:12:34 +0200
Subject: [PATCH 1/2] Use info@redaction-tools.com as the About page contact
The structured data keeps info@stabrise.com: it describes StabRise as the
operator and has to match what pdf-redaction.com publishes.
---
frontend/src/app/(site)/about/page.tsx | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
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.
From 7e7a44a0bb8e44ed5089cf2ebb1f295322f1da65 Mon Sep 17 00:00:00 2001
From: Mykola Melnyk
Date: Thu, 8 Oct 2026 21:12:34 +0200
Subject: [PATCH 2/2] Let readers subscribe to reviews, new tools and
benchmarks from the footer
A footer form takes an email and any of three topics. The list lives in a new
`apps/newsletter/` app; sending the actual updates is not built yet.
- Double opt-in: `POST /newsletter/subscriptions` stores the address as
`pending` and mails a confirmation link (7 days). Only the click, on
`/newsletter/confirm`, makes it `active` and sends a welcome email with an
unsubscribe link and a `List-Unsubscribe` header.
- Subscribing answers 204 whatever the address's state, so the form cannot
reveal who is on the list. An active address keeps its topics until its
owner confirms new ones: the topics ride in the token, not the row.
- Confirm and unsubscribe links are `django.core.signing` tokens, so nothing
secret is stored. Email tasks take the row and topics, never a link,
because django-q keeps task arguments in the database.
- Both landing pages act on a button, never on load, since mail scanners
open links before people do.
- `NEWSLETTER_SUBSCRIBE_RATE` (10/hour per IP) limits sign-ups; staff see the
list and its consent trail in the admin.
- `send_templated_email` takes optional headers. The footer's top row is now
two columns so the form sits beside the links.
- DEVELOPMENT.md and CLAUDE.md describe the flow.
---
CLAUDE.md | 12 +
DEVELOPMENT.md | 28 ++
backend/.env.example | 4 +
backend/apps/core/email.py | 5 +-
.../email/newsletter_confirm/body.html | 214 +++++++++++++
.../email/newsletter_confirm/body.mjml | 24 ++
.../email/newsletter_confirm/body.txt | 16 +
.../email/newsletter_confirm/preview.json | 4 +
.../email/newsletter_confirm/subject.txt | 1 +
.../email/newsletter_welcome/body.html | 235 ++++++++++++++
.../email/newsletter_welcome/body.mjml | 33 ++
.../email/newsletter_welcome/body.txt | 20 ++
.../email/newsletter_welcome/preview.json | 4 +
.../email/newsletter_welcome/subject.txt | 1 +
backend/apps/newsletter/__init__.py | 0
backend/apps/newsletter/admin.py | 14 +
backend/apps/newsletter/api.py | 52 ++++
backend/apps/newsletter/apps.py | 7 +
backend/apps/newsletter/emails.py | 68 ++++
.../newsletter/migrations/0001_initial.py | 32 ++
.../apps/newsletter/migrations/__init__.py | 0
backend/apps/newsletter/models.py | 42 +++
backend/apps/newsletter/schemas.py | 19 ++
backend/apps/newsletter/services.py | 71 +++++
backend/apps/newsletter/throttles.py | 12 +
backend/apps/newsletter/tokens.py | 42 +++
backend/config/api.py | 2 +
backend/config/settings/base.py | 5 +
backend/openapi.json | 144 +++++++++
backend/tests/test_newsletter.py | 214 +++++++++++++
.../app/(site)/newsletter/confirm/page.tsx | 22 ++
.../(site)/newsletter/unsubscribe/page.tsx | 20 ++
.../components/layout/site-footer.test.tsx | 10 +-
.../src/components/layout/site-footer.tsx | 92 +++---
.../newsletter/newsletter-link.test.tsx | 75 +++++
.../features/newsletter/newsletter-link.tsx | 115 +++++++
.../newsletter/newsletter-signup.test.tsx | 79 +++++
.../features/newsletter/newsletter-signup.tsx | 96 ++++++
frontend/src/lib/api/generated/index.ts | 1 +
frontend/src/lib/api/generated/model/index.ts | 3 +
.../lib/api/generated/model/subscribeIn.ts | 14 +
.../api/generated/model/subscriptionOut.ts | 12 +
.../src/lib/api/generated/model/tokenIn.ts | 11 +
.../api/generated/newsletter/newsletter.ts | 291 ++++++++++++++++++
44 files changed, 2125 insertions(+), 41 deletions(-)
create mode 100644 backend/apps/core/templates/email/newsletter_confirm/body.html
create mode 100644 backend/apps/core/templates/email/newsletter_confirm/body.mjml
create mode 100644 backend/apps/core/templates/email/newsletter_confirm/body.txt
create mode 100644 backend/apps/core/templates/email/newsletter_confirm/preview.json
create mode 100644 backend/apps/core/templates/email/newsletter_confirm/subject.txt
create mode 100644 backend/apps/core/templates/email/newsletter_welcome/body.html
create mode 100644 backend/apps/core/templates/email/newsletter_welcome/body.mjml
create mode 100644 backend/apps/core/templates/email/newsletter_welcome/body.txt
create mode 100644 backend/apps/core/templates/email/newsletter_welcome/preview.json
create mode 100644 backend/apps/core/templates/email/newsletter_welcome/subject.txt
create mode 100644 backend/apps/newsletter/__init__.py
create mode 100644 backend/apps/newsletter/admin.py
create mode 100644 backend/apps/newsletter/api.py
create mode 100644 backend/apps/newsletter/apps.py
create mode 100644 backend/apps/newsletter/emails.py
create mode 100644 backend/apps/newsletter/migrations/0001_initial.py
create mode 100644 backend/apps/newsletter/migrations/__init__.py
create mode 100644 backend/apps/newsletter/models.py
create mode 100644 backend/apps/newsletter/schemas.py
create mode 100644 backend/apps/newsletter/services.py
create mode 100644 backend/apps/newsletter/throttles.py
create mode 100644 backend/apps/newsletter/tokens.py
create mode 100644 backend/tests/test_newsletter.py
create mode 100644 frontend/src/app/(site)/newsletter/confirm/page.tsx
create mode 100644 frontend/src/app/(site)/newsletter/unsubscribe/page.tsx
create mode 100644 frontend/src/features/newsletter/newsletter-link.test.tsx
create mode 100644 frontend/src/features/newsletter/newsletter-link.tsx
create mode 100644 frontend/src/features/newsletter/newsletter-signup.test.tsx
create mode 100644 frontend/src/features/newsletter/newsletter-signup.tsx
create mode 100644 frontend/src/lib/api/generated/model/subscribeIn.ts
create mode 100644 frontend/src/lib/api/generated/model/subscriptionOut.ts
create mode 100644 frontend/src/lib/api/generated/model/tokenIn.ts
create mode 100644 frontend/src/lib/api/generated/newsletter/newsletter.ts
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.