Files
odoo_source/addons/mass_mailing/controllers/main.py
T
Thibault Delavallée be3c92d805 [REF] mass_mailing: improve portal subscription management page
PURPOSE

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

SPECIFICATIONS

Purpose of this commit is to cleanup and improve the portal subscription page
that allows to manage subscription to mailing lists and blacklist status.

Main features updated or added

  * allow to give a feedback when unsubscribing from mailing not related to
    mailing lists. It was previously limited to mailings done on mailing lists.
    Now the feedback is allowed in all cases and posts it on the related
    document;
  * clean display of opt-in and opt-out lists;
  * display all public lists, even if not already join. This allows to opt-in
    to new lists, which was not possible before;
  * switch on a neutral name for non public lists (as they may contain
    marketing hints);
  * give UI feedback to customer when using buttons: add confirmation of
    block list addition / removal, of updated subscriptions, ...
  * globally improve wording;

Unsubscribe from a document now uses the same layout as unsubscribe from
mailing lists. Indeed the first one blacklists the email while the second
one opt-outs from mailing lists. But overall form is the same and options
are also the same. After all previous cleaning we can now keep a single
page for everything.

Task-2150462 (Mass Mailing: Improve subscription management)

Part-of: odoo/odoo#86084
2023-10-06 06:13:53 +00:00

