diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py index b95074ee3e8..71513f06b61 100644 --- a/addons/mass_mailing/controllers/main.py +++ b/addons/mass_mailing/controllers/main.py @@ -34,6 +34,13 @@ class MassMailController(http.Controller): [('email', '=', tools.email_normalize(email))] ) + def _fetch_contacts(self, email): + if not email or not tools.email_normalize(email): + return request.env['mailing.contact'] + return request.env['mailing.contact'].sudo().search( + [('email_normalized', '=', tools.email_normalize(email))] + ) + # ------------------------------------------------------------ # SUBSCRIPTION MANAGEMENT # ------------------------------------------------------------ @@ -51,7 +58,7 @@ class MassMailController(http.Controller): def _mailing_unsubscribe_from_list(self, mailing, document_id, email, hash_token): # Unsubscribe directly + Let the user choose their subscriptions - mailing.update_opt_out(email, mailing.contact_list_ids.ids, True) + mailing.contact_list_ids._update_subscription_from_email(email, opt_out=True) contacts = request.env['mailing.contact'].sudo().search([('email_normalized', '=', tools.email_normalize(email))]) subscription_list_ids = contacts.mapped('subscription_list_ids') @@ -128,18 +135,28 @@ class MassMailController(http.Controller): @http.route('/mailing/list/update', type='json', auth='public', csrf=True) def mailing_update_list_subscription(self, mailing_id=None, document_id=None, email=None, hash_token=None, - opt_in_ids=None, opt_out_ids=None, **post): + lists_optin_ids=None, **post): 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) except BadRequest: return 'error' except (NotFound, Unauthorized): return 'unauthorized' - if opt_in_ids: - mailing_sudo.update_opt_out(email, opt_in_ids, False) - if opt_out_ids: - mailing_sudo.update_opt_out(email, opt_out_ids, True) + contacts = self._fetch_contacts(email) + 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( + lambda sub: not sub.opt_out and sub.list_id not in lists_optin + ).list_id + # opt-in in either already member, either public (to avoid trying to opt-in + # in private lists) + lists_to_optin = lists_optin.filtered( + lambda mlist: mlist.is_public or mlist in contacts.lists_ids + ) + lists_to_optout._update_subscription_from_email(email, opt_out=True) + lists_to_optin._update_subscription_from_email(email, opt_out=False) + return True @http.route('/mailing/feedback', type='json', auth='public', csrf=True) diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py index 2fd89771c74..d08bf3454e5 100644 --- a/addons/mass_mailing/models/mailing.py +++ b/addons/mass_mailing/models/mailing.py @@ -785,25 +785,6 @@ class MassMailing(models.Model): return values_list - def update_opt_out(self, email, list_ids, value): - if len(list_ids) > 0: - model = self.env['mailing.contact'].with_context(active_test=False) - records = model.search([('email_normalized', '=', tools.email_normalize(email))]) - opt_out_records = self.env['mailing.contact.subscription'].search([ - ('contact_id', 'in', records.ids), - ('list_id', 'in', list_ids), - ('opt_out', '!=', value) - ]) - - opt_out_records.write({'opt_out': value}) - message = Markup(_('The recipient unsubscribed from %s mailing list(s)')) \ - if value else Markup(_('The recipient subscribed to %s mailing list(s)')) - for record in records: - # filter the list_id by record - record_lists = opt_out_records.filtered(lambda rec: rec.contact_id.id == record.id) - if len(record_lists) > 0: - record.sudo().message_post(body=message % ', '.join(str(list.name) for list in record_lists.mapped('list_id'))) - # ------------------------------------------------------ # A/B Test # ------------------------------------------------------ diff --git a/addons/mass_mailing/models/mailing_list.py b/addons/mass_mailing/models/mailing_list.py index 7e634f35c04..a850c67332b 100644 --- a/addons/mass_mailing/models/mailing_list.py +++ b/addons/mass_mailing/models/mailing_list.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import _, api, Command, fields, models +from markupsafe import Markup + +from odoo import _, api, Command, fields, models, tools from odoo.exceptions import UserError @@ -267,6 +269,91 @@ class MassMailingList(models.Model): def close_dialog(self): return {'type': 'ir.actions.act_window_close'} + # ------------------------------------------------------ + # SUBSCRIPTION MANAGEMENT + # ------------------------------------------------------ + + def _update_subscription_from_email(self, email, opt_out=True, force_message=None): + """ When opting-out: we have to switch opted-in subscriptions. We don't + need to create subscription for other lists as opt-out = not being a + member. + + When opting-in: we have to switch opted-out subscriptions and create + subscription for other mailing lists id they are public. Indeed a + contact is opted-in when being subscribed in a mailing list. + + :param str email: email address that should opt-in or opt-out from + mailing lists; + :param boolean opt_out: if True, opt-out from lists given by self if + 'email' is member of it. If False, opt-in in lists givben by self + and create membership if not already member; + :param str force_message: if given, post a note using that body on + contact instead of generated update message. Give False to entirely + skip the note step; + """ + email_normalized = tools.email_normalize(email) + if not self or not email_normalized: + return + + contacts = self.env['mailing.contact'].with_context(active_test=False).search( + [('email_normalized', '=', email_normalized)] + ) + if not contacts: + return + + # switch opted-in subscriptions + if opt_out: + current_opt_in = contacts.subscription_list_ids.filtered( + lambda sub: not sub.opt_out and sub.list_id in self + ) + if current_opt_in: + current_opt_in.write({'opt_out': True}) + # switch opted-out subscription and create missing subscriptions + else: + subscriptions = contacts.subscription_list_ids.filtered(lambda sub: sub.list_id in self) + current_opt_out = subscriptions.filtered('opt_out') + if current_opt_out: + current_opt_out.write({'opt_out': False}) + + # create a subscription (for a single contact) for missing lists + missing_lists = self - subscriptions.list_id + if missing_lists: + self.env['mailing.contact.subscription'].create([ + {'contact_id': contacts[0].id, + 'list_id': mailing_list.id} + for mailing_list in missing_lists + ]) + + for contact in contacts: + # do not log if no opt-out / opt-in was actually done + if opt_out: + updated = current_opt_in.filtered(lambda sub: sub.contact_id == contact).list_id + else: + updated = current_opt_out.filtered(lambda sub: sub.contact_id == contact).list_id + missing_lists + if not updated: + continue + + if force_message is False: + continue + if force_message: + body = force_message + elif opt_out: + body = Markup('

