-
Notifications
You must be signed in to change notification settings - Fork 1
feat(notifications): Implement one-click unsubscribe and enhance management command #360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat-notifications
Are you sure you want to change the base?
Changes from all commits
969b33f
1276814
0624764
540b0bb
8ab3e22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # Generated by Django 4.2.28 on 2026-03-07 07:49 | ||
|
|
||
| from django.db import migrations, models | ||
| import django.db.models.deletion | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ('contenttypes', '0002_remove_content_type_name'), | ||
| ('sections', '0024_initial'), | ||
| ('notifications', '0001_initial'), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AlterModelOptions( | ||
| name='sectionnotificationpreferences', | ||
| options={'verbose_name': 'section notification preference', 'verbose_name_plural': 'section notification preferences'}, | ||
| ), | ||
| migrations.AlterField( | ||
| model_name='schedulednotification', | ||
| name='content_type', | ||
| field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype', verbose_name='content type'), | ||
| ), | ||
| migrations.AlterField( | ||
| model_name='schedulednotification', | ||
| name='kind', | ||
| field=models.CharField(choices=[('buddy_matched_issuer', 'Buddy matched — issuer'), ('pickup_matched_issuer', 'Pickup matched — issuer'), ('member_waiting_digest', 'New member waiting digest')], max_length=64, verbose_name='kind'), | ||
| ), | ||
| migrations.AlterField( | ||
| model_name='schedulednotification', | ||
| name='section', | ||
| field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_notifications', to='sections.section', verbose_name='section'), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,8 @@ | |
| from django.core.mail import EmailMultiAlternatives | ||
| from django.template.loader import render_to_string | ||
|
|
||
| from apps.notifications.services.unsubscribe import generate_unsubscribe_token | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| if typing.TYPE_CHECKING: | ||
|
|
@@ -37,19 +39,31 @@ def send_notification_email( | |
| except ObjectDoesNotExist: | ||
| pass # No profile — proceed with sending | ||
|
|
||
| html_content = render_to_string(f"{template_prefix}.html", context) | ||
| text_content = render_to_string(f"{template_prefix}.txt", context) | ||
| email_context = dict(context) | ||
| unsubscribe_url = "" | ||
| if recipient_user is not None: | ||
| token = generate_unsubscribe_token(recipient_user.pk) | ||
| unsubscribe_url = f"https://{settings.ROOT_DOMAIN}/notifications/unsubscribe/{token}/" | ||
|
|
||
|
Comment on lines
+42
to
+47
|
||
| email_context["unsubscribe_url"] = unsubscribe_url | ||
|
|
||
| msg = EmailMultiAlternatives( | ||
| html_content = render_to_string(f"{template_prefix}.html", email_context) | ||
| text_content = render_to_string(f"{template_prefix}.txt", email_context) | ||
|
|
||
| email = EmailMultiAlternatives( | ||
| subject=subject, | ||
| body=text_content, | ||
| from_email=settings.DEFAULT_FROM_EMAIL, | ||
| to=[recipient_email], | ||
| ) | ||
| msg.attach_alternative(html_content, "text/html") | ||
| if unsubscribe_url: | ||
| email.extra_headers["List-Unsubscribe"] = f"<{unsubscribe_url}>" | ||
| email.extra_headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" | ||
|
|
||
| email.attach_alternative(html_content, "text/html") | ||
|
|
||
| try: | ||
| msg.send() | ||
| email.send() | ||
| except Exception: | ||
| logger.exception( | ||
| "Failed to send notification email (template: %s)", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from django.core.signing import BadSignature, TimestampSigner | ||
|
|
||
| UNSUBSCRIBE_SALT = "notifications-unsubscribe" | ||
|
|
||
|
|
||
| def generate_unsubscribe_token(user_id: int | str, action: str = "global") -> str: | ||
| signer = TimestampSigner(salt=UNSUBSCRIBE_SALT) | ||
| return signer.sign(f"{user_id}:{action}") | ||
|
|
||
|
|
||
| def verify_unsubscribe_token(token: str, max_age: int = 30 * 24 * 60 * 60) -> tuple[str, str]: | ||
| signer = TimestampSigner(salt=UNSUBSCRIBE_SALT) | ||
| value = signer.unsign(token, max_age=max_age) | ||
| try: | ||
| user_id_str, action = value.rsplit(":", 1) | ||
| return user_id_str, action | ||
| except (TypeError, ValueError) as exc: | ||
| raise BadSignature("Invalid unsubscribe payload") from exc |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| {% extends "fiesta/base.html" %} | ||
| {% load i18n %} | ||
|
|
||
| {% block main %} | ||
| <div class="container mx-auto max-w-lg py-8"> | ||
| {% if error %} | ||
| <h1 class="text-2xl font-bold mb-4">{% trans "Unsubscribe Error" %}</h1> | ||
| <p>{{ error }}</p> | ||
| {% elif success %} | ||
| <h1 class="text-2xl font-bold mb-4">{% trans "Unsubscribed" %}</h1> | ||
| <p> | ||
| {% blocktrans with action=action_description %}You have been successfully unsubscribed from {{ action }}.{% endblocktrans %} | ||
| </p> | ||
| {% else %} | ||
| <h1 class="text-2xl font-bold mb-4">{% trans "Unsubscribe from Notifications" %}</h1> | ||
| <p> | ||
| {% blocktrans with email=email action=action_description %} | ||
| You are about to unsubscribe {{ email }} from {{ action }}. | ||
| {% endblocktrans %} | ||
| </p> | ||
| <form method="post"> | ||
| <button type="submit" class="btn btn-primary mt-4">{% trans "Unsubscribe" %}</button> | ||
| </form> | ||
| {% endif %} | ||
| </div> | ||
| {% endblock main %} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new “claim” mechanism sets
sent_at=nowbefore any email I/O occurs. If the process crashes or is killed after claiming but before sending/rollback, those notifications will look sent and will never be retried, causing silent message loss. Consider introducing a separate claim field (e.g.,claimed_at/locked_at), or only settingsent_atafter a successful send while using another durable marker to prevent concurrent workers.