Skip to content
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

[feat] Add unsubscribe link to email notifications #307

Open
wants to merge 22 commits into
base: notification-preferences
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
002dddf
[chore] Create EmailTokenGenerator
Dhanus3133 Aug 21, 2024
7322a73
[chore] Unsubscribe Implementation
Dhanus3133 Aug 24, 2024
6290e16
[chore] Remove token time expiry
Dhanus3133 Sep 1, 2024
cfff9aa
[chore] Handle logic for any one email setting type enabled even when…
Dhanus3133 Sep 1, 2024
286b22b
[chore] Translatable i18n and js file refactor
Dhanus3133 Sep 1, 2024
b82f441
[chore] Add tests
Dhanus3133 Sep 1, 2024
ede83b3
Merge branch 'gsoc24-rebased' into feat/manage-notifications-unsubscribe
Dhanus3133 Sep 1, 2024
21b9362
Merge branch 'notification-preferences' into feat/manage-notification…
Dhanus3133 Sep 1, 2024
3b96255
[chore] Handle check_token function for older django version
Dhanus3133 Sep 1, 2024
27952c7
[ci] Add notification-preferences target branches PR to build actions
Dhanus3133 Sep 1, 2024
6ebb734
Merge branch 'notification-preferences' into feat/manage-notification…
Dhanus3133 Sep 3, 2024
90a6056
[chore] Bump changes
Dhanus3133 Sep 5, 2024
735c4d0
[chore] Add tests
Dhanus3133 Sep 5, 2024
7528ed7
[chore] Add selenium tests
Dhanus3133 Sep 6, 2024
b37fce5
Merge branch 'notification-preferences' into feat/manage-notification…
Dhanus3133 Sep 7, 2024
2f802be
[chore] Bump changes
Dhanus3133 Sep 7, 2024
5c9df62
[fix] JS console
Dhanus3133 Sep 7, 2024
8b46ca7
[fix] Tests
Dhanus3133 Sep 7, 2024
35ea832
[chore] Reuse base_entrance.html
Dhanus3133 Sep 11, 2024
dfe62ed
Merge branch 'notification-preferences' into feat/manage-notification…
Dhanus3133 Oct 2, 2024
4171bd4
[chore] CSS changes
Dhanus3133 Oct 2, 2024
2afa9fe
[qa] Fixes
Dhanus3133 Oct 2, 2024
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
98 changes: 98 additions & 0 deletions openwisp_notifications/base/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import base64
import json

from django.contrib.auth import get_user_model
from django.http import JsonResponse
from django.shortcuts import render
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView

from openwisp_notifications.swapper import load_model

from ..tokens import email_token_generator

User = get_user_model()
NotificationSetting = load_model('NotificationSetting')


@method_decorator(csrf_exempt, name='dispatch')
class UnsubscribeView(TemplateView):
template_name = 'openwisp_notifications/unsubscribe.html'

def get(self, request, *args, **kwargs):
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
encoded_token = request.GET.get('token')
if not encoded_token:
return render(request, self.template_name, {'valid': False})

user, valid = self._validate_token(encoded_token)
if not valid:
return render(request, self.template_name, {'valid': False})

notification_preference = self.get_user_preference(user)

return render(
request,
self.template_name,
{
'valid': True,
'user': user,
'is_subscribed': notification_preference.email,
},
)

def post(self, request, *args, **kwargs):
encoded_token = request.GET.get('token')
if not encoded_token:
return JsonResponse(
{'success': False, 'message': 'No token provided'}, status=400
)

user, valid = self._validate_token(encoded_token)
if not valid:
return JsonResponse(
{'success': False, 'message': 'Invalid or expired token'}, status=400
)

subscribe = False
if request.body:
try:
data = json.loads(request.body)
subscribe = data.get('subscribe', False)
except json.JSONDecodeError:
return JsonResponse(
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
{'success': False, 'message': 'Invalid JSON data'}, status=400
)

notification_preference = self.get_user_preference(user)
notification_preference.email = subscribe
notification_preference.save()

status_message = 'subscribed' if subscribe else 'unsubscribed'
return JsonResponse(
{'success': True, 'message': f'Successfully {status_message}'}
)

def _validate_token(self, encoded_token):
try:
decoded_data = base64.urlsafe_b64decode(encoded_token).decode()
data = json.loads(decoded_data)
user_id = data.get('user_id')
token = data.get('token')

user = User.objects.get(id=user_id)
if email_token_generator.check_token(user, token):
return user, True
except (
User.DoesNotExist,
ValueError,
json.JSONDecodeError,
base64.binascii.Error,
):
pass

return None, False

def get_user_preference(self, user):
# TODO: Should update this once the Notification Preferences Page PR is merged.
return NotificationSetting.objects.filter(user=user).first()
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
body,
html {
height: 100%;
margin: 0;
background-color: #f0f4f8;
}
.container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 100vh;
}
.content {
background: #ffffff;
padding: 40px;
border-radius: 12px;
text-align: center;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 100%;
}
.logo {
width: 300px;
margin-bottom: 20px;
}
.icon {
font-size: 56px;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
}
p {
color: #555;
font-size: 16px;
margin-bottom: 20px;
line-height: 1.5;
}
button {
background-color: #0077b5;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
background-color: #005f8a;
}
a {
color: #0077b5;
text-decoration: none;
font-weight: bold;
}
a:hover {
text-decoration: underline;
}
.footer {
margin-top: 20px;
}
#confirmation-msg {
color: green;
margin-top: 20px;
font-weight: bold;
}
23 changes: 20 additions & 3 deletions openwisp_notifications/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
from django.db.utils import OperationalError
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _

