diff --git a/addons/digest/controllers/portal.py b/addons/digest/controllers/portal.py index 8a2cdda9ab2..62ed0712cf6 100644 --- a/addons/digest/controllers/portal.py +++ b/addons/digest/controllers/portal.py @@ -11,8 +11,25 @@ from odoo.tools import consteq class DigestController(Controller): - @route('/digest//unsubscribe', type='http', website=True, auth='public') - def digest_unsubscribe(self, digest_id, token=None, user_id=None): + # csrf is disabled here because it will be called by the MUA with unpredictable session at that time + @route('/digest//unsubscribe', type='http', website=True, auth='public', methods=['GET', 'POST'], + csrf=False) + def digest_unsubscribe(self, digest_id, token=None, user_id=None, one_click=None): + """ Unsubscribe a given user from a given digest + + :param int digest_id: id of digest to unsubscribe from + :param str token: token preventing URL forgery + :param user_id: id of user to unsubscribe + :param int one_click: set it to 1 when using the URL in the header of + the email to allow mail user agent to propose a one click button to the + user to unsubscribe as defined in rfc8058. When set to True, only POST + method is allowed preventing the risk that anti-spam trigger unwanted + unsubscribe (scenario explained in the same rfc). Note: this method + must support encoding method 'multipart/form-data' and 'application/x-www-form-urlencoded'. + """ + if one_click and int(one_click) and request.httprequest.method != "POST": + raise Forbidden() + digest_sudo = request.env['digest.digest'].sudo().browse(digest_id).exists() # new route parameters diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py index 3c7662145bf..735a78f4be3 100644 --- a/addons/digest/models/digest.py +++ b/addons/digest/models/digest.py @@ -7,6 +7,7 @@ import pytz from datetime import datetime, date from dateutil.relativedelta import relativedelta from markupsafe import Markup +from werkzeug.urls import url_join from odoo import api, fields, models, tools, _ from odoo.addons.base.models.ir_mail_server import MailDeliveryException @@ -113,18 +114,38 @@ class Digest(models.Model): self.periodicity = periodicity def action_send(self): - to_slowdown = self._check_daily_logs() + """ Send digests emails to all the registered users. """ + return self._action_send(update_periodicity=True) + + def action_send_manual(self): + """ Manually send digests emails to all registered users. In that case + do not update periodicity as this is not an automated action that could + be considered as unwanted spam. """ + return self._action_send(update_periodicity=False) + + def _action_send(self, update_periodicity=True): + """ Send digests email to all the registered users. + + :param bool update_periodicity: if True, check user logs to update + periodicity of digests. Purpose is to slow down digest whose users + do not connect to avoid spam; + """ + to_slowdown = self._check_daily_logs() if update_periodicity else self.env['digest.digest'] + for digest in self: for user in digest.user_ids: digest.with_context( digest_slowdown=digest in to_slowdown, lang=user.lang )._action_send_to_user(user, tips_count=1) - if digest in to_slowdown: - digest.write({'periodicity': self._get_next_periodicity()[0]}) digest.next_run_date = digest._get_next_run_date() - def _action_send_to_user(self, user, tips_count=1, consum_tips=True): + for digest in to_slowdown: + digest.periodicity = digest._get_next_periodicity()[0] + + def _action_send_to_user(self, user, tips_count=1, consume_tips=True): + unsubscribe_token = self._get_unsubscribe_token(user.id) + rendered_body = self.env['mail.render.mixin']._render_template( 'digest.digest_mail_main', 'digest.digest', @@ -136,12 +157,12 @@ class Digest(models.Model): 'top_button_url': self.get_base_url(), 'company': user.company_id, 'user': user, - 'unsubscribe_token': self._get_unsubscribe_token(user.id), + 'unsubscribe_token': unsubscribe_token, 'tips_count': tips_count, 'formatted_date': datetime.today().strftime('%B %d, %Y'), 'display_mobile_banner': True, 'kpi_data': self._compute_kpis(user.company_id, user), - 'tips': self._compute_tips(user.company_id, user, tips_count=tips_count, consumed=consum_tips), + 'tips': self._compute_tips(user.company_id, user, tips_count=tips_count, consumed=consume_tips), 'preferences': self._compute_preferences(user.company_id, user), }, post_process=True, @@ -156,16 +177,24 @@ class Digest(models.Model): }, ) # create a mail_mail based on values, without attachments + unsub_url = url_join(self.get_base_url(), + f'/digest/{self.id}/unsubscribe?token={unsubscribe_token}&user_id={user.id}&one_click=1') mail_values = { 'auto_delete': True, 'author_id': self.env.user.partner_id.id, + 'body_html': full_mail, 'email_from': ( self.company_id.partner_id.email_formatted or self.env.user.email_formatted or self.env.ref('base.user_root').email_formatted ), 'email_to': user.email_formatted, - 'body_html': full_mail, + # Add headers that allow the MUA to offer a one click button to unsubscribe (requires DKIM to work) + 'headers': { + 'List-Unsubscribe': f'<{unsub_url}>', + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'X-Auto-Response-Suppress': 'OOF', # avoid out-of-office replies from MS Exchange + }, 'state': 'outgoing', 'subject': '%s: %s' % (user.company_id.name, self.name), } diff --git a/addons/digest/tests/test_digest.py b/addons/digest/tests/test_digest.py index 6b69f98bbc1..c1a4c3b819c 100644 --- a/addons/digest/tests/test_digest.py +++ b/addons/digest/tests/test_digest.py @@ -6,7 +6,7 @@ import random from dateutil.relativedelta import relativedelta from lxml import html -from werkzeug.urls import url_encode +from werkzeug.urls import url_encode, url_join from odoo import fields, SUPERUSER_ID from odoo.addons.base.tests.common import HttpCaseWithUserDemo @@ -256,6 +256,24 @@ class TestUnsubscribe(HttpCaseWithUserDemo): self.test_digest.invalidate_recordset() self.assertNotIn(self.user_demo, self.test_digest.user_ids) + def test_unsubscribe_token_one_click(self): + self.assertIn(self.user_demo, self.test_digest.user_ids) + self.authenticate(None, None) + + # Ensure we cannot unregister using GET method (method not allowed) + response = self._url_unsubscribe(token=self.user_demo_unsubscribe_token, user_id=self.user_demo.id, + one_click='1', method='GET') + self.assertEqual(response.status_code, 403, 'GET method is forbidden') + self.test_digest.invalidate_recordset() + self.assertIn(self.user_demo, self.test_digest.user_ids) + + # Ensure we can unregister with POST method + response = self._url_unsubscribe(token=self.user_demo_unsubscribe_token, user_id=self.user_demo.id, + one_click='1', method='POST') + self.assertEqual(response.status_code, 200) + self.test_digest.invalidate_recordset() + self.assertNotIn(self.user_demo, self.test_digest.user_ids) + def test_unsubscribe_public(self): """ Check public users are redirected when trying to catch unsubscribe route. """ @@ -264,16 +282,18 @@ class TestUnsubscribe(HttpCaseWithUserDemo): response = self._url_unsubscribe() self.assertEqual(response.status_code, 404) - def _url_unsubscribe(self, token=None, user_id=None): + def _url_unsubscribe(self, token=None, user_id=None, one_click=None, method='GET'): url_params = {} if token is not None: url_params['token'] = token if user_id is not None: url_params['user_id'] = user_id + if one_click is not None: + url_params['one_click'] = one_click - url = "%s/digest/%s/unsubscribe?%s" % ( - self.base_url, - self.test_digest.id, - url_encode(url_params) - ) - return self.url_open(url) + url = url_join(self.base_url, f'digest/{self.test_digest.id}/unsubscribe?{url_encode(url_params)}') + if method == 'GET': + return self.opener.get(url, timeout=10, allow_redirects=True) + if method == 'POST': + return self.opener.post(url, timeout=10, allow_redirects=True) + raise Exception(f'Invalid method {method}') diff --git a/addons/digest/views/digest_views.xml b/addons/digest/views/digest_views.xml index 49232643f21..4cf13d690ee 100644 --- a/addons/digest/views/digest_views.xml +++ b/addons/digest/views/digest_views.xml @@ -20,7 +20,7 @@
-