[MERGE][IMP] mass_mailing: improve logging, fix assets, cleanup code bits

PURPOSE

Fix assets in mass mailing (unsubscription page is broken and assets usage is
a complete mess). Improve blocklist logging. Update tools methods, controllers
and templates naming to ease future improvements and refactoring.

SPECIFICATIONS

[FIX] mass_mailing: use frontend assets in subscription page

Unsubscribe page uses a custom layout allowing a portal-like behavior for
mass mailing without any link to website or portal. However it currently
uses the complete backend assets. This leads to a lot of unnecessary css
and js being loaded.

When having frontend pages like the one used in mass mailing it should rely
on frontend assets with some custom assets if necessary for the page to
be correctly displayed.

In this commit we choose to use frontend assets like other portal-like pages.
Mailing layout now uses the frontend layout defined in web and adds its own
subset of assets (mailing_assets). Some custom css is moved or added as well
as few tweaks to ensure layouting is globally the same as before this commit.

[IMP] mail, phone_validation: allow to log a message when (un)blacklisting

Add possibility to add a message when adding or removing a blacklist entry
for both mail and phone_validation (used for SMS). This replaces the
``action_remove_with_reason`` method.

When updating active flag this is added as a tracking note to avoid having
several messages. Indeed message is concatenated with the tracking itself
instead of adding message for tracking + a message for the log itself. When
a new record is created, a note is logged.

Use it in mass mailing.

[REF] mass_mailing: rename templates and controller methods

Rename templates to better understand their purpose. Current naming is not
crystal clear.

Rename controller methods in order to be clearer and more aligned with current
guidelines. Notably namespace by mailing, try to be clearer about their
purpose, ...

Add a helper field to know when a mailing runs on mailing lists as it triggers
some specific behavior.

In this commit we rename methods generating tokens (for recipients and report
management). Otherwise it is hard to distinguish them as naming is not really
convenient to use

  * _unsubscribe_token -> was actually generating token for a given recipient
    on a given mailing;
  * _get_unsubscribe_token -> was actually generating token for mailing
    users to deactivate D+1 KPI reporting;

Hash for mail tracking is also moved in its own method to avoid having the
same code twice. Better delegate it to a tool sub method.

Rename main routes, and keep a legacy for the main unsubscribe route to
keep retro compatibility for existing mailings.

[IMP] mass_mailing: show blacklist button by default on portal

We now consider being able to blacklist their email is a default behavior for
users being redirected to the subscription management portal page.

LINKS

Task-3082546 (Mass Mailing: Fix assets and unsubscribe page)
Task-2710804 (Mail: Clean MailThread API)
Prepares Task-2669037 (Mass Mailing: Subscription page code cleaning)
Prepares Task-2150462 (Mass Mailing: Unsubscribe flow refactoring)

closes odoo/odoo#106568