344 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from markupsafe import Markup, escape
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
from odoo import _, http, tools
from odoo.http import request, Response
from odoo.tools import consteq
class MassMailController(http.Controller):
def _check_mailing_email_token(self, mailing_id, document_id, email, hash_token):
""" Return the mailing based on given credentials, sudo-ed. Raises if
there is an issue fetching it. """
if not mailing_id or not email or not document_id or not hash_token:
raise BadRequest()
mailing_sudo = request.env['mailing.mailing'].sudo().browse(mailing_id)
if not mailing_sudo.exists():
raise NotFound()
if not consteq(mailing_sudo._generate_mailing_recipient_token(document_id, email), hash_token):
raise Unauthorized()
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))]
)
# ------------------------------------------------------------
# SUBSCRIPTION MANAGEMENT
# ------------------------------------------------------------
@http.route(['/mailing/<int:mailing_id>/unsubscribe'], type='http', website=True, auth='public')
def mailing_unsubscribe(self, mailing_id, email=None, res_id=None, token="", **post):
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, res_id, email, token)
except NotFound as e: # avoid leaking ID existence
raise Unauthorized() from e
if mailing_sudo.mailing_model_real == 'mailing.contact':
return self._mailing_unsubscribe_from_list(mailing_sudo, res_id, email, token)
return self._mailing_unsubscribe_from_document(mailing_sudo, res_id, email, token)
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)
# 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)
)
return request.render(
'mass_mailing.page_mailing_unsubscribe',
dict(
self._prepare_mailing_subscription_values(
mailing, document_id, email, hash_token
),
unsubscribed_name=lists_unsubscribed_name,
)
)
def _mailing_unsubscribe_from_document(self, mailing, document_id, email, hash_token):
message = Markup(
_(
'Blocklist request from unsubscribe link of mailing %(mailing_link)s (document %(record_link)s)',
**self._format_bl_request(mailing, document_id)
)
)
_blocklist_rec = request.env['mail.blacklist'].sudo()._add(email, message=message)
return request.render(
'mass_mailing.page_mailing_unsubscribe',
dict(
self._prepare_mailing_subscription_values(
mailing, document_id, email, hash_token
),
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)
# as there may be several contacts / email -> consider any opt-in overrides
# opt-out
contacts = self._fetch_contacts(email)
lists_optin = contacts.subscription_list_ids.filtered(
lambda sub: not sub.opt_out
).list_id
lists_optout = contacts.subscription_list_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,
# blocklist
'blocklist_enabled': bool(
request.env['ir.config_parameter'].sudo().get_param(
'mass_mailing.show_blacklist_buttons',
default=True,
)
),
'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_list_ids.list_id,
'lists_optin': lists_optin,
'lists_optout': lists_optout,
'lists_public': lists_public,
}
@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):
try:
_mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
except BadRequest:
return 'error'
except (NotFound, Unauthorized):
return 'unauthorized'
contacts = self._fetch_contacts(email)
lists_optin = request.env['mailing.list'].sudo().browse(lists_optin_ids or []).exists()
# opt-out all not chosen lists
lists_to_optout = contacts.subscription_list_ids.filtered(
lambda sub: not sub.opt_out and sub.list_id not in lists_optin
).list_id
# opt-in in either already member, either public (to avoid trying to opt-in
# in private lists)
lists_to_optin = lists_optin.filtered(
lambda mlist: mlist.is_public or mlist in contacts.list_ids
)
lists_to_optout._update_subscription_from_email(email, opt_out=True)
lists_to_optin._update_subscription_from_email(email, opt_out=False)
return True
@http.route('/mailing/feedback', type='json', auth='public', csrf=True)
def mailing_send_feedback(self, mailing_id=None, document_id=None,
email=None, hash_token=None,
feedback=None, **post):
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
except BadRequest:
return 'error'
except (NotFound, Unauthorized):
return 'unauthorized'
model = request.env[mailing_sudo.mailing_model_real]
records = model.sudo().search([('email_normalized', '=', tools.email_normalize(email))])
for record in records:
record.sudo().message_post(body=_("Feedback from %(email)s: %(feedback)s", email=email, feedback=feedback))
return bool(records)
@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 BadRequest()
# ------------------------------------------------------------
# TRACKING
# ------------------------------------------------------------
@http.route('/mail/track/<int:mail_id>/<string:token>/blank.gif', type='http', auth='public')
def track_mail_open(self, mail_id, token, **post):
""" Email tracking. """
expected_token = request.env['mail.mail']._generate_mail_recipient_token(mail_id)
if not consteq(token, expected_token):
raise Unauthorized()
request.env['mailing.trace'].sudo().set_opened(domain=[('mail_mail_id_int', 'in', [mail_id])])
response = Response()
response.mimetype = 'image/gif'
response.data = base64.b64decode(b'R0lGODlhAQABAIAAANvf7wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==')
return response
@http.route('/r/<string:code>/m/<int:mailing_trace_id>', type='http', auth="public")
def full_url_redirect(self, code, mailing_trace_id, **post):
request.env['link.tracker.click'].sudo().add_click(
code,
ip=request.httprequest.remote_addr,
country_code=request.geoip.country_code,
mailing_trace_id=mailing_trace_id
)
redirect_url = request.env['link.tracker'].get_url_from_code(code)
if not redirect_url:
raise NotFound()
return request.redirect(redirect_url, code=301, local=False)
# ------------------------------------------------------------
# MAILING MANAGEMENT
# ------------------------------------------------------------
@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 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/<int:mailing_id>/view'], type='http', website=True, auth='public')
def mailing_view_in_browser(self, mailing_id, email=None, res_id=None, token=""):
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, res_id, email, token)
except NotFound as e:
raise Unauthorized() from e
except (BadRequest, Unauthorized):
if not request.env.user.has_group('mass_mailing.group_mass_mailing_user'):
raise
mailing_sudo = request.env['mailing.mailing'].sudo().browse(mailing_id).exists()
if not mailing_sudo:
raise
# do not force lang, will simply use user context
res_id = int(res_id) if res_id and res_id.isdigit() else 0
html_markupsafe = mailing_sudo._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_sudo._get_unsubscribe_url(email, res_id)
)
return request.render(
'mass_mailing.mailing_view',
{
'body': html_markupsafe,
},
)
# ------------------------------------------------------------
# BLACKLIST
# ------------------------------------------------------------
@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):
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
except BadRequest:
return 'error'
except (NotFound, Unauthorized):
return 'unauthorized'
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('<p>%s</p>') % _('Blocklist request from portal')
_blocklist_rec = request.env['mail.blacklist'].sudo()._add(email, message=message)
return True
@http.route('/mailing/blocklist/remove', type='json', auth='public')
def mail_blocklist_remove(self, mailing_id=None, document_id=None,
email=None, hash_token=None):
try:
mailing_sudo = self._check_mailing_email_token(mailing_id, document_id, email, hash_token)
except BadRequest:
return 'error'
except (NotFound, Unauthorized):
return 'unauthorized'
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('<p>%s</p>') % _('Blocklist removal request from portal')
_blocklist_rec = request.env['mail.blacklist'].sudo()._remove(email, 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'<a href="#" data-oe-model="mailing.mailing" data-oe-id="{mailing.id}">{escape(mailing.subject)}</a>'),
'record_link': Markup(f'<a href="#" data-oe-model="{escape(mailing.mailing_model_real)}" data-oe-id="{int(document_id)}">{escape(mailing_model_name)}</a>'),
}