diff --git a/addons/mass_mailing/__manifest__.py b/addons/mass_mailing/__manifest__.py index 69465997bcc..daf6de26c9a 100644 --- a/addons/mass_mailing/__manifest__.py +++ b/addons/mass_mailing/__manifest__.py @@ -28,7 +28,8 @@ 'data/ir_module_data.xml', 'data/mailing_data_templates.xml', 'data/mailing_list_contact.xml', - 'data/mailing_contact_subscription.xml', + 'data/mailing_subscription_optout.xml', + 'data/mailing_subscription.xml', 'data/res_users_data.xml', 'wizard/mail_compose_message_views.xml', 'wizard/mailing_contact_import_views.xml', @@ -37,13 +38,15 @@ 'wizard/mailing_mailing_test_views.xml', 'wizard/mailing_mailing_schedule_date_views.xml', 'report/mailing_trace_report_views.xml', + 'views/mail_blacklist_views.xml', 'views/mailing_filter_views.xml', 'views/mailing_trace_views.xml', 'views/link_tracker_views.xml', 'views/mailing_contact_views.xml', - 'views/mailing_contact_subscription_views.xml', 'views/mailing_list_views.xml', 'views/mailing_mailing_views.xml', + 'views/mailing_subscription_views.xml', + 'views/mailing_subscription_optout_views.xml', 'views/res_config_settings_views.xml', 'views/utm_campaign_views.xml', 'views/mailing_menus.xml', @@ -82,7 +85,7 @@ 'demo': [ 'demo/utm.xml', 'demo/mailing_list_contact.xml', - 'demo/mailing_contact_subscription.xml', + 'demo/mailing_subscription.xml', 'demo/mailing_mailing.xml', 'demo/mailing_trace.xml', ], @@ -103,7 +106,13 @@ ], 'mass_mailing.mailing_assets': [ 'mass_mailing/static/src/scss/mailing_portal.scss', - 'mass_mailing/static/src/js/mailing_portal.js', + 'mass_mailing/static/src/js/mailing_portal_subscription.js', + 'mass_mailing/static/src/js/mailing_portal_subscription_blocklist.js', + 'mass_mailing/static/src/js/mailing_portal_subscription_feedback.js', + 'mass_mailing/static/src/js/mailing_portal_subscription_form.js', + 'mass_mailing/static/src/xml/mailing_portal_subscription_blocklist.xml', + 'mass_mailing/static/src/xml/mailing_portal_subscription_feedback.xml', + 'mass_mailing/static/src/xml/mailing_portal_subscription_form.xml', ], 'web_editor.assets_wysiwyg': [ 'mass_mailing/static/src/js/mass_mailing_wysiwyg.js', diff --git a/addons/mass_mailing/controllers/legacy.py b/addons/mass_mailing/controllers/legacy.py index 03392fb84ce..3db14b6ca7e 100644 --- a/addons/mass_mailing/controllers/legacy.py +++ b/addons/mass_mailing/controllers/legacy.py @@ -1,15 +1,21 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import werkzeug + from odoo import http from odoo.http import request -from odoo.addons.base.models.ir_qweb import keep_query + class MailingLegacy(http.Controller): """ Retro compatibility layer for legacy endpoint""" @http.route(['/mail/mailing//unsubscribe'], type='http', website=True, auth='public') - def mailing_unsubscribe(self, mailing_id, **post): - return request.redirect( - f'/mailing/{mailing_id}/unsubscribe?{keep_query("*")}' + def mailing_unsubscribe(self, mailing_id, email=None, res_id=None, token="", **post): + """ Old route, using mail/mailing prefix, and outdated parameter names """ + params = werkzeug.urls.url_encode( + dict(**post, document_id=res_id, email=email, hash_token=token) + ) + return request.redirect( + f'/mailing/{mailing_id}/unsubscribe?{params}' ) diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py index df3089ea121..aa1b03c9d0d 100644 --- a/addons/mass_mailing/controllers/main.py +++ b/addons/mass_mailing/controllers/main.py @@ -2,113 +2,325 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +import werkzeug -from odoo import _, exceptions, http, tools +from datetime import timedelta +from markupsafe import Markup, escape +from werkzeug.exceptions import BadRequest, NotFound, Unauthorized + +from odoo import _, fields, http, tools from odoo.http import request, Response from odoo.tools import consteq -from markupsafe import Markup, escape -from werkzeug.exceptions import BadRequest, NotFound - class MassMailController(http.Controller): - def _check_mailing_email_token(self, mailing_id, res_id, email, token): - if not (mailing_id and res_id and email and token): - return False - mailing = request.env['mailing.mailing'].sudo().browse(mailing_id) - return consteq(mailing._generate_mailing_recipient_token(res_id, email), 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. + + 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 is not required for mailing specific page if the user + is a mailing user; + * hash_token is not required for generic page for logged user, aka + if no mailing_id is given and if mailing_id is not required; + * 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: + if request.env.user._is_public(): + raise BadRequest() + if mailing_id and not request.env.user.has_group('mass_mailing.group_mass_mailing_user'): + raise BadRequest() + 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): + if not email or not tools.email_normalize(email): + return None + return request.env['mail.blacklist'].sudo().with_context( + active_test=False + ).search( + [('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))] + ) + + def _fetch_subscription_optouts(self): + return request.env['mailing.subscription.optout'].sudo().search([]) + + 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, email=None, res_id=None, token="", **post): - mailing = request.env['mailing.mailing'].sudo().browse(mailing_id) - if mailing.exists(): - res_id = res_id and int(res_id) - if not self._check_mailing_email_token(mailing_id, res_id, email, str(token)): - raise exceptions.AccessDenied() + 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_found, hash_token_found, + required_mailing_id=True + ) + except NotFound as e: # avoid leaking ID existence + raise Unauthorized() from e - if mailing.mailing_model_real == 'mailing.contact': - # Unsubscribe directly + Let the user choose their subscriptions - mailing.update_opt_out(email, mailing.contact_list_ids.ids, True) + if mailing_sudo.mailing_on_mailing_list: + 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) - contacts = request.env['mailing.contact'].sudo().search([('email_normalized', '=', tools.email_normalize(email))]) - subscription_list_ids = contacts.mapped('subscription_list_ids') - # In many user are found : if user is opt_out on the list with contact_id 1 but not with contact_id 2, - # assume that the user is not opt_out on both - # TODO DBE Fixme : Optimise the following to get real opt_out and opt_in - opt_out_list_ids = subscription_list_ids.filtered(lambda rel: rel.opt_out).mapped('list_id') - opt_in_list_ids = subscription_list_ids.filtered(lambda rel: not rel.opt_out).mapped('list_id') - opt_out_list_ids = set([list.id for list in opt_out_list_ids if list not in opt_in_list_ids]) + def _mailing_unsubscribe_from_list(self, mailing, document_id, email, hash_token): + # Unsubscribe directly + Let the user choose their subscriptions + mailing.contact_list_ids._update_subscription_from_email(email, opt_out=True) - unique_list_ids = set([list.list_id.id for list in subscription_list_ids]) - list_ids = request.env['mailing.list'].sudo().browse(unique_list_ids) - unsubscribed_list = ', '.join(str(list.name) for list in mailing.contact_list_ids if list.is_public) - return request.render('mass_mailing.page_mailing_unsubscribe', { - 'contacts': contacts, - 'list_ids': list_ids, - 'opt_out_list_ids': opt_out_list_ids, - 'unsubscribed_list': unsubscribed_list, - 'email': email, - 'mailing_id': mailing_id, - 'res_id': res_id, - 'show_blacklist_button': request.env['ir.config_parameter'].sudo().get_param('mass_mailing.show_blacklist_buttons'), - }) - else: - opt_in_lists = request.env['mailing.contact.subscription'].sudo().search([ - ('contact_id.email_normalized', '=', email), - ('opt_out', '=', False) - ]).mapped('list_id') + # compute name of unsubscribed list: hide non public lists + if all(not mlist.is_public for mlist in mailing.contact_list_ids): + lists_unsubscribed_name = _('You are no longer part of our mailing list(s).') + elif len(mailing.contact_list_ids) == 1: + lists_unsubscribed_name = _('You are no longer part of the %(mailing_name)s mailing list.', + mailing_name=mailing.contact_list_ids.name) + else: + lists_unsubscribed_name = _( + 'You are no longer part of the %(mailing_names)s mailing list.', + mailing_names=', '.join(mlist.name for mlist in mailing.contact_list_ids if mlist.is_public) + ) - message = Markup('

%s

') % Markup( - _( - 'Blocklist request from unsubscribe link of mailing %(mailing_link)s (document %(record_link)s)', - **self._format_bl_request(mailing, res_id) - ) + return request.render( + 'mass_mailing.page_mailing_unsubscribe', + dict( + self._prepare_mailing_subscription_values( + mailing, document_id, email, hash_token + ), + last_action='subscription_updated', + unsubscribed_name=lists_unsubscribed_name, + ) + ) + + def _mailing_unsubscribe_from_document(self, mailing, document_id, email, hash_token): + if document_id: + message = Markup(_( + 'Blocklist request from unsubscribe link of mailing %(mailing_link)s (document %(record_link)s)', + **self._format_bl_request(mailing, document_id) + )) + else: + message = Markup(_( + 'Blocklist request from unsubscribe link of mailing %(mailing_link)s (direct link usage)', + **self._format_bl_request(mailing, document_id) + )) + _blocklist_rec = request.env['mail.blacklist'].sudo()._add(email, message=Markup('

%s

') % message) + + return request.render( + 'mass_mailing.page_mailing_unsubscribe', + dict( + self._prepare_mailing_subscription_values( + mailing, document_id, email, hash_token + ), + last_action='blocklist_add', + unsubscribed_name=_('You are no longer part of our services and will not be contacted again.'), + ) + ) + + def _prepare_mailing_subscription_values(self, mailing, document_id, email, hash_token): + """ Prepare common values used in various subscription management or + blocklist flows done in portal. """ + mail_blocklist = self._fetch_blocklist_record(email) + email_normalized = tools.email_normalize(email) + + # fetch optout/blacklist reasons + opt_out_reasons = self._fetch_subscription_optouts() + + # as there may be several contacts / email -> consider any opt-in overrides + # opt-out + contacts = self._fetch_contacts(email) + lists_optin = contacts.subscription_ids.filtered( + lambda sub: not sub.opt_out + ).list_id + lists_optout = contacts.subscription_ids.filtered( + lambda sub: sub.opt_out and sub.list_id not in lists_optin + ).list_id + lists_public = request.env['mailing.list'].sudo().search( + [('is_public', '=', True), + ('id', 'not in', (lists_optin + lists_optout).ids) + ], + limit=10, + order='create_date DESC, id DESC', + ) + + return { + # customer + 'document_id': document_id, + 'email': email, + 'email_valid': bool(email_normalized), + 'hash_token': hash_token, + 'mailing_id': mailing.id, + 'res_id': document_id, + # feedback + 'feedback_enabled': True, + 'feedback_readonly': False, + 'opt_out_reasons': opt_out_reasons, + # blocklist + 'blocklist_enabled': bool( + request.env['ir.config_parameter'].sudo().get_param( + 'mass_mailing.show_blacklist_buttons', + default=True, ) - _blacklist_rec = request.env['mail.blacklist'].sudo()._add(email, message=message) + ), + 'blocklist_possible': mail_blocklist is not None, + 'is_blocklisted': mail_blocklist.active if mail_blocklist else False, + # mailing lists + 'contacts': contacts, + 'lists_contacts': contacts.subscription_ids.list_id, + 'lists_optin': lists_optin, + 'lists_optout': lists_optout, + 'lists_public': lists_public, + } - return request.render('mass_mailing.page_mailing_unsubscribe_done', { - 'email': email, - 'mailing_id': mailing_id, - 'res_id': res_id, - 'list_ids': opt_in_lists, - 'show_blacklist_button': request.env['ir.config_parameter'].sudo().get_param( - 'mass_mailing.show_blacklist_buttons'), - }) - return request.redirect('/web') + @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, + 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_found, hash_token_found, + required_mailing_id=False + ) + except BadRequest: + return 'error' + except (NotFound, Unauthorized): + return 'unauthorized' - @http.route('/mailing/list/update', type='json', auth='public') - def mailing_update_list_subscription(self, mailing_id, opt_in_ids, opt_out_ids, email, res_id, token): - mailing = request.env['mailing.mailing'].sudo().browse(mailing_id) - if mailing.exists(): - if not self._check_mailing_email_token(mailing_id, res_id, email, token): - return 'unauthorized' - mailing.update_opt_out(email, opt_in_ids, False) - mailing.update_opt_out(email, opt_out_ids, True) - return True - return 'error' + 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_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.list_ids + ) + lists_to_optout._update_subscription_from_email(email_found, opt_out=True) + lists_to_optin._update_subscription_from_email(email_found, opt_out=False) - @http.route('/mailing/feedback', type='json', auth='public') - def mailing_send_feedback(self, mailing_id, res_id, email, feedback, token): - mailing = request.env['mailing.mailing'].sudo().browse(mailing_id) - if mailing.exists() and email: - if not self._check_mailing_email_token(mailing_id, res_id, email, token): - return 'unauthorized' - model = request.env[mailing.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) - return 'error' + return len(lists_to_optout) + + @http.route('/mailing/feedback', type='json', auth='public', csrf=True) + def mailing_send_feedback(self, mailing_id=None, document_id=None, + email=None, hash_token=None, + last_action=None, + opt_out_reason_id=False, feedback=None, + **post): + """ Feedback can be given after some actions, notably after opt-outing + from mailing lists or adding an email in the blocklist. + + This controller tries to write the customer feedback in the most relevant + record. Feedback consists in two parts, the opt-out reason (based on data + in 'mailing.subscription.optout' model) and the feedback itself (which + is triggered by the optout reason 'is_feedback' fields). + """ + 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_found, hash_token_found, + required_mailing_id=False, + ) + except BadRequest: + return 'error' + except (NotFound, Unauthorized): + return 'unauthorized' + + if not opt_out_reason_id: + return 'error' + feedback = feedback.strip() if feedback else '' + message = '' + if feedback: + if not request.env.user._is_public(): + author_name = f'{request.env.user.name} ({email_found})' + else: + author_name = email_found + message = Markup("

%s
%s

") % ( + _('Feedback from %(author_name)s', author_name=author_name), + feedback + ) + + # blocklist addition: opt-out and feedback linked to the mail.blacklist records + if last_action == 'blocklist_add': + mail_blocklist = self._fetch_blocklist_record(email) + if mail_blocklist: + if message: + mail_blocklist._track_set_log_message(message) + mail_blocklist.opt_out_reason_id = opt_out_reason_id + + # opt-outed from mailing lists (either from a mailing or directly from 'my') + # -> in that case, update recently-updated subscription records and log on + # contacts + documents_for_post = [] + if (last_action in {'subscription_updated', 'subscription_updated_optout'} or + (not last_action and (not mailing_sudo or mailing_sudo.mailing_on_mailing_list))): + contacts = self._fetch_contacts(email_found) + contacts.subscription_ids.filtered( + lambda sub: sub.opt_out and sub.opt_out_datetime >= (fields.Datetime.now() - timedelta(minutes=10)) + ).opt_out_reason_id = opt_out_reason_id + if message: + documents_for_post = contacts + # feedback coming from a mailing, without additional context information: log + elif mailing_sudo and message: + 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=message) + + return True @http.route(['/unsubscribe_from_list'], type='http', website=True, multilang=False, auth='public', sitemap=False) def mailing_unsubscribe_placeholder_link(self, **post): """Dummy route so placeholder is not prefixed by language, MUST have multilang=False""" - raise NotFound() + return request.redirect('/mailing/my', code=301, local=True) # ------------------------------------------------------------ # TRACKING @@ -119,7 +331,7 @@ class MassMailController(http.Controller): """ Email tracking. """ expected_token = request.env['mail.mail']._generate_mail_recipient_token(mail_id) if not consteq(token, expected_token): - raise BadRequest() + raise Unauthorized() request.env['mailing.trace'].sudo().set_opened(domain=[('mail_mail_id_int', 'in', [mail_id])]) response = Response() @@ -148,101 +360,124 @@ class MassMailController(http.Controller): @http.route('/mailing/report/unsubscribe', type='http', website=True, auth='public') def mailing_report_deactivate(self, token, user_id): if not token or not user_id: - raise NotFound() - user_id = int(user_id) - correct_token = consteq(token, request.env['mailing.mailing']._generate_mailing_report_token(user_id)) - user = request.env['res.users'].sudo().browse(user_id) - if correct_token and user.has_group('mass_mailing.group_mass_mailing_user'): - request.env['ir.config_parameter'].sudo().set_param('mass_mailing.mass_mailing_reports', False) - if user.has_group('base.group_system'): - menu_id = request.env.ref('mass_mailing.menu_mass_mailing_global_settings').id - return request.render('mass_mailing.mailing_report_deactivated', {'menu_id': menu_id}) - return request.render('mass_mailing.mailing_report_deactivated') - raise NotFound() + raise BadRequest() + user = request.env['res.users'].sudo().browse(int(user_id)).exists() + if not user or not user.has_group('mass_mailing.group_mass_mailing_user') or \ + not consteq(token, request.env['mailing.mailing']._generate_mailing_report_token(user.id)): + raise Unauthorized() + + request.env['ir.config_parameter'].sudo().set_param('mass_mailing.mass_mailing_reports', False) + render_vals = {} + if user.has_group('base.group_system'): + render_vals = {'menu_id': request.env.ref('mass_mailing.menu_mass_mailing_global_settings').id} + return request.render('mass_mailing.mailing_report_deactivated', render_vals) @http.route(['/mailing//view'], type='http', website=True, auth='public') - def mailing_view_in_browser(self, mailing_id, email=None, res_id=None, token=""): - mailing = request.env['mailing.mailing'].sudo().browse(mailing_id) - if mailing.exists(): - res_id = int(res_id) if res_id else False - if not self._check_mailing_email_token(mailing_id, res_id, email, str(token)) and not request.env.user.has_group('mass_mailing.group_mass_mailing_user'): - raise exceptions.AccessDenied() + def mailing_view_in_browser(self, mailing_id, email=None, document_id=None, hash_token=None, **kwargs): + # backward compatibility: temporary for mailings sent before migation to 17 + 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, + required_mailing_id=True, + ) + except NotFound as e: + raise Unauthorized() from e - # do not force lang, will simply use user context - html_markupsafe = mailing._render_field( - 'body_html', - [res_id], - compute_lang=False, - options={'post_process': False} - )[res_id] - # Update generic URLs (without parameters) to final ones - html_markupsafe = html_markupsafe.replace('/unsubscribe_from_list', - mailing._get_unsubscribe_url(email, res_id)) + # do not force lang, will simply use user context + document_id = int(document_id) if document_id and document_id.isdigit() else 0 + html_markupsafe = mailing_sudo._render_field( + 'body_html', + [document_id], + compute_lang=False, + options={'post_process': False} + )[document_id] + # Update generic URLs (without parameters) to final ones + if document_id: + html_markupsafe = html_markupsafe.replace( + '/unsubscribe_from_list', + mailing_sudo._get_unsubscribe_url(email, document_id) + ) + else: # when manually trying a /view on a mailing, not through email link + html_markupsafe = html_markupsafe.replace( + '/unsubscribe_from_list', + werkzeug.urls.url_join( + mailing_sudo.get_base_url(), + f'/mailing/{mailing_sudo.id}/unsubscribe', + ) + ) - return request.render('mass_mailing.mailing_view', { - 'body': html_markupsafe, - }) - - return request.redirect('/web') + return request.render( + 'mass_mailing.mailing_view', + { + 'body': html_markupsafe, + }, + ) # ------------------------------------------------------------ # BLACKLIST # ------------------------------------------------------------ - @http.route('/mailing/blacklist/check', type='json', auth='public') - def mail_blacklist_check(self, mailing_id, res_id, email, token): - if not self._check_mailing_email_token(mailing_id, res_id, email, token): + @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_found, hash_token_found, + required_mailing_id=False, + ) + except BadRequest: + return 'error' + except (NotFound, Unauthorized): return 'unauthorized' - if email: - record = request.env['mail.blacklist'].sudo().with_context(active_test=False).search([('email', '=', tools.email_normalize(email))]) - if record['active']: - return True - return False - return 'error' - @http.route('/mailing/blacklist/add', type='json', auth='public') - def mail_blacklist_add(self, mailing_id, res_id, email, token): - if not self._check_mailing_email_token(mailing_id, res_id, email, token): - return 'unauthorized' - if email: - if mailing_id and res_id: - mailing_sudo = request.env['mailing.mailing'].sudo().browse(mailing_id) - message = Markup('

%s

') % Markup( - _( - 'Blocklist request from portal of mailing %(mailing_link)s (document %(record_link)s)', - **self._format_bl_request(mailing_sudo, res_id) - ) + if mailing_sudo: + message = Markup( + _( + 'Blocklist request from portal of mailing %(mailing_link)s (document %(record_link)s)', + **self._format_bl_request(mailing_sudo, document_id) ) - else: - message = Markup('

%s

') % _('Blocklist request from portal') + ) + else: + message = Markup('

%s

') % _('Blocklist request from portal') - _blacklist_rec = request.env['mail.blacklist'].sudo()._add(email, message=message) - return True - return 'error' + _blocklist_rec = request.env['mail.blacklist'].sudo()._add(email_found, message=message) + return True - @http.route('/mailing/blacklist/remove', type='json', auth='public') - def mail_blacklist_remove(self, mailing_id, res_id, email, token): - if not self._check_mailing_email_token(mailing_id, res_id, email, token): + @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_found, hash_token_found, + required_mailing_id=False, + ) + except BadRequest: + return 'error' + except (NotFound, Unauthorized): return 'unauthorized' - if email: - if mailing_id and res_id: - mailing_sudo = request.env['mailing.mailing'].sudo().browse(mailing_id) - message = Markup('

%s

') % Markup( - _( - 'Blocklist removal request from portal of mailing %(mailing_link)s (document %(record_link)s)', - **self._format_bl_request(mailing_sudo, res_id) - ) - ) - else: - message = Markup('

%s

') % _('Blocklist removal request from portal') - _blacklist_rec = request.env['mail.blacklist'].sudo()._remove(email, message=message) - return True - return 'error' + if mailing_sudo and document_id: + message = Markup( + _( + 'Blocklist removal request from portal of mailing %(mailing_link)s (document %(record_link)s)', + **self._format_bl_request(mailing_sudo, document_id) + ) + ) + else: + message = Markup('

%s

') % _('Blocklist removal request from portal') + + _blocklist_rec = request.env['mail.blacklist'].sudo()._remove(email_found, message=message) + return True def _format_bl_request(self, mailing, document_id): mailing_model_name = request.env['ir.model']._get(mailing.mailing_model_real).display_name return { 'mailing_link': Markup(f'{escape(mailing.subject)}'), - 'record_link': Markup(f'{escape(mailing_model_name)}'), + 'record_link': Markup( + f'{escape(mailing_model_name)}' + ) if document_id else '', } diff --git a/addons/mass_mailing/data/mailing_contact_subscription.xml b/addons/mass_mailing/data/mailing_subscription.xml similarity index 92% rename from addons/mass_mailing/data/mailing_contact_subscription.xml rename to addons/mass_mailing/data/mailing_subscription.xml index 8c6302d421b..fb0b8e3bd79 100644 --- a/addons/mass_mailing/data/mailing_contact_subscription.xml +++ b/addons/mass_mailing/data/mailing_subscription.xml @@ -1,7 +1,7 @@ - + diff --git a/addons/mass_mailing/data/mailing_subscription_optout.xml b/addons/mass_mailing/data/mailing_subscription_optout.xml new file mode 100644 index 00000000000..217035dec87 --- /dev/null +++ b/addons/mass_mailing/data/mailing_subscription_optout.xml @@ -0,0 +1,24 @@ + + + + I never subscribed to this list + 1 + + + I changed my mind + 2 + + + I receive too many emails from this list + 3 + + + The content of these emails is not relevant to me + 4 + + + Other + 99 + + + diff --git a/addons/mass_mailing/demo/mailing_contact_subscription.xml b/addons/mass_mailing/demo/mailing_subscription.xml similarity index 91% rename from addons/mass_mailing/demo/mailing_contact_subscription.xml rename to addons/mass_mailing/demo/mailing_subscription.xml index d15cf6b1bd4..b323b91ce46 100644 --- a/addons/mass_mailing/demo/mailing_contact_subscription.xml +++ b/addons/mass_mailing/demo/mailing_subscription.xml @@ -2,38 +2,38 @@ - + - + - + - + True - + - + True - + - + diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py index efdb955fe76..9a89811e7cc 100644 --- a/addons/mass_mailing/models/__init__.py +++ b/addons/mass_mailing/models/__init__.py @@ -4,9 +4,11 @@ from . import ir_mail_server from . import ir_model from . import link_tracker -from . import mailing_contact_subscription +from . import mail_blacklist +from . import mailing_subscription # keep before due to decorated m2m from . import mailing_contact from . import mailing_list +from . import mailing_subscription_optout from . import mailing_trace from . import mailing from . import mailing_filter diff --git a/addons/mass_mailing/models/mail_blacklist.py b/addons/mass_mailing/models/mail_blacklist.py new file mode 100644 index 00000000000..20b60c0e4c6 --- /dev/null +++ b/addons/mass_mailing/models/mail_blacklist.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class MailBlackList(models.Model): + """ Model of blacklisted email addresses to stop sending emails.""" + _inherit = ['mail.blacklist'] + + opt_out_reason_id = fields.Many2one( + 'mailing.subscription.optout', string='Reason', + ondelete='restrict', + tracking=10) + + def _track_subtype(self, init_values): + self.ensure_one() + if 'opt_out_reason_id' in init_values and self.opt_out_reason_id: + return self.env.ref('mail.mt_comment') + return super()._track_subtype(init_values) diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py index 07525652f29..7c650884337 100644 --- a/addons/mass_mailing/models/mail_mail.py +++ b/addons/mass_mailing/models/mail_mail.py @@ -63,28 +63,43 @@ class MailMail(models.Model): return body def _prepare_outgoing_list(self, recipients_follower_status=None): - """ Update mailing specific links to add tracking based on res_id """ + """ Update mailing specific links to replace generic unsubscribe and + view links by email-specific links. Also add headers to allow + unsubscribe from email managers. """ email_list = super()._prepare_outgoing_list(recipients_follower_status) if not self.res_id or not self.mailing_id: return email_list base_url = self.mailing_id.get_base_url() for email_values in email_list: - if tools.is_html_empty(email_values['body']) or not email_values['email_to']: + if not email_values['email_to']: continue + emails = tools.email_split(email_values['email_to'][0]) email_to = emails[0] if emails else False + unsubscribe_url = self.mailing_id._get_unsubscribe_url(email_to, self.res_id) + view_url = self.mailing_id._get_view_url(email_to, self.res_id) - if f'{base_url}/unsubscribe_from_list' in email_values['body']: - email_values['body'] = email_values['body'].replace( - f'{base_url}/unsubscribe_from_list', - self.mailing_id._get_unsubscribe_url(email_to, self.res_id), - ) - if f'{base_url}/view' in email_values['body']: - email_values['body'] = email_values['body'].replace( - f'{base_url}/view', - self.mailing_id._get_view_url(email_to, self.res_id), - ) + # replace links in body + if not tools.is_html_empty(email_values['body']): + if f'{base_url}/unsubscribe_from_list' in email_values['body']: + email_values['body'] = email_values['body'].replace( + f'{base_url}/unsubscribe_from_list', + unsubscribe_url, + ) + if f'{base_url}/view' in email_values['body']: + email_values['body'] = email_values['body'].replace( + f'{base_url}/view', + view_url, + ) + + # add headers + email_values['headers'].update({ + 'List-Unsubscribe': f'<{unsubscribe_url}>', + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'Precedence': 'list', + 'X-Auto-Response-Suppress': 'OOF', # avoid out-of-office replies from MS Exchange + }) return email_list def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None): diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py index 2fd89771c74..3a38fc1ba4f 100644 --- a/addons/mass_mailing/models/mailing.py +++ b/addons/mass_mailing/models/mailing.py @@ -749,7 +749,7 @@ class MassMailing(models.Model): if self.contact_list_ids: action['context'] = { 'default_mailing_list_ids': self.contact_list_ids[0].ids, - 'default_subscription_list_ids': [(0, 0, {'list_id': self.contact_list_ids[0].id})], + 'default_subscription_ids': [(0, 0, {'list_id': self.contact_list_ids[0].id})], } action['domain'] = [('list_ids', 'in', self.contact_list_ids.ids)] return action @@ -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 # ------------------------------------------------------ @@ -1019,9 +1000,9 @@ class MassMailing(models.Model): self.get_base_url(), 'mailing/%(mailing_id)s/unsubscribe?%(params)s' % { 'mailing_id': self.id, 'params': werkzeug.urls.url_encode({ - 'res_id': res_id, + 'document_id': res_id, 'email': email_to, - 'token': self._generate_mailing_recipient_token(res_id, email_to), + 'hash_token': self._generate_mailing_recipient_token(res_id, email_to), }), } ) @@ -1032,9 +1013,9 @@ class MassMailing(models.Model): self.get_base_url(), 'mailing/%(mailing_id)s/view?%(params)s' % { 'mailing_id': self.id, 'params': werkzeug.urls.url_encode({ - 'res_id': res_id, + 'document_id': res_id, 'email': email_to, - 'token': self._generate_mailing_recipient_token(res_id, email_to), + 'hash_token': self._generate_mailing_recipient_token(res_id, email_to), }), } ) diff --git a/addons/mass_mailing/models/mailing_contact.py b/addons/mass_mailing/models/mailing_contact.py index bde7c2c057c..f7026737cc2 100644 --- a/addons/mass_mailing/models/mailing_contact.py +++ b/addons/mass_mailing/models/mailing_contact.py @@ -14,18 +14,18 @@ class MassMailingContact(models.Model): _name = 'mailing.contact' _inherit = ['mail.thread.blacklist'] _description = 'Mailing Contact' - _order = 'email' + _order = 'name ASC, id DESC' _mailing_enabled = True def default_get(self, fields_list): """ When coming from a mailing list we may have a default_list_ids context - key. We should use it to create subscription_list_ids default value that + key. We should use it to create subscription_ids default value that are displayed to the user as list_ids is not displayed on form view. """ res = super(MassMailingContact, self).default_get(fields_list) - if 'subscription_list_ids' in fields_list and not res.get('subscription_list_ids'): + if 'subscription_ids' in fields_list and not res.get('subscription_ids'): list_ids = self.env.context.get('default_list_ids') if 'default_list_ids' not in res and list_ids and isinstance(list_ids, (list, tuple)): - res['subscription_list_ids'] = [ + res['subscription_ids'] = [ (0, 0, {'list_id': list_id}) for list_id in list_ids] return res @@ -34,10 +34,10 @@ class MassMailingContact(models.Model): title_id = fields.Many2one('res.partner.title', string='Title') email = fields.Char('Email') list_ids = fields.Many2many( - 'mailing.list', 'mailing_contact_list_rel', + 'mailing.list', 'mailing_subscription', 'contact_id', 'list_id', string='Mailing Lists') - subscription_list_ids = fields.One2many( - 'mailing.contact.subscription', 'contact_id', string='Subscription Information') + subscription_ids = fields.One2many( + 'mailing.subscription', 'contact_id', string='Subscription Information') country_id = fields.Many2one('res.country', string='Country') tag_ids = fields.Many2many('res.partner.category', string='Tags') opt_out = fields.Boolean( @@ -57,17 +57,17 @@ class MassMailingContact(models.Model): if 'default_list_ids' in self._context and isinstance(self._context['default_list_ids'], (list, tuple)) and len(self._context['default_list_ids']) == 1: [active_list_id] = self._context['default_list_ids'] - contacts = self.env['mailing.contact.subscription'].search([('list_id', '=', active_list_id)]) + contacts = self.env['mailing.subscription'].search([('list_id', '=', active_list_id)]) return [('id', 'in', [record.contact_id.id for record in contacts if record.opt_out == value])] return expression.FALSE_DOMAIN if value else expression.TRUE_DOMAIN - @api.depends('subscription_list_ids') + @api.depends('subscription_ids') @api.depends_context('default_list_ids') def _compute_opt_out(self): if 'default_list_ids' in self._context and isinstance(self._context['default_list_ids'], (list, tuple)) and len(self._context['default_list_ids']) == 1: [active_list_id] = self._context['default_list_ids'] for record in self: - active_subscription_list = record.subscription_list_ids.filtered(lambda l: l.list_id.id == active_list_id) + active_subscription_list = record.subscription_ids.filtered(lambda l: l.list_id.id == active_list_id) record.opt_out = active_subscription_list.opt_out else: for record in self: @@ -76,7 +76,7 @@ class MassMailingContact(models.Model): @api.model_create_multi def create(self, vals_list): """ Synchronize default_list_ids (currently used notably for computed - fields) default key with subscription_list_ids given by user when creating + fields) default key with subscription_ids given by user when creating contacts. Those two values have the same purpose, adding a list to to the contact @@ -90,21 +90,21 @@ class MassMailingContact(models.Model): default_list_ids = default_list_ids if isinstance(default_list_ids, (list, tuple)) else [] for vals in vals_list: - if vals.get('list_ids') and vals.get('subscription_list_ids'): - raise UserError(_('You should give either list_ids, either subscription_list_ids to create new contacts.')) + if vals.get('list_ids') and vals.get('subscription_ids'): + raise UserError(_('You should give either list_ids, either subscription_ids to create new contacts.')) if default_list_ids: for vals in vals_list: if vals.get('list_ids'): continue current_list_ids = [] - subscription_ids = vals.get('subscription_list_ids') or [] + subscription_ids = vals.get('subscription_ids') or [] for subscription in subscription_ids: if len(subscription) == 3: current_list_ids.append(subscription[2]['list_id']) for list_id in set(default_list_ids) - set(current_list_ids): subscription_ids.append((0, 0, {'list_id': list_id})) - vals['subscription_list_ids'] = subscription_ids + vals['subscription_ids'] = subscription_ids return super(MassMailingContact, self.with_context(default_list_ids=False)).create(vals_list) diff --git a/addons/mass_mailing/models/mailing_list.py b/addons/mass_mailing/models/mailing_list.py index 7e634f35c04..c8ca4e4743b 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 @@ -25,14 +27,14 @@ class MassMailingList(models.Model): contact_pct_blacklisted = fields.Float(compute="_compute_mailing_list_statistics", string="Percentage of Blacklisted") contact_pct_bounce = fields.Float(compute="_compute_mailing_list_statistics", string="Percentage of Bouncing") contact_ids = fields.Many2many( - 'mailing.contact', 'mailing_contact_list_rel', 'list_id', 'contact_id', + 'mailing.contact', 'mailing_subscription', 'list_id', 'contact_id', string='Mailing Lists', copy=False) mailing_count = fields.Integer(compute="_compute_mailing_count", string="Number of Mailing") mailing_ids = fields.Many2many( 'mailing.mailing', 'mail_mass_mailing_list_rel', string='Mass Mailings', copy=False) subscription_ids = fields.One2many( - 'mailing.contact.subscription', 'list_id', + 'mailing.subscription', 'list_id', string='Subscription Information', copy=True, depends=['contact_ids']) is_public = fields.Boolean( @@ -76,13 +78,13 @@ class MassMailingList(models.Model): bounce_per_mailing = {} if self.ids: sql = ''' - SELECT mclr.list_id, COUNT(DISTINCT mc.id) + SELECT list_sub.list_id, COUNT(DISTINCT mc.id) FROM mailing_contact mc - LEFT OUTER JOIN mailing_contact_list_rel mclr - ON mc.id = mclr.contact_id + LEFT OUTER JOIN mailing_subscription list_sub + ON mc.id = list_sub.contact_id WHERE mc.message_bounce > 0 - AND mclr.list_id in %s - GROUP BY mclr.list_id + AND list_sub.list_id in %s + GROUP BY list_sub.list_id ''' self.env.cr.execute(sql, (tuple(self.ids),)) bounce_per_mailing = dict(self.env.cr.fetchall()) @@ -142,7 +144,7 @@ class MassMailingList(models.Model): action['context'] = { **self.env.context, 'default_mailing_list_ids': self.ids, - 'default_subscription_list_ids': [ + 'default_subscription_ids': [ Command.create({'list_id': mailing_list.id}) for mailing_list in self ], @@ -230,7 +232,7 @@ class MassMailingList(models.Model): src_lists |= self self.env.flush_all() self.env.cr.execute(""" - INSERT INTO mailing_contact_list_rel (contact_id, list_id) + INSERT INTO mailing_subscription (contact_id, list_id) SELECT st.contact_id AS contact_id, %s AS list_id FROM ( @@ -241,7 +243,7 @@ class MassMailingList(models.Model): row_number() OVER (PARTITION BY email ORDER BY email) AS rn FROM mailing_contact contact, - mailing_contact_list_rel contact_list_rel, + mailing_subscription contact_list_rel, mailing_list list WHERE contact.id=contact_list_rel.contact_id AND COALESCE(contact_list_rel.opt_out,FALSE) = FALSE @@ -253,7 +255,7 @@ class MassMailingList(models.Model): SELECT 1 FROM mailing_contact contact2, - mailing_contact_list_rel contact_list_rel2 + mailing_subscription contact_list_rel2 WHERE contact2.email = contact.email AND contact_list_rel2.contact_id = contact2.id AND contact_list_rel2.list_id = %s @@ -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_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_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.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('
    %s
' % ''.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('
      %s
    ' % ''.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 # ------------------------------------------------------ @@ -310,7 +397,7 @@ class MassMailingList(models.Model): SELECT {','.join(self._get_contact_statistics_fields().values())} FROM - mailing_contact_list_rel r + mailing_subscription r {self._get_contact_statistics_joins()} WHERE list_id IN %s GROUP BY diff --git a/addons/mass_mailing/models/mailing_contact_subscription.py b/addons/mass_mailing/models/mailing_subscription.py similarity index 67% rename from addons/mass_mailing/models/mailing_contact_subscription.py rename to addons/mass_mailing/models/mailing_subscription.py index f9876578cab..479933ca50a 100644 --- a/addons/mass_mailing/models/mailing_contact_subscription.py +++ b/addons/mass_mailing/models/mailing_subscription.py @@ -4,13 +4,13 @@ from odoo import api, fields, models -class MassMailingContactListRel(models.Model): +class MailingSubscription(models.Model): """ Intermediate model between mass mailing list and mass mailing contact Indicates if a contact is opted out for a particular list """ - _name = 'mailing.contact.subscription' - _description = 'Mass Mailing Subscription Information' - _table = 'mailing_contact_list_rel' + _name = 'mailing.subscription' + _description = 'Mailing List Subscription' + _table = 'mailing_subscription' _rec_name = 'contact_id' _order = 'list_id DESC, contact_id DESC' @@ -20,9 +20,12 @@ class MassMailingContactListRel(models.Model): string='Opt Out', default=False, help='The contact has chosen not to receive mails anymore from this list') - unsubscription_date = fields.Datetime( + opt_out_reason_id = fields.Many2one( + 'mailing.subscription.optout', string='Reason', + ondelete='restrict') + opt_out_datetime = fields.Datetime( string='Unsubscription Date', - compute='_compute_unsubscription_date', readonly=False, store=True) + compute='_compute_opt_out_datetime', readonly=False, store=True) message_bounce = fields.Integer(related='contact_id.message_bounce', store=False, readonly=False) is_blacklisted = fields.Boolean(related='contact_id.is_blacklisted', store=False, readonly=False) @@ -32,19 +35,19 @@ class MassMailingContactListRel(models.Model): ] @api.depends('opt_out') - def _compute_unsubscription_date(self): - self.filtered(lambda sub: not sub.opt_out).unsubscription_date = False + def _compute_opt_out_datetime(self): + self.filtered(lambda sub: not sub.opt_out).opt_out_datetime = False for subscription in self.filtered('opt_out'): - subscription.unsubscription_date = self.env.cr.now() + subscription.opt_out_datetime = self.env.cr.now() @api.model_create_multi def create(self, vals_list): for vals in vals_list: - if vals.get('unsubscription_date'): + if vals.get('opt_out_datetime') or vals.get('opt_out_reason_id'): vals['opt_out'] = True return super().create(vals_list) def write(self, vals): - if vals.get('unsubscription_date'): + if vals.get('opt_out_datetime') or vals.get('opt_out_reason_id'): vals['opt_out'] = True return super().write(vals) diff --git a/addons/mass_mailing/models/mailing_subscription_optout.py b/addons/mass_mailing/models/mailing_subscription_optout.py new file mode 100644 index 00000000000..42a6ab142a5 --- /dev/null +++ b/addons/mass_mailing/models/mailing_subscription_optout.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class MailingSubscriptionReason(models.Model): + """ Reason for opting out of mailing lists or for blacklisting. """ + _name = 'mailing.subscription.optout' + _description = 'Mailing Subscription Reason' + _order = 'sequence ASC, create_date DESC, id DESC' + + name = fields.Char(string='Reason', translate=True) + sequence = fields.Integer(string='Sequence', default=10) + is_feedback = fields.Boolean(string='Allow Feedback') diff --git a/addons/mass_mailing/security/ir.model.access.csv b/addons/mass_mailing/security/ir.model.access.csv index 3ec47664f6f..ccc36574be8 100644 --- a/addons/mass_mailing/security/ir.model.access.csv +++ b/addons/mass_mailing/security/ir.model.access.csv @@ -2,7 +2,8 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_utm_tag_mass_mailing_campaign,utm.tag,utm.model_utm_tag,mass_mailing.group_mass_mailing_campaign,1,1,1,1 access_mailing_contact_mm_user,access.mailing.contact.mm.user,model_mailing_contact,mass_mailing.group_mass_mailing_user,1,1,1,1 access_mailing_contact_import_mailing_user,access.mailing.contact.import.mailing.user,model_mailing_contact_import,mass_mailing.group_mass_mailing_user,1,1,1,1 -access_mailing_contact_subscription_mm_user,access.mailing.contact.subscription.mm.user,model_mailing_contact_subscription,mass_mailing.group_mass_mailing_user,1,1,1,1 +access_mailing_subscription_mm_user,access.mailing.subscription.mm.user,model_mailing_subscription,mass_mailing.group_mass_mailing_user,1,1,1,1 +access_mailing_subscription_optout_mm_user,access.mailing.subscription.optout.mm.user,model_mailing_subscription_optout,mass_mailing.group_mass_mailing_user,1,1,1,1 access_mailing_list_mm_user,access.mailing.list.mm.user,model_mailing_list,mass_mailing.group_mass_mailing_user,1,1,1,1 access_utm_stage,utm.stage,utm.model_utm_stage,mass_mailing.group_mass_mailing_user,1,1,1,1 access_mailing_mailing_mm_user,access.mailing.mailing.mm.user,model_mailing_mailing,mass_mailing.group_mass_mailing_user,1,1,1,1 diff --git a/addons/mass_mailing/static/src/js/mailing_portal.js b/addons/mass_mailing/static/src/js/mailing_portal.js deleted file mode 100644 index 7be8aafd2f7..00000000000 --- a/addons/mass_mailing/static/src/js/mailing_portal.js +++ /dev/null @@ -1,195 +0,0 @@ -/** @odoo-module **/ - -import { escape } from "@web/core/utils/strings"; -import { whenReady } from "@odoo/owl"; -import { _t } from "@web/core/l10n/translation"; -import { jsonrpc } from "@web/core/network/rpc_service"; - -whenReady(() => { - var email = $("input[name='email']").val(); - var mailing_id = parseInt($("input[name='mailing_id']").val()); - var res_id = parseInt($("input[name='res_id']").val()); - var token = (location.search.split('token' + '=')[1] || '').split('&')[0]; - - if ($('.o_unsubscribe_form').length) { - if (email != '' && email != undefined){ - jsonrpc('/mailing/blacklist/check', {'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token}) - .then(function (result) { - if (result == 'unauthorized'){ - $('#button_add_blacklist').hide(); - $('#button_remove_blacklist').hide(); - } - else if (result == true) { - $('#button_remove_blacklist').show(); - toggle_opt_out_section(false); - } - else if (result == false) { - $('#button_add_blacklist').show(); - toggle_opt_out_section(true); - } - else { - $('#subscription_info').text(_t('An error occurred. Please try again later or contact us.')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error'); - } - }) - .guardedCatch(function () { - $('#subscription_info').text(_t('An error occurred. Please try again later or contact us.')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error'); - }); - } - else { - $('#div_blacklist').hide(); - } - - var unsubscribed_list = $("input[name='unsubscribed_list']").val(); - if (unsubscribed_list){ - $("#subscription_info").html( - _t( - "You have been successfully unsubscribed from %s.", - escape(unsubscribed_list) - ) - ) - } - else{ - $('#subscription_info').html(_t('You have been successfully unsubscribed.')); - } - - $('#unsubscribe_form').on('submit', function (e) { - e.preventDefault(); - - var checked_ids = []; - $("input[type='checkbox']:checked").each(function (i){ - checked_ids[i] = parseInt($(this).val()); - }); - - var unchecked_ids = []; - $("input[type='checkbox']:not(:checked)").each(function (i){ - unchecked_ids[i] = parseInt($(this).val()); - }); - - jsonrpc('/mailing/list/update', {'opt_in_ids': checked_ids, 'opt_out_ids': unchecked_ids, 'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token}) - .then(function (result) { - if (result == 'unauthorized'){ - $('#subscription_info').text(_t('You are not authorized to do this!')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning'); - } - else if (result == true) { - $('#subscription_info').text(_t('Your changes have been saved.')); - $('#info_state').removeClass('alert-info').addClass('alert-success'); - } - else { - $('#subscription_info').text(_t('An error occurred. Your changes have not been saved, try again later.')); - $('#info_state').removeClass('alert-info').addClass('alert-warning'); - } - }) - .guardedCatch(function () { - $('#subscription_info').text(_t('An error occurred. Your changes have not been saved, try again later.')); - $('#info_state').removeClass('alert-info').addClass('alert-warning'); - }); - }); - - // ================== - // Blacklist - // ================== - $('#button_add_blacklist').click(function (e) { - e.preventDefault(); - - jsonrpc('/mailing/blacklist/add', {'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token}) - .then(function (result) { - if (result == 'unauthorized'){ - $('#subscription_info').text(_t('You are not authorized to do this!')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning'); - } - else - { - if (result) { - $('#subscription_info').html(_t('You have been successfully added to our blacklist. ' - + 'You will not be contacted anymore by our services.')); - $('#info_state').removeClass('alert-warning').removeClass('alert-info').removeClass('alert-error').addClass('alert-success'); - toggle_opt_out_section(false); - } - else { - $('#subscription_info').text(_t('An error occurred. Please try again later or contact us.')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error'); - } - $('#button_add_blacklist').hide(); - $('#button_remove_blacklist').show(); - $('#unsubscribed_info').hide(); - } - }) - .guardedCatch(function () { - $('#subscription_info').text(_t('An error occurred. Please try again later or contact us.')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error'); - }); - }); - - $('#button_remove_blacklist').click(function (e) { - e.preventDefault(); - - jsonrpc('/mailing/blacklist/remove', {'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token}) - .then(function (result) { - if (result == 'unauthorized'){ - $('#subscription_info').text(_t('You are not authorized to do this!')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning'); - } - else - { - if (result) { - $('#subscription_info').html(_t("You have been successfully removed from our blacklist. " - + "You are now able to be contacted by our services.")); - $('#info_state').removeClass('alert-warning').removeClass('alert-info').removeClass('alert-error').addClass('alert-success'); - toggle_opt_out_section(true); - } - else { - $('#subscription_info').text(_t('An error occurred. Please try again later or contact us.')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error'); - } - $('#button_add_blacklist').show(); - $('#button_remove_blacklist').hide(); - $('#unsubscribed_info').hide(); - } - }) - .guardedCatch(function () { - $('#subscription_info').text(_t('An error occurred. Please try again later or contact us.')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error'); - }); - }); - - // ================== - // Feedback - // ================== - $('#button_feedback').click(function (e) { - var feedback = $("textarea[name='opt_out_feedback']").val(); - e.preventDefault(); - jsonrpc('/mailing/feedback', {'mailing_id': mailing_id, 'res_id': res_id, 'email': email, 'feedback': feedback, 'token': token}) - .then(function (result) { - if (result == 'unauthorized'){ - $('#subscription_info').text(_t('You are not authorized to do this!')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning'); - } - else if (result == true){ - $('#subscription_info').text(_t('Thank you! Your feedback has been sent successfully!')); - $('#info_state').removeClass('alert-warning').removeClass('alert-info').removeClass('alert-error').addClass('alert-success'); - $("#div_feedback").hide(); - } - else { - $('#subscription_info').text(_t('An error occurred. Please try again later or contact us.')); - $('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning'); - } - }) - .guardedCatch(function () { - $('#subscription_info').text(_t('An error occurred. Please try again later or contact us.')); - $('#info_state').removeClass('alert-info').removeClass('alert-success').removeClass('alert-error').addClass('alert-warning'); - }); - }); - } -}); - -function toggle_opt_out_section(value) { - var result = !value; - $("#div_opt_out").find('*').attr('disabled',result); - $("#button_add_blacklist").attr('disabled', false); - $("#button_remove_blacklist").attr('disabled', false); - if (value) { $('[name="button_subscription"]').addClass('clickable'); } - else { $('[name="button_subscription"]').removeClass('clickable'); } -} diff --git a/addons/mass_mailing/static/src/js/mailing_portal_subscription.js b/addons/mass_mailing/static/src/js/mailing_portal_subscription.js new file mode 100644 index 00000000000..169e1562ce3 --- /dev/null +++ b/addons/mass_mailing/static/src/js/mailing_portal_subscription.js @@ -0,0 +1,123 @@ +/** @odoo-module alias=mailing.PortalSubscription **/ + +import publicWidget from "@web/legacy/js/public/public_widget"; + + +publicWidget.registry.MailingPortalSubscription = publicWidget.Widget.extend({ + custom_events: { + 'blocklist_add': '_onBlocklistAdd', + 'blocklist_remove': '_onBlocklistRemove', + 'feedback_sent': '_onFeedbackSent', + 'subscription_updated': '_onSubscriptionUpdated', + }, + selector: '#o_mailing_portal_subscription', + + /** + * @override + */ + start: function () { + this.customerData = {...document.getElementById('o_mailing_portal_subscription').dataset}; + this.customerData.documentId = parseInt(this.customerData.documentId || 0); + this.customerData.mailingId = parseInt(this.customerData.mailingId || 0); + this.lastAction = this.customerData.lastAction; + + // nodes for widgets (jquery due to widget implementation) + this.$bl_elem = this.$('#o_mailing_subscription_blocklist'); + this.$feedback_elem = this.$('#o_mailing_subscription_feedback'); + this.$form_elem = this.$('#o_mailing_subscription_form'); + + this._attachBlocklist(); + this._attachFeedback(); + this._attachForm(); + this._updateDisplay(); + return this._super.apply(this, arguments); + }, + + _attachBlocklist: function () { + if (this.$bl_elem.length) { + this.blocklistWidget = new publicWidget.registry.MailingPortalSubscriptionBlocklist( + this, + {customerData: this.customerData} + ); + this.blocklistWidget.attachTo(this.$bl_elem); + } + }, + + _attachFeedback: function () { + if (this.$feedback_elem.length) { + this.feedbackWidget = new publicWidget.registry.MailingPortalSubscriptionFeedback( + this, + {customerData: this.customerData} + ); + this.feedbackWidget.attachTo(this.$feedback_elem); + this.feedbackWidget._setLastAction(this.lastAction); + } + }, + + _attachForm: function () { + if (this.$form_elem.length) { + this.formWidget = new publicWidget.registry.MailingPortalSubscriptionForm( + this, + {customerData: this.customerData} + ); + this.formWidget.attachTo(this.$form_elem); + } + }, + + _onActionDone: function (callKey) { + this.lastAction = callKey; + this._updateDisplay(); + }, + + _onBlocklistAdd: function (event) { + const callKey = event.data.callKey; + this.customerData.isBlocklisted = event.data.isBlocklisted; + if (callKey === 'blocklist_add') { + this.customerData.feedbackEnabled = true; + } + this._onActionDone(callKey); + }, + + _onBlocklistRemove: function (event) { + const callKey = event.data.callKey; + this.customerData.isBlocklisted = false; + if (callKey === 'blocklist_remove') { + this.customerData.feedbackEnabled = false; + } + this._onActionDone(callKey); + }, + + _onFeedbackSent: function (event) { + const callKey = event.data.callKey; + this.lastAction = callKey; + }, + + _onSubscriptionUpdated: function (event) { + const callKey = event.data.callKey; + if (callKey === 'subscription_updated_optout') { + this.customerData.feedbackEnabled = true; + } + else if (callKey === 'subscription_updated') { + this.customerData.feedbackEnabled = false; + } + this._onActionDone(callKey); + }, + + _updateDisplay: function () { + if (! this.customerData.feedbackEnabled && this.$feedback_elem.length) { + this.$feedback_elem.addClass('d-none'); + } else if (this.$feedback_elem.length) { + this.$feedback_elem.removeClass('d-none'); + } + if (this.formWidget) { + this.formWidget._setBlocklisted(this.customerData.isBlocklisted); + this.formWidget._setReadonly(this.customerData.isBlocklisted); + } + if (this.feedbackWidget) { + this.feedbackWidget._setLastAction(this.lastAction); + this.feedbackWidget._updateDisplay(true, false); + } + }, +}); + +export default publicWidget.registry.MailingPortalSubscription; diff --git a/addons/mass_mailing/static/src/js/mailing_portal_subscription_blocklist.js b/addons/mass_mailing/static/src/js/mailing_portal_subscription_blocklist.js new file mode 100644 index 00000000000..c9a4b6ed65f --- /dev/null +++ b/addons/mass_mailing/static/src/js/mailing_portal_subscription_blocklist.js @@ -0,0 +1,140 @@ +/** @odoo-module alias=mailing.PortalSubscriptionBlocklist **/ + +import { jsonrpc } from "@web/core/network/rpc_service"; +import { renderToElement } from "@web/core/utils/render"; +import publicWidget from "@web/legacy/js/public/public_widget"; + + +publicWidget.registry.MailingPortalSubscriptionBlocklist = publicWidget.Widget.extend({ + events: { + 'click #button_blocklist_add': '_onBlocklistAddClick', + 'click #button_blocklist_remove': '_onBlocklistRemoveClick', + }, + + /** + * @override + */ + init: function (parent, options) { + this.customerData = options.customerData; + return this._super.apply(this, arguments); + }, + + /** + * @override + */ + start: function () { + this._updateDisplay(); + return this._super.apply(this, arguments); + }, + + /* + * Triggers call to add current email in blocklist. Update widget internals + * and DOM accordingly (buttons display mainly). Bubble up to let parent + * handle returned result if necessary. + */ + _onBlocklistAddClick: async function (event) { + event.preventDefault(); + return await jsonrpc( + '/mailing/blocklist/add', + { + document_id: this.customerData.documentId, + email: this.customerData.email, + hash_token: this.customerData.hashToken, + mailing_id: this.customerData.mailingId, + } + ).then((result) => { + if (result === true) { + this.customerData.isBlocklisted = true; + } + this._updateDisplay(); + this._updateInfo(result === true ? 'blocklist_add' : 'error'); + this.trigger_up( + 'blocklist_add', + {'callKey': result === true ? 'blocklist_add' : result, + 'isBlocklisted': result === true ? true: this.customerData.isBlocklisted, + }, + ); + }); + }, + + /* + * Triggers call to remove current email from blocklist. Update widget + * internals and DOM accordingly (buttons display mainly). Bubble up to let + * parent handle returned result if necessary. + */ + _onBlocklistRemoveClick: async function (event) { + event.preventDefault(); + return await jsonrpc( + '/mailing/blocklist/remove', + { + document_id: this.customerData.documentId, + email: this.customerData.email, + hash_token: this.customerData.hashToken, + mailing_id: this.customerData.mailingId, + } + ).then((result) => { + if (result === true) { + this.customerData.isBlocklisted = false; + } + this._updateDisplay(); + this._updateInfo(result === true ? 'blocklist_remove' : 'error'); + this.trigger_up( + 'blocklist_remove', + {'callKey': result === true ? 'blocklist_remove' : result, + 'isBlocklisted': result === true ? false: this.customerData.isBlocklisted, + }, + ); + }); + }, + + /* + * Display buttons and info according to current state. Removing from blocklist + * is always available when being blocklisted. Adding in blocklist is available + * when not being blocklisted, if the action is possible (valid email mainly) + * and if the option is activated. + */ + _updateDisplay: function () { + const buttonAddNode = document.getElementById('button_blocklist_add'); + const buttonRemoveNode = document.getElementById('button_blocklist_remove'); + if (this.customerData.blocklistEnabled && this.customerData.blocklistPossible && !this.customerData.isBlocklisted) { + buttonAddNode.classList.remove('d-none'); + } else { + buttonAddNode.classList.add('d-none'); + } + if (this.customerData.isBlocklisted) { + buttonRemoveNode.classList.remove('d-none'); + } else { + buttonRemoveNode.classList.add('d-none'); + } + }, + + /* + * Display feedback (visual tips) to the user concerning the last done action. + */ + _updateInfo: function (infoKey) { + const updateInfo = document.getElementById('o_mailing_subscription_update_info'); + if (infoKey !== undefined) { + const infoContent = renderToElement( + "mass_mailing.portal.blocklist_update_info", + { + infoKey: infoKey, + } + ); + updateInfo.innerHTML = infoContent.innerHTML; + if (['blocklist_add', 'blocklist_remove'].includes(infoKey)) { + updateInfo.classList.add('text-success'); + updateInfo.classList.remove('text-danger'); + } + else { + updateInfo.classList.add('text-danger'); + updateInfo.classList.remove('text-success'); + } + updateInfo.classList.remove('d-none'); + } + else { + updateInfo.classList.add('d-none'); + } + }, +}); + +export default publicWidget.registry.MailingPortalSubscriptionBlocklist; diff --git a/addons/mass_mailing/static/src/js/mailing_portal_subscription_feedback.js b/addons/mass_mailing/static/src/js/mailing_portal_subscription_feedback.js new file mode 100644 index 00000000000..eb767e18a92 --- /dev/null +++ b/addons/mass_mailing/static/src/js/mailing_portal_subscription_feedback.js @@ -0,0 +1,149 @@ +/** @odoo-module alias=mailing.PortalSubscriptionFeedback **/ + +import { _t } from "@web/core/l10n/translation"; +import { jsonrpc } from "@web/core/network/rpc_service"; +import publicWidget from "@web/legacy/js/public/public_widget"; +import { renderToElement } from "@web/core/utils/render"; + + +publicWidget.registry.MailingPortalSubscriptionFeedback = publicWidget.Widget.extend({ + events: { + 'click #button_feedback': '_onFeedbackClick', + 'click .o_mailing_subscription_opt_out_reason': '_onOptOutReasonClick', + }, + + /** + * @override + */ + init: function (parent, options) { + this.customerData = options.customerData; + this.allowFeedback = false; + this.lastAction = false; + return this._super.apply(this, arguments); + }, + + /** + * @override + */ + start: function () { + this._updateDisplay(true, false); + return this._super.apply(this, arguments); + }, + + /* + * Triggers call to give a feedback about current subscription update. + * Bubble up to let parent handle returned result if necessary. + */ + _onFeedbackClick: async function (event) { + event.preventDefault(); + const formData = new FormData(document.querySelector('div#o_mailing_subscription_feedback form')); + const optoutReasonId = parseInt(formData.get('opt_out_reason_id')); + return await jsonrpc( + '/mailing/feedback', + { + csrf_token: formData.get('csrf_token'), + document_id: this.customerData.documentId, + email: this.customerData.email, + feedback: formData.get('feedback'), + hash_token: this.customerData.hashToken, + last_action: this.lastAction, + mailing_id: this.customerData.mailingId, + opt_out_reason_id: optoutReasonId, + } + ).then((result) => { + if (result === true) { + this._updateDisplay(false, true); + this._updateInfo('feedback_sent'); + } + else { + this._updateDisplay(false, false); + this._updateInfo(result); + } + this.trigger_up( + 'feedback_sent', + {'callKey': result === true ? 'feedback_sent' : result}, + ); + }); + }, + + + /* + * Toggle feedback textarea display based on reason configuration + */ + _onOptOutReasonClick: function (event) { + this.allowFeedback = $(event.currentTarget).data('isFeedback'); + this._updateDisplay() + }, + + /* + * Set last done action, which triggers some update in the feedback form + * allowing to contextualize the explanation given to the customer. + */ + _setLastAction: function (lastAction) { + this.lastAction = lastAction; + if (this.lastAction === 'blocklist_add') { + document.querySelector('div#o_mailing_subscription_feedback p').innerHTML = _t( + 'Please let us know why you want to be in our block list.' + ); + } + else { + document.querySelector('div#o_mailing_subscription_feedback p').innerHTML = _t( + 'Please let us know why you updated your subscription.' + ); + } + }, + + /* + * Update display after option changes, notably feedback textarea not being + * always accessible. Opt-out reasons are displayed only if user opt-outed + * from some mailing lists or added their email in the blocklist. + */ + _updateDisplay: function (cleanFeedback, setReadonly) { + const feedbackArea = document.querySelector('div#o_mailing_subscription_feedback textarea'); + const feedbackButton = document.getElementById('button_feedback'); + const feedbackReasons = document.querySelectorAll('div#o_mailing_subscription_feedback input'); + const feedbackInfo = document.getElementById('o_mailing_subscription_feedback_info'); + if (this.allowFeedback) { + feedbackArea.classList.remove('d-none'); + } else { + feedbackArea.classList.add('d-none'); + } + if (setReadonly) { + feedbackArea.setAttribute('disabled', 'disabled'); + feedbackButton.setAttribute('disabled', 'disabled'); + feedbackReasons.forEach(node => node.setAttribute('disabled', 'disabled')); + } + else { + feedbackArea.removeAttribute('disabled'); + feedbackButton.removeAttribute('disabled'); + feedbackReasons.forEach(node => node.removeAttribute('disabled')); + } + if (cleanFeedback) { + feedbackArea.value = ''; + feedbackInfo.innerHTML = ""; + } + }, + + /* + * Display feedback (visual tips) to the user concerning the last done action. + */ + _updateInfo: function (infoKey) { + const feedbackInfo = document.getElementById('o_mailing_subscription_feedback_info'); + if (infoKey !== undefined) { + const infoContent = renderToElement( + "mass_mailing.portal.feedback_update_info", + { + infoKey: infoKey, + } + ); + feedbackInfo.innerHTML = infoContent.innerHTML; + feedbackInfo.classList.add(infoKey === 'feedback_sent' ? 'text-success' : 'text-danger'); + feedbackInfo.classList.remove('d-none', infoKey === 'feedback_sent' ? 'text-danger': 'text-success'); + } + else { + feedbackInfo.classList.add('d-none'); + } + }, +}); + +export default publicWidget.registry.MailingPortalSubscriptionFeedback; 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 new file mode 100644 index 00000000000..b0e755865d0 --- /dev/null +++ b/addons/mass_mailing/static/src/js/mailing_portal_subscription_form.js @@ -0,0 +1,177 @@ +/** @odoo-module alias=mailing.PortalSubscriptionForm **/ + +import { jsonrpc } from "@web/core/network/rpc_service"; +import { renderToFragment } from "@web/core/utils/render"; +import publicWidget from "@web/legacy/js/public/public_widget"; + + +publicWidget.registry.MailingPortalSubscriptionForm = publicWidget.Widget.extend({ + events: { + 'click #button_form_send': '_onFormSend', + }, + + /** + * @override + */ + init: function (parent, options) { + this.customerData = options.customerData; + return this._super.apply(this, arguments); + }, + + /** + * @override + * Parse start values of mailing lists subscriptions based on generated DOM + * from server. Done here to avoid having to generate it server-side and + * propagating it through various layers. + */ + start: function () { + const def = this._super.apply(this, arguments); + this.listInfo = [...document.querySelectorAll('#o_mailing_subscription_form_manage input')].map( + node => { + const listInfo = { + id: parseInt(node.getAttribute('value')), + member: node.dataset.member === '1', + name: node.getAttribute('title'), + opt_out: node.getAttribute('checked') !== 'checked', + }; + return listInfo; + } + ); + return def; + }, + + /* + * Triggers call to update list subscriptions. Bubble up to let parent + * handle returned result if necessary. RPC call returns number of optouted + * lists, used by parent widget notably to know which feedback to ask. + */ + _onFormSend: async function (event) { + event.preventDefault(); + const formData = new FormData(document.querySelector('div#o_mailing_subscription_form form')); + const mailingListOptinIds = formData.getAll('mailing_list_ids').map(id_str => parseInt(id_str)); + return await jsonrpc( + '/mailing/list/update', + { + csrf_token: formData.get('csrf_token'), + document_id: this.customerData.documentId, + email: this.customerData.email, + hash_token: this.customerData.hashToken, + lists_optin_ids: mailingListOptinIds, + mailing_id: this.customerData.mailingId, + } + ).then((result) => { + const has_error = ['error', 'unauthorized'].includes(result); + let callKey; + if (has_error) { + callKey = 'error'; + } + else { + callKey = (parseInt(result) > 0) ? 'subscription_updated_optout': 'subscription_updated'; + this._updateDisplay(mailingListOptinIds); + } + this._updateInfo(has_error ? 'error' : 'subscription_updated'); + this.trigger_up( + 'subscription_updated', + {'callKey': callKey}, + ); + }); + }, + + /** + * Set form elements as hidden / displayed, as this form contains either an + * informational text when being blocklisted, either the complete form to + * manage their subscription. + * @private + */ + _setBlocklisted: function (isBlocklisted) { + if (isBlocklisted) { + document.getElementById('o_mailing_subscription_form_blocklisted').classList.remove('d-none'); + document.getElementById('o_mailing_subscription_form_manage').classList.add('d-none'); + } + else { + document.getElementById('o_mailing_subscription_form_blocklisted').classList.add('d-none'); + document.getElementById('o_mailing_subscription_form_manage').classList.remove('d-none'); + } + }, + + /** + * Set form elements as readonly, e.g. when blocklisted email take precedence + * over subscription update. + * @private + */ + _setReadonly: function (isReadonly) { + const formInputNodes = document.querySelectorAll('#o_mailing_subscription_form_manage input'); + const formButtonNode = document.getElementById('button_form_send'); + if (isReadonly) { + formInputNodes.forEach(node => {node.setAttribute('disabled', 'disabled')}); + formButtonNode.setAttribute('disabled', 'disabled'); + formButtonNode.classList.add('d-none'); + } else { + formInputNodes.forEach(node => {node.removeAttribute('disabled')}); + formButtonNode.removeAttribute('disabled'); + formButtonNode.classList.remove('d-none'); + } + }, + + /* + * Update display after subscription, notably to update mailing list subscription + * status. We simply update opt_out status based on the ID being present in the + * newly-selected opt-in mailing lists, then rerender the inputs. + */ + _updateDisplay: function (listOptinIds) { + /* update internal status*/ + this.listInfo.forEach( + (listItem) => { + listItem.member = listItem.member || listOptinIds.includes(listItem.id); + listItem.opt_out = !listOptinIds.includes(listItem.id); + } + ); + + /* update form of lists for update */ + const formContent = renderToFragment( + "mass_mailing.portal.list_form_content", + { + listsMember: this.listInfo.filter(item => item.member === true), + listsProposal: this.listInfo.filter(item => item.member === false), + } + ); + const manageForm = document.getElementById('o_mailing_subscription_form_manage'); + /*manageForm.innerHTML = formContent.innerHTML;*/ + manageForm.replaceChildren(formContent); + + /* update readonly display of customer's lists */ + const formReadonlyContent = renderToFragment( + "mass_mailing.portal.list_form_content_readonly", + { + listsOptin: this.listInfo.filter(item => item.opt_out === false), + } + ); + const readonlyForm = document.getElementById('o_mailing_subscription_form_blocklisted'); + /*readonlyForm.innerHTML = formReadonlyContent.innerHTML;*/ + readonlyForm.replaceChildren(formReadonlyContent); + }, + + /* + * Display feedback (visual tips) to the user concerning the last done action. + */ + _updateInfo: function (infoKey) { + const updateInfo = document.getElementById('o_mailing_subscription_update_info'); + if (infoKey !== undefined) { + const infoContent = renderToFragment( + "mass_mailing.portal.list_form_update_info", + { + infoKey: infoKey, + } + ); + /*updateInfo.innerHTML = infoContent.innerHTML;*/ + updateInfo.replaceChildren(infoContent); + updateInfo.classList.add(infoKey === 'error' ? 'text-danger': 'text-success'); + updateInfo.classList.remove('d-none'); + } + else { + updateInfo.classList.add('d-none'); + } + }, +}); + +export default publicWidget.registry.MailingPortalSubscriptionForm; diff --git a/addons/mass_mailing/static/src/scss/mailing_portal.scss b/addons/mass_mailing/static/src/scss/mailing_portal.scss index 85ea4a0f20f..0813e65f3e5 100644 --- a/addons/mass_mailing/static/src/scss/mailing_portal.scss +++ b/addons/mass_mailing/static/src/scss/mailing_portal.scss @@ -8,7 +8,7 @@ text-transform: uppercase; } - .o_mailing_portal_list_unsubscribed { + .o_mailing_list_unsubscribed { margin-left: 20px; color: #005326; font-size: 90%; diff --git a/addons/mass_mailing/static/src/xml/mailing_portal_subscription_blocklist.xml b/addons/mass_mailing/static/src/xml/mailing_portal_subscription_blocklist.xml new file mode 100644 index 00000000000..631c5f8bcbc --- /dev/null +++ b/addons/mass_mailing/static/src/xml/mailing_portal_subscription_blocklist.xml @@ -0,0 +1,20 @@ + + + + + + + + Email added to our blocklist + + + + Email removed from our blocklist + + + + An error occurred. Please retry later. + + + + diff --git a/addons/mass_mailing/static/src/xml/mailing_portal_subscription_feedback.xml b/addons/mass_mailing/static/src/xml/mailing_portal_subscription_feedback.xml new file mode 100644 index 00000000000..66f2a411cd9 --- /dev/null +++ b/addons/mass_mailing/static/src/xml/mailing_portal_subscription_feedback.xml @@ -0,0 +1,16 @@ + + + + + + + + Sent. Thanks you for your feedback! + + + + An error occurred. Please retry later or contact us. + + + + diff --git a/addons/mass_mailing/static/src/xml/mailing_portal_subscription_form.xml b/addons/mass_mailing/static/src/xml/mailing_portal_subscription_form.xml new file mode 100644 index 00000000000..d3c00e014c4 --- /dev/null +++ b/addons/mass_mailing/static/src/xml/mailing_portal_subscription_form.xml @@ -0,0 +1,86 @@ + + + + + +

    + Choose your mailing subscriptions +

    +
      +
    • + + + + + Not subscribed + + + Subscribed + + +
    • +
    +

    + You may also be interested in +

    +
      +
    • + + +
    • +
    +
    + + + +

    + Your email is currently in our block list. + + You will not receive any news from those mailing lists you are a member of: + + + You will not hear from us anymore. + +

    +
      + +
    • + +
    • +
      +
    +
    + + + + + + Membership updated + + + + An error occurred. Please retry later. + + +
    diff --git a/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_document.js b/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_document.js new file mode 100644 index 00000000000..ec4f0ddedef --- /dev/null +++ b/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_document.js @@ -0,0 +1,110 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; + +/* + * Tour: unsubscribe from a mailing done on documents (aka not on contacts or + * mailing lists). We assume email is not member of any mailing list in this test. + */ +registry.category("web_tour.tours").add('mailing_portal_unsubscribe_from_document', { + test: true, + steps: () => [ + { + content: "Confirmation unsubscribe is done", + trigger: "div#o_mailing_subscription_info span:contains('You are no longer part of our services and will not be contacted again.')", + }, { + content: "No warning should be displayed", + trigger: "div#o_mailing_subscription_form_blocklisted:not(:has(p:contains('You will not receive any news from those mailing lists you are a member of')))", + }, { + contnet: "Warning will not receive anything anymore", + trigger: "div#o_mailing_subscription_form_blocklisted p:contains('You will not hear from us anymore.')", + }, { + content: "Feedback textarea not displayed (see data)", + trigger: "div#o_mailing_portal_subscription:not(textarea)", + }, { + content: "Choose 'Other' reason", + trigger: "fieldset label:contains('Other')", + }, { + content: "This should display the Feedback area", + trigger: "div#o_mailing_portal_subscription 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: "Revert exclusion list", + trigger: "div#button_blocklist_remove", + }, { + content: "Confirmation exclusion list is removed", + trigger: "div#o_mailing_subscription_update_info span:contains('Email removed from our blocklist')", + }, { + content: "Now exclude me (again)", + trigger: "div#button_blocklist_add", + }, { + content: "Confirmation exclusion is done", + trigger: "div#o_mailing_subscription_update_info span:contains('Email added to our blocklist')", + isCheck: true, + }, + ], +}); + + +/* + * Tour: unsubscribe from a mailing done on documents (aka not on contacts or + * mailing lists). We assume email is member of mailing lists in this test. + */ +registry.category("web_tour.tours").add('mailing_portal_unsubscribe_from_document_with_lists', { + test: true, + steps: () => [ + { + content: "Confirmation unsubscribe is done", + trigger: "div#o_mailing_subscription_info span:contains('You are no longer part of our services and will not be contacted again.')", + }, { + 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('List1')", + }, { + content: "Feedback textarea not displayed (see data)", + trigger: "div#o_mailing_portal_subscription:not(textarea)", + }, { + content: "Choose 'Other' reason", + trigger: "fieldset label:contains('Other')", + }, { + content: "This should display the Feedback area", + trigger: "div#o_mailing_portal_subscription 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: "Revert exclusion list", + trigger: "div#button_blocklist_remove", + }, { + content: "Confirmation exclusion list is removed", + trigger: "div#o_mailing_subscription_update_info span:contains('Email removed from our blocklist')", + }, { + content: "Now exclude me (again)", + trigger: "div#button_blocklist_add", + }, { + content: "Confirmation exclusion is done", + trigger: "div#o_mailing_subscription_update_info span:contains('Email added to our blocklist')", + isCheck: true, + }, + ], +}); diff --git a/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_list.js b/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_list.js new file mode 100644 index 00000000000..e4372cf2969 --- /dev/null +++ b/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_list.js @@ -0,0 +1,153 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; + +/* + * Tour: unsubscribe from a mailing done on lists (aka playing with opt-out flag + * instead of directly blocking emails). + */ +registry.category("web_tour.tours").add('mailing_portal_unsubscribe_from_list', { + test: true, + steps: () => [ + { + content: "Confirmation unsubscribe is done", + trigger: "div#o_mailing_subscription_info span:contains('You are no longer part of the List1, List2 mailing list')", + }, { + content: "Feedback textarea not displayed (see data)", + trigger: "div#o_mailing_portal_subscription:not(textarea)", + }, { + content: "Choose 'Other' reason", + trigger: "fieldset label:contains('Other')", + }, { + 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')", + isCheck: true, + }, + ], +}); + + +/* + * Tour: unsubscribe from a mailing done on lists (aka playing with opt-out flag + * instead of directly blocking emails), then play with list subscriptions and + * blocklist addition / removal. This is mainly an extended version of the tour + * hereabove, easing debug and splitting checks. + */ +registry.category("web_tour.tours").add('mailing_portal_unsubscribe_from_list_with_update', { + test: true, + steps: () => [ + { + content: "Confirmation unsubscribe is done", + trigger: "div#o_mailing_subscription_info span:contains('You are no longer part of the List1, List2 mailing list')", + }, { + content: "List1 is present, just opt-outed", + trigger: "ul#o_mailing_subscription_form_lists li.list-group-item:contains('List1') span:contains('Not 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 textarea not displayed (see data)", + trigger: "div#o_mailing_portal_subscription:not(textarea)", + }, { + content: "Choose 'Other' reason", + trigger: "fieldset label:contains('Other')", + }, { + 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' (Apply changes) button", + trigger: "div#o_mailing_subscription_blocklist:not(button#button_form_send)", + isCheck: true, + }, { + content: "Revert exclusion list", + trigger: "div#button_blocklist_remove", + }, { + content: "Confirmation exclusion list is removed", + trigger: "div#o_mailing_subscription_update_info span:contains('Email removed from our blocklist')", + }, { + content: "'Update my subscriptions' button usable again", + trigger: "button#button_form_send:not([disabled])", + isCheck: true, + }, { + content: "Choose the mailing list 3 to come back", + trigger: "ul#o_mailing_subscription_form_lists input[title='List3']", + }, { + content: "Add list 2", + trigger: "ul#o_mailing_subscription_form_lists_additional input[title='List2']", + }, { + 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: "List 3 is noted as subscribed again", + trigger: "ul#o_mailing_subscription_form_lists li.list-group-item:contains('List3') span:contains('Subscribed')", + }, { + content: "List 2 has joined the subscriptions", + trigger: "ul#o_mailing_subscription_form_lists li.list-group-item:contains('List2') span:contains('Subscribed')", + }, { + content: "No list in proposals", + trigger: "div#o_mailing_subscription_form_manage:not(:has(ul#o_mailing_subscription_form_lists_additional))", + }, { + content: "Feedback area is not displayed (nothing opt-out or no blocklist done, no feedback required)", + trigger: "div#o_mailing_portal_subscription:not(textarea)", + extra_trigger: "div#o_mailing_portal_subscription:not(fieldset)", + }, { + content: "Now exclude me (again)", + 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: "Should 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')", + }, { + content: "Give a reason for blocklist (first one)", + trigger: "fieldset input.o_mailing_subscription_opt_out_reason:first", + }, { + 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!')", + isCheck: true, + } + ], +}); 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..9610640d480 --- /dev/null +++ b/addons/mass_mailing/static/tests/tours/mailing_portal_unsubscribe_from_my.js @@ -0,0 +1,108 @@ +/** @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)", + extra_trigger: "div#o_mailing_portal_subscription:not(fieldset)", + }, { + 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 reasons choice appear (feedback still not displayed, linked to reasons)", + trigger: "div#o_mailing_portal_subscription fieldset", + extra_trigger: "div#o_mailing_portal_subscription:not(textarea)", + }, { + content: "Choose first reason, which should not display feedback (see data)", + trigger: "fieldset input.o_mailing_subscription_opt_out_reason:first", + }, { + content: "Feedback textarea not displayed (see data)", + trigger: "div#o_mailing_portal_subscription:not(textarea)", + }, { + content: "Choose 'Other' reason", + trigger: "fieldset label:contains('Other')", + }, { + content: "This should display the Feedback area", + trigger: "div#o_mailing_portal_subscription 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: "Once sent feedback area is readonly", + trigger: "fieldset input.o_mailing_subscription_opt_out_reason[disabled]", + extra_trigger: "textarea[disabled]", + isCheck: true, + }, { + 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' (Apply changes) button", + trigger: "div#o_mailing_subscription_blocklist:not(button#button_form_send)", + isCheck: true, + }, { + content: "This should enabled Feedback again", + trigger: "div#o_mailing_portal_subscription textarea", + 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')", + }, { + content: "Give a reason for blocklist (first one)", + trigger: "fieldset input.o_mailing_subscription_opt_out_reason:first", + }, { + 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!')", + isCheck: true, + } + ], +}); diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py index a43ccde80ef..f67df385eac 100644 --- a/addons/mass_mailing/tests/common.py +++ b/addons/mass_mailing/tests/common.py @@ -291,23 +291,33 @@ class MassMailCommon(MailCommon, MassMailCase): def _create_mailing_list(cls): """ Shortcut to create mailing lists. Currently hardcoded, maybe evolve in a near future. """ - cls.mailing_list_1 = cls.env['mailing.list'].with_context(cls._test_context).create({ - 'name': 'List1', - 'contact_ids': [ - (0, 0, {'name': 'Déboulonneur', 'email': 'fleurus@example.com'}), - (0, 0, {'name': 'Gorramts', 'email': 'gorramts@example.com'}), - (0, 0, {'name': 'Ybrant', 'email': 'ybrant@example.com'}), - ] - }) - cls.mailing_list_2 = cls.env['mailing.list'].with_context(cls._test_context).create({ - 'name': 'List2', - 'contact_ids': [ - (0, 0, {'name': 'Gilberte', 'email': 'gilberte@example.com'}), - (0, 0, {'name': 'Gilberte En Mieux', 'email': 'gilberte@example.com'}), - (0, 0, {'name': 'Norbert', 'email': 'norbert@example.com'}), - (0, 0, {'name': 'Ybrant', 'email': 'ybrant@example.com'}), - ] - }) + cls.mailing_list_1, cls.mailing_list_2, cls.mailing_list_3, cls.mailing_list_4 = cls.env['mailing.list'].with_context(cls._test_context).create([ + { + 'contact_ids': [ + (0, 0, {'name': 'Déboulonneur', 'email': 'fleurus@example.com'}), + (0, 0, {'name': 'Gorramts', 'email': 'gorramts@example.com'}), + (0, 0, {'name': 'Ybrant', 'email': 'ybrant@example.com'}), + ], + 'name': 'List1', + }, { + 'contact_ids': [ + (0, 0, {'name': 'Gilberte', 'email': 'gilberte@example.com'}), + (0, 0, {'name': 'Gilberte En Mieux', 'email': 'gilberte@example.com'}), + (0, 0, {'name': 'Norbert', 'email': 'norbert@example.com'}), + (0, 0, {'name': 'Ybrant', 'email': 'ybrant@example.com'}), + ], + 'name': 'List2', + }, { + 'contact_ids': [ + (0, 0, {'name': 'Déboulonneur', 'email': 'fleurus@example.com'}), + ], + 'name': 'List3', + }, { + 'is_public': False, + 'name': 'List4', + } + ]) + cls.mailing_list_3.subscription_ids[0].opt_out = True @classmethod def _create_mailing_list_of_x_contacts(cls, contacts_nbr): @@ -316,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 49ad26ef02b..dc20221e358 100644 --- a/addons/mass_mailing/tests/test_mailing_controllers.py +++ b/addons/mass_mailing/tests/test_mailing_controllers.py @@ -2,32 +2,53 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from freezegun import freeze_time +from markupsafe import Markup +from requests import Session, PreparedRequest, Response 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 requests import Session, PreparedRequest, Response - -from odoo.tests.common import HttpCase -from odoo.tests import tagged +from odoo.tests import HttpCase, tagged +from odoo.tools import mute_logger -@tagged('link_tracker') -class TestMailingControllers(MassMailCommon, HttpCase): +class TestMailingControllersCommon(MassMailCommon, HttpCase): @classmethod def setUpClass(cls): - super(TestMailingControllers, cls).setUpClass() + super(TestMailingControllersCommon, cls).setUpClass() + + # cleanup lists + cls.env['mailing.list'].search([]).unlink() + cls._create_mailing_list() - cls.test_mailing = cls.env['mailing.mailing'].create({ + cls.test_mailing_on_contacts = cls.env['mailing.mailing'].create({ + 'body_html': '

    Hello
    Go to this link

    ', + 'mailing_domain': [], + 'mailing_model_id': cls.env['ir.model']._get_id('mailing.contact'), + 'mailing_type': 'mail', + 'name': 'TestMailing on Contacts', + 'subject': 'TestMailing on Contacts', + }) + cls.test_mailing_on_documents = cls.env['mailing.mailing'].create({ + 'body_html': '

    Hello
    Go to this link

    ', + 'mailing_domain': [], + 'mailing_model_id': cls.env['ir.model']._get_id('res.partner'), + 'mailing_type': 'mail', + 'name': 'TestMailing on Documents', + 'subject': 'TestMailing on Documents', + }) + cls.test_mailing_on_lists = cls.env['mailing.mailing'].create({ 'body_html': '

    Hello
    Go to this link

    ', 'contact_list_ids': [(4, cls.mailing_list_1.id), (4, cls.mailing_list_2.id)], - 'mailing_model_id': cls.env['ir.model']._get('mailing.list').id, + 'mailing_model_id': cls.env['ir.model']._get_id('mailing.list'), 'mailing_type': 'mail', - 'name': 'TestMailing', + 'name': 'TestMailing on Lists', 'reply_to': cls.email_reply_to, - 'subject': 'Test', + 'subject': 'TestMailing on Lists', }) cls.test_contact = cls.mailing_list_1.contact_ids[0] @@ -43,10 +64,569 @@ class TestMailingControllers(MassMailCommon, HttpCase): return r return super()._request_handler(s, r, **kw) + +@tagged('mailing_portal', 'post_install', '-at_install') +class TestMailingControllers(TestMailingControllersCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_email = tools.formataddr(('Déboulonneur', '')) + cls.test_email_normalized = 'fleurus@example.com' + + def test_assert_initial_values(self): + """ Ensure test base data to ease test understanding. Globally test_email + is member of 2 mailing public lists. """ + memberships = self.env['mailing.subscription'].search([ + ('contact_id.email_normalized', '=', self.test_email_normalized)] + ) + self.assertEqual(memberships.list_id, self.mailing_list_1 + self.mailing_list_3) + self.assertEqual(memberships.mapped('opt_out'), [False, True]) + + contact_l1 = self.mailing_list_1.contact_ids.filtered( + lambda contact: contact.email == self.test_email_normalized + ) + self.assertTrue(contact_l1) + self.assertFalse(contact_l1.is_blacklisted) + self.assertFalse(contact_l1.message_ids) + subscription_l1 = self.mailing_list_1.subscription_ids.filtered( + lambda subscription: subscription.contact_id == contact_l1 + ) + self.assertTrue(subscription_l1) + self.assertFalse(subscription_l1.is_blacklisted) + self.assertFalse(subscription_l1.opt_out) + self.assertFalse(subscription_l1.opt_out_datetime) + + contact_l2 = self.mailing_list_2.contact_ids.filtered( + lambda contact: contact.email == self.test_email_normalized + ) + self.assertFalse(contact_l2) + + contact_l3 = self.mailing_list_3.contact_ids.filtered( + lambda contact: contact.email == self.test_email_normalized + ) + self.assertTrue(contact_l3) + self.assertTrue(contact_l3 != contact_l1) + self.assertFalse(contact_l3.is_blacklisted) + subscription_l3 = self.mailing_list_3.subscription_ids.filtered( + lambda subscription: subscription.contact_id == contact_l3 + ) + self.assertFalse(subscription_l3.is_blacklisted) + self.assertTrue(subscription_l3.opt_out) + + contact_l4 = self.mailing_list_4.contact_ids.filtered( + lambda contact: contact.email == self.test_email_normalized + ) + self.assertFalse(contact_l4) + + self.assertFalse(self.env['mail.blacklist'].search([('email', '=', self.test_email_normalized)])) + + @mute_logger('odoo.http', 'odoo.addons.website.models.ir_ui_view') + def test_mailing_report_unsubscribe(self): + """ Test deactivation of mailing report sending. It requires usage of + a hash token. """ + test_mailing = self.test_mailing_on_lists.with_env(self.env) + self.env['ir.config_parameter'].sudo().set_param( + 'mass_mailing.mass_mailing_reports', True + ) + hash_token = test_mailing._generate_mailing_report_token(self.user_marketing.id) + self.authenticate('user_marketing', 'user_marketing') + + # TEST: various invalid cases + for test_user_id, test_token, error_code in [ + (self.user_marketing.id, '', 400), # no token + (self.user_marketing.id, 'zboobs', 418), # invalid token + (self.env.uid, hash_token, 418), # invalid credentials + ]: + with self.subTest(test_user_id=test_user_id, test_token=test_token): + res = self.url_open( + werkzeug.urls.url_join( + test_mailing.get_base_url(), + f'mailing/report/unsubscribe?user_id={test_user_id}&token={test_token}', + ) + ) + self.assertEqual(res.status_code, error_code) + self.assertTrue(self.env['ir.config_parameter'].sudo().get_param('mass_mailing.mass_mailing_reports')) + + # TEST: not mailing user + self.user_marketing.write({ + 'groups_id': [(3, self.env.ref('mass_mailing.group_mass_mailing_user').id)], + }) + res = self.url_open( + werkzeug.urls.url_join( + test_mailing.get_base_url(), + f'mailing/report/unsubscribe?user_id={self.user_marketing.id}&token={hash_token}', + ) + ) + self.assertEqual(res.status_code, 418) + self.assertTrue(self.env['ir.config_parameter'].sudo().get_param('mass_mailing.mass_mailing_reports')) + + # TEST: finally valid call + self.user_marketing.write({ + 'groups_id': [(4, self.env.ref('mass_mailing.group_mass_mailing_user').id)], + }) + res = self.url_open( + werkzeug.urls.url_join( + test_mailing.get_base_url(), + f'mailing/report/unsubscribe?user_id={self.user_marketing.id}&token={hash_token}', + ) + ) + self.assertEqual(res.status_code, 200) + self.assertFalse(self.env['ir.config_parameter'].sudo().get_param('mass_mailing.mass_mailing_reports')) + + def test_mailing_unsubscribe_from_document_tour(self): + """ Test portal unsubscribe on mailings performed on documents (not + mailing lists or contacts). Primary effect is to automatically exclude + the email (see tour). + + Two tests are performed (with and without existing list subscriptions) + as it triggers the display of the mailing list part of the UI. + + Tour effects + * unsubscribe from mailing based on a document = blocklist; + * add feedback (block list): Other reason, with 'My feedback' feedback; + * remove email from exclusion list; + * re-add email to exclusion list; + """ + opt_out_reasons = self.env['mailing.subscription.optout'].search([]) + test_mailing = self.test_mailing_on_documents.with_env(self.env) + test_feedback = "My feedback" + + for test_email, tour_name in [ + ('"Not Déboulonneur" ', 'mailing_portal_unsubscribe_from_document'), + (self.test_email, 'mailing_portal_unsubscribe_from_document_with_lists'), + ]: + with self.subTest(test_email=test_email, tour_name=tour_name): + test_partner = self.env['res.partner'].create({ + 'email': test_email, + 'name': 'Test Déboulonneur' + }) + self.assertFalse(test_partner.is_blacklisted) + previous_messages = test_partner.message_ids + test_email_normalized = tools.email_normalize(test_email) + + # launch unsubscription tour + hash_token = test_mailing._generate_mailing_recipient_token(test_partner.id, test_partner.email_normalized) + with freeze_time(self._reference_now): + self.start_tour( + f"/mailing/{test_mailing.id}/unsubscribe?email={test_partner.email_normalized}&document_id={test_partner.id}&hash_token={hash_token}", + tour_name, + login=None, + ) + + # status update check + self.assertTrue(test_partner.is_blacklisted) + + # partner (document): new message for blocklist addition with feedback + self.assertEqual(len(test_partner.message_ids), len(previous_messages) + 1) + msg_fb = test_partner.message_ids[0] + self.assertEqual( + msg_fb.body, + Markup(f'

    Feedback from {test_email_normalized}
    {test_feedback}

    ') + ) + + # posted messages on exclusion list record: activated, feedback, deactivated, activated again + bl_record = self.env['mail.blacklist'].search([('email', '=', test_partner.email_normalized)]) + self.assertEqual(len(bl_record.message_ids), 5) + self.assertEqual(bl_record.opt_out_reason_id, opt_out_reasons[-1]) + msg_bl2, msg_unbl, msg_fb, msg_bl, msg_create = bl_record.message_ids + self.assertEqual( + msg_bl2.body, + Markup(f'

    Blocklist request from portal of mailing {test_mailing.subject} (document Contact)

    ') + ) + self.assertEqual( + msg_unbl.body, + Markup(f'

    Blocklist removal request from portal of mailing {test_mailing.subject} (document Contact)

    ') + ) + self.assertEqual( + msg_fb.body, + Markup(f'

    Feedback from {test_email_normalized}
    {test_feedback}

    ') + ) + self.assertTracking(msg_fb, [('opt_out_reason_id', 'many2one', False, opt_out_reasons[-1])]) + self.assertEqual( + msg_bl.body, + Markup(f'

    Blocklist request from unsubscribe link of mailing {test_mailing.subject} (document Contact)

    ') + ) + self.assertEqual(msg_create.body, Markup('

    Mail Blacklist created

    ')) + + def test_mailing_unsubscribe_from_document_tour_mailing_user(self): + """ Test portal unsubscribe on mailings performed on documents (not + mailing lists or contacts) using a generic '/unsubscribe' link allowing + mailing users to see and edit unsubcribe page. + + Tour effects + * unsubscribe from mailing based on a document = blocklist; + * add feedback (block list): Other reason, with 'My feedback' feedback; + * remove email from exclusion list; + * re-add email to exclusion list; + """ + # update user to link it with existing mailing contacts and allow the tour + # to run; test without and with mailing group + self.user_marketing.write({ + 'email': tools.formataddr(("Déboulonneur", "fleurus@example.com")), + 'groups_id': [(3, self.env.ref('mass_mailing.group_mass_mailing_user').id)], + }) + test_mailing = self.test_mailing_on_documents.with_env(self.env) + self.authenticate('user_marketing', 'user_marketing') + + # no group -> no direct access to /unsubscribe + res = self.url_open( + werkzeug.urls.url_join( + test_mailing.get_base_url(), + f'mailing/{test_mailing.id}/unsubscribe', + ) + ) + self.assertEqual(res.status_code, 400) + + # group -> direct access to /unsubscribe should wokr + self.user_marketing.write({ + 'groups_id': [(4, self.env.ref('mass_mailing.group_mass_mailing_user').id)], + }) + # launch unsubscription tour + with freeze_time(self._reference_now): + self.start_tour( + f"/mailing/{test_mailing.id}/unsubscribe", + "mailing_portal_unsubscribe_from_document_with_lists", + login=self.user_marketing.login, + ) + + def test_mailing_unsubscribe_from_list_tour(self): + """ Test portal unsubscribe on mailings performed on mailing lists. Their + effect is to opt-out from the mailing list. + + Tour effects + * unsubscribe from mailing based on lists = opt-out from lists; + * add feedback (opt-out): Other reason, with 'My feedback' feedback; + * add email to exclusion list; + """ + opt_out_reasons = self.env['mailing.subscription.optout'].search([]) + test_mailing = self.test_mailing_on_lists.with_env(self.env) + test_feedback = "My feedback" + + # 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 == self.test_email_normalized + ) + subscription_l1 = self.mailing_list_1.subscription_ids.filtered( + lambda subscription: subscription.contact_id == contact_l1 + ) + + # launch unsubscribe tour + hash_token = test_mailing._generate_mailing_recipient_token(contact_l1.id, contact_l1.email) + with freeze_time(self._reference_now): + self.start_tour( + f"/mailing/{test_mailing.id}/unsubscribe?email={self.test_email_normalized}&document_id={contact_l1.id}&hash_token={hash_token}", + "mailing_portal_unsubscribe_from_list", + login=None, + ) + + # status update check on list 1 + self.assertTrue(subscription_l1.opt_out) + self.assertEqual(subscription_l1.opt_out_datetime, self._reference_now) + self.assertEqual(subscription_l1.opt_out_reason_id, opt_out_reasons[-1]) + # status update check on list 2: unmodified (was not member, still not member) + contact_l2 = self.mailing_list_2.contact_ids.filtered( + lambda contact: contact.email == self.test_email_normalized + ) + self.assertFalse(contact_l2) + + # posted messages on contact record for mailing list 1: feedback, unsubscription + message_feedback = contact_l1.message_ids[0] + self.assertEqual( + message_feedback.body, + Markup(f'

    Feedback from {contact_l1.email_normalized}
    {test_feedback}

    ') + ) + message_unsub = contact_l1.message_ids[1] + self.assertEqual( + message_unsub.body, + Markup(f'

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

    • {self.mailing_list_1.name}
    ') + ) + + # posted messages on exclusion list record: activated, deactivated, activated again + bl_record = self.env['mail.blacklist'].search([('email', '=', contact_l1.email_normalized)]) + self.assertEqual(len(bl_record.message_ids), 2) + self.assertFalse(bl_record.opt_out_reason_id) + msg_bl, msg_create = bl_record.message_ids + self.assertEqual( + msg_bl.body, + Markup(f'

    Blocklist request from portal of mailing {test_mailing.subject} (document Mailing Contact)

    ') + ) + self.assertEqual(msg_create.body, Markup('

    Mail Blacklist created

    ')) + + def test_mailing_unsubscribe_from_list_with_update_tour(self): + """ Test portal unsubscribe on mailings performed on mailing lists. Their + effect is to opt-out from the mailing list. Optional exclusion list can + be done through interface (see tour). + + Tour effects + * unsubscribe from mailing based on lists = opt-out from lists; + * add feedback (opt-out): Other reason, with 'My feedback' feedback; + * add email to exclusion list; + * remove email from exclusion list; + * come back to List3; + * join List2 (with no feedback, as no opt-out / block list was done); + * re-add email to exclusion list; + """ + opt_out_reasons = self.env['mailing.subscription.optout'].search([]) + test_mailing = self.test_mailing_on_lists.with_env(self.env) + test_feedback = "My feedback" + + # 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 == self.test_email_normalized + ) + subscription_l1 = self.mailing_list_1.subscription_ids.filtered( + lambda subscription: subscription.contact_id == contact_l1 + ) + contact_l3 = self.mailing_list_3.contact_ids.filtered( + lambda contact: contact.email == self.test_email_normalized + ) + subscription_l3 = self.mailing_list_3.subscription_ids.filtered( + lambda subscription: subscription.contact_id == contact_l3 + ) + + # launch unsubscription tour + hash_token = test_mailing._generate_mailing_recipient_token(contact_l1.id, contact_l1.email) + with freeze_time(self._reference_now): + self.start_tour( + f"/mailing/{test_mailing.id}/unsubscribe?email={contact_l1.email}&document_id={contact_l1.id}&hash_token={hash_token}", + "mailing_portal_unsubscribe_from_list_with_update", + login=None, + ) + + # status update check on list 1 + self.assertTrue(subscription_l1.opt_out) + self.assertEqual(subscription_l1.opt_out_datetime, self._reference_now) + self.assertEqual(subscription_l1.opt_out_reason_id, opt_out_reasons[-1]) + # status update check on list 3 (opt-in during test) + self.assertFalse(subscription_l3.opt_out) + self.assertFalse(subscription_l3.opt_out_datetime) + + # posted messages on contact record for mailing list 1: subscription update, feedback, unsubscription + message_update = contact_l1.message_ids[0] + self.assertEqual( + message_update.body, + Markup(f'

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

    ' + f'
    • {self.mailing_list_2.name}
    ') + ) + message_feedback = contact_l1.message_ids[1] + self.assertEqual( + message_feedback.body, + Markup(f'

    Feedback from {contact_l1.email_normalized}
    {test_feedback}

    ') + ) + message_unsub = contact_l1.message_ids[2] + self.assertEqual( + message_unsub.body, + Markup(f'

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

    ' + f'
    • {self.mailing_list_1.name}
    ') + ) + + # posted messages on contact record for mailing list 3: subscription + message_sub = contact_l3.message_ids[0] + self.assertEqual( + message_sub.body, + Markup(f'

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

    ' + f'
    • {self.mailing_list_3.name}
    • {self.mailing_list_2.name}
    ') + ) + + # posted messages on exclusion list record: activated, deactivated, activated again, feedback + bl_record = self.env['mail.blacklist'].search([('email', '=', contact_l1.email_normalized)]) + self.assertEqual(bl_record.opt_out_reason_id, opt_out_reasons[0]) + self.assertEqual(len(bl_record.message_ids), 5) + msg_fb, msg_bl2, msg_unbl, msg_bl, msg_create = bl_record.message_ids + self.assertTracking(msg_fb, [('opt_out_reason_id', 'many2one', False, opt_out_reasons[0])]) + self.assertFalse(msg_fb.body) + self.assertEqual( + msg_bl2.body, + Markup(f'

    Blocklist request from portal of mailing {test_mailing.subject} (document Mailing Contact)

    ') + ) + self.assertEqual( + msg_unbl.body, + Markup(f'

    Blocklist removal request from portal of mailing {test_mailing.subject} (document Mailing Contact)

    ') + ) + self.assertEqual( + msg_bl.body, + Markup(f'

    Blocklist request from portal of mailing {test_mailing.subject} (document Mailing Contact)

    ') + ) + 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): Other reason, with 'My feedback' feedback; + * add email in block list; + * add feedback (as block list addition): First reason (hence no feedback); + """ + 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éboulonneur User', + signature='--\nDéboulonneur', + ) + _test_email, test_email_normalized = portal_user.email, portal_user.email_normalized + opt_out_reasons = self.env['mailing.subscription.optout'].search([]) + + # launch 'my' mailing' tour + self.authenticate(portal_user.login, portal_user.login) + with freeze_time(self._reference_now): + self.start_tour( + "/mailing/my", + "mailing_portal_unsubscribe_from_my", + login=portal_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_2.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(contact_l1.is_blacklisted) + self.assertTrue(contact_l3.is_blacklisted) + self.assertTrue(subscription_l1.opt_out) + self.assertEqual(subscription_l1.opt_out_datetime, self._reference_now, + 'Subscription: opt-outed during test, datetime should have been set') + self.assertEqual(subscription_l1.opt_out_reason_id, opt_out_reasons[-1]) + self.assertFalse(subscription_l2.opt_out) + self.assertFalse(subscription_l2.opt_out_datetime) + self.assertFalse(subscription_l2.opt_out_reason_id) + self.assertFalse(subscription_l3.opt_out) + self.assertFalse(subscription_l3.opt_out_datetime, + 'Subscription: opt-in during test, datetime should have been reset') + self.assertFalse(subscription_l3.opt_out_reason_id) + # message on contact for list 1: opt-out L1, join L2 + msg_fb, msg_sub, msg_uns = contact_l1.message_ids + self.assertEqual( + msg_fb.body, + Markup(f'

    Feedback from {portal_user.name} ({test_email_normalized})
    {test_feedback}

    ') + ) + self.assertEqual( + msg_sub.body, + Markup(f'

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

    ' + f'
    • {self.mailing_list_2.name}
    ') + ) + self.assertEqual( + msg_uns.body, + Markup(f'

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

    ' + f'
    • {self.mailing_list_1.name}
    ') + ) + # message on contact for list 2: opt-in L3 and L2 + msg_fb, msg_sub = contact_l3.message_ids + self.assertEqual( + msg_fb.body, + Markup(f'

    Feedback from {portal_user.name} ({test_email_normalized})
    {test_feedback}

    ') + ) + self.assertEqual( + msg_sub.body, + Markup(f'

    {contact_l3.name} subscribed to the following mailing list(s)

    ' + f'
    • {self.mailing_list_3.name}
    • {self.mailing_list_2.name}
    ') + ) + + # block list record created, feedback logged + bl_record = self.env['mail.blacklist'].search([('email', '=', contact_l1.email_normalized)]) + self.assertEqual(bl_record.opt_out_reason_id, opt_out_reasons[0]) + self.assertEqual(len(bl_record.message_ids), 3) + msg_fb, msg_bl, _msg_create = bl_record.message_ids + self.assertTracking(msg_fb, [('opt_out_reason_id', 'many2one', False, opt_out_reasons[0])]) + self.assertEqual(msg_bl.body, Markup('

    Blocklist request from portal

    ')) + + @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({ + 'groups_id': [(3, self.env.ref('mass_mailing.group_mass_mailing_user').id)], + }) + self.authenticate('user_marketing', 'user_marketing') + + # TEST: various invalid cases + 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_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_mid}/view?email={test_email}&document_id={test_doc_id}&hash_token={test_token}', + ) + ) + self.assertEqual(res.status_code, error_code) + + # TEST: valid call using credentials + res = self.url_open( + werkzeug.urls.url_join( + test_mailing.get_base_url(), + f'mailing/{test_mailing.id}/view?email={email_normalized}&document_id={doc_id}&hash_token={hash_token}', + ) + ) + self.assertEqual(res.status_code, 200) + + # TEST: invalid credentials but mailing user + self.user_marketing.write({ + 'groups_id': [(4, self.env.ref('mass_mailing.group_mass_mailing_user').id)], + }) + res = self.url_open( + werkzeug.urls.url_join( + test_mailing.get_base_url(), + f'mailing/{test_mailing.id}/view', + ) + ) + self.assertEqual(res.status_code, 200) + + +@tagged('link_tracker', 'mailing_portal') +class TestMailingTracking(TestMailingControllersCommon): + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mass_mailing.models.mailing') def test_tracking_short_code(self): """ Test opening short code linked to a mailing trace: should set the trace as opened and clicked, create a click record. """ - mailing = self.test_mailing.with_env(self.env) + mailing = self.test_mailing_on_lists.with_env(self.env) with self.mock_mail_gateway(mail_unlink_sent=False): mailing.action_send_mail() @@ -75,10 +655,11 @@ class TestMailingControllers(MassMailCommon, HttpCase): self.assertEqual(mailing_trace.open_datetime, self._reference_now) self.assertEqual(mailing_trace.trace_status, 'open') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mass_mailing.models.mailing') def test_tracking_url_token(self): """ Test tracking of mails linked to a mailing trace: should set the trace as opened. """ - mailing = self.test_mailing.with_env(self.env) + mailing = self.test_mailing_on_lists.with_env(self.env) with self.mock_mail_gateway(mail_unlink_sent=False): mailing.action_send_mail() @@ -104,4 +685,4 @@ class TestMailingControllers(MassMailCommon, HttpCase): f'mail/track/{mail_id_int}/fake_token/blank.gif' ) response = self.url_open(track_url) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 401) diff --git a/addons/mass_mailing/tests/test_mailing_list.py b/addons/mass_mailing/tests/test_mailing_list.py index 53514b40f35..0630e8cb3ec 100644 --- a/addons/mass_mailing/tests/test_mailing_list.py +++ b/addons/mass_mailing/tests/test_mailing_list.py @@ -87,7 +87,7 @@ class TestMailingListMerge(MassMailCommon): new = self.env['mailing.contact'].with_context(default_list_ids=default_list_ids).create([{ 'name': 'Contact_%d' % x, 'email': 'contact_%d@test.example.com' % x, - 'subscription_list_ids': [(0, 0, { + 'subscription_ids': [(0, 0, { 'list_id': self.mailing_list_1.id, 'opt_out': True, }), (0, 0, { @@ -109,15 +109,15 @@ class TestMailingListMerge(MassMailCommon): patch.object(self.env.cr, 'now', lambda: datetime(2022, 1, 1, 12, 0, 0)): contact_form = Form(self.env['mailing.contact']) contact_form.name = 'Contact_test' - with contact_form.subscription_list_ids.new() as subscription: + with contact_form.subscription_ids.new() as subscription: subscription.list_id = self.mailing_list_1 subscription.opt_out = True - with contact_form.subscription_list_ids.new() as subscription: + with contact_form.subscription_ids.new() as subscription: subscription.list_id = self.mailing_list_2 subscription.opt_out = False contact = contact_form.save() - self.assertEqual(contact.subscription_list_ids[0].unsubscription_date, datetime(2022, 1, 1, 12, 0, 0)) - self.assertFalse(contact.subscription_list_ids[1].unsubscription_date) + self.assertEqual(contact.subscription_ids[0].opt_out_datetime, datetime(2022, 1, 1, 12, 0, 0)) + self.assertFalse(contact.subscription_ids[1].opt_out_datetime) @users('user_marketing') def test_mailing_list_contact_copy_in_context_of_mailing_list(self): @@ -125,7 +125,7 @@ class TestMailingListMerge(MassMailCommon): contact_1 = MailingContact.create({ 'name': 'Sam', 'email': 'gamgee@shire.com', - 'subscription_list_ids': [(0, 0, {'list_id': self.mailing_list_3.id})], + 'subscription_ids': [(0, 0, {'list_id': self.mailing_list_3.id})], }) # Copy the contact with default_list_ids in context, which should not raise anything contact_2 = contact_1.with_context(default_list_ids=self.mailing_list_3.ids).copy() @@ -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.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.subscription'].search( + [('contact_id', '=', contact.id)] + ) + self.assertEqual(subs.list_id, ml_1 + ml_2) diff --git a/addons/mass_mailing/views/mail_blacklist_views.xml b/addons/mass_mailing/views/mail_blacklist_views.xml new file mode 100644 index 00000000000..ec0f292b063 --- /dev/null +++ b/addons/mass_mailing/views/mail_blacklist_views.xml @@ -0,0 +1,40 @@ + + + + mail.blacklist.view.tree.inherit.mailing + mail.blacklist + + + + + + + + + + mail.blacklist.view.form.inherit.mailing + mail.blacklist + + + + + + + + + + mail.blacklist.view.search + mail.blacklist + + + + + + + + + + + diff --git a/addons/mass_mailing/views/mailing_contact_subscription_views.xml b/addons/mass_mailing/views/mailing_contact_subscription_views.xml deleted file mode 100644 index 74c774f87e3..00000000000 --- a/addons/mass_mailing/views/mailing_contact_subscription_views.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - mailing.contact.subscription.view.form - mailing.contact.subscription - 10 - -
    - - - - - - -
    -
    -
    - - - mailing.contact.subscription.view.tree - mailing.contact.subscription - - - - - - - - - - - - - mailing.contact.subscription.view.search - mailing.contact.subscription - - - - - - - - -
    diff --git a/addons/mass_mailing/views/mailing_contact_views.xml b/addons/mass_mailing/views/mailing_contact_views.xml index 7c5a19dce3c..addad64f748 100644 --- a/addons/mass_mailing/views/mailing_contact_views.xml +++ b/addons/mass_mailing/views/mailing_contact_views.xml @@ -144,11 +144,12 @@ - + - + + diff --git a/addons/mass_mailing/views/mailing_menus.xml b/addons/mass_mailing/views/mailing_menus.xml index 1f6ed9c90ff..63e6f027d56 100644 --- a/addons/mass_mailing/views/mailing_menus.xml +++ b/addons/mass_mailing/views/mailing_menus.xml @@ -42,8 +42,17 @@ + + + + + + + mailing.subscription.optout.view.form + mailing.subscription.optout + 10 + +
    + + + + + + +
    +
    +
    + + + mailing.subscription.optout.view.tree + mailing.subscription.optout + + + + + + + + + + mailing.subscription.optout.view.search + mailing.subscription.optout + + + + + + + + + Optout Reasons + mailing.subscription.optout + tree,form + +

    + No data yet. +

    +
    +
    +
    diff --git a/addons/mass_mailing/views/mailing_subscription_views.xml b/addons/mass_mailing/views/mailing_subscription_views.xml new file mode 100644 index 00000000000..9c58dcf6b2f --- /dev/null +++ b/addons/mass_mailing/views/mailing_subscription_views.xml @@ -0,0 +1,79 @@ + + + + mailing.subscription.view.form + mailing.subscription + 10 + +
    + + + + + + +
    +
    +
    + + + mailing.subscription.view.tree + mailing.subscription + + + + + + + + + + + + + + + mailing.subscription.view.search + mailing.subscription + + + + + + + + + + + + + + + + + + Subscriptions + mailing.subscription + tree,form + { + 'search_default_group_by_opt_out_reason_id': 1, + 'search_default_filter_optout': 1, + } + [] + +

    + No data yet. +

    +
    +
    +
    diff --git a/addons/mass_mailing/views/mailing_templates_portal_unsubscribe.xml b/addons/mass_mailing/views/mailing_templates_portal_unsubscribe.xml index 8fa8dd490d6..3ff601d12bf 100644 --- a/addons/mass_mailing/views/mailing_templates_portal_unsubscribe.xml +++ b/addons/mass_mailing/views/mailing_templates_portal_unsubscribe.xml @@ -7,113 +7,159 @@ - - - -
    diff --git a/addons/mass_mailing_sms/__manifest__.py b/addons/mass_mailing_sms/__manifest__.py index 818721509d4..309adfb3e80 100644 --- a/addons/mass_mailing_sms/__manifest__.py +++ b/addons/mass_mailing_sms/__manifest__.py @@ -29,7 +29,7 @@ 'demo': [ 'demo/utm.xml', 'demo/mailing_list_contact.xml', - 'demo/mailing_contact_subscription.xml', + 'demo/mailing_subscription.xml', 'demo/mailing_mailing.xml', 'demo/mailing_trace.xml', ], diff --git a/addons/mass_mailing_sms/controllers/main.py b/addons/mass_mailing_sms/controllers/main.py index 858a2f0494a..59e00008b85 100644 --- a/addons/mass_mailing_sms/controllers/main.py +++ b/addons/mass_mailing_sms/controllers/main.py @@ -59,7 +59,7 @@ class MailingSMSController(http.Controller): if tocheck_number and trace: mailing_list_ids = trace.mass_mailing_id.contact_list_ids if mailing_list_ids: - subscriptions = request.env['mailing.contact.subscription'].sudo().search([ + subscriptions = request.env['mailing.subscription'].sudo().search([ ('list_id', 'in', mailing_list_ids.ids), ('contact_id.phone_sanitized', '=', tocheck_number), ]) @@ -70,7 +70,7 @@ class MailingSMSController(http.Controller): blacklist_rec._message_log( body=_('Blacklist through SMS Marketing unsubscribe (mailing ID: %s - model: %s)') % (trace.mass_mailing_id.id, trace.mass_mailing_id.mailing_model_id.display_name)) - lists_optin = request.env['mailing.contact.subscription'].sudo().search([ + lists_optin = request.env['mailing.subscription'].sudo().search([ ('contact_id.phone_sanitized', '=', tocheck_number), ('list_id', 'not in', mailing_list_ids.ids), ('opt_out', '=', False), diff --git a/addons/mass_mailing_sms/demo/mailing_contact_subscription.xml b/addons/mass_mailing_sms/demo/mailing_subscription.xml similarity index 90% rename from addons/mass_mailing_sms/demo/mailing_contact_subscription.xml rename to addons/mass_mailing_sms/demo/mailing_subscription.xml index f0671cee8cc..ec1a9da67b4 100644 --- a/addons/mass_mailing_sms/demo/mailing_contact_subscription.xml +++ b/addons/mass_mailing_sms/demo/mailing_subscription.xml @@ -1,22 +1,22 @@ - + - + - + - + - + diff --git a/addons/test_mail_full/tests/test_mass_mailing.py b/addons/test_mail_full/tests/test_mass_mailing.py index 4f5549ca069..cb302978e11 100644 --- a/addons/test_mail_full/tests/test_mass_mailing.py +++ b/addons/test_mail_full/tests/test_mass_mailing.py @@ -79,11 +79,11 @@ class TestMassMailing(TestMailFullCommon): unsubscribe_href = self._get_href_from_anchor_id(email['body'], "url6") unsubscribe_url = werkzeug.urls.url_parse(unsubscribe_href) unsubscribe_params = unsubscribe_url.decode_query().to_dict(flat=True) - self.assertEqual(int(unsubscribe_params['res_id']), recipient.id) + self.assertEqual(int(unsubscribe_params['document_id']), recipient.id) self.assertEqual(unsubscribe_params['email'], recipient.email_normalized) self.assertEqual( - mailing._generate_mailing_recipient_token(unsubscribe_params['res_id'], (unsubscribe_params['email'])), - unsubscribe_params['token'] + mailing._generate_mailing_recipient_token(unsubscribe_params['document_id'], (unsubscribe_params['email'])), + unsubscribe_params['hash_token'] ) # rendered view self.assertIn( @@ -92,11 +92,11 @@ class TestMassMailing(TestMailFullCommon): view_href = self._get_href_from_anchor_id(email['body'], "url6") view_url = werkzeug.urls.url_parse(view_href) view_params = view_url.decode_query().to_dict(flat=True) - self.assertEqual(int(view_params['res_id']), recipient.id) + self.assertEqual(int(view_params['document_id']), recipient.id) self.assertEqual(view_params['email'], recipient.email_normalized) self.assertEqual( - mailing._generate_mailing_recipient_token(view_params['res_id'], (view_params['email'])), - view_params['token'] + mailing._generate_mailing_recipient_token(view_params['document_id'], (view_params['email'])), + view_params['hash_token'] ) self.assertMailTraces( diff --git a/addons/test_mass_mailing/models/mailing_models.py b/addons/test_mass_mailing/models/mailing_models.py index e6ed803c4ba..eca2ecb18f5 100644 --- a/addons/test_mass_mailing/models/mailing_models.py +++ b/addons/test_mass_mailing/models/mailing_models.py @@ -61,6 +61,7 @@ class MailingBLacklist(models.Model): _description = 'Mailing Blacklist Enabled' _name = 'mailing.test.blacklist' _inherit = ['mail.thread.blacklist'] + _order = 'name ASC, id DESC' _primary_email = 'email_from' name = fields.Char() diff --git a/addons/test_mass_mailing/tests/test_mailing.py b/addons/test_mass_mailing/tests/test_mailing.py index dc969fbf92a..832ef476937 100644 --- a/addons/test_mass_mailing/tests/test_mailing.py +++ b/addons/test_mass_mailing/tests/test_mailing.py @@ -565,7 +565,7 @@ class TestMassMailing(TestMassMailCommon): # contact_1 is optout but same email is not optout from the same list # contact 3 is optout in list 1 but not in list 2 # contact 5 is optout - subs = self.env['mailing.contact.subscription'].search([ + subs = self.env['mailing.subscription'].search([ '|', '|', '&', ('contact_id', '=', mailing_contact_1.id), ('list_id', '=', mailing_list_1.id), '&', ('contact_id', '=', mailing_contact_3.id), ('list_id', '=', mailing_list_1.id), @@ -591,7 +591,8 @@ class TestMassMailing(TestMassMailCommon): {'email': 'test4@test.example.com'}, {'email': 'test5@test.example.com', 'trace_status': 'cancel', 'failure_type': 'mail_optout'}], mailing, - mailing_contact_1 + mailing_contact_2 + mailing_contact_3 + mailing_contact_4 + mailing_contact_5, + # mailing_contact_1 + mailing_contact_2 + mailing_contact_3 + mailing_contact_4 + mailing_contact_5, + mailing_contact_2 + mailing_contact_1 + mailing_contact_3 + mailing_contact_4 + mailing_contact_5, check_mail=True ) self.assertEqual(mailing.canceled, 2) diff --git a/addons/test_mass_mailing/tests/test_mailing_test.py b/addons/test_mass_mailing/tests/test_mailing_test.py index 5e0d8ac149a..7aa6e6ed7d4 100644 --- a/addons/test_mass_mailing/tests/test_mailing_test.py +++ b/addons/test_mass_mailing/tests/test_mailing_test.py @@ -11,18 +11,31 @@ from odoo.tools import mute_logger @tagged('mailing_manage') class TestMailingTest(TestMassMailCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_records = cls.env['mailing.test.blacklist'].create([ + { + 'email_from': f'test.mailing.{idx}@test.example.com', + 'name': f'Test Mailing {idx}', + 'user_id': cls.user_marketing.id, + } + for idx in range(5) + ]) + cls.test_mailing_bl = cls.env['mailing.mailing'].create({ + 'body_html': '

    Hello

    ', + 'mailing_domain': [('id', 'in', cls.test_records.ids)], + 'mailing_model_id': cls.env['ir.model']._get_id('mailing.test.blacklist'), + 'mailing_type': 'mail', + 'name': 'TestButton', + 'preview': 'Preview {{ object.name }}', + 'subject': 'Subject {{ object.name }}', + }) + @users('user_marketing') @mute_logger('odoo.addons.mail.models.mail_render_mixin') def test_mailing_test_button(self): - mailing = self.env['mailing.mailing'].create({ - 'name': 'TestButton', - 'subject': 'Subject {{ object.name }}', - 'preview': 'Preview {{ object.name }}', - 'state': 'draft', - 'mailing_type': 'mail', - 'body_html': '

    Hello

    ', - 'mailing_model_id': self.env['ir.model']._get('res.partner').id, - }) + mailing = self.test_mailing_bl.with_env(self.env) mailing_test = self.env['mailing.mailing.test'].create({ 'email_to': 'test@test.com', 'mass_mailing_id': mailing.id, @@ -63,25 +76,12 @@ class TestMailingTest(TestMassMailCommon): mailing_test.send_mail_test() def test_mailing_test_equals_reality(self): - """ - Check that both test and real emails will format the qweb and inline placeholders correctly in body and subject. - """ - contact_list = self.env['mailing.list'].create({ - 'name': 'Testers', - 'contact_ids': [Command.create({ - 'name': 'Mitchell Admin', - 'email': 'real@real.com', - })], - }) - - mailing = self.env['mailing.mailing'].create({ - 'name': 'TestButton', - 'subject': 'Subject {{ object.name }} ', - 'state': 'draft', - 'mailing_type': 'mail', + """ Check that both test and real emails will format the qweb and inline + placeholders correctly in body and subject. """ + mailing = self.test_mailing_bl.with_env(self.env) + mailing.write({ 'body_html': '

    Hello {{ object.name }}

    ', - 'mailing_model_id': self.env['ir.model']._get('mailing.list').id, - 'contact_list_ids': [contact_list.id], + 'subject': 'Subject {{ object.name }} ', }) mailing_test = self.env['mailing.mailing.test'].create({ 'email_to': 'test@test.com', @@ -91,8 +91,10 @@ class TestMailingTest(TestMassMailCommon): with self.mock_mail_gateway(): mailing_test.send_mail_test() - expected_subject = 'Subject Mitchell Admin ' - expected_body = 'Hello {{ object.name }} Mitchell Admin' + expected_test_record = self.env[mailing.mailing_model_real].search([], limit=1) + self.assertEqual(expected_test_record, self.test_records[0], 'Should take first found one') + expected_subject = f'Subject {expected_test_record.name} ' + expected_body = 'Hello {{ object.name }}' + f' {expected_test_record.name}' self.assertSentEmail(self.env.user.partner_id, ['test@test.com'], subject=expected_subject, @@ -103,6 +105,9 @@ class TestMailingTest(TestMassMailCommon): mailing.action_launch() self.env.ref('mass_mailing.ir_cron_mass_mailing_queue').method_direct_trigger() - self.assertSentEmail(self.env.user.partner_id, ['real@real.com'], + self.assertSentEmail( + self.env.user.partner_id, + [expected_test_record.email_from], subject=expected_subject, - body_content=expected_body) + body_content=expected_body, + ) diff --git a/addons/website_mass_mailing/controllers/main.py b/addons/website_mass_mailing/controllers/main.py index 111627cb0f2..e4f413bf2ea 100644 --- a/addons/website_mass_mailing/controllers/main.py +++ b/addons/website_mass_mailing/controllers/main.py @@ -14,7 +14,7 @@ class MassMailController(main.MassMailController): fname = self._get_fname(subscription_type) is_subscriber = False if value and fname: - contacts_count = request.env['mailing.contact.subscription'].sudo().search_count( + contacts_count = request.env['mailing.subscription'].sudo().search_count( [('list_id', 'in', [int(list_id)]), (f'contact_id.{fname}', '=', value), ('opt_out', '=', False)]) is_subscriber = contacts_count > 0 @@ -40,7 +40,7 @@ class MassMailController(main.MassMailController): 'toast_content': _("Suspicious activity detected by Google reCaptcha."), } - ContactSubscription = request.env['mailing.contact.subscription'].sudo() + ContactSubscription = request.env['mailing.subscription'].sudo() Contacts = request.env['mailing.contact'].sudo() if subscription_type == 'email': name, value = tools.parse_contact_from_email(value)