Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/`; `DEVELOPMENT.md` has the
Expand Down
28 changes: 28 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion backend/apps/core/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand All @@ -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()
Expand Down
214 changes: 214 additions & 0 deletions backend/apps/core/templates/email/newsletter_confirm/body.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<!doctype html>
<html lang="en" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">

<head>
<title>Confirm your Redaction Tools subscription</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}

body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}

table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}

img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}

p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
</head>

<body style="word-spacing:normal;background-color:#f4f4f5;">
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">One click and you will hear about new reviews, tools and benchmarks.</div>
<div aria-label="Confirm your Redaction Tools subscription" aria-roledescription="email" role="article" lang="en" dir="auto" style="word-spacing:normal;background-color:#f4f4f5;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:624px;" width="624" bgcolor="#f4f4f5" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f4f4f5;background-color:#f4f4f5;margin:0px auto;max-width:624px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f4f4f5;background-color:#f4f4f5;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:32px 32px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="background-color:#ffffff;border-bottom:1px solid #e4e4e7;vertical-align:top;padding:20px 32px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:0;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:18px;font-weight:600;line-height:32px;text-align:left;color:#18181b;"><a href="{{ site_url }}" style="color:#18181b;text-decoration:none;"><img src="{{ site_url }}/images/email/logo.png" width="32" height="32" alt="" style="display:inline-block;vertical-align:middle;border:0;margin-right:10px;">Redaction Tools</a></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:624px;" width="624" bgcolor="#f4f4f5" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f4f4f5;background-color:#f4f4f5;margin:0px auto;max-width:624px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f4f4f5;background-color:#f4f4f5;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 32px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="background-color:#ffffff;vertical-align:top;padding:32px 32px 16px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:0 0 16px;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;text-align:left;color:#18181b;">Hi,</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:0 0 8px;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;text-align:left;color:#18181b;">Someone - hopefully you - asked to get Redaction Tools updates at this address:</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:0 0 16px;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;text-align:left;color:#18181b;">
<ul style="margin:0;padding-left:20px;"> {% for topic in topics %}<li style="margin-bottom:4px;">{{ topic }}</li>{% endfor %} </ul>
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:8px 0 16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tbody>
<tr>
<td align="center" bgcolor="#18181b" role="presentation" style="border:none;border-radius:6px;cursor:auto;mso-padding-alt:10px 25px;background:#18181b;" valign="middle">
<a href="{{ confirm_url }}" style="display:inline-block;background:#18181b;color:#ffffff;font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:15px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:6px;" target="_blank"> Confirm subscription </a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:0 0 16px;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.6;text-align:left;color:#71717a;">The link works for 7 days. If this wasn't you, ignore this email: nothing is sent until the link is clicked.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:624px;" width="624" bgcolor="#f4f4f5" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f4f4f5;background-color:#f4f4f5;margin:0px auto;max-width:624px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f4f4f5;background-color:#f4f4f5;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 32px 32px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="background-color:#ffffff;border-top:1px solid #e4e4e7;vertical-align:top;padding:16px 32px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:0;word-break:break-word;">
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;font-size:12px;line-height:1.5;text-align:left;color:#71717a;">You received this because of an action on <a href="{{ site_url }}" style="color:#71717a;">Redaction Tools</a>.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>

</html>
24 changes: 24 additions & 0 deletions backend/apps/core/templates/email/newsletter_confirm/body.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<mjml lang="en">
<mj-head>
<mj-title>Confirm your Redaction Tools subscription</mj-title>
<mj-preview>One click and you will hear about new reviews, tools and benchmarks.</mj-preview>
<mj-include path="../_partials/head.mjml" />
</mj-head>
<mj-body background-color="#f4f4f5" width="624px">
<mj-include path="../_partials/header.mjml" />
<mj-section padding="0 32px" background-color="#f4f4f5">
<mj-column background-color="#ffffff" padding="32px 32px 16px">
<mj-text>Hi,</mj-text>
<mj-text padding="0 0 8px">Someone - hopefully you - asked to get Redaction Tools updates at this address:</mj-text>
<mj-text>
<ul style="margin:0;padding-left:20px;">
{% for topic in topics %}<li style="margin-bottom:4px;">{{ topic }}</li>{% endfor %}
</ul>
</mj-text>
<mj-button href="{{ confirm_url }}" background-color="#18181b" color="#ffffff" font-size="15px" border-radius="6px" align="left" padding="8px 0 16px">Confirm subscription</mj-button>
<mj-text font-size="13px" color="#71717a">The link works for 7 days. If this wasn't you, ignore this email: nothing is sent until the link is clicked.</mj-text>
</mj-column>
</mj-section>
<mj-include path="../_partials/footer.mjml" />
</mj-body>
</mjml>
16 changes: 16 additions & 0 deletions backend/apps/core/templates/email/newsletter_confirm/body.txt
Original file line number Diff line number Diff line change
@@ -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 %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"topics": ["Reviews", "New tools", "Benchmarks"],
"confirm_url": "http://localhost:3007/newsletter/confirm?token=preview"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% autoescape off %}Confirm your Redaction Tools subscription{% endautoescape %}
Loading
Loading