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
344 lines
15 KiB
Python
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>'),
|
|
}
|