%s

') % _( + '%(contact_name)s unsubscribed from the following mailing list(s)', + contact_name=contact.display_name + ) + body += Markup('' % ''.join(Markup('
  • %s
  • ') % name for name in updated.mapped('name'))) + else: + body = Markup('

    %s

    ') % _( + '%(contact_name)s subscribed to the following mailing list(s)', + contact_name=contact.display_name + ) + body += Markup('' % ''.join(Markup('
  • %s
  • ') % name for name in updated.mapped('name'))) + contact.with_context(mail_create_nosubscribe=True).message_post( + body=body, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + ) + # ------------------------------------------------------ # MAILING # ------------------------------------------------------ diff --git a/addons/mass_mailing/static/src/js/mailing_portal_subscription_form.js b/addons/mass_mailing/static/src/js/mailing_portal_subscription_form.js index d056c63e2f3..f09f4f6473d 100644 --- a/addons/mass_mailing/static/src/js/mailing_portal_subscription_form.js +++ b/addons/mass_mailing/static/src/js/mailing_portal_subscription_form.js @@ -32,8 +32,8 @@ publicWidget.registry.MailingPortalSubscriptionForm = publicWidget.Widget.extend document_id: this.customerData.documentId, email: this.customerData.email, hash_token: this.customerData.hashToken, + lists_optin_ids: mailingListIds, mailing_id: this.customerData.mailingId, - opt_in_ids: mailingListIds, } ).then((result) => { this.trigger_up( diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py index b6857d8c10d..f67df385eac 100644 --- a/addons/mass_mailing/tests/common.py +++ b/addons/mass_mailing/tests/common.py @@ -326,7 +326,10 @@ class MassMailCommon(MailCommon, MassMailCase): return cls.env['mailing.list'].with_context(cls._test_context).create({ 'name': 'Test List', 'contact_ids': [ - (0, 0, {'name': 'Contact %s' % i, 'email': 'contact%s@example.com' % i}) - for i in range(contacts_nbr) + (0, 0, { + 'name': f'Contact %{idx}', + 'email': f'contact%{idx}@example.com' + }) + for idx in range(contacts_nbr) ], }) diff --git a/addons/mass_mailing/tests/test_mailing_controllers.py b/addons/mass_mailing/tests/test_mailing_controllers.py index f8789c1139f..e3d2483c33b 100644 --- a/addons/mass_mailing/tests/test_mailing_controllers.py +++ b/addons/mass_mailing/tests/test_mailing_controllers.py @@ -278,7 +278,7 @@ class TestMailingControllers(TestMailingControllersCommon): message_unsub = contact_l1.message_ids[1] self.assertEqual( message_unsub.body, - Markup(f'

    The recipient unsubscribed from {self.mailing_list_1.name} mailing list(s)

    ') + Markup(f'

    {contact_l1.display_name} unsubscribed from the following mailing list(s)

    ') ) # posted messages on exclusion list record: activated, deactivated, activated again @@ -346,14 +346,14 @@ class TestMailingControllers(TestMailingControllersCommon): message_unsub = contact_l1.message_ids[1] self.assertEqual( message_unsub.body, - Markup(f'

    The recipient unsubscribed from {self.mailing_list_1.name} mailing list(s)

    ') + Markup(f'

    {contact_l1.display_name} unsubscribed from the following mailing list(s)

    ') ) # posted messages on contact record for mailing list 3: subscription message_unsub = contact_l3.message_ids[0] self.assertEqual( message_unsub.body, - Markup(f'

    The recipient subscribed to {self.mailing_list_3.name} mailing list(s)

    ') + Markup(f'

    {contact_l1.display_name} subscribed to the following mailing list(s)

    ') ) # posted messages on exclusion list record: activated, deactivated, activated again diff --git a/addons/mass_mailing/tests/test_mailing_list.py b/addons/mass_mailing/tests/test_mailing_list.py index 53514b40f35..a0f7be5a79c 100644 --- a/addons/mass_mailing/tests/test_mailing_list.py +++ b/addons/mass_mailing/tests/test_mailing_list.py @@ -274,3 +274,49 @@ class TestMailingContactImport(MassMailCommon): contact = self.env['mailing.contact'].search([('email', '=', 'already_exists_list_1@example.com')]) self.assertEqual(len(contact), 1, 'Should have updated the existing contact instead of creating a new one') + + +@tagged('mailing_list') +class TestSubscriptionManagement(MassMailCommon): + + @users('user_marketing') + def test_mailing_update_optout(self): + _email_formatted = '"Mireille Labeille" ' + _email_formatted_upd = '"Mireille Oreille-Labeille" ' + _email_normalized = 'mireille@test.example.com' + self._create_mailing_list() + ml_1, ml_2 = self.mailing_list_1.with_env(self.env), self.mailing_list_2.with_env(self.env) + ml_3 = self._create_mailing_list_of_x_contacts(3) + self.assertEqual(ml_1.contact_count, 3) + self.assertEqual(ml_1.contact_count_blacklisted, 0) + self.assertEqual(ml_1.contact_count_email, 3) + self.assertEqual(ml_1.contact_count_opt_out, 0) + self.assertEqual(ml_2.contact_count, 4) + self.assertEqual(ml_2.contact_count_blacklisted, 0) + self.assertEqual(ml_2.contact_count_email, 4) + self.assertEqual(ml_2.contact_count_opt_out, 0) + self.assertEqual(ml_3.contact_count, 3) + self.assertEqual(ml_3.contact_count_blacklisted, 0) + self.assertEqual(ml_3.contact_count_email, 3) + self.assertEqual(ml_3.contact_count_opt_out, 0) + + # create a new test contact + contact = self.env['mailing.contact'].browse( + self.env['mailing.contact'].name_create(_email_formatted)[0] + ) + self.assertEqual(contact.email, _email_normalized) + self.assertEqual(contact.name, 'Mireille Labeille') + + # add new subscriptions (and ensure email_normalized is used) + (ml_1 + ml_2)._update_subscription_from_email(_email_formatted_upd, opt_out=False) + subs = self.env['mailing.contact.subscription'].search( + [('contact_id', '=', contact.id)] + ) + self.assertEqual(subs.list_id, ml_1 + ml_2) + + # opt-out from opted-in mailing list + 1 non opted-in mailing list + (ml_2 + ml_3)._update_subscription_from_email(_email_formatted_upd, opt_out=True) + subs = self.env['mailing.contact.subscription'].search( + [('contact_id', '=', contact.id)] + ) + self.assertEqual(subs.list_id, ml_1 + ml_2)