Skip to content

Commit f45f574

Browse files
Dhanus3133nemesifierpandafy
committed
[feat] Added unsubscribe link to email notifications #256
Implements and closes #256 --------- Co-authored-by: Federico Capoano <[email protected]> Co-authored-by: Gagan Deep <[email protected]>
1 parent 5b0d712 commit f45f574

File tree

15 files changed

+623
-69
lines changed

15 files changed

+623
-69
lines changed

docs/user/web-email-notifications.rst

+17-4
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ notification toast.
4747
Email Notifications
4848
-------------------
4949

50-
.. figure:: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/email-template.png
51-
:target: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/email-template.png
50+
.. figure:: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/25/emails/template.png
51+
:target: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/25/emails/template.png
5252
:align: center
5353

5454
Along with web notifications OpenWISP Notifications also sends email
@@ -60,8 +60,8 @@ notifications leveraging the :ref:`send_email feature of OpenWISP Utils
6060
Email Batches
6161
~~~~~~~~~~~~~
6262

63-
.. figure:: https://i.imgur.com/W5P009W.png
64-
:target: https://i.imgur.com/W5P009W.png
63+
.. figure:: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/25/emails/batch-email.png
64+
:target: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/25/emails/batch-email.png
6565
:align: center
6666

6767
Batching email notifications helps manage the flow of emails sent to
@@ -89,3 +89,16 @@ following settings:
8989
<openwisp_notifications_email_batch_interval>`.
9090
- :ref:`OPENWISP_NOTIFICATIONS_EMAIL_BATCH_DISPLAY_LIMIT
9191
<openwisp_notifications_email_batch_display_limit>`.
92+
93+
Unsubscribing from Email Notifications
94+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
95+
96+
In addition to updating notification preferences via the :ref:`preferences
97+
page <notification-preferences>`, users can opt out of receiving email
98+
notifications using the unsubscribe link included in every notification
99+
email.
100+
101+
Furthermore, email notifications include `List-Unsubscribe headers
102+
<https://www.ietf.org/rfc/rfc2369.txt>`_, enabling modern email clients to
103+
provide an unsubscribe button directly within their interface, offering a
104+
seamless opt-out experience.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#content,
2+
.content {
3+
padding: 0 !important;
4+
}
5+
.unsubscribe-container {
6+
display: flex;
7+
justify-content: center;
8+
align-items: center;
9+
flex-direction: column;
10+
height: 95vh;
11+
}
12+
.unsubscribe-content {
13+
padding: 40px;
14+
border-radius: 12px;
15+
text-align: center;
16+
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
17+
max-width: 500px;
18+
width: 100%;
19+
}
20+
.unsubscribe-content h1 {
21+
padding-top: 10px;
22+
}
23+
.logo {
24+
width: 200px;
25+
margin-bottom: 80px;
26+
}
27+
.email-icon {
28+
background-image: url("../../openwisp-notifications/images/icons/icon-email.svg");
29+
background-repeat: no-repeat;
30+
width: 50px;
31+
height: 50px;
32+
margin: 0 auto;
33+
transform: scale(2) translate(25%, 25%);
34+
}
35+
.footer {
36+
margin-top: 20px;
37+
}
38+
.confirmation-msg {
39+
color: green;
40+
margin-top: 20px;
41+
font-weight: bold;
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use strict";
2+
3+
// Ensure `gettext` is defined
4+
if (typeof gettext === "undefined") {
5+
var gettext = function (word) {
6+
return word;
7+
};
8+
}
9+
10+
function updateSubscription(subscribe) {
11+
const toggleBtn = document.querySelector("#toggle-btn");
12+
const subscribedMessage = document.querySelector("#subscribed-message");
13+
const unsubscribedMessage = document.querySelector("#unsubscribed-message");
14+
const confirmationMsg = document.querySelector(".confirmation-msg-container");
15+
const confirmSubscribed = document.querySelector("#confirm-subscribed");
16+
const confirmUnsubscribed = document.querySelector("#confirm-unsubscribed");
17+
const errorMessage = document.querySelector("#error-msg-container");
18+
const managePreferences = document.querySelector("#manage-preferences");
19+
const footer = document.querySelector(".footer");
20+
21+
fetch(window.location.href, {
22+
method: "POST",
23+
headers: {
24+
"Content-Type": "application/json",
25+
},
26+
body: JSON.stringify({ subscribe }),
27+
})
28+
.then((response) => response.json())
29+
.then((data) => {
30+
if (data.success) {
31+
// Toggle visibility of messages
32+
subscribedMessage.classList.toggle("hidden", !subscribe);
33+
unsubscribedMessage.classList.toggle("hidden", subscribe);
34+
35+
// Update button text and attribute
36+
toggleBtn.textContent = subscribe
37+
? gettext("Unsubscribe")
38+
: gettext("Subscribe");
39+
toggleBtn.dataset.hasSubscribe = subscribe.toString();
40+
41+
// Show confirmation message
42+
confirmSubscribed.classList.toggle("hidden", !subscribe);
43+
confirmUnsubscribed.classList.toggle("hidden", subscribe);
44+
confirmationMsg.classList.remove("hidden");
45+
} else {
46+
showErrorState();
47+
}
48+
})
49+
.catch((error) => {
50+
console.error("Error updating subscription:", error);
51+
showErrorState();
52+
});
53+
54+
function showErrorState() {
55+
managePreferences.classList.add("hidden");
56+
footer.classList.add("hidden");
57+
errorMessage.classList.remove("hidden");
58+
}
59+
}
60+
61+
document.addEventListener("DOMContentLoaded", () => {
62+
const toggleBtn = document.querySelector("#toggle-btn");
63+
64+
if (toggleBtn) {
65+
toggleBtn.addEventListener("click", function () {
66+
const isSubscribed = toggleBtn.dataset.hasSubscribe === "true";
67+
updateSubscription(!isSubscribed);
68+
});
69+
}
70+
});

openwisp_notifications/tasks.py

+26-13
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
from openwisp_notifications import settings as app_settings
1616
from openwisp_notifications import types
1717
from openwisp_notifications.swapper import load_model, swapper_load_model
18-
from openwisp_notifications.utils import send_notification_email
18+
from openwisp_notifications.utils import (
19+
get_unsubscribe_url_email_footer,
20+
get_unsubscribe_url_for_user,
21+
send_notification_email,
22+
)
1923
from openwisp_utils.admin_theme.email import send_email
2024
from openwisp_utils.tasks import OpenwispCeleryTask
2125

@@ -272,33 +276,42 @@ def send_batched_email_notifications(instance_id):
272276
'%B %-d, %Y, %-I:%M %p %Z'
273277
)
274278