from openwisp_notifications import settings as app_settings
from openwisp_notifications import types
from openwisp_notifications.swapper import load_model, swapper_load_model
from openwisp_notifications.utils import send_notification_email
from openwisp_notifications.utils import (
generate_unsubscribe_link,
send_notification_email,
)
from openwisp_utils.admin_theme.email import send_email
from openwisp_utils.tasks import OpenwispCeleryTask

Expand Down Expand Up @@ -259,17 +263,26 @@ def send_batched_email_notifications(instance_id):
.replace('pm', 'p.m.')
) + ' UTC'

user = User.objects.get(id=instance_id)

context = {
'notifications': unsent_notifications[:display_limit],
'notifications_count': notifications_count,
'site_name': current_site.name,
'start_time': starting_time,
}

extra_context = {}
unsubscribe_link = generate_unsubscribe_link(user)

extra_context = {
'footer': mark_safe(
'To unsubscribe from these notifications, '
f'<a href="{unsubscribe_link}">click here</a>.'
),
}
if notifications_count > display_limit:
extra_context = {
'call_to_action_url': f"https://{current_site.domain}/admin/#notifications",
'call_to_action_url': f'https://{current_site.domain}/admin/#notifications',
'call_to_action_text': _('View all Notifications'),
}
context.update(extra_context)
Expand All @@ -284,6 +297,10 @@ def send_batched_email_notifications(instance_id):
body_html=html_content,
recipients=[email_id],
extra_context=extra_context,
headers={
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
'List-Unsubscribe': f'<{unsubscribe_link}>',
},
)

unsent_notifications_query.update(emailed=True)
Expand Down
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
{% load static %}

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Manage Subscription Preferences</title>
<link rel="stylesheet" type="text/css" href="{% static 'openwisp-notifications/css/unsubscribe.css' %}" />
</head>
<body>
<div class="container">
<img
src="{% static 'ui/openwisp/images/openwisp-logo-black.svg' %}"
alt="OpenWISP Logo"
class="logo"
/>
<div class="content">
<div class="icon">✉️</div>
<h1>Manage Notification Preferences</h1>
{% if valid %}
<p id="status-message">
{% if is_subscribed %}
You are currently subscribed to notifications.
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
{% else %}
You are currently unsubscribed from notifications.
{% endif %}
</p>
<button id="toggle-btn">
{% if is_subscribed %}
Unsubscribe
{% else %}
Subscribe
{% endif %}
</button>
<p id="confirmation-msg" style="display:none;"></p>
{% else %}
<h1>Invalid or Expired Link</h1>
<p>The link you used is invalid or expired. Please contact support.</p>
{% endif %}
<div class="footer">
<p>Manage Other Preferences? <a href="/notifications/preferences">Click here</a></p>
</div>
</div>
</div>

<script>
function updateSubscription(subscribe) {
const token = '{{ request.GET.token }}';

fetch(window.location.href, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subscribe: subscribe })
})
.then(response => response.json())
.then(data => {
if (data.success) {
const toggleBtn = document.getElementById('toggle-btn');
const statusMessage = document.getElementById('status-message');
const confirmationMsg = document.getElementById('confirmation-msg');

if (subscribe) {
statusMessage.textContent = "You are currently subscribed to notifications.";
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
toggleBtn.textContent = "Unsubscribe";
confirmationMsg.textContent = data.message;
} else {
statusMessage.textContent = "You are currently unsubscribed from notifications.";
toggleBtn.textContent = "Subscribe";
confirmationMsg.textContent = data.message;
}

confirmationMsg.style.display = 'block';
} else {
alert(data.message);
}
});
}

const toggleBtn = document.getElementById('toggle-btn');
toggleBtn.addEventListener('click', function() {
const currentStatus = toggleBtn.textContent.trim().toLowerCase();
const subscribe = currentStatus === 'subscribe';
updateSubscription(subscribe);
});
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
</script>
</body>
</html>
68 changes: 68 additions & 0 deletions openwisp_notifications/tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.utils.crypto import constant_time_compare
from django.utils.http import base36_to_int


class EmailTokenGenerator(PasswordResetTokenGenerator):
"""
Email token generator that extends the default PasswordResetTokenGenerator
with a fixed 7-day expiry period and a salt key.
"""

key_salt = "openwisp_notifications.tokens.EmailTokenGenerator"

def __init__(self):
super().__init__()
self.expiry_days = 7
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved

def check_token(self, user, token):
"""
Check that a token is correct for a given user and has not expired.
"""
if not (user and token):
return False

# Parse the token
try:
ts_b36, _ = token.split("-")
except ValueError:
return False

try:
ts = base36_to_int(ts_b36)
except ValueError:
return False

# Check that the timestamp/uid has not been tampered with
for secret in [self.secret, *self.secret_fallbacks]:
if constant_time_compare(
self._make_token_with_timestamp(user, ts, secret),
token,
):
break
else:
return False

# Check the timestamp is within the expiry limit.
if (self._num_seconds(self._now()) - ts) > self._expiry_seconds():
return False

return True

def _make_hash_value(self, user, timestamp):
"""
Hash the user's primary key and password to produce a token that is
invalidated when the password is reset.
"""
email_field = user.get_email_field_name()
email = getattr(user, email_field, "") or ""
return f"{user.pk}{user.password}{timestamp}{email}"

def _expiry_seconds(self):
"""
Returns the number of seconds representing the token's expiry period.
"""
return self.expiry_days * 24 * 3600


email_token_generator = EmailTokenGenerator()
Loading
Loading