diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py index 7bba37b77ea..bc8da673020 100644 --- a/addons/mass_mailing/controllers/main.py +++ b/addons/mass_mailing/controllers/main.py @@ -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//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('

%s

') % _('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('

%s

') % _('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): diff --git a/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_my.js b/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_my.js new file mode 100644 index 00000000000..b1cba5db5f4 --- /dev/null +++ b/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_my.js @@ -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')", + }, + ], +}); diff --git a/addons/mass_mailing/tests/test_mailing_controllers.py b/addons/mass_mailing/tests/test_mailing_controllers.py index ebbd9fc1035..66fbf5d343c 100644 --- a/addons/mass_mailing/tests/test_mailing_controllers.py +++ b/addons/mass_mailing/tests/test_mailing_controllers.py @@ -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('

Mail Blacklist created

')) + 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'

Feedback from {contact.email_normalized}: {test_feedback}

') + ) + @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)