275-
context = {
279+
extra_context = {
276280
'notifications': unsent_notifications[:display_limit],
277281
'notifications_count': notifications_count,
278282
'site_name': current_site.name,
279283
'start_time': start_time,
280284
}
281285

282-
extra_context = {}
286+
user = User.objects.get(id=instance_id)
287+
unsubscribe_url = get_unsubscribe_url_for_user(user)
288+
extra_context['footer'] = get_unsubscribe_url_email_footer(unsubscribe_url)
289+
283290
if notifications_count > display_limit:
284-
extra_context = {
285-
'call_to_action_url': f"https://{current_site.domain}/admin/#notifications",
286-
'call_to_action_text': _('View all Notifications'),
287-
}
288-
context.update(extra_context)
289-
290-
html_content = render_to_string('emails/batch_email.html', context)
291-
plain_text_content = render_to_string('emails/batch_email.txt', context)
291+
extra_context.update(
292+
{
293+
'call_to_action_url': f"https://{current_site.domain}/admin/#notifications",
294+
'call_to_action_text': _('View all Notifications'),
295+
}
296+
)
297+
298+
plain_text_content = render_to_string(
299+
'openwisp_notifications/emails/batch_email.txt', extra_context
300+
)
292301
notifications_count = min(notifications_count, display_limit)
293302

294303
send_email(
295304
subject=f'[{current_site.name}] {notifications_count} new notifications since {start_time}',
296305
body_text=plain_text_content,
297-
body_html=html_content,
306+
body_html=True,
298307
recipients=[email_id],
299308
extra_context=extra_context,
309+
headers={
310+
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
311+
'List-Unsubscribe': f'<{unsubscribe_url}>',
312+
},
313+
html_email_template='openwisp_notifications/emails/batch_email.html',
300314
)
301315

302316
unsent_notifications_query.update(emailed=True)
303-
Notification.objects.bulk_update(unsent_notifications_query, ['emailed'])
304317
cache.delete(cache_key)

openwisp_notifications/templates/emails/batch_email.html renamed to openwisp_notifications/templates/openwisp_notifications/emails/batch_email.html

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
{% extends "openwisp_utils/email_template.html" %}
2+
13
{% block styles %}
4+
{{ block.super }}
25
<style type="text/css">
36
.alert {
47
border: 1px solid #e0e0e0;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{% load i18n %}
2+
<p style="margin-bottom: 0px; font-size: small; text-align: center">{% blocktrans %}To stop receiving all email notifications, <a href="{{ unsubscribe_url }}">unsubscribe</a>.{% endblocktrans %}</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
{% extends 'account/base_entrance.html' %}
2+
{% load i18n %}
3+
{% load static %}
4+
5+
{% block head_title %}{% trans 'Manage Subscription Preferences' %}{% endblock %}
6+
7+
{% block extrastyle %}
8+
{{ block.super }}
9+
<link rel="stylesheet" type="text/css" href="{% static 'openwisp-notifications/css/unsubscribe.css' %}">
10+
{% endblock %}
11+
12+
{% block menu-bar %}{% endblock %}
13+
14+
{% block content %}
15+
<div class="unsubscribe-container">
16+
<img
17+
src="{% static 'ui/openwisp/images/openwisp-logo-black.svg' %}"
18+
alt="{% trans 'OpenWISP Logo' %}"
19+
class="logo"
20+
/>
21+
<div class="unsubscribe-content">
22+
<div class="icon email-icon"></div>
23+
<h1>{% trans 'Manage Notification Preferences' %}</h1>
24+
25+
{% if valid %}
26+
<div id="manage-preferences">
27+
<div class="status-msg-container">
28+
<p id="subscribed-message" class="{% if not is_subscribed %}hidden{% endif %}">
29+
{% trans 'You are currently subscribed to notifications.' %}
30+
</p>
31+
<p id="unsubscribed-message" class="{% if is_subscribed %}hidden{% endif %}">
32+
{% trans 'You are currently unsubscribed from notifications.' %}
33+
</p>
34+
</div>
35+
36+
<button
37+
id="toggle-btn"
38+
class="button"
39+
data-has-subscribe="{{ is_subscribed|yesno:'true,false' }}"
40+
>
41+
{% if is_subscribed %}
42+
{% trans 'Unsubscribe' %}
43+
{% else %}
44+
{% trans 'Subscribe' %}
45+
{% endif %}
46+
</button>
47+
<div class="confirmation-msg-container hidden">
48+
<p id="confirm-subscribed" class="confirmation-msg hidden">
49+
{% trans 'You have successfully subscribed to all email notifications.' %}
50+
</p>
51+
<p id="confirm-unsubscribed" class="confirmation-msg hidden">
52+
{% trans 'You have successfully unsubscribed from all email notifications.' %}
53+
</p>
54+
</div>
55+
</div>
56+
<div id="error-msg-container" class="hidden">
57+
<p id="error-msg" class="error-msg">
58+
{% trans 'An error occurred while updating your notification preferences.' %}<br>
59+
{% trans 'You can update your preferences by ' %}
60+
<a href="{% url 'notifications:notification_preference' %}">
61+
{% trans 'logging into your account.' %}
62+
</a>
63+
</p>
64+
</div>
65+
66+
{% else %}
67+
<h2>{% trans 'Invalid or Expired Link' %}</h2>
68+
<p>{% trans 'The link you used is invalid or expired.' %}</p>
69+
{% endif %}
70+
71+
<div class="footer">
72+
<p>
73+
{% url 'notifications:notification_preference' as notification_preference_url %}
74+
{% blocktrans with url=notification_preference_url %}
75+
You can manage your notification preferences in your <a href="{{ url }}">account settings</a>.
76+
{% endblocktrans %}
77+
</p>
78+
</div>
79+
</div>
80+
</div>
81+
{% endblock %}
82+
83+
{% block footer %}
84+
{{ block.super }}
85+
<script src="{% static 'openwisp-notifications/js/unsubscribe.js' %}"></script>
86+
{% endblock %}

0 commit comments

Comments
 (0)