[REF] mass_mailing: add a 'my' page to handle subscriptions

PURPOSE

Globally improve usability and features given by mailing portal about exclusion
list and opt-out management.

SPECIFICATIONS

Currently mailing portal is usable only through dedicated links added in
mailings. Those use a hash token based on mailing, document_id (if mailing
ran on business documents) and email of the recipient. However this is not
convenient, especially for users that want to update their subscriptions
manually.

Purpose of this commit is to add a generic page in mass mailing allowing
to manage subscriptions to mailing lists as well as blocklist status directly
from portal. That way people don't need to come from a given email mailing
link.

A new page is added. It is located on ``mailing/my`` and has the same
capabilities as the unsubscribe pages, except it works outside of a given
mailing contact. Page (html, js) and behavior are shared among the various
use cases.

This page is available for logged user, both internal and share. Other people
should come through existing unsubscribe links using email, document id and
hash token.

Access control is updated so that it is now possible to use mailing routes
without requiring always email / document_id / hash_token. User can be used
instead.

A tour is added allowing to test this new feature.

Task-2150462 (Mass Mailing: Improve subscription management)

Part-of: odoo/odoo#86084
This commit is contained in:
Thibault Delavallée
2023-10-06 06:13:53 +00:00
parent 8019a605c7
commit d27797dd9b
3 changed files with 242 additions and 33 deletions
+93 -26
View File
@@ -13,16 +13,34 @@ from odoo.tools import consteq
class MassMailController(http.Controller):
def _check_mailing_email_token(self, mailing_id, document_id, email, hash_token):
def _check_mailing_email_token(self, mailing_id, document_id, email, hash_token,
required_mailing_id=False):
""" Return the mailing based on given credentials, sudo-ed. Raises if
there is an issue fetching it. """
if not mailing_id or not email or not document_id or not hash_token:
there is an issue fetching it.
Specific use case
* hash_token is always required for public users, no generic page is
available for them;
* hash_token is not required for generic page for logged user, aka
if no mailing_id is given;
* hash_token always requires the triplet mailing_id, email and
document_id, as it indicates it comes from a mailing email and
is used when comparing hashes;
"""
if not hash_token and (mailing_id or request.env.user._is_public()):
raise BadRequest()
mailing_sudo = request.env['mailing.mailing'].sudo().browse(mailing_id)
if not mailing_sudo.exists():
raise NotFound()
if not consteq(mailing_sudo._generate_mailing_recipient_token(document_id, email), hash_token):
raise Unauthorized()
if hash_token and (not mailing_id or not email or not document_id):
raise BadRequest()
if mailing_id:
mailing_sudo = request.env['mailing.mailing'].sudo().browse(mailing_id)
if not mailing_sudo.exists():
raise NotFound()
if hash_token and not consteq(mailing_sudo._generate_mailing_recipient_token(document_id, email), hash_token):
raise Unauthorized()
else:
if required_mailing_id:
raise BadRequest()
mailing_sudo = request.env['mailing.mailing'].sudo()
return mailing_sudo
def _fetch_blocklist_record(self, email):
@@ -41,20 +59,44 @@ class MassMailController(http.Controller):
[('email_normalized', '=', tools.email_normalize(email))]
)
def _fetch_user_information(self, email, hash_token):
if hash_token or request.env.user._is_public():
return email, hash_token
return request.env.user.email_normalized, None
# ------------------------------------------------------------
# SUBSCRIPTION MANAGEMENT
# ------------------------------------------------------------
@http.route('/mailing/my', type='http', website=True, auth='user')
def mailing_my(self):
email, _hash_token = self._fetch_user_information(None, None)
if not email:
raise Unauthorized()
render_values = self._prepare_mailing_subscription_values(
request.env['mailing.mailing'], False, email, None
)
render_values.update(feedback_enabled=False)
return request.render(
'mass_mailing.page_mailing_unsubscribe',
render_values
)
@http.route(['/mailing/<int:mailing_id>/unsubscribe'], type='http', website=True, auth='public')
def mailing_unsubscribe(self, mailing_id, document_id=None, email=None, hash_token=None):
email_found, hash_token_found = self._fetch_user_information(email, hash_token)
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
mailing_sudo = self._check_mailing_email_token(
mailing_id, document_id, email_found, hash_token_found,
required_mailing_id=True
)
except NotFound as e: # avoid leaking ID existence
raise Unauthorized() from e
if mailing_sudo.mailing_model_real == 'mailing.contact':
return self._mailing_unsubscribe_from_list(mailing_sudo, document_id, email, hash_token)
return self._mailing_unsubscribe_from_document(mailing_sudo, document_id, email, hash_token)
return self._mailing_unsubscribe_from_list(mailing_sudo, document_id, email_found, hash_token_found)
return self._mailing_unsubscribe_from_document(mailing_sudo, document_id, email_found, hash_token_found)
def _mailing_unsubscribe_from_list(self, mailing, document_id, email, hash_token):
# Unsubscribe directly + Let the user choose their subscriptions
@@ -156,14 +198,18 @@ class MassMailController(http.Controller):
def mailing_update_list_subscription(self, mailing_id=None, document_id=None,
email=None, hash_token=None,
lists_optin_ids=None, **post):
email_found, hash_token_found = self._fetch_user_information(email, hash_token)
try:
_mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
_mailing_sudo = self._check_mailing_email_token(
mailing_id, document_id, email_found, hash_token_found,
required_mailing_id=False
)
except BadRequest:
return 'error'
except (NotFound, Unauthorized):
return 'unauthorized'
contacts = self._fetch_contacts(email)
contacts = self._fetch_contacts(email_found)
lists_optin = request.env['mailing.list'].sudo().browse(lists_optin_ids or []).exists()
# opt-out all not chosen lists
lists_to_optout = contacts.subscription_list_ids.filtered(
@@ -174,8 +220,8 @@ class MassMailController(http.Controller):
lists_to_optin = lists_optin.filtered(
lambda mlist: mlist.is_public or mlist in contacts.list_ids
)
lists_to_optout._update_subscription_from_email(email, opt_out=True)
lists_to_optin._update_subscription_from_email(email, opt_out=False)
lists_to_optout._update_subscription_from_email(email_found, opt_out=True)
lists_to_optin._update_subscription_from_email(email_found, opt_out=False)
return True
@@ -183,18 +229,28 @@ class MassMailController(http.Controller):
def mailing_send_feedback(self, mailing_id=None, document_id=None,
email=None, hash_token=None,
feedback=None, **post):
email_found, hash_token_found = self._fetch_user_information(email, hash_token)
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
mailing_sudo = self._check_mailing_email_token(
mailing_id, document_id, email_found, hash_token_found,
required_mailing_id=False,
)
except BadRequest:
return 'error'
except (NotFound, Unauthorized):
return 'unauthorized'
model = request.env[mailing_sudo.mailing_model_real]
records = model.sudo().search([('email_normalized', '=', tools.email_normalize(email))])
for record in records:
record.sudo().message_post(body=_("Feedback from %(email)s: %(feedback)s", email=email, feedback=feedback))
return bool(records)
if not mailing_sudo or mailing_sudo.mailing_on_mailing_list:
documents_for_post = self._fetch_contacts(email_found)
else:
documents_for_post = request.env[mailing_sudo.mailing_model_real].sudo().search(
[('id', '=', document_id)
])
for document_sudo in documents_for_post:
document_sudo.message_post(
body=_("Feedback from %(email)s: %(feedback)s", email=email_found, feedback=feedback)
)
return bool(documents_for_post)
@http.route(['/unsubscribe_from_list'], type='http', website=True, multilang=False, auth='public', sitemap=False)
def mailing_unsubscribe_placeholder_link(self, **post):
@@ -257,7 +313,10 @@ class MassMailController(http.Controller):
document_id = document_id or kwargs.get('res_id')
hash_token = hash_token or kwargs.get('token')
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
mailing_sudo = self._check_mailing_email_token(
mailing_id, document_id, email, hash_token,
required_mailing_id=True,
)
except NotFound as e:
raise Unauthorized() from e
except (BadRequest, Unauthorized):
@@ -295,8 +354,12 @@ class MassMailController(http.Controller):
@http.route('/mailing/blocklist/add', type='json', auth='public')
def mail_blocklist_add(self, mailing_id=None, document_id=None,
email=None, hash_token=None):
email_found, hash_token_found = self._fetch_user_information(email, hash_token)
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
mailing_sudo = self._check_mailing_email_token(
mailing_id, document_id, email_found, hash_token_found,
required_mailing_id=False,
)
except BadRequest:
return 'error'
except (NotFound, Unauthorized):
@@ -312,14 +375,18 @@ class MassMailController(http.Controller):
else:
message = Markup('<p>%s</p>') % _('Blocklist request from portal')
_blocklist_rec = request.env['mail.blacklist'].sudo()._add(email, message=message)
_blocklist_rec = request.env['mail.blacklist'].sudo()._add(email_found, message=message)
return True
@http.route('/mailing/blocklist/remove', type='json', auth='public')
def mail_blocklist_remove(self, mailing_id=None, document_id=None,
email=None, hash_token=None):
email_found, hash_token_found = self._fetch_user_information(email, hash_token)
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
mailing_sudo = self._check_mailing_email_token(
mailing_id, document_id, email_found, hash_token_found,
required_mailing_id=False,
)
except BadRequest:
return 'error'
except (NotFound, Unauthorized):
@@ -335,7 +402,7 @@ class MassMailController(http.Controller):
else:
message = Markup('<p>%s</p>') % _('Blocklist removal request from portal')
_blocklist_rec = request.env['mail.blacklist'].sudo()._remove(email, message=message)
_blocklist_rec = request.env['mail.blacklist'].sudo()._remove(email_found, message=message)
return True
def _format_bl_request(self, mailing, document_id):
@@ -0,0 +1,75 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
/*
* Tour: use 'my' portal page of mailing to manage mailing lists subscription
* as well as manage blocklist (add / remove my own email from block list).
*/
registry.category("web_tour.tours").add('mailing_portal_unsubscribe_from_my', {
test: true,
steps: () => [
{
content: "List1 is present, opt-in member",
trigger: "ul#o_mailing_subscription_form_lists li.list-group-item:contains('List1') span:contains('Subscribed')",
}, {
content: "List3 is present, opt-outed (test starting data)",
trigger: "ul#o_mailing_subscription_form_lists li.list-group-item:contains('List3') span:contains('Not subscribed')",
}, {
content: "List2 is proposed (not member -> proposal to join)",
trigger: "ul#o_mailing_subscription_form_lists_additional li.list-group-item:contains('List2')",
}, {
content: "List4 is not proposed (not member but not private)",
trigger: "ul#o_mailing_subscription_form_lists_additional:not(:has(li.list-group-item:contains('List4')))",
}, {
content: "Feedback area is not displayed (nothing done, no feedback required)",
trigger: "div#o_mailing_portal_subscription:not(textarea)",
}, {
content: "List3: come back (choose to opt-in instead of opt-out)",
trigger: "ul#o_mailing_subscription_form_lists input[title='List3']",
}, {
content: "List2: join (opt-in, not already member)",
trigger: "ul#o_mailing_subscription_form_lists_additional input[title='List2']",
}, {
content: "List1: opt-out",
trigger: "ul#o_mailing_subscription_form_lists input[title='List1']",
}, {
content: "Update subscription",
trigger: "button#button_form_send",
}, {
content: "Confirmation changes are done",
trigger: "div#o_mailing_subscription_update_info span:contains('Membership updated')",
}, {
content: "Should make feedback area appear",
trigger: "div#o_mailing_subscription_info textarea",
isCheck: true,
}, {
content: "Write feedback reason",
trigger: "textarea[name='feedback']",
run: "text My feedback",
}, {
content: "Hit Send",
trigger: "button#button_feedback",
}, {
content: "Confirmation feedback is sent",
trigger: "div#o_mailing_subscription_feedback_info span:contains('Sent. Thanks you for your feedback!')",
}, {
content: "Now exclude me",
trigger: "div#button_blocklist_add",
}, {
content: "Confirmation exclusion is done",
trigger: "div#o_mailing_subscription_update_info span:contains('Email added to our blocklist')",
}, {
content: "This should disable the 'Update my subscriptions' button",
trigger: "button#button_form_send[disabled]",
isCheck: true,
}, {
content: "Display warning about mailing lists",
trigger: "div#o_mailing_subscription_form_blocklisted p:contains('You will not receive any news from those mailing lists you are a member of')",
}, {
content: "Warning should contain reference to memberships",
trigger: "div#o_mailing_subscription_form_blocklisted li strong:contains('List2')",
extra_trigger: "div#o_mailing_subscription_form_blocklisted li strong:contains('List3')",
},
],
});
@@ -9,6 +9,7 @@ import datetime
import werkzeug
from odoo import tools
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.tests import HttpCase, tagged
from odoo.tools import mute_logger
@@ -402,11 +403,75 @@ class TestMailingControllers(TestMailingControllersCommon):
)
self.assertEqual(msg_create.body, Markup('<p>Mail Blacklist created</p>'))
def test_mailing_unsubscribe_from_my(self):
""" Test portal unsubscribe using the 'my' mailing-specific portal page.
It allows to opt-in / opt-out from mailing lists as well as to manage
blocklist (see tour).
Tour effects
* opt-in List3 from opt-out, opt-in List2, opt-out List1;
* add feedback (as new opt-out) 'My feedback';
* add email in block list;
"""
test_feedback = "My feedback"
portal_user = mail_new_test_user(
self.env,
email=tools.formataddr(("Déboulonneur", "fleurus@example.com")),
groups='base.group_portal',
login='user_portal_fleurus',
name='Déboulonneut from Fleurus',
signature='--\nDéboulonneur',
)
for test_user in [portal_user]: # self.user_marketing
with self.subTest(test_user=test_user):
_test_email, test_email_normalized = test_user.email, test_user.email_normalized
self.authenticate(test_user.login, test_user.login)
# launch 'my' mailing' tour
with freeze_time(self._reference_now):
self.start_tour(
"/mailing/my",
"mailing_portal_unsubscribe_from_my",
login=test_user.login,
)
# fetch contact and its subscription and blacklist status, to see the tour effects
contact_l1 = self.mailing_list_1.contact_ids.filtered(
lambda contact: contact.email == test_email_normalized
)
subscription_l1 = self.mailing_list_1.subscription_ids.filtered(
lambda subscription: subscription.contact_id == contact_l1
)
contact_l2 = self.mailing_list_2.contact_ids.filtered(
lambda contact: contact.email == test_email_normalized
)
subscription_l2 = self.mailing_list_3.subscription_ids.filtered(
lambda subscription: subscription.contact_id == contact_l2
)
contact_l3 = self.mailing_list_3.contact_ids.filtered(
lambda contact: contact.email == test_email_normalized
)
subscription_l3 = self.mailing_list_3.subscription_ids.filtered(
lambda subscription: subscription.contact_id == contact_l3
)
self.assertEqual(contact_l2, contact_l3,
'When creating new membership, should link with first found existing contact')
self.assertTrue(subscription_l1.opt_out)
self.assertFalse(subscription_l2.opt_out)
self.assertFalse(subscription_l3.opt_out)
for contact in (contact_l1 + contact_l3):
msg_fb = contact.message_ids[0]
self.assertEqual(
msg_fb.body,
Markup(f'<p>Feedback from {contact.email_normalized}: {test_feedback}</p>')
)
@mute_logger('odoo.http', 'odoo.addons.website.models.ir_ui_view')
def test_mailing_view(self):
""" Test preview of mailing. It requires either a token, either being
mailing user. """
test_mailing = self.test_mailing_on_documents.with_env(self.env)
shadow_mailing = test_mailing.copy()
doc_id, email_normalized = self.user_marketing.partner_id.id, self.user_marketing.email_normalized
hash_token = test_mailing._generate_mailing_recipient_token(doc_id, email_normalized)
self.user_marketing.write({
@@ -415,17 +480,19 @@ class TestMailingControllers(TestMailingControllersCommon):
self.authenticate('user_marketing', 'user_marketing')
# TEST: various invalid cases
for test_doc_id, test_email, test_token, error_code in [
(doc_id, email_normalized, '', 400), # no token
(doc_id, email_normalized, 'zboobs', 418), # wrong token
(self.env.user.partner_id.id, email_normalized, hash_token, 418), # mismatch
(doc_id, 'not.email@example.com', hash_token, 418), # mismatch
for test_mid, test_doc_id, test_email, test_token, error_code in [
(test_mailing.id, doc_id, email_normalized, '', 400), # no token
(test_mailing.id, doc_id, email_normalized, 'zboobs', 418), # wrong token
(test_mailing.id, self.env.user.partner_id.id, email_normalized, hash_token, 418), # mismatch
(test_mailing.id, doc_id, 'not.email@example.com', hash_token, 418), # mismatch
(shadow_mailing.id, doc_id, email_normalized, hash_token, 418), # valid credentials but wrong mailing_id
(0, doc_id, email_normalized, hash_token, 400), # valid credentials but missing mailing_id
]:
with self.subTest(test_email=test_email, test_doc_id=test_doc_id, test_token=test_token):
with self.subTest(test_mid=test_mid, test_email=test_email, test_doc_id=test_doc_id, test_token=test_token):
res = self.url_open(
werkzeug.urls.url_join(
test_mailing.get_base_url(),
f'mailing/{test_mailing.id}/view?email={test_email}&document_id={test_doc_id}&hash_token={test_token}',
f'mailing/{test_mid}/view?email={test_email}&document_id={test_doc_id}&hash_token={test_token}',
)
)
self.assertEqual(res.status_code, error_code)