Skip to content

Commit 9ac43a8

Browse files
Dhanus3133nemesifierpandafy
committed
[feat] Added unsubscribe link to email notifications #256
Implements and closes #256 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io> Co-authored-by: Gagan Deep <pandafy.dev@gmail.com>
1 parent c193d24 commit 9ac43a8

File tree

15 files changed

+621
-67
lines changed

15 files changed

+621
-67
lines changed

docs/user/web-email-notifications.rst

+15-2
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
@@ -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,69 @@
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 =
37+
subscribe ? gettext("Unsubscribe") : gettext("Subscribe");
38+
toggleBtn.dataset.hasSubscribe = subscribe.toString();
39+
40+
// Show confirmation message
41+
confirmSubscribed.classList.toggle("hidden", !subscribe);
42+
confirmUnsubscribed.classList.toggle("hidden", subscribe);
43+
confirmationMsg.classList.remove("hidden");
44+
} else {
45+
showErrorState();
46+
}
47+
})
48+
.catch((error) => {
49+
console.error("Error updating subscription:", error);
50+
showErrorState();
51+
});
52+
53+
function showErrorState() {
54+
managePreferences.classList.add("hidden");
55+
footer.classList.add("hidden");
56+
errorMessage.classList.remove("hidden");
57+
}
58+
}
59+
60+
document.addEventListener("DOMContentLoaded", () => {
61+
const toggleBtn = document.querySelector("#toggle-btn");
62+
63+
if (toggleBtn) {
64+
toggleBtn.addEventListener("click", function () {
65+
const isSubscribed = toggleBtn.dataset.hasSubscribe === "true";
66+
updateSubscription(!isSubscribed);
67+
});
68+
}
69+
});

openwisp_notifications/tasks.py

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

@@ -268,33 +272,42 @@ def send_batched_email_notifications(instance_id):
268272
'%B %-d, %Y, %-I:%M %p %Z'
269273
)
270274

271-
context = {
275+
extra_context = {
272276
'notifications': unsent_notifications[:display_limit],
273277
'notifications_count': notifications_count,
274278
'site_name': current_site.name,
275279
'start_time': start_time,
276280
}
277281

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

290299
send_email(
291300
subject=f'[{current_site.name}] {notifications_count} new notifications since {start_time}',
292301
body_text=plain_text_content,
293-
body_html=html_content,
302+
body_html=True,
294303
recipients=[email_id],
295304
extra_context=extra_context,
305+
headers={
306+
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
307+
'List-Unsubscribe': f'<{unsubscribe_url}>',
308+
},
309+
html_email_template='openwisp_notifications/emails/batch_email.html',
296310
)
297311

298312
unsent_notifications_query.update(emailed=True)
299-
Notification.objects.bulk_update(unsent_notifications_query, ['emailed'])
300313
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)