Related: odoo/upgrade#4077
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2022-11-25 18:55:22 +01:00
committed by GitHub
22 changed files with 272 additions and 173 deletions
+16 -9
View File
@@ -69,29 +69,36 @@ class MailBlackList(models.Model):
new_args = args
return super(MailBlackList, self)._search(new_args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
def _add(self, email):
def _add(self, email, message=None):
normalized = tools.email_normalize(email)
record = self.env["mail.blacklist"].with_context(active_test=False).search([('email', '=', normalized)])
if len(record) > 0:
if message:
record._track_set_log_message(message)
record.action_unarchive()
else:
record = self.create({'email': email})
if message:
record.with_context(mail_create_nosubscribe=True).message_post(
body=message,
subtype_id=self.env.ref('mail.mt_note').id,
)
return record
def action_remove_with_reason(self, email, reason=None):
record = self._remove(email)
if reason:
record.message_post(body=_("Unblacklisting Reason: %s", reason))
return record
def _remove(self, email):
def _remove(self, email, message=None):
normalized = tools.email_normalize(email)
record = self.env["mail.blacklist"].with_context(active_test=False).search([('email', '=', normalized)])
if len(record) > 0:
if message:
record._track_set_log_message(message)
record.action_archive()
else:
record = record.create({'email': email, 'active': False})
if message:
record.with_context(mail_create_nosubscribe=True).message_post(
body=message,
subtype_id=self.env.ref('mail.mt_note').id,
)
return record
def mail_action_blacklist_remove(self):
+5 -5
View File
@@ -189,11 +189,11 @@ class Users(models.Model):
super(Users, self)._deactivate_portal_user(**post)
for user in users_to_blacklist:
blacklist = self.env['mail.blacklist']._add(user.email)
blacklist._message_log(
body=_('Blocked by deletion of portal account %(portal_user_name)s by %(user_name)s (#%(user_id)s)',
user_name=current_user.name, user_id=current_user.id,
portal_user_name=user.name),
self.env['mail.blacklist']._add(
user.email,
message=_('Blocked by deletion of portal account %(portal_user_name)s by %(user_name)s (#%(user_id)s)',
user_name=current_user.name, user_id=current_user.id,
portal_user_name=user.name)
)
# ------------------------------------------------------------
+10 -2
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
from markupsafe import Markup
from odoo import fields, models, _
class MailBlacklistRemove(models.TransientModel):
@@ -11,4 +12,11 @@ class MailBlacklistRemove(models.TransientModel):
reason = fields.Char(name="Reason")
def action_unblacklist_apply(self):
return self.env['mail.blacklist'].action_remove_with_reason(self.email, self.reason)
if self.reason:
message = Markup('<p>%s</p>') % _("Unblock Reason: %(reason)s", reason=self.reason)
else:
message = None
return self.env['mail.blacklist']._remove(
self.email,
message=message,
)
+5 -2
View File
@@ -4,7 +4,7 @@
{
'name': 'Email Marketing',
'summary': 'Design, send and track emails',
'version': '2.5',
'version': '2.6',
'sequence': 60,
'website': 'https://www.odoo.com/app/email-marketing',
'category': 'Marketing/Email Marketing',
@@ -85,6 +85,10 @@
],
'application': True,
'assets': {
'mass_mailing.mailing_assets': [
'mass_mailing/static/src/scss/mailing_portal.scss',
'mass_mailing/static/src/js/mailing_portal.js',
],
'web.assets_backend': [
'mass_mailing/static/src/scss/mailing_filter_widget.scss',
'mass_mailing/static/src/scss/mass_mailing.scss',
@@ -98,7 +102,6 @@
'mass_mailing/static/src/js/mass_mailing_mobile_preview.js',
'mass_mailing/static/src/js/mass_mailing_html_field.js',
'mass_mailing/static/src/js/mailing_mailing_view_form_full_width.js',
'mass_mailing/static/src/js/mailing_portal.js',
'mass_mailing/static/src/xml/mailing_filter_widget.xml',
'mass_mailing/static/src/xml/mass_mailing.xml',
'mass_mailing/static/src/views/*.xml',
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import legacy
from . import main
+15
View File
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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/<int:mailing_id>/unsubscribe'], type='http', website=True, auth='public')
def mailing_unsubscribe(self, mailing_id, **post):
return request.redirect(
f'/mailing/{mailing_id}/unsubscribe?{keep_query("*")}'
)
+68 -42
View File
@@ -7,32 +7,29 @@ import werkzeug
from odoo import _, exceptions, http, tools
from odoo.http import request
from odoo.tools import consteq
from markupsafe import Markup, escape
from werkzeug.exceptions import BadRequest, NotFound
class MassMailController(http.Controller):
def _valid_unsubscribe_token(self, mailing_id, res_id, email, token):
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._unsubscribe_token(res_id, email), token)
def _log_blacklist_action(self, blacklist_entry, mailing_id, description):
mailing = request.env['mailing.mailing'].sudo().browse(mailing_id)
model_display = mailing.mailing_model_id.display_name
blacklist_entry._message_log(body=description + " ({})".format(model_display))
return consteq(mailing._generate_mailing_recipient_token(res_id, email), token)
# ------------------------------------------------------------
# SUBSCRIPTION MANAGEMENT
# ------------------------------------------------------------
@http.route(['/mail/mailing/<int:mailing_id>/unsubscribe'], type='http', website=True, auth='public')
def mailing(self, mailing_id, email=None, res_id=None, token="", **post):
@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):
mailing = request.env['mailing.mailing'].sudo().browse(mailing_id)
if mailing.exists():
res_id = res_id and int(res_id)
if not self._valid_unsubscribe_token(mailing_id, res_id, email, str(token)):
if not self._check_mailing_email_token(mailing_id, res_id, email, str(token)):
raise exceptions.AccessDenied()
if mailing.mailing_model_real == 'mailing.contact':
@@ -51,7 +48,7 @@ class MassMailController(http.Controller):
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_unsubscribe', {
return request.render('mass_mailing.page_mailing_unsubscribe', {
'contacts': contacts,
'list_ids': list_ids,
'opt_out_list_ids': opt_out_list_ids,
@@ -66,11 +63,16 @@ class MassMailController(http.Controller):
('contact_id.email_normalized', '=', email),
('opt_out', '=', False)
]).mapped('list_id')
blacklist_rec = request.env['mail.blacklist'].sudo()._add(email)
self._log_blacklist_action(
blacklist_rec, mailing_id,
_("""Requested blacklisting via unsubscribe link."""))
return request.render('mass_mailing.page_unsubscribed', {
message = Markup('<p>%s</p>') % Markup(
_(
'Blocklist request from unsubscribe link of mailing %(mailing_link)s (document %(record_link)s)',
**self._format_bl_request(mailing, res_id)
)
)
_blacklist_rec = request.env['mail.blacklist'].sudo()._add(email, message=message)
return request.render('mass_mailing.page_mailing_unsubscribe_done', {
'email': email,
'mailing_id': mailing_id,
'res_id': res_id,
@@ -80,11 +82,11 @@ class MassMailController(http.Controller):
})
return request.redirect('/web')
@http.route('/mail/mailing/unsubscribe', type='json', auth='public')
def unsubscribe(self, mailing_id, opt_in_ids, opt_out_ids, email, res_id, token):
@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._valid_unsubscribe_token(mailing_id, res_id, email, token):
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)
@@ -92,10 +94,10 @@ class MassMailController(http.Controller):
return 'error'
@http.route('/mailing/feedback', type='json', auth='public')
def send_feedback(self, mailing_id, res_id, email, feedback, token):
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._valid_unsubscribe_token(mailing_id, res_id, email, token):
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))])
@@ -105,7 +107,7 @@ class MassMailController(http.Controller):
return 'error'
@http.route(['/unsubscribe_from_list'], type='http', website=True, multilang=False, auth='public', sitemap=False)
def unsubscribe_placeholder_link(self, **post):
def mailing_unsubscribe_placeholder_link(self, **post):
"""Dummy route so placeholder is not prefixed by language, MUST have multilang=False"""
raise werkzeug.exceptions.NotFound()
@@ -116,7 +118,8 @@ class MassMailController(http.Controller):
@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. """
if not consteq(token, tools.hmac(request.env(su=True), 'mass_mailing-mail_mail-open', mail_id)):
mail = request.env['mail.mail'].sudo().browse(mail_id).exists()
if not mail or not consteq(token, mail._generate_mail_recipient_token()):
raise BadRequest()
request.env['mailing.trace'].sudo().set_opened(domain=[('mail_mail_id_int', 'in', [mail_id])])
@@ -145,11 +148,11 @@ class MassMailController(http.Controller):
# ------------------------------------------------------------
@http.route('/mailing/report/unsubscribe', type='http', website=True, auth='public')
def turn_off_mailing_reports(self, token, user_id):
def mailing_report_deactivate(self, token, user_id):
if not token or not user_id:
raise werkzeug.exceptions.NotFound()
user_id = int(user_id)
correct_token = consteq(token, request.env['mailing.mailing']._get_unsubscribe_token(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)
@@ -160,11 +163,11 @@ class MassMailController(http.Controller):
raise werkzeug.exceptions.NotFound()
@http.route(['/mailing/<int:mailing_id>/view'], type='http', website=True, auth='public')
def view(self, mailing_id, email=None, res_id=None, token=""):
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._valid_unsubscribe_token(mailing_id, res_id, email, str(token)) and not request.env.user.has_group('mass_mailing.group_mass_mailing_user'):
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()
# do not force lang, will simply use user context
@@ -178,7 +181,7 @@ class MassMailController(http.Controller):
html_markupsafe = html_markupsafe.replace('/unsubscribe_from_list',
mailing._get_unsubscribe_url(email, res_id))
return request.render('mass_mailing.view', {
return request.render('mass_mailing.mailing_view', {
'body': html_markupsafe,
})
@@ -189,8 +192,8 @@ class MassMailController(http.Controller):
# ------------------------------------------------------------
@http.route('/mailing/blacklist/check', type='json', auth='public')
def blacklist_check(self, mailing_id, res_id, email, token):
if not self._valid_unsubscribe_token(mailing_id, res_id, email, token):
def mail_blacklist_check(self, mailing_id, res_id, email, token):
if not self._check_mailing_email_token(mailing_id, res_id, email, token):
return 'unauthorized'
if email:
record = request.env['mail.blacklist'].sudo().with_context(active_test=False).search([('email', '=', tools.email_normalize(email))])
@@ -200,29 +203,52 @@ class MassMailController(http.Controller):
return 'error'
@http.route('/mailing/blacklist/add', type='json', auth='public')
def blacklist_add(self, mailing_id, res_id, email, token):
if not self._valid_unsubscribe_token(mailing_id, res_id, email, token):
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:
blacklist_rec = request.env['mail.blacklist'].sudo()._add(email)
self._log_blacklist_action(
blacklist_rec, mailing_id,
_("""Requested blacklisting via unsubscription page."""))
if mailing_id and res_id:
mailing_sudo = request.env['mailing.mailing'].sudo().browse(mailing_id)
message = Markup('<p>%s</p>') % Markup(
_(
'Blocklist request from portal of mailing %(mailing_link)s (document %(record_link)s)',
**self._format_bl_request(mailing_sudo, res_id)
)
)
else:
message = Markup('<p>%s</p>') % _('Blocklist request from portal')
_blacklist_rec = request.env['mail.blacklist'].sudo()._add(email, message=message)
return True
return 'error'
@http.route('/mailing/blacklist/remove', type='json', auth='public')
def blacklist_remove(self, mailing_id, res_id, email, token):
if not self._valid_unsubscribe_token(mailing_id, res_id, email, token):
def mail_blacklist_remove(self, mailing_id, res_id, email, token):
if not self._check_mailing_email_token(mailing_id, res_id, email, token):
return 'unauthorized'
if email:
blacklist_rec = request.env['mail.blacklist'].sudo()._remove(email)
self._log_blacklist_action(
blacklist_rec, mailing_id,
_("""Requested de-blacklisting via unsubscription page."""))
if mailing_id and res_id:
mailing_sudo = request.env['mailing.mailing'].sudo().browse(mailing_id)
message = Markup('<p>%s</p>') % 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('<p>%s</p>') % _('Blocklist removal request from portal')
_blacklist_rec = request.env['mail.blacklist'].sudo()._remove(email, message=message)
return True
return 'error'
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>'),
}
# ------------------------------------------------------------
# MISCELLANEOUS
# ------------------------------------------------------------
@@ -5,5 +5,9 @@
<function model="ir.config_parameter"
name="set_param"
eval="('mass_mailing.mass_mailing_reports', 'True')"/>
<!-- Display blacklist button to customers on portal page -->
<function model="ir.config_parameter"
name="set_param"
eval="('mass_mailing.show_blacklist_buttons', True)"/>
</data>
</odoo>
+9 -2
View File
@@ -25,8 +25,15 @@ class MailMail(models.Model):
return mails
def _get_tracking_url(self):
token = tools.hmac(self.env(su=True), 'mass_mailing-mail_mail-open', self.id)
return werkzeug.urls.url_join(self.get_base_url(), 'mail/track/%s/%s/blank.gif' % (self.id, token))
token = self._generate_mail_recipient_token()
return werkzeug.urls.url_join(
self.get_base_url(),
f'mail/track/{self.id}/{token}/blank.gif'
)
def _generate_mail_recipient_token(self):
self.ensure_one()
return tools.hmac(self.env(su=True), 'mass_mailing-mail_mail-open', self.id)
def _send_prepare_body(self):
""" Override to add the tracking URL to the body and to add
+6 -4
View File
@@ -2,8 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from markupsafe import Markup
from odoo import api, models, fields, tools
from odoo import api, models, fields, tools, _
BLACKLIST_MAX_BOUNCED_LIMIT = 5
@@ -59,9 +60,10 @@ class MailThread(models.AbstractModel):
stats = self.env['mailing.trace'].search(['&', '&', ('trace_status', '=', 'bounce'), ('write_date', '>', three_months_ago), ('email', '=ilike', bounced_email)]).mapped('write_date')
if len(stats) >= BLACKLIST_MAX_BOUNCED_LIMIT and (not bounced_partner or any(p.message_bounce >= BLACKLIST_MAX_BOUNCED_LIMIT for p in bounced_partner)):
if max(stats) > min(stats) + datetime.timedelta(weeks=1):
blacklist_rec = self.env['mail.blacklist'].sudo()._add(bounced_email)
blacklist_rec._message_log(
body='This email has been automatically blacklisted because of too much bounced.')
self.env['mail.blacklist'].sudo()._add(
bounced_email,
message=Markup('<p>%s</p>') % _('This email has been automatically added in blocklist because of too much bounced.')
)
@api.model
def message_new(self, msg_dict, custom_values=None):
+25 -19
View File
@@ -53,7 +53,7 @@ class MassMailing(models.Model):
})
if 'contact_list_ids' in fields_list and not vals.get('contact_list_ids') and vals.get('mailing_model_id'):
if vals.get('mailing_model_id') == self.env['ir.model']._get('mailing.list').id:
if vals.get('mailing_model_id') == self.env['ir.model']._get_id('mailing.list'):
mailing_list = self.env['mailing.list'].search([], limit=2)
if len(mailing_list) == 1:
vals['contact_list_ids'] = [(6, 0, [mailing_list.id])]
@@ -154,6 +154,9 @@ class MassMailing(models.Model):
mailing_model_name = fields.Char(
string='Recipients Model Name',
related='mailing_model_id.model', readonly=True, related_sudo=True)
mailing_on_mailing_list = fields.Boolean(
string='Based on Mailing Lists',
compute='_compute_mailing_on_mailing_list')
mailing_domain = fields.Char(
string='Domain',
compute='_compute_mailing_domain', readonly=False, store=True)
@@ -383,6 +386,12 @@ class MassMailing(models.Model):
for mailing in self:
mailing.mailing_model_real = 'mailing.contact' if mailing.mailing_model_id.model == 'mailing.list' else mailing.mailing_model_id.model
@api.depends('mailing_model_id')
def _compute_mailing_on_mailing_list(self):
mailing_list_model_id = self.env['ir.model']._get('mailing.list')
self.mailing_on_mailing_list = False
self.filtered(lambda m: m.mailing_model_id == mailing_list_model_id).mailing_on_mailing_list = True
@api.depends('mailing_model_id', 'contact_list_ids', 'mailing_type', 'mailing_filter_id')
def _compute_mailing_domain(self):
for mailing in self:
@@ -1008,12 +1017,12 @@ class MassMailing(models.Model):
def _get_unsubscribe_url(self, email_to, res_id):
url = werkzeug.urls.url_join(
self.get_base_url(), 'mail/mailing/%(mailing_id)s/unsubscribe?%(params)s' % {
self.get_base_url(), 'mailing/%(mailing_id)s/unsubscribe?%(params)s' % {
'mailing_id': self.id,
'params': werkzeug.urls.url_encode({
'res_id': res_id,
'email': email_to,
'token': self._unsubscribe_token(res_id, email_to),
'token': self._generate_mailing_recipient_token(res_id, email_to),
}),
}
)
@@ -1026,7 +1035,7 @@ class MassMailing(models.Model):
'params': werkzeug.urls.url_encode({
'res_id': res_id,
'email': email_to,
'token': self._unsubscribe_token(res_id, email_to),
'token': self._generate_mailing_recipient_token(res_id, email_to),
}),
}
)
@@ -1158,7 +1167,7 @@ class MassMailing(models.Model):
** mailing._prepare_statistics_email_values(),
}
if mail_user.has_group('mass_mailing.group_mass_mailing_user'):
rendering_data['mailing_report_token'] = self._get_unsubscribe_token(mail_user.id)
rendering_data['mailing_report_token'] = self._generate_mailing_report_token(mail_user.id)
rendering_data['user_id'] = mail_user.id
rendered_body = self.env['ir.qweb']._render(
@@ -1258,8 +1267,8 @@ class MassMailing(models.Model):
def _get_pretty_mailing_type(self):
return _('Emails')
def _get_unsubscribe_token(self, user_id):
"""Generate a secure hash for this user. It allows to opt out from
def _generate_mailing_report_token(self, user_id):
"""Generate a secure token for this user. It allows to opt out from
mailing reports while keeping some security in that process. """
return tools.hmac(self.env(su=True), 'mailing-report-deactivated', user_id)
@@ -1285,21 +1294,18 @@ class MassMailing(models.Model):
mailing_domain = [('id', 'in', [])]
return mailing_domain
def _unsubscribe_token(self, res_id, email):
"""Generate a secure hash for this mailing list and parameters.
def _generate_mailing_recipient_token(self, document_id, email):
"""Generate a secure token for a given mailing and recipient (based on
their email). This allows notably to unsubscribe from the mailing or
to blacklist their email entirely without need of a user account.
This is appended to the unsubscription URL and then checked at
unsubscription time to ensure no malicious unsubscriptions are
performed.
:param int res_id:
ID of the resource that will be unsubscribed.
:param str email:
Email of the resource that will be unsubscribed.
:param int document_id: ID of the business document on which mailing
is performed;
:param str email: recipient email, used to unsubscribe / blacklist;
"""
self.ensure_one()
secret = self.env["ir.config_parameter"].sudo().get_param("database.secret")
token = (self.env.cr.dbname, self.id, int(res_id), tools.ustr(email))
token = (self.env.cr.dbname, self.id, int(document_id), tools.ustr(email))
return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha512).hexdigest()
def _convert_inline_images_to_urls(self, body_html):
@@ -71,7 +71,7 @@ odoo.define('mass_mailing.unsubscribe', function (require) {
unchecked_ids[i] = parseInt($(this).val());
});
ajax.jsonRpc('/mail/mailing/unsubscribe', 'call', {'opt_in_ids': checked_ids, 'opt_out_ids': unchecked_ids, 'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token})
ajax.jsonRpc('/mailing/list/update', 'call', {'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!'));
@@ -0,0 +1,16 @@
.o_mailing_portal_body {
// Taken from webclient.scss to copy backend styling
// Uppercase primary and secondary except when secondary is used as a dropdown
// toggler.
.btn-primary,
.btn-secondary:not(.dropdown-toggle):not(.dropdown-item),
.btn-secondary.o_arrow_button:not(.dropdown-item) {
text-transform: uppercase;
}
.o_mailing_portal_list_unsubscribed {
margin-left: 20px;
color: #005326;
font-size: 90%;
}
}
@@ -210,15 +210,6 @@
bottom: 55px;
}
}
.o_white_body {
background-color: white;
}
.o_mass_mailing_unsubscribed {
margin-left: 20px;
color: #005326;
font-size: 90%;
}
.o_mailing_contact_import_list {
@@ -194,14 +194,14 @@
<field name="mailing_model_id" options="{'no_open': True, 'no_create': True}"
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
</div>
<div attrs="{'invisible': [('mailing_model_name', '!=', 'mailing.list')]}" class="o_mass_mailing_contact_list_ids pt-1">
<div attrs="{'invisible': [('mailing_on_mailing_list', '=', False)]}" class="o_mass_mailing_contact_list_ids pt-1">
<label for="contact_list_ids" string="Select mailing lists:" class="oe_edit_only"/>
<div class="d-inline-flex flex-row align-items-center">
<field name="contact_list_ids" widget="many2many_tags"
placeholder="Select mailing lists..." class="oe_inline mb-0"
context="{'form_view_ref': 'mass_mailing.mailing_list_view_form_simplified'}"
attrs="{
'required':[('mailing_model_name','=','mailing.list')],
'required':[('mailing_on_mailing_list', '=', True)],
'readonly': [('state', 'in', ('sending', 'done'))]
}"/>
<button icon="fa-user-plus" type="object" class="btn btn-secondary py-0 px-1 ms-1"
@@ -209,7 +209,7 @@
name="action_view_mailing_contacts" title="Add Mailing Contacts"/>
</div>
</div>
<div attrs="{'invisible': [('mailing_model_name', '=', 'mailing.list')]}" class="o_td_label">
<div attrs="{'invisible': [('mailing_on_mailing_list', '=', True)]}" class="o_td_label">
<!-- We don't want to display label in edit mode, unless mailing is in sending or done state (where filter will be readonly) -->
<label for="mailing_filter_id" string="Filter" class="oe_read_only me-4"
attrs="{'invisible': ['|', ('state', 'in', ('sending', 'done')), ('mailing_filter_id', '=', False)]}"/>
@@ -225,8 +225,9 @@
</div>
<field name="mailing_model_name" invisible="1"/>
<field name="mailing_on_mailing_list" invisible="1"/>
<field name="mailing_model_real" invisible="1"/>
<div attrs="{'invisible': [('mailing_model_name', '=', 'mailing.list')]}">
<div attrs="{'invisible': [('mailing_on_mailing_list', '=', True)]}">
<field name="mailing_domain" widget="domain" options="{'model': 'mailing_model_real'}"
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
</div>
@@ -483,7 +484,7 @@
<field name='failed'/>
<field name='total'/>
<field name='mailing_model_id'/>
<field name='mailing_model_name'/>
<field name='mailing_on_mailing_list'/>
<field name='sent_date'/>
<field name='schedule_date'/>
<field name='next_departure'/>
@@ -536,8 +537,8 @@
<div>
<span attrs="{'invisible': [('sent_date', '=', False)]}" class="me-1"><b><field name="delivered"/> / <field name="expected"/></b> Delivered to</span>
<span attrs="{'invisible': [('sent_date', '!=', False)]}" class="me-1"><b><field name='total'/></b></span>
<field name='mailing_model_id' attrs="{'invisible': [('mailing_model_name','=','mailing.list')]}"/>
<span attrs="{'invisible': [('mailing_model_name','!=','mailing.list')]}">Mailing Contact</span>
<field name='mailing_model_id' attrs="{'invisible': [('mailing_on_mailing_list', '=', True)]}"/>
<span attrs="{'invisible': [('mailing_on_mailing_list', '=', False)]}">Mailing Contact</span>
</div>
<div attrs="{'invisible': [('sent_date', '=', False)]}" class="d-flex justify-content-between">
<div name="stat_opened">
@@ -567,8 +568,8 @@
<span attrs="{'invisible': ['|', '|', ('sent_date', '!=', False), ('schedule_date', '!=', False), ('state', '=', 'in_queue')]}"
class="clearfix">
<b><field name='total' class="me-1"/></b>
<field name='mailing_model_id' attrs="{'invisible': [('mailing_model_name','=','mailing.list')]}"/>
<span attrs="{'invisible': [('mailing_model_name','!=','mailing.list')]}">Mailing Contact</span>
<field name='mailing_model_id' attrs="{'invisible': [('mailing_on_mailing_list', '=', True)]}"/>
<span attrs="{'invisible': [('mailing_on_mailing_list', '=', False)]}">Mailing Contact</span>
</span>
<span attrs="{'invisible': ['|', '|', ('schedule_date', '!=', False), ('state', '!=', 'in_queue'), ('next_departure', '=', False)]}"
t-attf-title="Scheduled on #{record.next_departure.value}" class="d-inline-flex">
@@ -2,18 +2,8 @@
<odoo>
<!-- new layout for mass_mailing -->
<template id="mass_mailing.layout" name="Mass Mailing Layout">
<t t-call="web.layout">
<t t-set="head">
<t t-if="request.httprequest.cookies.get('color_scheme') == 'dark'">
<t t-call-assets="web.dark_mode_assets_common"/>
<t t-call-assets="web.dark_mode_assets_backend"/>
</t>
<t t-else="">
<t t-call-assets="web.assets_common"/>
<t t-call-assets="web.assets_backend"/>
</t>
</t>
<body class="o_white_body">
<t t-call="web.frontend_layout">
<body class="bg-white o_mailing_portal_body">
<header>
<div><title>Odoo</title></div>
<div class="text-center">
@@ -25,16 +15,11 @@
<t t-out="0"/>
</main>
</body>
<xpath expr="//footer" position="replace">
<div class="container mt16 mb8">
<div class="float-end" t-ignore="true" t-if="not editable">
Create a <a target="_blank" href="https://www.odoo.com/app/website">free website</a> with
<a target="_blank" class="label label-danger" href="https://www.odoo.com/app/website">Odoo</a>
</div>
<div class="float-start text-muted" itemscope="itemscope" itemtype="https://schema.org/Organization">
Copyright &amp;copy; <span t-field="res_company.name" itemprop="name">Company name</span>
</div>
</div>
<xpath expr="//head/t[@t-call-assets][last()]" position="after">
<t t-call-assets="mass_mailing.mailing_assets" lazy_load="True"/>
</xpath>
<xpath expr="//header" position="before">
<t t-set="no_header" t-value="True"/>
</xpath>
</t>
</template>
@@ -25,7 +25,7 @@
</template>
<!-- Dummy layout to "view" a template content (html) -->
<template id="view" name="Browser View">
<template id="mailing_view" name="Browser View">
&lt;!DOCTYPE html&gt;
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="unsubscribe">
<template id="page_mailing_unsubscribe" name="Unsubscribe">
<t t-call="mass_mailing.layout">
<t t-call="mass_mailing.unsubscribe_form"/>
</t>
</template>
<template id="unsubscribe_form">
<div class="container o_unsubscribe_form">
<div class="row">
<form action="/mail/mailing/unsubscribe" method="POST" id="unsubscribe_form" class="col-lg-6 offset-lg-3 mt-4">
<form action="/mailing/list/update" method="POST" id="unsubscribe_form" class="col-lg-6 offset-lg-3 mt-4">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="email" t-att-value="email"/>
<input type="hidden" name="mailing_id" t-att-value="mailing_id"/>
@@ -29,11 +35,11 @@
<t t-foreach="list_ids" t-as="list_id">
<t t-if="list_id.is_public == True">
<li class="list-group-item">
<input type="checkbox" class="mail_list_checkbox" name="contact_ids"
<input type="checkbox" name="contact_ids"
t-att-value="list_id['id']" t-att-checked="None if list_id['id'] in opt_out_list_ids else 'checked'"/>
<t t-esc="list_id.name"/>
<span t-if="list_id['id'] in opt_out_list_ids"
class="o_mass_mailing_unsubscribed">
class="o_mailing_portal_list_unsubscribed">
Unsubscribed
</span>
</li>
@@ -42,11 +48,15 @@
</ul>
<div class="mb64 pt-3">
<div t-if="show_blacklist_button">
<div class="btn btn-secondary float-end" id="button_add_blacklist" style="display:none">Blacklist Me</div>
</div>
<div class="btn btn-secondary float-end" id="button_remove_blacklist" style="display:none">Come Back</div>
<button type="submit" id="send_form" class="btn btn-primary">Update my subscriptions</button>
<div class="btn btn-link float-end pe-0 text-uppercase"
t-if="show_blacklist_button"
id="button_add_blacklist"
style="display:none">Blacklist Me</div>
<div class="btn btn-link float-end pe-0 text-uppercase"
id="button_remove_blacklist"
style="display:none">Come Back</div>
<button type="submit" id="send_form"
class="btn btn-primary">Update my subscriptions</button>
</div>
</div>
@@ -62,7 +72,13 @@
</div>
</template>
<template id="unsubscribed">
<template id="page_mailing_unsubscribe_done" name="Unsubscribed">
<t t-call="mass_mailing.layout">
<t t-call="mass_mailing.unsubscribe_done"/>
</t>
</template>
<template id="unsubscribe_done">
<div class="container o_unsubscribe_form">
<div class="row">
<input type="hidden" name="email" t-att-value="email"/>
@@ -89,23 +105,15 @@
</div>
<div t-if="show_blacklist_button" class="mb64">
<div class="btn btn-secondary float-end" id="button_add_blacklist" style="display:none">Blacklist Me</div>
<div class="btn btn-secondary float-end" id="button_remove_blacklist" style="display:none">Come Back</div>
<div class="btn btn-link float-end"
id="button_add_blacklist"
style="display:none">Blacklist Me</div>
<div class="btn btn-link float-end"
id="button_remove_blacklist"
style="display:none">Come Back</div>
</div>
</div>
</div>
</div>
</template>
<template id="page_unsubscribe" name="Unsubscribe">
<t t-call="mass_mailing.layout">
<t t-call="mass_mailing.unsubscribe"/>
</t>
</template>
<template id="page_unsubscribed" name="Unsubscribed">
<t t-call="mass_mailing.layout">
<t t-call="mass_mailing.unsubscribed"/>
</t>
</template>
</odoo>
@@ -78,43 +78,54 @@ class PhoneBlackList(models.Model):
new_args = args
return super(PhoneBlackList, self)._search(new_args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
def add(self, number):
def add(self, number, message=None):
sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized']
return self._add([sanitized])
return self._add([sanitized], message=message)
def _add(self, numbers):
def _add(self, numbers, message=None):
""" Add or re activate a phone blacklist entry.
:param numbers: list of sanitized numbers """
records = self.env["phone.blacklist"].with_context(active_test=False).search([('number', 'in', numbers)])
todo = [n for n in numbers if n not in records.mapped('number')]
if records:
if message:
records._track_set_log_message(message)
records.action_unarchive()
if todo:
records += self.create([{'number': n} for n in todo])
new_records = self.create([{'number': n} for n in todo])
if message:
for record in new_records:
record.with_context(mail_create_nosubscribe=True).message_post(
body=message,
subtype_id=self.env.ref('mail.mt_note').id,
)
records += new_records
return records
def action_remove_with_reason(self, number, reason=None):
records = self.remove(number)
if reason:
for record in records:
record.message_post(body=_("Unblacklisting Reason: %s", reason))
return records
def remove(self, number):
def remove(self, number, message=None):
sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized']
return self._remove([sanitized])
return self._remove([sanitized], message=message)
def _remove(self, numbers):
def _remove(self, numbers, message=None):
""" Add de-activated or de-activate a phone blacklist entry.
:param numbers: list of sanitized numbers """
records = self.env["phone.blacklist"].with_context(active_test=False).search([('number', 'in', numbers)])
todo = [n for n in numbers if n not in records.mapped('number')]
if records:
if message:
records._track_set_log_message(message)
records.action_archive()
if todo:
records += self.create([{'number': n, 'active': False} for n in todo])
new_records = self.create([{'number': n, 'active': False} for n in todo])
if message:
for record in new_records:
record.with_context(mail_create_nosubscribe=True).message_post(
body=message,
subtype_id=self.env.ref('mail.mt_note').id,
)
records += new_records
return records
def phone_action_blacklist_remove(self):
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
from markupsafe import Markup
from odoo import fields, models, _
class PhoneBlacklistRemove(models.TransientModel):
@@ -11,4 +12,11 @@ class PhoneBlacklistRemove(models.TransientModel):
reason = fields.Char(name="Reason")
def action_unblacklist_apply(self):
return self.env['phone.blacklist'].action_remove_with_reason(self.phone, self.reason)
if self.reason:
message = Markup('<p>%s</p>') % _("Unblock Reason: %(reason)s", reason=self.reason)
else:
message = None
return self.env['phone.blacklist']._remove(
self.phone,
message=message,
)
@@ -74,7 +74,7 @@ class TestMassMailing(TestMailFullCommon):
email['body'])
# rendered unsubscribe
self.assertIn(
'%s/mail/mailing/%s/unsubscribe' % (mailing.get_base_url(), mailing.id),
'%s/mailing/%s/unsubscribe' % (mailing.get_base_url(), mailing.id),
email['body'])
unsubscribe_href = self._get_href_from_anchor_id(email['body'], "url6")
unsubscribe_url = werkzeug.urls.url_parse(unsubscribe_href)
@@ -82,7 +82,7 @@ class TestMassMailing(TestMailFullCommon):
self.assertEqual(int(unsubscribe_params['res_id']), recipient.id)
self.assertEqual(unsubscribe_params['email'], recipient.email_normalized)
self.assertEqual(
mailing._unsubscribe_token(unsubscribe_params['res_id'], (unsubscribe_params['email'])),
mailing._generate_mailing_recipient_token(unsubscribe_params['res_id'], (unsubscribe_params['email'])),
unsubscribe_params['token']
)
# rendered view
@@ -95,7 +95,7 @@ class TestMassMailing(TestMailFullCommon):
self.assertEqual(int(view_params['res_id']), recipient.id)
self.assertEqual(view_params['email'], recipient.email_normalized)
self.assertEqual(
mailing._unsubscribe_token(view_params['res_id'], (view_params['email'])),
mailing._generate_mailing_recipient_token(view_params['res_id'], (view_params['email'])),
view_params['token']
)
@@ -229,8 +229,8 @@ class TestMassMailing(TestMassMailCommon):
self.env['mail.blacklist'].create({'email': recipients[4].email_normalized})
# unblacklist record 2
self.env['mail.blacklist'].action_remove_with_reason(
recipients[2].email_normalized, "human error"
self.env['mail.blacklist']._remove(
recipients[2].email_normalized, message="human error"
)
self.env['mail.blacklist'].flush_model(['active'])