[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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user