[IMP] digest: ease the way people can unsubscribe from a digest
Ease the way people can unsubscribe from a digest by adding information to the email enabling a user agent to present a button to the user that automate the unsubscription. Technical notes: To ease the way people can unsubscribe from a digest, we have followed the advice of rfc8058 by adding the following header to the email: - List-Unsubscribe: https_URL_to_unsubscribe - List-Unsubscribe-Post: List-Unsubscribe=One-Click The https_URL_to_unsubscribe - must work with the POST method but not the GET. The GET version is disabled to avoid unintended unsubscription that could be triggered by an anti-spam accessing the URLs in the headers. - must unsubscribe the user without any additional steps (one click) - should contain an opaque identifier hard-to-forge to identify the list and the user (our token fulfills that goal). The goal is to avoid that someone could unregister someone else easily. - must fulfill other conditions like no cookie, no redirection, ... It requires also that List-Unsubscribe and List-Unsubscribe-Post be covered by a DKIM signature. Task-2800499 closes odoo/odoo#86934 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
committed by
Thibault Delavallée
parent
1ce603366e
commit
e1f86d3d80
@@ -11,8 +11,25 @@ from odoo.tools import consteq
|
||||
|
||||
class DigestController(Controller):
|
||||
|
||||
@route('/digest/<int:digest_id>/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/<int:digest_id>/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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<form string="KPI Digest">
|
||||
<field name="is_subscribed" invisible="1"/>
|
||||
<header>
|
||||
<button type="object" name="action_send" string="Send Now"
|
||||
<button type="object" name="action_send_manual" string="Send Now"
|
||||
class="oe_highlight"
|
||||
attrs="{'invisible': [('state','=','deactivated')]}" groups="base.group_system"/>
|
||||
<button type="object" name="action_deactivate" string="Deactivate"
|
||||
|
||||
Reference in New Issue
Block a user